diff --git a/README.md b/README.md index 969ec275..a603dba1 100644 --- a/README.md +++ b/README.md @@ -547,9 +547,10 @@ is set to `"application/json"`. - `isRejected` true when the last promise was rejected. - `isSettled` true when the last promise was fulfilled or rejected (not initial or pending). - `counter` The number of times a promise was started. -- `cancel` Cancel any pending promise. +- `promise` A reference to the internal wrapper promise, which can be chained on. - `run` Invokes the `deferFn`. - `reload` Re-runs the promise when invoked, using any previous arguments. +- `cancel` Cancel any pending promise. - `setData` Sets `data` to the passed value, unsets `error` and cancels any pending promise. - `setError` Sets `error` to the passed value and cancels any pending promise. @@ -636,17 +637,19 @@ Alias: `isResolved` The number of times a promise was started. -#### `cancel` +#### `promise` -> `function(): void` +> `Promise` -Cancels the currently pending promise by ignoring its result and calls `abort()` on the AbortController. +A reference to the internal wrapper promise created when starting a new promise (either automatically or by invoking +`run` / `reload`). It fulfills or rejects along with the provided `promise` / `promiseFn` / `deferFn`. Useful as a +chainable alternative to the `onResolve` / `onReject` callbacks. #### `run` -> `function(...args: any[]): Promise` +> `function(...args: any[]): void` -Runs the `deferFn`, passing any arguments provided as an array. The returned Promise always **fulfills** to `data` or `error`, it never rejects. +Runs the `deferFn`, passing any arguments provided as an array. #### `reload` @@ -654,6 +657,12 @@ Runs the `deferFn`, passing any arguments provided as an array. The returned Pro Re-runs the promise when invoked, using the previous arguments. +#### `cancel` + +> `function(): void` + +Cancels the currently pending promise by ignoring its result and calls `abort()` on the AbortController. + #### `setData` > `function(data: any, callback?: () => void): any` diff --git a/packages/react-async/src/Async.js b/packages/react-async/src/Async.js index b0d9c99b..8a5733e3 100644 --- a/packages/react-async/src/Async.js +++ b/packages/react-async/src/Async.js @@ -32,6 +32,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => { this.mounted = false this.counter = 0 this.args = [] + this.promise = undefined this.abortController = { abort: () => {} } this.state = { ...init({ initialValue, promise, promiseFn }), @@ -96,6 +97,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => { getMeta(meta) { return { counter: this.counter, + promise: this.promise, debugLabel: this.debugLabel, ...meta, } @@ -107,28 +109,25 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => { this.abortController = new globalScope.AbortController() } this.counter++ - return new Promise((resolve, reject) => { + return (this.promise = new Promise((resolve, reject) => { if (!this.mounted) return const executor = () => promiseFn().then(resolve, reject) this.dispatch({ type: actionTypes.start, payload: executor, meta: this.getMeta() }) - }) + })) } load() { const promise = this.props.promise - if (promise) { - return this.start(() => promise).then( - this.onResolve(this.counter), - this.onReject(this.counter) - ) - } const promiseFn = this.props.promiseFn || defaultProps.promiseFn - if (promiseFn) { + if (promise) { + this.start(() => promise) + .then(this.onResolve(this.counter)) + .catch(this.onReject(this.counter)) + } else if (promiseFn) { const props = { ...defaultProps, ...this.props } - return this.start(() => promiseFn(props, this.abortController)).then( - this.onResolve(this.counter), - this.onReject(this.counter) - ) + this.start(() => promiseFn(props, this.abortController)) + .then(this.onResolve(this.counter)) + .catch(this.onReject(this.counter)) } } diff --git a/packages/react-async/src/index.d.ts b/packages/react-async/src/index.d.ts index 6db286d7..8e0a6e4b 100644 --- a/packages/react-async/src/index.d.ts +++ b/packages/react-async/src/index.d.ts @@ -60,8 +60,9 @@ export interface AsyncProps extends AsyncOptions { interface AbstractState { initialValue?: T | Error counter: number + promise: Promise cancel: () => void - run: (...args: any[]) => Promise + run: (...args: any[]) => void reload: () => void setData: (data: T, callback?: () => void) => T setError: (error: Error, callback?: () => void) => Error diff --git a/packages/react-async/src/propTypes.js b/packages/react-async/src/propTypes.js index ff8e3964..d8fe293a 100644 --- a/packages/react-async/src/propTypes.js +++ b/packages/react-async/src/propTypes.js @@ -22,9 +22,10 @@ const stateObject = isRejected: PropTypes.bool, isSettled: PropTypes.bool, counter: PropTypes.number, - cancel: PropTypes.func, + promise: PropTypes.instanceOf(Promise), run: PropTypes.func, reload: PropTypes.func, + cancel: PropTypes.func, setData: PropTypes.func, setError: PropTypes.func, }) diff --git a/packages/react-async/src/reducer.js b/packages/react-async/src/reducer.js index df22f5d7..27bf65a2 100644 --- a/packages/react-async/src/reducer.js +++ b/packages/react-async/src/reducer.js @@ -16,6 +16,7 @@ export const init = ({ initialValue, promise, promiseFn }) => ({ finishedAt: initialValue ? new Date() : undefined, ...getStatusProps(getInitialStatus(initialValue, promise || promiseFn)), counter: 0, + promise: undefined, }) export const reducer = (state, { type, payload, meta }) => { @@ -27,6 +28,7 @@ export const reducer = (state, { type, payload, meta }) => { finishedAt: undefined, ...getStatusProps(statusTypes.pending), counter: meta.counter, + promise: meta.promise, } case actionTypes.cancel: return { @@ -35,6 +37,7 @@ export const reducer = (state, { type, payload, meta }) => { finishedAt: undefined, ...getStatusProps(getIdleStatus(state.error || state.data)), counter: meta.counter, + promise: meta.promise, } case actionTypes.fulfill: return { @@ -44,6 +47,7 @@ export const reducer = (state, { type, payload, meta }) => { error: undefined, finishedAt: new Date(), ...getStatusProps(statusTypes.fulfilled), + promise: meta.promise, } case actionTypes.reject: return { @@ -52,6 +56,7 @@ export const reducer = (state, { type, payload, meta }) => { value: payload, finishedAt: new Date(), ...getStatusProps(statusTypes.rejected), + promise: meta.promise, } default: return state diff --git a/packages/react-async/src/specs.js b/packages/react-async/src/specs.js index a4557c59..51d1c202 100644 --- a/packages/react-async/src/specs.js +++ b/packages/react-async/src/specs.js @@ -12,7 +12,7 @@ export const rejectTo = rejectIn(0) export const sleep = ms => resolveIn(ms)() export const common = Async => () => { - test("passes `data`, `error`, metadata and methods as render props", async () => { + test("passes `data`, `error`, `promise`, metadata and methods as render props", async () => { render( {renderProps => { @@ -28,9 +28,10 @@ export const common = Async => () => { expect(renderProps).toHaveProperty("isRejected") expect(renderProps).toHaveProperty("isSettled") expect(renderProps).toHaveProperty("counter") - expect(renderProps).toHaveProperty("cancel") + expect(renderProps).toHaveProperty("promise") expect(renderProps).toHaveProperty("run") expect(renderProps).toHaveProperty("reload") + expect(renderProps).toHaveProperty("cancel") expect(renderProps).toHaveProperty("setData") expect(renderProps).toHaveProperty("setError") return null @@ -166,6 +167,38 @@ export const withPromise = Async => () => { await findByText("init") await findByText("done") }) + + test("exposes the wrapper promise", async () => { + const onFulfilled = jest.fn() + const onRejected = jest.fn() + const { findByText } = render( + + {({ data, promise }) => { + promise && promise.then(onFulfilled, onRejected) + return data || null + }} + + ) + await findByText("done") + expect(onFulfilled).toHaveBeenCalledWith("done") + expect(onRejected).not.toHaveBeenCalled() + }) + + test("the wrapper promise rejects on error", async () => { + const onFulfilled = jest.fn() + const onRejected = jest.fn() + const { findByText } = render( + + {({ error, promise }) => { + promise && promise.then(onFulfilled, onRejected) + return error ? error.message : null + }} + + ) + await findByText("err") + expect(onFulfilled).not.toHaveBeenCalled() + expect(onRejected).toHaveBeenCalledWith(new Error("err")) + }) } export const withPromiseFn = (Async, abortCtrl) => () => { diff --git a/packages/react-async/src/useAsync.js b/packages/react-async/src/useAsync.js index ffa0616f..de2d00b4 100644 --- a/packages/react-async/src/useAsync.js +++ b/packages/react-async/src/useAsync.js @@ -12,6 +12,7 @@ const useAsync = (arg1, arg2) => { const isMounted = useRef(true) const lastArgs = useRef(undefined) const lastOptions = useRef(undefined) + const lastPromise = useRef(undefined) const abortController = useRef({ abort: noop }) const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ @@ -29,9 +30,10 @@ const useAsync = (arg1, arg2) => { ) const { debugLabel } = options - const getMeta = useCallback(meta => ({ counter: counter.current, debugLabel, ...meta }), [ - debugLabel, - ]) + const getMeta = useCallback( + meta => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta }), + [debugLabel] + ) const setData = useCallback( (data, callback = noop) => { @@ -72,29 +74,26 @@ const useAsync = (arg1, arg2) => { abortController.current = new globalScope.AbortController() } counter.current++ - return new Promise((resolve, reject) => { + return (lastPromise.current = new Promise((resolve, reject) => { if (!isMounted.current) return const executor = () => promiseFn().then(resolve, reject) dispatch({ type: actionTypes.start, payload: executor, meta: getMeta() }) - }) + })) }, [dispatch, getMeta] ) const { promise, promiseFn, initialValue } = options const load = useCallback(() => { - if (promise) { - return start(() => promise).then( - handleResolve(counter.current), - handleReject(counter.current) - ) - } const isPreInitialized = initialValue && counter.current === 0 - if (promiseFn && !isPreInitialized) { - return start(() => promiseFn(lastOptions.current, abortController.current)).then( - handleResolve(counter.current), - handleReject(counter.current) - ) + if (promise) { + start(() => promise) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } else if (promiseFn && !isPreInitialized) { + start(() => promiseFn(lastOptions.current, abortController.current)) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) } }, [start, promise, promiseFn, initialValue, handleResolve, handleReject]) @@ -103,17 +102,16 @@ const useAsync = (arg1, arg2) => { (...args) => { if (deferFn) { lastArgs.current = args - return start(() => deferFn(args, lastOptions.current, abortController.current)).then( - handleResolve(counter.current), - handleReject(counter.current) - ) + start(() => deferFn(args, lastOptions.current, abortController.current)) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) } }, [start, deferFn, handleResolve, handleReject] ) const reload = useCallback(() => { - return lastArgs.current ? run(...lastArgs.current) : load() + lastArgs.current ? run(...lastArgs.current) : load() }, [run, load]) const { onCancel } = options @@ -130,7 +128,9 @@ const useAsync = (arg1, arg2) => { useEffect(() => { if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load() }) - useEffect(() => (lastOptions.current = options) && undefined) + useEffect(() => { + lastOptions.current = options + }) useEffect(() => { if (counter.current) cancel() if (promise || promiseFn) load() diff --git a/packages/react-async/src/useAsync.spec.js b/packages/react-async/src/useAsync.spec.js index bc0cede5..ccf3a104 100644 --- a/packages/react-async/src/useAsync.spec.js +++ b/packages/react-async/src/useAsync.spec.js @@ -93,7 +93,16 @@ describe("useAsync", () => { const [count, setCount] = React.useState(0) const onReject = count === 0 ? onReject1 : onReject2 const { run } = useAsync({ deferFn, onReject }) - return + return ( + + ) } const { getByText } = render() fireEvent.click(getByText("run")) @@ -110,7 +119,7 @@ test("does not return a new `run` function on every render", async () => { const DeleteScheduleForm = () => { const [value, setValue] = React.useState() const { run } = useAsync({ deferFn }) - React.useEffect(() => value && run() && undefined, [value, run]) + React.useEffect(() => value && run(), [value, run]) return } const component =