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) {
)}