|
| 1 | +import { useCallback, useDebugValue, useEffect, useMemo, useRef, useReducer } from "react" |
| 2 | + |
| 3 | +import globalScope from "./globalScope" |
| 4 | +import { |
| 5 | + neverSettle, |
| 6 | + actionTypes, |
| 7 | + init, |
| 8 | + dispatchMiddleware, |
| 9 | + reducer as asyncReducer, |
| 10 | +} from "./reducer" |
| 11 | + |
| 12 | +const noop = () => {} |
| 13 | + |
| 14 | +const useAsync = (arg1, arg2) => { |
| 15 | + const options = typeof arg1 === "function" ? { ...arg2, promiseFn: arg1 } : arg1 |
| 16 | + |
| 17 | + const counter = useRef(0) |
| 18 | + const isMounted = useRef(true) |
| 19 | + const lastArgs = useRef(undefined) |
| 20 | + const lastOptions = useRef(undefined) |
| 21 | + const lastPromise = useRef(neverSettle) |
| 22 | + const abortController = useRef({ abort: noop }) |
| 23 | + |
| 24 | + const { devToolsDispatcher } = globalScope.__REACT_ASYNC__ |
| 25 | + const { reducer, dispatcher = devToolsDispatcher } = options |
| 26 | + const [state, _dispatch] = useReducer( |
| 27 | + reducer ? (state, action) => reducer(state, action, asyncReducer) : asyncReducer, |
| 28 | + options, |
| 29 | + init |
| 30 | + ) |
| 31 | + const dispatch = useCallback( |
| 32 | + dispatcher |
| 33 | + ? action => dispatcher(action, dispatchMiddleware(_dispatch), lastOptions.current) |
| 34 | + : dispatchMiddleware(_dispatch), |
| 35 | + [dispatcher] |
| 36 | + ) |
| 37 | + |
| 38 | + const { debugLabel } = options |
| 39 | + const getMeta = useCallback( |
| 40 | + meta => ({ counter: counter.current, promise: lastPromise.current, debugLabel, ...meta }), |
| 41 | + [debugLabel] |
| 42 | + ) |
| 43 | + |
| 44 | + const setData = useCallback( |
| 45 | + (data, callback = noop) => { |
| 46 | + if (isMounted.current) { |
| 47 | + dispatch({ type: actionTypes.fulfill, payload: data, meta: getMeta() }) |
| 48 | + callback() |
| 49 | + } |
| 50 | + return data |
| 51 | + }, |
| 52 | + [dispatch, getMeta] |
| 53 | + ) |
| 54 | + |
| 55 | + const setError = useCallback( |
| 56 | + (error, callback = noop) => { |
| 57 | + if (isMounted.current) { |
| 58 | + dispatch({ type: actionTypes.reject, payload: error, error: true, meta: getMeta() }) |
| 59 | + callback() |
| 60 | + } |
| 61 | + return error |
| 62 | + }, |
| 63 | + [dispatch, getMeta] |
| 64 | + ) |
| 65 | + |
| 66 | + const { onResolve, onReject } = options |
| 67 | + const handleResolve = useCallback( |
| 68 | + count => data => count === counter.current && setData(data, () => onResolve && onResolve(data)), |
| 69 | + [setData, onResolve] |
| 70 | + ) |
| 71 | + const handleReject = useCallback( |
| 72 | + count => err => count === counter.current && setError(err, () => onReject && onReject(err)), |
| 73 | + [setError, onReject] |
| 74 | + ) |
| 75 | + |
| 76 | + const start = useCallback( |
| 77 | + promiseFn => { |
| 78 | + if ("AbortController" in globalScope) { |
| 79 | + abortController.current.abort() |
| 80 | + abortController.current = new globalScope.AbortController() |
| 81 | + } |
| 82 | + counter.current++ |
| 83 | + return (lastPromise.current = new Promise((resolve, reject) => { |
| 84 | + if (!isMounted.current) return |
| 85 | + const executor = () => promiseFn().then(resolve, reject) |
| 86 | + dispatch({ type: actionTypes.start, payload: executor, meta: getMeta() }) |
| 87 | + })) |
| 88 | + }, |
| 89 | + [dispatch, getMeta] |
| 90 | + ) |
| 91 | + |
| 92 | + const { promise, promiseFn, initialValue } = options |
| 93 | + const load = useCallback(() => { |
| 94 | + const isPreInitialized = initialValue && counter.current === 0 |
| 95 | + if (promise) { |
| 96 | + start(() => promise) |
| 97 | + .then(handleResolve(counter.current)) |
| 98 | + .catch(handleReject(counter.current)) |
| 99 | + } else if (promiseFn && !isPreInitialized) { |
| 100 | + start(() => promiseFn(options, abortController.current)) |
| 101 | + .then(handleResolve(counter.current)) |
| 102 | + .catch(handleReject(counter.current)) |
| 103 | + } |
| 104 | + }, [initialValue, promise, promiseFn, start, handleResolve, handleReject, options]) |
| 105 | + |
| 106 | + const { deferFn } = options |
| 107 | + const run = useCallback( |
| 108 | + (...args) => { |
| 109 | + if (deferFn) { |
| 110 | + lastArgs.current = args |
| 111 | + start(() => deferFn(args, lastOptions.current, abortController.current)) |
| 112 | + .then(handleResolve(counter.current)) |
| 113 | + .catch(handleReject(counter.current)) |
| 114 | + } |
| 115 | + }, |
| 116 | + [start, deferFn, handleResolve, handleReject] |
| 117 | + ) |
| 118 | + |
| 119 | + const reload = useCallback(() => { |
| 120 | + lastArgs.current ? run(...lastArgs.current) : load() |
| 121 | + }, [run, load]) |
| 122 | + |
| 123 | + const { onCancel } = options |
| 124 | + const cancel = useCallback(() => { |
| 125 | + onCancel && onCancel() |
| 126 | + counter.current++ |
| 127 | + abortController.current.abort() |
| 128 | + isMounted.current && dispatch({ type: actionTypes.cancel, meta: getMeta() }) |
| 129 | + }, [onCancel, dispatch, getMeta]) |
| 130 | + |
| 131 | + /* These effects should only be triggered on changes to specific props */ |
| 132 | + /* eslint-disable react-hooks/exhaustive-deps */ |
| 133 | + const { watch, watchFn } = options |
| 134 | + useEffect(() => { |
| 135 | + if (watchFn && lastOptions.current && watchFn(options, lastOptions.current)) load() |
| 136 | + }) |
| 137 | + useEffect(() => { |
| 138 | + lastOptions.current = options |
| 139 | + }, [options]) |
| 140 | + useEffect(() => { |
| 141 | + if (counter.current) cancel() |
| 142 | + if (promise || promiseFn) load() |
| 143 | + }, [promise, promiseFn, watch]) |
| 144 | + useEffect(() => () => (isMounted.current = false), []) |
| 145 | + useEffect(() => () => cancel(), []) |
| 146 | + /* eslint-enable react-hooks/exhaustive-deps */ |
| 147 | + |
| 148 | + useDebugValue(state, ({ status }) => `[${counter.current}] ${status}`) |
| 149 | + |
| 150 | + if (options.suspense && state.isPending && lastPromise.current !== neverSettle) { |
| 151 | + // Rely on Suspense to handle the loading state |
| 152 | + throw lastPromise.current |
| 153 | + } |
| 154 | + |
| 155 | + return useMemo( |
| 156 | + () => ({ |
| 157 | + ...state, |
| 158 | + run, |
| 159 | + reload, |
| 160 | + cancel, |
| 161 | + setData, |
| 162 | + setError, |
| 163 | + }), |
| 164 | + [state, run, reload, cancel, setData, setError] |
| 165 | + ) |
| 166 | +} |
| 167 | + |
| 168 | +export class FetchError extends Error { |
| 169 | + constructor(response) { |
| 170 | + super(`${response.status} ${response.statusText}`) |
| 171 | + /* istanbul ignore next */ |
| 172 | + if (Object.setPrototypeOf) { |
| 173 | + // Not available in IE 10, but can be polyfilled |
| 174 | + Object.setPrototypeOf(this, FetchError.prototype) |
| 175 | + } |
| 176 | + this.response = response |
| 177 | + } |
| 178 | +} |
| 179 | + |
| 180 | +const parseResponse = (accept, json) => res => { |
| 181 | + if (!res.ok) return Promise.reject(new FetchError(res)) |
| 182 | + if (typeof json === "boolean") return json ? res.json() : res |
| 183 | + return accept === "application/json" ? res.json() : res |
| 184 | +} |
| 185 | + |
| 186 | +const useAsyncFetch = (resource, init, { defer, json, ...options } = {}) => { |
| 187 | + const method = resource.method || (init && init.method) |
| 188 | + const headers = resource.headers || (init && init.headers) || {} |
| 189 | + const accept = headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept")) |
| 190 | + const doFetch = (resource, init) => |
| 191 | + globalScope.fetch(resource, init).then(parseResponse(accept, json)) |
| 192 | + const isDefer = |
| 193 | + typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method) !== -1 |
| 194 | + const fn = isDefer ? "deferFn" : "promiseFn" |
| 195 | + const identity = JSON.stringify({ resource, init, isDefer }) |
| 196 | + const state = useAsync({ |
| 197 | + ...options, |
| 198 | + [fn]: useCallback( |
| 199 | + (arg1, arg2, arg3) => { |
| 200 | + const [override, signal] = isDefer ? [arg1[0], arg3.signal] : [undefined, arg2.signal] |
| 201 | + const isEvent = typeof override === "object" && "preventDefault" in override |
| 202 | + if (!override || isEvent) { |
| 203 | + return doFetch(resource, { signal, ...init }) |
| 204 | + } |
| 205 | + if (typeof override === "function") { |
| 206 | + const { resource: runResource, ...runInit } = override({ resource, signal, ...init }) |
| 207 | + return doFetch(runResource || resource, { signal, ...runInit }) |
| 208 | + } |
| 209 | + const { resource: runResource, ...runInit } = override |
| 210 | + return doFetch(runResource || resource, { signal, ...init, ...runInit }) |
| 211 | + }, |
| 212 | + [identity] // eslint-disable-line react-hooks/exhaustive-deps |
| 213 | + ), |
| 214 | + }) |
| 215 | + useDebugValue(state, ({ counter, status }) => `[${counter}] ${status}`) |
| 216 | + return state |
| 217 | +} |
| 218 | + |
| 219 | +/* istanbul ignore next */ |
| 220 | +const unsupported = () => { |
| 221 | + throw new Error( |
| 222 | + "useAsync requires React v16.8 or up. Upgrade your React version or use the <Async> component instead." |
| 223 | + ) |
| 224 | +} |
| 225 | + |
| 226 | +export default useEffect ? useAsync : unsupported |
| 227 | +export const useFetch = useEffect ? useAsyncFetch : unsupported |
0 commit comments