Skip to content

Commit

Permalink
Add onCancel callback.
Browse files Browse the repository at this point in the history
  • Loading branch information
ghengeveld committed Jul 8, 2019
1 parent f607939 commit ed200a3
Show file tree
Hide file tree
Showing 4 changed files with 70 additions and 23 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -335,6 +335,7 @@ These can be passed in an object to `useAsync()`, or as props to `<Async>` 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.
Expand Down Expand Up @@ -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))`
Expand Down
24 changes: 17 additions & 7 deletions packages/react-async/src/Async.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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() })
Expand Down
53 changes: 40 additions & 13 deletions packages/react-async/src/specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Async onCancel={onCancel}>{() => "done"}</Async>)
await waitForElement(() => getByText("done"))
expect(onCancel).not.toHaveBeenCalled()
})
}

export const withPromise = Async => () => {
Expand Down Expand Up @@ -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(<Async promise={resolveTo("ok")} onResolve={onResolve} />)
const { unmount } = render(
<Async promise={resolveTo("ok")} onCancel={onCancel} onResolve={onResolve} />
)
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(<Async promise={promise1} onResolve={onResolve} />)
rerender(<Async promise={promise2} onResolve={onResolve} />)
const { rerender } = render(
<Async promise={promise1} onCancel={onCancel} onResolve={onResolve} />
)
rerender(<Async promise={promise2} onCancel={onCancel} onResolve={onResolve} />)
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(<Async promise={resolveTo()} onResolve={onResolve} />)
rerender(<Async onResolve={onResolve} />)
const { rerender } = render(
<Async promise={resolveTo()} onCancel={onCancel} onResolve={onResolve} />
)
rerender(<Async onCancel={onCancel} onResolve={onResolve} />)
await sleep(10)
expect(onCancel).toHaveBeenCalled()
expect(onResolve).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -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", () => {
Expand All @@ -271,31 +291,38 @@ 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(<Async promiseFn={() => resolveTo("ok")} onResolve={onResolve} />)
const { unmount } = render(
<Async promiseFn={() => resolveTo("ok")} onCancel={onCancel} onResolve={onResolve} />
)
unmount()
await sleep(10)
expect(onCancel).toHaveBeenCalled()
expect(onResolve).not.toHaveBeenCalled()
expect(abortCtrl.abort).toHaveBeenCalledTimes(1)
})

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(<Async promiseFn={promiseFn1} onResolve={onResolve} />)
rerender(<Async promiseFn={promiseFn2} onResolve={onResolve} />)
const { rerender } = render(
<Async promiseFn={promiseFn1} onCancel={onCancel} onResolve={onResolve} />
)
rerender(<Async promiseFn={promiseFn2} onCancel={onCancel} onResolve={onResolve} />)
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 () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/react-async/src/useAsync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() })
Expand All @@ -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}`)
Expand Down

0 comments on commit ed200a3

Please sign in to comment.