diff --git a/src/Poll.test.tsx b/src/Poll.test.tsx new file mode 100644 index 00000000..5243e63b --- /dev/null +++ b/src/Poll.test.tsx @@ -0,0 +1,352 @@ +import "isomorphic-fetch"; +import "jest-dom/extend-expect"; +import nock from "nock"; +import React from "react"; +import { cleanup, render, wait } from "react-testing-library"; + +import { Poll, RestfulProvider } from "./index"; + +afterEach(() => { + cleanup(); + nock.cleanAll(); +}); + +describe("Poll", () => { + describe("classic usage", () => { + it("should call the url set in provider", async () => { + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "1" }); + + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;index=1" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "2" }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + }); + + it("should compose the url with the base", async () => { + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;" + } + }) + .get("/plop") + .reply(200, { data: "hello" }, { "x-polling-index": "1" }); + + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;index=1" + } + }) + .get("/plop") + .reply(200, { data: "hello" }, { "x-polling-index": "2" }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + }); + + it("should set loading to `true` on mount", async () => { + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "1" }); + + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;index=1" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "2" }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[0][1].loading).toEqual(true); + }); + + it("should set loading to `false` on data", async () => { + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "1" }); + + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;index=1" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "2" }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][1].loading).toEqual(false); + }); + + it("should send data on data", async () => { + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "1" }); + + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;index=1" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "2" }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][0]).toEqual({ data: "hello" }); + }); + + it("should update data if the response change", async () => { + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;" + } + }) + .get("/") + .reply(200, { data: "hello" }, { "x-polling-index": "1" }); + + nock("https://my-awesome-api.fake", { + reqheaders: { + prefer: "wait=60s;index=1" + } + }) + .get("/") + .reply(200, { data: "hello you" }, { "x-polling-index": "2" }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + + ); + + await wait(() => expect(children.mock.calls.length).toBe(3)); + expect(children.mock.calls[2][0]).toEqual({ data: "hello you" }); + }); + }); + + describe.skip("with error", () => { + it("should set the `error` object properly", async () => { + nock("https://my-awesome-api.fake") + .get("/") + .reply(401, { message: "You shall not pass!" }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][0]).toEqual(null); + expect(children.mock.calls[1][1].error).toEqual({ + data: { message: "You shall not pass!" }, + message: "Failed to fetch: 401 Unauthorized" + }); + }); + + it("should deal with non standard server error response (nginx style)", async () => { + nock("https://my-awesome-api.fake") + .get("/") + .reply(200, "404 - this is not a json!", { + "content-type": "application/json" + }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + {children} + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][0]).toEqual(null); + expect(children.mock.calls[1][1].error).toEqual({ + data: + "invalid json response body at https://my-awesome-api.fake reason: Unexpected token < in JSON at position 0", + message: + "Failed to fetch: 200 OK - invalid json response body at https://my-awesome-api.fake reason: Unexpected token < in JSON at position 0" + }); + }); + }); + + describe("with custom resolver", () => { + it("should transform data", async () => { + nock("https://my-awesome-api.fake") + .get("/") + .reply(200, { hello: "world" }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + ({ ...data, foo: "bar" })}> + {children} + + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][0]).toEqual({ hello: "world", foo: "bar" }); + }); + }); + + describe("with lazy", () => { + it("should not fetch on mount", async () => { + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {children} + + + ); + + await wait(() => expect(children.mock.calls.length).toBe(1)); + expect(children.mock.calls[0][1].loading).toBe(false); + expect(children.mock.calls[0][0]).toBe(null); + }); + }); + + describe("with base", () => { + it("should override the base url", async () => { + nock("https://my-awesome-api.fake") + .get("/") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {children} + + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][1].loading).toEqual(false); + expect(children.mock.calls[1][0]).toEqual({ id: 1 }); + }); + + it("should override the base url and compose with the path", async () => { + nock("https://my-awesome-api.fake") + .get("/plop") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {children} + + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][1].loading).toEqual(false); + expect(children.mock.calls[1][0]).toEqual({ id: 1 }); + }); + }); + + describe("with custom request options", () => { + it("should add a custom header", async () => { + nock("https://my-awesome-api.fake", { reqheaders: { foo: "bar" } }) + .get("/") + .reply(200, { id: 1 }); + + const children = jest.fn(); + children.mockReturnValue(
); + + render( + + + {children} + + + ); + + await wait(() => expect(children.mock.calls.length).toBe(2)); + expect(children.mock.calls[1][1].loading).toEqual(false); + expect(children.mock.calls[1][0]).toEqual({ id: 1 }); + }); + }); +}); diff --git a/src/Poll.tsx b/src/Poll.tsx index a0b14328..0694d4e8 100644 --- a/src/Poll.tsx +++ b/src/Poll.tsx @@ -59,7 +59,12 @@ export interface PollProps { * 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; + children: ( + data: TData | null, + states: States, + actions: Actions, + meta: Meta + ) => React.ReactNode; /** * How long do we wait between repeating a request? * Value in milliseconds. @@ -150,17 +155,23 @@ class ContextlessPoll extends React.Component< lastResponse: null, polling: !this.props.lazy, finished: false, - error: null, + error: null }; public static defaultProps = { interval: 1000, wait: 60, - resolve: (data: any) => data, + resolve: (data: any) => data }; 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; @@ -172,10 +183,13 @@ class ContextlessPoll extends React.Component< }; private getRequestOptions = () => - typeof this.props.requestOptions === "function" ? this.props.requestOptions() : this.props.requestOptions || {}; + typeof this.props.requestOptions === "function" + ? this.props.requestOptions() + : this.props.requestOptions || {}; // 304 is not a OK status code but is green in Chrome 🤦🏾‍♂️ - private isResponseOk = (response: Response) => response.ok || response.status === 304; + private isResponseOk = (response: Response) => + response.ok || response.status === 304; /** * This thing does the actual poll. @@ -187,7 +201,10 @@ class ContextlessPoll extends React.Component< } // Should we stop? - if (this.props.until && this.props.until(this.state.data, this.state.lastResponse)) { + if ( + this.props.until && + this.props.until(this.state.data, this.state.lastResponse) + ) { await this.stop(); // stop. return; } @@ -199,44 +216,60 @@ class ContextlessPoll extends React.Component< const request = new Request(`${base}${path}`, { ...requestOptions, - headers: { - Prefer: `wait=${wait}s;${lastPollIndex ? `index=${lastPollIndex}` : ""}`, - - ...requestOptions.headers, - }, + Prefer: `wait=${wait}s;${ + lastPollIndex ? `index=${lastPollIndex}` : "" + }`, + ...requestOptions.headers + } }); - const response = await fetch(request); - const { data, responseError } = await processResponse(response); - - if (!this.isResponseOk(response) || responseError) { - const error = { message: `${response.status} ${response.statusText}${responseError ? " - " + data : ""}`, data }; - this.setState({ loading: false, lastResponse: response, data, error }); - throw new Error(`Failed to Poll: ${error}`); - } - - if (this.isModified(response, data)) { - this.setState(() => ({ - loading: false, - lastResponse: response, - data: resolve ? resolve(data) : data, - lastPollIndex: response.headers.get("x-polling-index") || undefined, - })); + try { + const response = await fetch(request, { signal: this.signal }); + const { data, responseError } = await processResponse(response); + + if (!this.keepPolling) { + // Early return if we have stopped polling to avoid memory leaks + return; + } + + if (!this.isResponseOk(response) || responseError) { + const error = { + message: `${response.status} ${response.statusText}${ + responseError ? " - " + data : "" + }`, + data + }; + this.setState({ loading: false, lastResponse: response, data, error }); + throw new Error(`Failed to Poll: ${error}`); + } + + if (this.isModified(response, data)) { + this.setState(() => ({ + loading: false, + lastResponse: response, + data: resolve ? resolve(data) : data, + 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 } - - // Wait for interval to pass. - await new Promise(resolvePromise => setTimeout(resolvePromise, interval)); - this.cycle(); // Do it all again! }; - public start = async () => { + public start = () => { this.keepPolling = true; - this.setState(() => ({ polling: true })); // let everyone know we're done here. + if (!this.state.polling) { + this.setState(() => ({ polling: true })); // let everyone know we're done here. + } this.cycle(); }; - public stop = async () => { + public stop = () => { this.keepPolling = false; this.setState(() => ({ polling: false, finished: true })); // let everyone know we're done here. }; @@ -244,9 +277,9 @@ class ContextlessPoll extends React.Component< public componentDidMount() { const { path, lazy } = this.props; - if (!path) { + 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.`, + `[restful-react]: You're trying to poll something without a path. Please specify a "path" prop on your Poll component.` ); } @@ -256,28 +289,39 @@ class ContextlessPoll extends React.Component< } public componentWillUnmount() { + // Cancel the current query + this.abortController.abort(); + + // Stop the polling cycle this.stop(); } public render() { - const { lastResponse: response, data, polling, loading, error, finished } = this.state; + const { + lastResponse: response, + data, + polling, + loading, + error, + finished + } = this.state; const { children, base, path } = this.props; const meta: Meta = { response, - absolutePath: `${base}${path}`, + absolutePath: `${base}${path}` }; const states: States = { polling, loading, error, - finished, + finished }; const actions: Actions = { stop: this.stop, - start: this.start, + start: this.start }; return children(data, states, actions, meta); @@ -292,7 +336,10 @@ function Poll(props: PollProps) { )}