From 059b1d682df254ad70e0dda8dabc3f292b5ece9c Mon Sep 17 00:00:00 2001 From: Sal Olivares Date: Wed, 16 Oct 2019 20:38:34 -0700 Subject: [PATCH 1/4] update tests pertaining to watch/watchFn to ensure new props are being passed in --- packages/react-async/src/specs.js | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/react-async/src/specs.js b/packages/react-async/src/specs.js index dac4fa74..14054789 100644 --- a/packages/react-async/src/specs.js +++ b/packages/react-async/src/specs.js @@ -286,7 +286,7 @@ export const withPromiseFn = (Async, abortCtrl) => () => { expect(abortCtrl.abort).toHaveBeenCalledTimes(1) }) - test("re-runs the promise when the value of `watch` changes", () => { + test("re-runs the promise with new props when the value of `watch` changes", () => { class Counter extends React.Component { constructor(props) { super(props) @@ -304,19 +304,31 @@ export const withPromiseFn = (Async, abortCtrl) => () => { } const promiseFn = jest.fn().mockReturnValue(resolveTo()) const { getByText } = render( - {count => } + {count => } ) expect(promiseFn).toHaveBeenCalledTimes(1) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 0 }), + expect.any(Object) + ) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 1 }), + expect.any(Object) + ) expect(abortCtrl.abort).toHaveBeenCalled() abortCtrl.abort.mockClear() fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(3) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 2 }), + expect.any(Object) + ) expect(abortCtrl.abort).toHaveBeenCalled() }) - test("re-runs the promise when `watchFn` returns truthy", () => { + test("re-runs the promise with new props when `watchFn` returns truthy", () => { class Counter extends React.Component { constructor(props) { super(props) @@ -338,11 +350,23 @@ export const withPromiseFn = (Async, abortCtrl) => () => { {count => } ) expect(promiseFn).toHaveBeenCalledTimes(1) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 0 }), + expect.any(Object) + ) fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(1) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 0 }), + expect.any(Object) + ) expect(abortCtrl.abort).not.toHaveBeenCalled() fireEvent.click(getByText("increment")) expect(promiseFn).toHaveBeenCalledTimes(2) + expect(promiseFn).toHaveBeenLastCalledWith( + expect.objectContaining({ count: 2 }), + expect.any(Object) + ) expect(abortCtrl.abort).toHaveBeenCalled() }) From d5681a98266b0cf68b0ad2aab18dd2d4d5cfbb18 Mon Sep 17 00:00:00 2001 From: Sal Olivares Date: Sun, 20 Oct 2019 20:18:45 -0700 Subject: [PATCH 2/4] Pass options into promiseFn instead of lastOptions --- packages/react-async/src/useAsync.js | 227 +++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 packages/react-async/src/useAsync.js diff --git a/packages/react-async/src/useAsync.js b/packages/react-async/src/useAsync.js new file mode 100644 index 00000000..c80ddaa1 --- /dev/null +++ b/packages/react-async/src/useAsync.js @@ -0,0 +1,227 @@ +import { useCallback, useDebugValue, useEffect, useMemo, useRef, useReducer } from "react" + +import globalScope from "./globalScope" +import { + neverSettle, + actionTypes, + init, + dispatchMiddleware, + reducer as asyncReducer, +} from "./reducer" + +const noop = () => {} + +const useAsync = (arg1, arg2) => { + const options = typeof arg1 === "function" ? { ...arg2, promiseFn: arg1 } : arg1 + + const counter = useRef(0) + const isMounted = useRef(true) + const lastArgs = useRef(undefined) + const lastOptions = useRef(undefined) + const lastPromise = useRef(neverSettle) + const abortController = useRef({ abort: noop }) + + const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ + const { reducer, dispatcher = devToolsDispatcher } = options + const [state, _dispatch] = useReducer( + reducer ? (state, action) => reducer(state, action, asyncReducer) : asyncReducer, + options, + init + ) + const dispatch = useCallback( + dispatcher + ? action => dispatcher(action, dispatchMiddleware(_dispatch), lastOptions.current) + : dispatchMiddleware(_dispatch), + [dispatcher] + ) + + const { debugLabel } = options + const getMeta = useCallback( + meta => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta }), + [debugLabel] + ) + + const setData = useCallback( + (data, callback = noop) => { + if (isMounted.current) { + dispatch({ type: actionTypes.fulfill, payload: data, meta: getMeta() }) + callback() + } + return data + }, + [dispatch, getMeta] + ) + + const setError = useCallback( + (error, callback = noop) => { + if (isMounted.current) { + dispatch({ type: actionTypes.reject, payload: error, error: true, meta: getMeta() }) + callback() + } + return error + }, + [dispatch, getMeta] + ) + + const { onResolve, onReject } = options + const handleResolve = useCallback( + count => data => count === counter.current && setData(data, () => onResolve && onResolve(data)), + [setData, onResolve] + ) + const handleReject = useCallback( + count => err => count === counter.current && setError(err, () => onReject && onReject(err)), + [setError, onReject] + ) + + const start = useCallback( + promiseFn => { + if ("AbortController" in globalScope) { + abortController.current.abort() + abortController.current = new globalScope.AbortController() + } + counter.current++ + 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(() => { + const isPreInitialized = initialValue && counter.current === 0 + if (promise) { + start(() => promise) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } else if (promiseFn && !isPreInitialized) { + start(() => promiseFn(options, abortController.current)) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } + }, [initialValue, promise, promiseFn, start, handleResolve, handleReject, options]) + + const { deferFn } = options + const run = useCallback( + (...args) => { + if (deferFn) { + lastArgs.current = args + start(() => deferFn(args, lastOptions.current, abortController.current)) + .then(handleResolve(counter.current)) + .catch(handleReject(counter.current)) + } + }, + [start, deferFn, handleResolve, handleReject] + ) + + const reload = useCallback(() => { + lastArgs.current ? run(...lastArgs.current) : load() + }, [run, load]) + + const { onCancel } = options + const cancel = useCallback(() => { + onCancel && onCancel() + counter.current++ + abortController.current.abort() + isMounted.current && dispatch({ type: actionTypes.cancel, meta: getMeta() }) + }, [onCancel, dispatch, getMeta]) + + /* These effects should only be triggered on changes to specific props */ + /* eslint-disable react-hooks/exhaustive-deps */ + const { watch, watchFn } = options + useEffect(() => { + if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load() + }) + useEffect(() => { + lastOptions.current = options + }, [options]) + useEffect(() => { + if (counter.current) cancel() + if (promise || promiseFn) load() + }, [promise, promiseFn, watch]) + useEffect(() => () => (isMounted.current = false), []) + useEffect(() => () => cancel(), []) + /* eslint-enable react-hooks/exhaustive-deps */ + + useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`) + + if (options.suspense && state.isPending && lastPromise.current !== neverSettle) { + // Rely on Suspense to handle the loading state + throw lastPromise.current + } + + return useMemo( + () => ({ + ...state, + run, + reload, + cancel, + setData, + setError, + }), + [state, run, reload, cancel, setData, setError] + ) +} + +export class FetchError extends Error { + constructor(response) { + super(`${response.status} ${response.statusText}`) + /* istanbul ignore next */ + if (Object.setPrototypeOf) { + // Not available in IE 10, but can be polyfilled + Object.setPrototypeOf(this, FetchError.prototype) + } + this.response = response + } +} + +const parseResponse = (accept, json) => res => { + if (!res.ok) return Promise.reject(new FetchError(res)) + if (typeof json === "boolean") return json ? res.json() : res + return accept === "application/json" ? res.json() : res +} + +const useAsyncFetch = (resource, init, { defer, json, ...options } = {}) => { + const method = resource.method || (init && init.method) + const headers = resource.headers || (init && init.headers) || {} + const accept = headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept")) + const doFetch = (resource, init) => + globalScope.fetch(resource, init).then(parseResponse(accept, json)) + const isDefer = + typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method) !== -1 + const fn = isDefer ? "deferFn" : "promiseFn" + const identity = JSON.stringify({ resource, init, isDefer }) + const state = useAsync({ + ...options, + [fn]: useCallback( + (arg1, arg2, arg3) => { + const [override, signal] = isDefer ? [arg1[0], arg3.signal] : [undefined, arg2.signal] + const isEvent = typeof override === "object" && "preventDefault" in override + if (!override || isEvent) { + return doFetch(resource, { signal, ...init }) + } + if (typeof override === "function") { + const { resource: runResource, ...runInit } = override({ resource, signal, ...init }) + return doFetch(runResource || resource, { signal, ...runInit }) + } + const { resource: runResource, ...runInit } = override + return doFetch(runResource || resource, { signal, ...init, ...runInit }) + }, + [identity] // eslint-disable-line react-hooks/exhaustive-deps + ), + }) + useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`) + return state +} + +/* istanbul ignore next */ +const unsupported = () => { + throw new Error( + "useAsync requires React v16.8 or up. Upgrade your React version or use the component instead." + ) +} + +export default useEffect ? useAsync : unsupported +export const useFetch = useEffect ? useAsyncFetch : unsupported From 85ca6a31280d9296639a39a692df68ddb73bd154 Mon Sep 17 00:00:00 2001 From: Sal Olivares Date: Wed, 30 Oct 2019 20:51:33 -0700 Subject: [PATCH 3/4] alternate solution: set lastOptions.current on watchfn --- packages/react-async/src/useAsync.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-async/src/useAsync.js b/packages/react-async/src/useAsync.js index c80ddaa1..456cd5e6 100644 --- a/packages/react-async/src/useAsync.js +++ b/packages/react-async/src/useAsync.js @@ -97,11 +97,11 @@ const useAsync = (arg1, arg2) => { .then(handleResolve(counter.current)) .catch(handleReject(counter.current)) } else if (promiseFn && !isPreInitialized) { - start(() => promiseFn(options, abortController.current)) + start(() => promiseFn(lastOptions.current, abortController.current)) .then(handleResolve(counter.current)) .catch(handleReject(counter.current)) } - }, [initialValue, promise, promiseFn, start, handleResolve, handleReject, options]) + }, [start, promise, promiseFn, initialValue, handleResolve, handleReject]) const { deferFn } = options const run = useCallback( @@ -132,7 +132,10 @@ const useAsync = (arg1, arg2) => { /* eslint-disable react-hooks/exhaustive-deps */ const { watch, watchFn } = options useEffect(() => { - if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load() + if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) { + lastOptions.current = options + load() + } }) useEffect(() => { lastOptions.current = options From dffd39f58f574f7cf90043118782a9c5ca47a5a8 Mon Sep 17 00:00:00 2001 From: Sal Olivares Date: Sun, 10 Nov 2019 12:02:34 -0800 Subject: [PATCH 4/4] bring in latest typescript changes --- packages/react-async/src/useAsync.js | 230 -------------------------- packages/react-async/src/useAsync.tsx | 5 +- 2 files changed, 4 insertions(+), 231 deletions(-) delete mode 100644 packages/react-async/src/useAsync.js diff --git a/packages/react-async/src/useAsync.js b/packages/react-async/src/useAsync.js deleted file mode 100644 index 456cd5e6..00000000 --- a/packages/react-async/src/useAsync.js +++ /dev/null @@ -1,230 +0,0 @@ -import { useCallback, useDebugValue, useEffect, useMemo, useRef, useReducer } from "react" - -import globalScope from "./globalScope" -import { - neverSettle, - actionTypes, - init, - dispatchMiddleware, - reducer as asyncReducer, -} from "./reducer" - -const noop = () => {} - -const useAsync = (arg1, arg2) => { - const options = typeof arg1 === "function" ? { ...arg2, promiseFn: arg1 } : arg1 - - const counter = useRef(0) - const isMounted = useRef(true) - const lastArgs = useRef(undefined) - const lastOptions = useRef(undefined) - const lastPromise = useRef(neverSettle) - const abortController = useRef({ abort: noop }) - - const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ - const { reducer, dispatcher = devToolsDispatcher } = options - const [state, _dispatch] = useReducer( - reducer ? (state, action) => reducer(state, action, asyncReducer) : asyncReducer, - options, - init - ) - const dispatch = useCallback( - dispatcher - ? action => dispatcher(action, dispatchMiddleware(_dispatch), lastOptions.current) - : dispatchMiddleware(_dispatch), - [dispatcher] - ) - - const { debugLabel } = options - const getMeta = useCallback( - meta => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta }), - [debugLabel] - ) - - const setData = useCallback( - (data, callback = noop) => { - if (isMounted.current) { - dispatch({ type: actionTypes.fulfill, payload: data, meta: getMeta() }) - callback() - } - return data - }, - [dispatch, getMeta] - ) - - const setError = useCallback( - (error, callback = noop) => { - if (isMounted.current) { - dispatch({ type: actionTypes.reject, payload: error, error: true, meta: getMeta() }) - callback() - } - return error - }, - [dispatch, getMeta] - ) - - const { onResolve, onReject } = options - const handleResolve = useCallback( - count => data => count === counter.current && setData(data, () => onResolve && onResolve(data)), - [setData, onResolve] - ) - const handleReject = useCallback( - count => err => count === counter.current && setError(err, () => onReject && onReject(err)), - [setError, onReject] - ) - - const start = useCallback( - promiseFn => { - if ("AbortController" in globalScope) { - abortController.current.abort() - abortController.current = new globalScope.AbortController() - } - counter.current++ - 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(() => { - const isPreInitialized = initialValue && counter.current === 0 - 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]) - - const { deferFn } = options - const run = useCallback( - (...args) => { - if (deferFn) { - lastArgs.current = args - start(() => deferFn(args, lastOptions.current, abortController.current)) - .then(handleResolve(counter.current)) - .catch(handleReject(counter.current)) - } - }, - [start, deferFn, handleResolve, handleReject] - ) - - const reload = useCallback(() => { - lastArgs.current ? run(...lastArgs.current) : load() - }, [run, load]) - - const { onCancel } = options - const cancel = useCallback(() => { - onCancel && onCancel() - counter.current++ - abortController.current.abort() - isMounted.current && dispatch({ type: actionTypes.cancel, meta: getMeta() }) - }, [onCancel, dispatch, getMeta]) - - /* These effects should only be triggered on changes to specific props */ - /* eslint-disable react-hooks/exhaustive-deps */ - const { watch, watchFn } = options - useEffect(() => { - if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) { - lastOptions.current = options - load() - } - }) - useEffect(() => { - lastOptions.current = options - }, [options]) - useEffect(() => { - if (counter.current) cancel() - if (promise || promiseFn) load() - }, [promise, promiseFn, watch]) - useEffect(() => () => (isMounted.current = false), []) - useEffect(() => () => cancel(), []) - /* eslint-enable react-hooks/exhaustive-deps */ - - useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`) - - if (options.suspense && state.isPending && lastPromise.current !== neverSettle) { - // Rely on Suspense to handle the loading state - throw lastPromise.current - } - - return useMemo( - () => ({ - ...state, - run, - reload, - cancel, - setData, - setError, - }), - [state, run, reload, cancel, setData, setError] - ) -} - -export class FetchError extends Error { - constructor(response) { - super(`${response.status} ${response.statusText}`) - /* istanbul ignore next */ - if (Object.setPrototypeOf) { - // Not available in IE 10, but can be polyfilled - Object.setPrototypeOf(this, FetchError.prototype) - } - this.response = response - } -} - -const parseResponse = (accept, json) => res => { - if (!res.ok) return Promise.reject(new FetchError(res)) - if (typeof json === "boolean") return json ? res.json() : res - return accept === "application/json" ? res.json() : res -} - -const useAsyncFetch = (resource, init, { defer, json, ...options } = {}) => { - const method = resource.method || (init && init.method) - const headers = resource.headers || (init && init.headers) || {} - const accept = headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept")) - const doFetch = (resource, init) => - globalScope.fetch(resource, init).then(parseResponse(accept, json)) - const isDefer = - typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method) !== -1 - const fn = isDefer ? "deferFn" : "promiseFn" - const identity = JSON.stringify({ resource, init, isDefer }) - const state = useAsync({ - ...options, - [fn]: useCallback( - (arg1, arg2, arg3) => { - const [override, signal] = isDefer ? [arg1[0], arg3.signal] : [undefined, arg2.signal] - const isEvent = typeof override === "object" && "preventDefault" in override - if (!override || isEvent) { - return doFetch(resource, { signal, ...init }) - } - if (typeof override === "function") { - const { resource: runResource, ...runInit } = override({ resource, signal, ...init }) - return doFetch(runResource || resource, { signal, ...runInit }) - } - const { resource: runResource, ...runInit } = override - return doFetch(runResource || resource, { signal, ...init, ...runInit }) - }, - [identity] // eslint-disable-line react-hooks/exhaustive-deps - ), - }) - useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`) - return state -} - -/* istanbul ignore next */ -const unsupported = () => { - throw new Error( - "useAsync requires React v16.8 or up. Upgrade your React version or use the component instead." - ) -} - -export default useEffect ? useAsync : unsupported -export const useFetch = useEffect ? useAsyncFetch : unsupported diff --git a/packages/react-async/src/useAsync.tsx b/packages/react-async/src/useAsync.tsx index dc86bfb9..62f3cc8b 100644 --- a/packages/react-async/src/useAsync.tsx +++ b/packages/react-async/src/useAsync.tsx @@ -196,7 +196,10 @@ function useAsync( /* eslint-disable react-hooks/exhaustive-deps */ const { watch, watchFn } = options useEffect(() => { - if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load() + if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) { + lastOptions.current = options + load() + } }) useEffect(() => { lastOptions.current = options