From be41df12579b68b989e0cf96270eaa39aaaeb0ba Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Wed, 18 Aug 2021 23:18:47 +0200 Subject: [PATCH 1/6] refactor state and revalidation logic --- .eslintrc | 3 +- src/types.ts | 29 ++++++++---- src/use-swr.ts | 116 ++++++++++++++++++++++++--------------------- src/utils/cache.ts | 40 +++++++++++----- 4 files changed, 112 insertions(+), 76 deletions(-) diff --git a/.eslintrc b/.eslintrc index 22512182f..c88be12c7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -33,7 +33,8 @@ }, "rules": { "func-names": [2, "as-needed"], - "no-shadow": 2, + "no-shadow": 0, + "@typescript-eslint/no-shadow": 2, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/no-use-before-define": 0, "@typescript-eslint/ban-ts-ignore": 0, diff --git a/src/types.ts b/src/types.ts index 368dc2939..1d27f4385 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,14 +84,6 @@ export type Middleware = (useSWRNext: SWRHook) => SWRHookWithMiddleware export type ValueKey = string | any[] | null -export type Updater = ( - shouldRevalidate?: boolean, - data?: Data, - error?: Error, - shouldDedupe?: boolean, - dedupe?: boolean -) => boolean | Promise - export type MutatorCallback = ( currentValue?: Data ) => Promise | undefined | Data @@ -167,6 +159,27 @@ export type Revalidator = ( revalidateOpts?: RevalidatorOptions ) => Promise | void +export const enum RevalidateEvent { + FOCUS_EVENT = 0, + RECONNECT_EVENT = 1, + MUTATE_EVENT = 2 +} + +type RevalidateCallbackReturnType = { + [RevalidateEvent.FOCUS_EVENT]: void + [RevalidateEvent.RECONNECT_EVENT]: void + [RevalidateEvent.MUTATE_EVENT]: Promise +} +export type RevalidateCallback = ( + type: K +) => RevalidateCallbackReturnType[K] + +export type StateUpdateCallback = ( + data?: Data, + error?: Error, + isValidating?: boolean +) => void + export interface Cache { get(key: Key): Data | null | undefined set(key: Key, value: Data): void diff --git a/src/use-swr.ts b/src/use-swr.ts index bbffe36ba..7e94468ef 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -15,13 +15,14 @@ import { MutatorCallback, SWRResponse, RevalidatorOptions, - Updater, Configuration, SWRConfiguration, Cache, ScopedMutator, SWRHook, - Revalidator, + RevalidateCallback, + StateUpdateCallback, + RevalidateEvent, ProviderOptions } from './types' @@ -36,17 +37,27 @@ const broadcastState: Broadcaster = ( isValidating, shouldRevalidate = false ) => { - const [, , CACHE_REVALIDATORS] = SWRGlobalState.get(cache) as GlobalState - const updaters = CACHE_REVALIDATORS[key] - const promises = [] + const [EVENT_REVALIDATORS, STATE_UPDATERS] = SWRGlobalState.get( + cache + ) as GlobalState + const revalidators = EVENT_REVALIDATORS[key] + const updaters = STATE_UPDATERS[key] + + // Always update states of all hooks. if (updaters) { for (let i = 0; i < updaters.length; ++i) { - promises.push( - updaters[i](shouldRevalidate, data, error, isValidating, i > 0) - ) + updaters[i](data, error, isValidating) } } - return Promise.all(promises).then(() => cache.get(key)) + + // If we also need to revalidate, only do it for the first hook. + if (shouldRevalidate && revalidators && revalidators[0]) { + return revalidators[0](RevalidateEvent.MUTATE_EVENT).then(() => + cache.get(key) + ) + } + + return Promise.resolve(cache.get(key)) } const internalMutate = async ( @@ -58,7 +69,7 @@ const internalMutate = async ( const [key, , keyErr] = serialize(_key) if (!key) return UNDEFINED - const [, , , MUTATION_TS, MUTATION_END_TS] = SWRGlobalState.get( + const [, , MUTATION_TS, MUTATION_END_TS] = SWRGlobalState.get( cache ) as GlobalState @@ -137,21 +148,21 @@ const internalMutate = async ( }) } -// Add a callback function to a list of keyed revalidation functions and returns -// the unregister function. -const addRevalidator = ( - revalidators: Record)[]>, +// Add a callback function to a list of keyed callback functions and return +// the unsubscribe function. +const subscribeCallback = ( key: string, - callback: Revalidator | Updater + callbacks: Record, + callback: RevalidateCallback | StateUpdateCallback ) => { - if (!revalidators[key]) { - revalidators[key] = [callback] + if (!callbacks[key]) { + callbacks[key] = [callback] } else { - revalidators[key].push(callback) + callbacks[key].push(callback) } return () => { - const keyedRevalidators = revalidators[key] + const keyedRevalidators = callbacks[key] const index = keyedRevalidators.indexOf(callback) if (index >= 0) { @@ -180,9 +191,8 @@ export const useSWRHandler = ( } = config const [ - FOCUS_REVALIDATORS, - RECONNECT_REVALIDATORS, - CACHE_REVALIDATORS, + EVENT_REVALIDATORS, + STATE_UPDATERS, MUTATION_TS, MUTATION_END_TS, CONCURRENT_PROMISES, @@ -461,33 +471,12 @@ export const useSWRHandler = ( const isActive = () => configRef.current.isVisible() && configRef.current.isOnline() - // Add event listeners. - let nextFocusRevalidatedAt = 0 - const onFocus = () => { - const now = Date.now() - if ( - configRef.current.revalidateOnFocus && - now > nextFocusRevalidatedAt && - isActive() - ) { - nextFocusRevalidatedAt = now + configRef.current.focusThrottleInterval - softRevalidate() - } - } - - const onReconnect: Revalidator = () => { - if (configRef.current.revalidateOnReconnect && isActive()) { - softRevalidate() - } - } - - // Register global cache update listener. - const onUpdate: Updater = ( - shouldRevalidate = true, + // Expose state updater to global event listeners. So we can update hook's + // internal state from the outside. + const onStateUpdate: StateUpdateCallback = ( updatedData, updatedError, - updatedIsValidating, - dedupe = true + updatedIsValidating ) => { setState({ error: updatedError, @@ -499,16 +488,34 @@ export const useSWRHandler = ( } : null) }) + } - if (shouldRevalidate) { - return (dedupe ? softRevalidate : revalidate)() + // Expose revalidators to global event listeners. So we can trigger + // revalidation from the outside. + let nextFocusRevalidatedAt = 0 + const onRevalidate = (type: RevalidateEvent) => { + if (type === RevalidateEvent.FOCUS_EVENT) { + const now = Date.now() + if ( + configRef.current.revalidateOnFocus && + now > nextFocusRevalidatedAt && + isActive() + ) { + nextFocusRevalidatedAt = now + configRef.current.focusThrottleInterval + softRevalidate() + } + } else if (type === RevalidateEvent.RECONNECT_EVENT) { + if (configRef.current.revalidateOnReconnect && isActive()) { + softRevalidate() + } + } else if (type === RevalidateEvent.MUTATE_EVENT) { + return revalidate() } - return false + return UNDEFINED } - const unsubFocus = addRevalidator(FOCUS_REVALIDATORS, key, onFocus) - const unsubReconn = addRevalidator(RECONNECT_REVALIDATORS, key, onReconnect) - const unsubUpdate = addRevalidator(CACHE_REVALIDATORS, key, onUpdate) + const unsubUpdate = subscribeCallback(key, STATE_UPDATERS, onStateUpdate) + const unsubEvents = subscribeCallback(key, EVENT_REVALIDATORS, onRevalidate) // Mark the component as mounted and update corresponding refs. unmountedRef.current = false @@ -543,9 +550,8 @@ export const useSWRHandler = ( // Mark it as unmounted. unmountedRef.current = true - unsubFocus() - unsubReconn() unsubUpdate() + unsubEvents() } }, [key, revalidate]) diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 3a6f621c8..c13ceabc7 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -2,12 +2,17 @@ import { provider as defaultProvider } from './web-preset' import { IS_SERVER } from './env' import { UNDEFINED } from './helper' -import { Cache, Revalidator, Updater, ProviderOptions } from '../types' +import { + Cache, + RevalidateCallback, + StateUpdateCallback, + ProviderOptions, + RevalidateEvent +} from '../types' export type GlobalState = [ - Record, // FOCUS_REVALIDATORS - Record, // RECONNECT_REVALIDATORS - Record)[]>, // CACHE_REVALIDATORS + Record, // EVENT_REVALIDATORS + Record, // STATE_UPDATERS Record, // MUTATION_TS Record, // MUTATION_END_TS Record, // CONCURRENT_PROMISES @@ -17,19 +22,30 @@ export type GlobalState = [ // Global state used to deduplicate requests and store listeners export const SWRGlobalState = new WeakMap() -function revalidateAllKeys(revalidators: Record) { +function revalidateAllKeys( + revalidators: Record, + type: RevalidateEvent +) { for (const key in revalidators) { - if (revalidators[key][0]) revalidators[key][0]() + if (revalidators[key][0]) revalidators[key][0](type) } } function setupGlobalEvents(cache: Cache, options: ProviderOptions) { - const [FOCUS_REVALIDATORS, RECONNECT_REVALIDATORS] = SWRGlobalState.get( - cache - ) as GlobalState - options.setupOnFocus(revalidateAllKeys.bind(UNDEFINED, FOCUS_REVALIDATORS)) + const [EVENT_REVALIDATORS] = SWRGlobalState.get(cache) as GlobalState + options.setupOnFocus( + revalidateAllKeys.bind( + UNDEFINED, + EVENT_REVALIDATORS, + RevalidateEvent.FOCUS_EVENT + ) + ) options.setupOnReconnect( - revalidateAllKeys.bind(UNDEFINED, RECONNECT_REVALIDATORS) + revalidateAllKeys.bind( + UNDEFINED, + EVENT_REVALIDATORS, + RevalidateEvent.RECONNECT_EVENT + ) ) } @@ -39,7 +55,7 @@ export function wrapCache( ): Cache { // Initialize global state for the specific data storage that will be used to // deduplicate requests and store listeners. - SWRGlobalState.set(provider, [{}, {}, {}, {}, {}, {}, {}]) + SWRGlobalState.set(provider, [{}, {}, {}, {}, {}, {}]) // Setup DOM events listeners for `focus` and `reconnect` actions. if (!IS_SERVER) { From e4c85cbf79ffdbffcd04e4e9b0d7b180e8dc8475 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Wed, 18 Aug 2021 23:51:41 +0200 Subject: [PATCH 2/6] refactor cache --- src/utils/cache.ts | 38 ++++++++++++++++++-------------------- src/utils/config.ts | 12 ++++++++---- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/utils/cache.ts b/src/utils/cache.ts index c13ceabc7..e737a76de 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -31,35 +31,33 @@ function revalidateAllKeys( } } -function setupGlobalEvents(cache: Cache, options: ProviderOptions) { - const [EVENT_REVALIDATORS] = SWRGlobalState.get(cache) as GlobalState - options.setupOnFocus( - revalidateAllKeys.bind( - UNDEFINED, - EVENT_REVALIDATORS, - RevalidateEvent.FOCUS_EVENT - ) - ) - options.setupOnReconnect( - revalidateAllKeys.bind( - UNDEFINED, - EVENT_REVALIDATORS, - RevalidateEvent.RECONNECT_EVENT - ) - ) -} - export function wrapCache( provider: Cache, options?: Partial ): Cache { + const EVENT_REVALIDATORS = {} + const opts = { ...defaultProvider, ...options } + // Initialize global state for the specific data storage that will be used to // deduplicate requests and store listeners. - SWRGlobalState.set(provider, [{}, {}, {}, {}, {}, {}]) + SWRGlobalState.set(provider, [EVENT_REVALIDATORS, {}, {}, {}, {}, {}]) // Setup DOM events listeners for `focus` and `reconnect` actions. if (!IS_SERVER) { - setupGlobalEvents(provider, { ...defaultProvider, ...options }) + opts.setupOnFocus( + revalidateAllKeys.bind( + UNDEFINED, + EVENT_REVALIDATORS, + RevalidateEvent.FOCUS_EVENT + ) + ) + opts.setupOnReconnect( + revalidateAllKeys.bind( + UNDEFINED, + EVENT_REVALIDATORS, + RevalidateEvent.RECONNECT_EVENT + ) + ) } // We might want to inject an extra layer on top of `provider` in the future, diff --git a/src/utils/config.ts b/src/utils/config.ts index 2672a33d7..273b5556f 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -21,14 +21,18 @@ function onErrorRetry( const maxRetryCount = config.errorRetryCount const currentRetryCount = opts.retryCount + + // Exponential backoff + const timeout = + ~~( + (Math.random() + 0.5) * + (1 << (currentRetryCount < 8 ? currentRetryCount : 8)) + ) * config.errorRetryInterval + if (maxRetryCount !== UNDEFINED && currentRetryCount > maxRetryCount) { return } - // Exponential backoff - const timeout = - ~~((Math.random() + 0.5) * (1 << Math.min(currentRetryCount, 8))) * - config.errorRetryInterval setTimeout(revalidate, timeout, opts) } From dca0e6000064a7f7793f6177f094cf07e1b4a2cf Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 19 Aug 2021 00:04:38 +0200 Subject: [PATCH 3/6] decouple utils --- src/use-swr.ts | 35 ++++++----------------------------- src/utils/subscribe-key.ts | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 29 deletions(-) create mode 100644 src/utils/subscribe-key.ts diff --git a/src/use-swr.ts b/src/use-swr.ts index 7e94468ef..7836b570d 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -7,6 +7,7 @@ import { isUndefined, UNDEFINED } from './utils/helper' import ConfigProvider from './utils/config-context' import useStateWithDeps from './utils/state' import withArgs from './utils/resolve-args' +import { subscribeCallback } from './utils/subscribe-key' import { State, Broadcaster, @@ -20,12 +21,13 @@ import { Cache, ScopedMutator, SWRHook, - RevalidateCallback, StateUpdateCallback, RevalidateEvent, ProviderOptions } from './types' +const WITH_DEDUPE = { dedupe: true } + // Generate strictly increasing timestamps. let __timestamp = 0 @@ -148,31 +150,6 @@ const internalMutate = async ( }) } -// Add a callback function to a list of keyed callback functions and return -// the unsubscribe function. -const subscribeCallback = ( - key: string, - callbacks: Record, - callback: RevalidateCallback | StateUpdateCallback -) => { - if (!callbacks[key]) { - callbacks[key] = [callback] - } else { - callbacks[key].push(callback) - } - - return () => { - const keyedRevalidators = callbacks[key] - const index = keyedRevalidators.indexOf(callback) - - if (index >= 0) { - // O(1): faster than splice - keyedRevalidators[index] = keyedRevalidators[keyedRevalidators.length - 1] - keyedRevalidators.pop() - } - } -} - export const useSWRHandler = ( _key: Key, fn: Fetcher | null, @@ -466,7 +443,7 @@ export const useSWRHandler = ( // Not the initial render. const keyChanged = initialMountedRef.current - const softRevalidate = () => revalidate({ dedupe: true }) + const softRevalidate = revalidate.bind(UNDEFINED, WITH_DEDUPE) const isActive = () => configRef.current.isVisible() && configRef.current.isOnline() @@ -576,7 +553,7 @@ export const useSWRHandler = ( (refreshWhenHidden || config.isVisible()) && (refreshWhenOffline || config.isOnline()) ) { - revalidate({ dedupe: true }).then(() => next()) + revalidate(WITH_DEDUPE).then(() => next()) } else { // Schedule next interval to check again. next() @@ -601,7 +578,7 @@ export const useSWRHandler = ( // If there is no `error`, the `revalidation` promise needs to be thrown to // the suspense boundary. if (suspense && isUndefined(data)) { - throw isUndefined(error) ? revalidate({ dedupe: true }) : error + throw isUndefined(error) ? revalidate(WITH_DEDUPE) : error } return Object.defineProperties( diff --git a/src/utils/subscribe-key.ts b/src/utils/subscribe-key.ts new file mode 100644 index 000000000..10f3e3f4d --- /dev/null +++ b/src/utils/subscribe-key.ts @@ -0,0 +1,22 @@ +import { RevalidateCallback, StateUpdateCallback } from '../types' + +// Add a callback function to a list of keyed callback functions and return +// the unsubscribe function. +export const subscribeCallback = ( + key: string, + callbacks: Record, + callback: RevalidateCallback | StateUpdateCallback +) => { + const keyedRevalidators = callbacks[key] || (callbacks[key] = []) + keyedRevalidators.push(callback) + + return () => { + const index = keyedRevalidators.indexOf(callback) + + if (index >= 0) { + // O(1): faster than splice + keyedRevalidators[index] = keyedRevalidators[keyedRevalidators.length - 1] + keyedRevalidators.pop() + } + } +} From 0ed4ac330bb0d87e36fe6072984988a017a2fa10 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 19 Aug 2021 00:14:28 +0200 Subject: [PATCH 4/6] refine comments --- src/use-swr.ts | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/use-swr.ts b/src/use-swr.ts index 7836b570d..a68531b25 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -176,17 +176,21 @@ export const useSWRHandler = ( CONCURRENT_PROMISES_TS ] = SWRGlobalState.get(cache) as GlobalState - // `key` is the identifier of the SWR `data` state. - // `keyErr` and `keyValidating` are identifiers of `error` and `isValidating` - // which are derived from `key`. - // `fnArgs` is a list of arguments for `fn`. + // `key` is the identifier of the SWR `data` state, `keyErr` and + // `keyValidating` are identifiers of `error` and `isValidating`, + // all of them are derived from `_key`. + // `fnArgs` is an array of arguments parsed from the key, which will be passed + // to the fetcher. const [key, fnArgs, keyErr, keyValidating] = serialize(_key) - // If it's the first render of this hook. + // If it's the initial render of this hook. const initialMountedRef = useRef(false) + + // If the hook is unmounted already. This will be used to prevent some effects + // to be called after unmounting. const unmountedRef = useRef(false) - // The ref to trace the current key. + // Refs to keep the key and config. const keyRef = useRef(key) const configRef = useRef(config) @@ -239,7 +243,7 @@ export const useSWRHandler = ( let startAt: number let loading = true const { retryCount, dedupe } = revalidateOpts || {} - const shouldDeduping = !isUndefined(CONCURRENT_PROMISES[key]) && dedupe + const shouldDedupe = !isUndefined(CONCURRENT_PROMISES[key]) && dedupe // Do unmount check for callbacks: // If key has changed during the revalidation, or the component has been @@ -256,7 +260,7 @@ export const useSWRHandler = ( setState({ isValidating: true }) - if (!shouldDeduping) { + if (!shouldDedupe) { // also update other hooks broadcastState( cache, @@ -267,7 +271,7 @@ export const useSWRHandler = ( ) } - if (shouldDeduping) { + if (shouldDedupe) { // There's already an ongoing request, this one needs to be // deduplicated. startAt = CONCURRENT_PROMISES_TS[key] @@ -362,7 +366,7 @@ export const useSWRHandler = ( // merge the new state setState(newState) - if (!shouldDeduping) { + if (!shouldDedupe) { // also update other hooks broadcastState(cache, key, newData, newState.error, false) } @@ -376,18 +380,16 @@ export const useSWRHandler = ( return false } - // get a new error - // don't use deep equal for errors + // Get a new error, don't use deep comparison for errors. cache.set(keyErr, err) - if (stateRef.current.error !== err) { - // we keep the stale data + // Keep the stale data but update error. setState({ isValidating: false, error: err }) - if (!shouldDeduping) { - // also broadcast to update other hooks + if (!shouldDedupe) { + // Broadcast to update the states of other hooks. broadcastState(cache, key, UNDEFINED, err, false) } } @@ -422,11 +424,9 @@ export const useSWRHandler = ( [key] ) - // `mutate`, but bound to the current key. + // Similar to the global mutate, but bound to the current cache and key. const boundMutate: SWRResponse['mutate'] = useCallback( - (newData, shouldRevalidate) => { - return internalMutate(cache, keyRef.current, newData, shouldRevalidate) - }, + internalMutate.bind(UNDEFINED, cache, keyRef.current), // `cache` isn't allowed to change during the lifecycle // eslint-disable-next-line react-hooks/exhaustive-deps [] From 874941693fc5956152d8b3b861137dab4808ec4c Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 19 Aug 2021 14:03:14 +0200 Subject: [PATCH 5/6] fix review suggestions --- src/utils/subscribe-key.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/subscribe-key.ts b/src/utils/subscribe-key.ts index 10f3e3f4d..a2132db2e 100644 --- a/src/utils/subscribe-key.ts +++ b/src/utils/subscribe-key.ts @@ -1,11 +1,11 @@ -import { RevalidateCallback, StateUpdateCallback } from '../types' +type Callback = (...args: any[]) => any // Add a callback function to a list of keyed callback functions and return // the unsubscribe function. export const subscribeCallback = ( key: string, - callbacks: Record, - callback: RevalidateCallback | StateUpdateCallback + callbacks: Record, + callback: Callback ) => { const keyedRevalidators = callbacks[key] || (callbacks[key] = []) keyedRevalidators.push(callback) From 47874fdeda90771849a194cba0c261dc672da481 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Thu, 19 Aug 2021 14:40:24 +0200 Subject: [PATCH 6/6] adjust eslint ignore rules --- src/use-swr.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/use-swr.ts b/src/use-swr.ts index a68531b25..b4cdb7b6a 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -425,10 +425,10 @@ export const useSWRHandler = ( ) // Similar to the global mutate, but bound to the current cache and key. + // `cache` isn't allowed to change during the lifecycle. + // eslint-disable-next-line react-hooks/exhaustive-deps const boundMutate: SWRResponse['mutate'] = useCallback( internalMutate.bind(UNDEFINED, cache, keyRef.current), - // `cache` isn't allowed to change during the lifecycle - // eslint-disable-next-line react-hooks/exhaustive-deps [] )