From ed200a3c59ff3d310c223076b8a57c59936e208a Mon Sep 17 00:00:00 2001 From: Gert Hengeveld Date: Tue, 9 Jul 2019 01:55:15 +0200 Subject: [PATCH] Add onCancel callback. --- README.md | 10 +++++- packages/react-async/src/Async.js | 24 +++++++++---- packages/react-async/src/specs.js | 53 +++++++++++++++++++++------- packages/react-async/src/useAsync.js | 6 ++-- 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index aec5c8e0..cc9e7bdd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ error states, without assumptions about the shape of your data or the type of re - Provides convenient `isLoading`, `startedAt`, `finishedAt`, et al metadata - Provides `cancel` and `reload` actions - Automatic re-run using `watch` or `watchFn` prop -- Accepts `onResolve` and `onReject` callbacks +- Accepts `onResolve`, `onReject` and `onCancel` callbacks - Supports [abortable fetch] by providing an AbortController - Supports optimistic updates using `setData` - Supports server-side rendering through `initialValue` @@ -335,6 +335,7 @@ These can be passed in an object to `useAsync()`, or as props to `` and c - `initialValue` Provide initial data or error for server-side rendering. - `onResolve` Callback invoked when Promise resolves. - `onReject` Callback invoked when Promise rejects. +- `onCancel` Callback invoked when a Promise is cancelled. - `reducer` State reducer to control internal state updates. - `dispatcher` Action dispatcher to control internal action dispatching. - `debugLabel` Unique label used in DevTools. @@ -411,6 +412,13 @@ Callback function invoked when a promise resolves, receives data as argument. Callback function invoked when a promise rejects, receives rejection reason (error) as argument. +#### `onCancel` + +> `function(): void` + +Callback function invoked when a promise is cancelled, either manually using `cancel()` or automatically due to props +changes or unmounting. + #### `reducer` > `function(state: any, action: Object, internalReducer: function(state: any, action: Object))` diff --git a/packages/react-async/src/Async.js b/packages/react-async/src/Async.js index 1dc7b8d8..50d77d91 100644 --- a/packages/react-async/src/Async.js +++ b/packages/react-async/src/Async.js @@ -65,16 +65,24 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => { componentDidUpdate(prevProps) { const { watch, watchFn = defaultProps.watchFn, promise, promiseFn } = this.props - if (watch !== prevProps.watch) this.load() - if (watchFn && watchFn({ ...defaultProps, ...this.props }, { ...defaultProps, ...prevProps })) - this.load() + if (watch !== prevProps.watch) { + if (this.counter) this.cancel() + return this.load() + } + if ( + watchFn && + watchFn({ ...defaultProps, ...this.props }, { ...defaultProps, ...prevProps }) + ) { + if (this.counter) this.cancel() + return this.load() + } if (promise !== prevProps.promise) { - if (promise) this.load() - else this.cancel() + if (this.counter) this.cancel() + if (promise) return this.load() } if (promiseFn !== prevProps.promiseFn) { - if (promiseFn) this.load() - else this.cancel() + if (this.counter) this.cancel() + if (promiseFn) return this.load() } } @@ -135,6 +143,8 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => { } cancel() { + const onCancel = this.props.onCancel || defaultProps.onCancel + onCancel && onCancel() this.counter++ this.abortController.abort() this.mounted && this.dispatch({ type: actionTypes.cancel, meta: this.getMeta() }) diff --git a/packages/react-async/src/specs.js b/packages/react-async/src/specs.js index dbcfc559..a5372e3e 100644 --- a/packages/react-async/src/specs.js +++ b/packages/react-async/src/specs.js @@ -56,6 +56,13 @@ export const common = Async => () => { await waitForElement(() => getByText("outer undefined")) await waitForElement(() => getByText("outer inner")) }) + + test("does not cancel on initial mount", async () => { + const onCancel = jest.fn() + const { getByText } = render({() => "done"}) + await waitForElement(() => getByText("done")) + expect(onCancel).not.toHaveBeenCalled() + }) } export const withPromise = Async => () => { @@ -112,29 +119,41 @@ export const withPromise = Async => () => { }) test("cancels a pending promise when unmounted", async () => { + const onCancel = jest.fn() const onResolve = jest.fn() - const { unmount } = render() + const { unmount } = render( + + ) unmount() await sleep(10) + expect(onCancel).toHaveBeenCalled() expect(onResolve).not.toHaveBeenCalled() }) test("cancels and restarts the promise when `promise` changes", async () => { const promise1 = resolveTo("one") const promise2 = resolveTo("two") + const onCancel = jest.fn() const onResolve = jest.fn() - const { rerender } = render() - rerender() + const { rerender } = render( + + ) + rerender() await sleep(10) + expect(onCancel).toHaveBeenCalled() expect(onResolve).not.toHaveBeenCalledWith("one") expect(onResolve).toHaveBeenCalledWith("two") }) test("cancels the promise when `promise` is unset", async () => { + const onCancel = jest.fn() const onResolve = jest.fn() - const { rerender } = render() - rerender() + const { rerender } = render( + + ) + rerender() await sleep(10) + expect(onCancel).toHaveBeenCalled() expect(onResolve).not.toHaveBeenCalled() }) @@ -241,10 +260,11 @@ export const withPromiseFn = (Async, abortCtrl) => () => { expect(promiseFn).toHaveBeenCalledTimes(1) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) - expect(abortCtrl.abort).toHaveBeenCalledTimes(1) + expect(abortCtrl.abort).toHaveBeenCalled() + abortCtrl.abort.mockClear() fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(3) - expect(abortCtrl.abort).toHaveBeenCalledTimes(2) + expect(abortCtrl.abort).toHaveBeenCalled() }) test("re-runs the promise when `watchFn` returns truthy", () => { @@ -271,17 +291,21 @@ export const withPromiseFn = (Async, abortCtrl) => () => { expect(promiseFn).toHaveBeenCalledTimes(1) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(1) - expect(abortCtrl.abort).toHaveBeenCalledTimes(0) + expect(abortCtrl.abort).not.toHaveBeenCalled() fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) - expect(abortCtrl.abort).toHaveBeenCalledTimes(1) + expect(abortCtrl.abort).toHaveBeenCalled() }) test("cancels a pending promise when unmounted", async () => { + const onCancel = jest.fn() const onResolve = jest.fn() - const { unmount } = render( resolveTo("ok")} onResolve={onResolve} />) + const { unmount } = render( + resolveTo("ok")} onCancel={onCancel} onResolve={onResolve} /> + ) unmount() await sleep(10) + expect(onCancel).toHaveBeenCalled() expect(onResolve).not.toHaveBeenCalled() expect(abortCtrl.abort).toHaveBeenCalledTimes(1) }) @@ -289,13 +313,16 @@ export const withPromiseFn = (Async, abortCtrl) => () => { test("cancels and restarts the promise when `promiseFn` changes", async () => { const promiseFn1 = () => resolveTo("one") const promiseFn2 = () => resolveTo("two") + const onCancel = jest.fn() const onResolve = jest.fn() - const { rerender } = render() - rerender() + const { rerender } = render( + + ) + rerender() await sleep(10) + expect(onCancel).toHaveBeenCalled() expect(onResolve).not.toHaveBeenCalledWith("one") expect(onResolve).toHaveBeenCalledWith("two") - expect(abortCtrl.abort).toHaveBeenCalledTimes(1) }) test("cancels the promise when `promiseFn` is unset", async () => { diff --git a/packages/react-async/src/useAsync.js b/packages/react-async/src/useAsync.js index b047dd5b..5563e2c0 100644 --- a/packages/react-async/src/useAsync.js +++ b/packages/react-async/src/useAsync.js @@ -89,6 +89,7 @@ const useAsync = (arg1, arg2) => { } const cancel = () => { + options.onCancel && options.onCancel() counter.current++ abortController.current.abort() isMounted.current && dispatch({ type: actionTypes.cancel, meta: getMeta() }) @@ -99,10 +100,11 @@ const useAsync = (arg1, arg2) => { if (watchFn && prevOptions.current && watchFn(options, prevOptions.current)) load() }) useEffect(() => { - promise || promiseFn ? load() : cancel() + if (counter.current) cancel() + if (promise || promiseFn) load() }, [promise, promiseFn, watch]) useEffect(() => () => (isMounted.current = false), []) - useEffect(() => () => abortController.current.abort(), []) + useEffect(() => () => cancel(), []) useEffect(() => (prevOptions.current = options) && undefined) useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`)