import merge from "lodash/merge"; import * as React from "react"; import equal from "react-fast-compare"; import { InjectedProps, RestfulReactConsumer } from "./Context"; import { GetProps, GetState, Meta as GetComponentMeta } from "./Get"; import { composeUrl } from "./util/composeUrl"; import { processResponse } from "./util/processResponse"; import { constructUrl } from "./util/constructUrl"; import { IStringifyOptions } from "qs"; /** * Meta information returned from the poll. */ interface Meta extends GetComponentMeta { /** * The entire response object. */ response: Response | null; } /** * States of the current poll */ interface States { /** * Is the component currently polling? */ polling: PollState["polling"]; /** * Is the initial request loading? */ loading: PollState["loading"]; /** * Has the poll concluded? */ finished: PollState["finished"]; /** * Is there an error? What is it? */ error: PollState["error"]; } /** * Actions that can be executed within the * component. */ interface Actions { start: () => void; stop: () => void; } /** * Props that can control the Poll component. */ export interface PollProps { /** * What path are we polling on? */ path: GetProps["path"]; /** * A function that gets polled data, the current * states, meta information, and various actions * that can be executed at the poll-level. */ children: (data: TData | null, states: States, actions: Actions, meta: Meta) => React.ReactNode; /** * How long do we wait between repeating a request? * Value in milliseconds. * * Defaults to 1000. */ interval?: number; /** * How long should a request stay open? * Value in seconds. * * Defaults to 60. */ wait?: number; /** * A stop condition for the poll that expects * a boolean. * * @param data - The data returned from the poll. * @param response - The full response object. This could be useful in order to stop polling when !response.ok, for example. */ until?: (data: TData | null, response: Response | null) => boolean; /** * Are we going to wait to start the poll? * Use this with { start, stop } actions. */ lazy?: GetProps["lazy"]; /** * Should the data be transformed in any way? */ resolve?: (data: any, prevData: TData | null) => TData; /** * We can request foreign URLs with this prop. */ base?: GetProps["base"]; /** * Any options to be passed to this request. */ requestOptions?: GetProps["requestOptions"]; /** * Query parameters */ queryParams?: TQueryParams; /** * Query parameter stringify options */ queryParamStringifyOptions?: IStringifyOptions; /** * Don't send the error to the Provider */ localErrorOnly?: boolean; } /** * The state of the Poll component. This should contain * implementation details not necessarily exposed to * consumers. */ export interface PollState { /** * Are we currently polling? */ polling: boolean; /** * Have we finished polling? */ finished: boolean; /** * What was the last response? */ lastResponse: Response | null; /** * What data are we holding in here? */ data: GetState["data"]; /** * What data did we had before? */ previousData: GetState["data"]; /** * Are we loading? */ loading: GetState["loading"]; /** * Do we currently have an error? */ error: GetState["error"]; /** * Index of the last polled response. */ lastPollIndex?: string; } /** * The component without context. */ class ContextlessPoll extends React.Component< PollProps & InjectedProps, Readonly> > { public readonly state: Readonly> = { data: null, previousData: null, loading: !this.props.lazy, lastResponse: null, polling: !this.props.lazy, finished: false, error: null, }; public static defaultProps = { interval: 1000, wait: 60, base: "", resolve: (data: any) => data, queryParams: {}, }; private keepPolling = !this.props.lazy; /** * Abort controller to cancel the current fetch query */ private abortController = new AbortController(); private signal = this.abortController.signal; private isModified = (response: Response, nextData: TData) => { if (response.status === 304) { return false; } if (equal(this.state.data, nextData)) { return false; } return true; }; private getRequestOptions = (url: string) => typeof this.props.requestOptions === "function" ? this.props.requestOptions(url, "GET") : this.props.requestOptions || {}; // 304 is not a OK status code but is green in Chrome 🤦🏾‍♂️ private isResponseOk = (response: Response) => response.ok || response.status === 304; /** * This thing does the actual poll. */ public cycle = async () => { // Have we stopped? if (!this.keepPolling) { return; // stop. } // Should we stop? if (this.props.until && this.props.until(this.state.data, this.state.lastResponse)) { this.stop(); // stop. return; } // If we should keep going, const { base, path, interval, wait, onError, onRequest, onResponse } = this.props; const { lastPollIndex } = this.state; const url = constructUrl(base!, path, this.props.queryParams, { queryParamOptions: this.props.queryParamStringifyOptions, stripTrailingSlash: true, }); const requestOptions = await this.getRequestOptions(url); const request = new Request(url, { ...requestOptions, headers: { Prefer: `wait=${wait}s;${lastPollIndex ? `index=${lastPollIndex}` : ""}`, ...requestOptions.headers, }, }); if (onRequest) onRequest(request); try { const response = await fetch(request, { signal: this.signal }); if (onResponse) onResponse(response.clone()); const { data, responseError } = await processResponse(response); if (!this.keepPolling || this.signal.aborted) { // Early return if we have stopped polling or component was unmounted // to avoid memory leaks return; } if (!this.isResponseOk(response) || responseError) { const error = { message: `Failed to poll: ${response.status} ${response.statusText}${responseError ? " - " + data : ""}`, data, status: response.status, }; this.setState({ loading: false, lastResponse: response, error }); if (!this.props.localErrorOnly && onError) { onError(error, () => Promise.resolve(), response); } } else if (this.isModified(response, data)) { this.setState(prevState => ({ loading: false, lastResponse: response, previousData: prevState.data, data, error: null, lastPollIndex: response.headers.get("x-polling-index") || undefined, })); } // Wait for interval to pass. await new Promise(resolvePromise => setTimeout(resolvePromise, interval)); this.cycle(); // Do it all again! } catch (e) { // the only error not catched is the `fetch`, this means that we have cancelled the fetch } }; public start = () => { this.keepPolling = true; if (!this.state.polling) { this.setState(() => ({ polling: true })); // let everyone know we're done here. } this.cycle(); }; public stop = () => { this.keepPolling = false; this.setState(() => ({ polling: false, finished: true })); // let everyone know we're done here. }; public componentDidMount() { const { path, lazy } = this.props; if (path === undefined) { throw new Error( `[restful-react]: You're trying to poll something without a path. Please specify a "path" prop on your Poll component.`, ); } if (!lazy) { this.start(); } } public componentWillUnmount() { // Cancel the current query this.abortController.abort(); // Stop the polling cycle this.stop(); } public render() { const { lastResponse: response, previousData, data, polling, loading, error, finished } = this.state; const { children, base, path, resolve } = this.props; const meta: Meta = { response, absolutePath: composeUrl(base!, "", path), }; const states: States = { polling, loading, error, finished, }; const actions: Actions = { stop: this.stop, start: this.start, }; // data is parsed only when poll has already resolved so response is defined const resolvedData = response && resolve ? resolve(data, previousData) : data; return children(resolvedData, states, actions, meta); } } function Poll( props: PollProps, ) { // Compose Contexts to allow for URL nesting return ( {contextProps => { return ( { const contextRequestOptions = typeof contextProps.requestOptions === "function" ? await contextProps.requestOptions(url, method) : contextProps.requestOptions || {}; const propsRequestOptions = typeof props.requestOptions === "function" ? await props.requestOptions(url, method) : props.requestOptions || {}; return merge(contextRequestOptions, propsRequestOptions); }} queryParamStringifyOptions={{ ...contextProps.queryParamStringifyOptions, ...props.queryParamStringifyOptions, }} /> ); }} ); } export default Poll;