diff --git a/package.json b/package.json index e4ad3d90e..9e1cf1be2 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,10 @@ "jest": "25.5.4", "lint-staged": "8.2.1", "prettier": "1.18.2", - "react": "16.11.0", - "react-dom": "16.11.0", + "react": "17.0.1", + "react-dom": "17.0.1", + "react-dom-experimental": "npm:react-dom@experimental", + "react-experimental": "npm:react@experimental", "ts-jest": "25.5.1", "typescript": "3.6.4" }, diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 000000000..a1fc176d3 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,87 @@ +import { useRef, useCallback, useState, MutableRefObject } from 'react' + +import { useIsomorphicLayoutEffect } from './env' +import { State } from './types' + +type StateKeys = keyof State +type StateDeps = Record + +/** + * An implementation of state with dependency-tracking. + */ +export default function useStateWithDeps>( + state: S, + unmountedRef: MutableRefObject +): [ + MutableRefObject, + MutableRefObject>, + (payload: S) => void +] { + const rerender = useState({})[1] + + const stateRef = useRef(state) + useIsomorphicLayoutEffect(() => { + stateRef.current = state + }) + + // If a state property (data, error or isValidating) is accessed by the render + // function, we mark the property as a dependency so if it is updated again + // in the future, we trigger a rerender. + // This is also known as dependency-tracking. + const stateDependenciesRef = useRef({ + data: false, + error: false, + isValidating: false + }) + + /** + * @param payload To change stateRef, pass the values explicitly to setState: + * @example + * ```js + * setState({ + * isValidating: false + * data: newData // set data to newData + * error: undefined // set error to undefined + * }) + * + * setState({ + * isValidating: false + * data: undefined // set data to undefined + * error: err // set error to err + * }) + * ``` + */ + const setState = useCallback( + (payload: S) => { + let shouldRerender = false + + for (const _ of Object.keys(payload)) { + // Type casting to work around the `for...in` loop + // https://github.com/Microsoft/TypeScript/issues/3500 + const k = _ as keyof S & StateKeys + + // If the property hasn't changed, skip + if (stateRef.current[k] === payload[k]) { + continue + } + + stateRef.current[k] = payload[k] + + // If the property is accessed by the component, a rerender should be + // triggered. + if (stateDependenciesRef.current[k]) { + shouldRerender = true + } + } + + if (shouldRerender && !unmountedRef.current) { + rerender({}) + } + }, + // config.suspense isn't allowed to change during the lifecycle + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + return [stateRef, stateDependenciesRef, setState] +} diff --git a/src/types.ts b/src/types.ts index a1f7fa450..9cda764f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,22 +25,22 @@ export interface Configuration< isPaused: () => boolean onLoadingSlow: ( key: string, - config: Readonly>> + config: Readonly> ) => void onSuccess: ( data: Data, key: string, - config: Readonly>> + config: Readonly> ) => void onError: ( err: Error, key: string, - config: Readonly>> + config: Readonly> ) => void onErrorRetry: ( err: Error, key: string, - config: Readonly>>, + config: Readonly>, revalidate: Revalidator, revalidateOpts: Required ) => void @@ -72,7 +72,7 @@ export type Broadcaster = ( isValidating?: boolean ) => void -export type Action = { +export type State = { data?: Data error?: Error isValidating?: boolean @@ -118,6 +118,9 @@ export type responseInterface = { export interface SWRResponse { data?: Data error?: Error + /** + * @deprecated `revalidate` is deprecated, please use `mutate()` for the same purpose. + */ revalidate: () => Promise mutate: ( data?: Data | Promise | MutatorCallback, diff --git a/src/use-swr.ts b/src/use-swr.ts index 03e6414b4..03656a282 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -1,18 +1,13 @@ // TODO: use @ts-expect-error -import { - useCallback, - useContext, - useState, - useRef, - useMemo, - useDebugValue -} from 'react' +import { useCallback, useContext, useRef, useDebugValue } from 'react' import defaultConfig, { cache } from './config' import { IS_SERVER, rAF, useIsomorphicLayoutEffect } from './env' import SWRConfigContext from './swr-config-context' +import useStateWithDeps from './state' + import { - Action, + State, Broadcaster, Fetcher, Key, @@ -26,7 +21,7 @@ import { type Revalidator = (...args: any[]) => void -// global state managers +// Global states const CONCURRENT_PROMISES: Record = {} const CONCURRENT_PROMISES_TS: Record = {} const FOCUS_REVALIDATORS: Record = {} @@ -35,13 +30,13 @@ const CACHE_REVALIDATORS: Record = {} const MUTATION_TS: Record = {} const MUTATION_END_TS: Record = {} -// generate strictly increasing timestamps +// Generate strictly increasing timestamps const now = (() => { let ts = 0 return () => ++ts })() -// setup DOM events listeners for `focus` and `reconnect` actions +// Setup DOM events listeners for `focus` and `reconnect` actions if (!IS_SERVER) { const revalidate = (revalidators: Record) => { if (!defaultConfig.isDocumentVisible() || !defaultConfig.isOnline()) return @@ -121,7 +116,7 @@ async function mutate( let data: any, error: unknown let isAsyncMutation = false - if (_data && typeof _data === 'function') { + if (typeof _data === 'function') { // `_data` is a function, call it passing current cache value try { _data = (_data as MutatorCallback)(cache.get(key)) @@ -194,6 +189,29 @@ async function mutate( return data } +const addRevalidator = ( + revalidators: Record, + key: string, + callback: Revalidator +) => { + if (!revalidators[key]) { + revalidators[key] = [callback] + } else { + revalidators[key].push(callback) + } + + return () => { + const keyedRevalidators = revalidators[key] + const index = keyedRevalidators.indexOf(callback) + + if (index >= 0) { + // O(1): faster than splice + keyedRevalidators[index] = keyedRevalidators[keyedRevalidators.length - 1] + keyedRevalidators.pop() + } + } +} + function useSWR( ...args: | readonly [Key] @@ -205,6 +223,7 @@ function useSWR( SWRConfiguration | undefined ] ): SWRResponse { + // Resolve arguments const _key = args[0] const config = Object.assign( {}, @@ -217,25 +236,24 @@ function useSWR( : {} ) - // in typescript args.length > 2 is not same as args.lenth === 3 - // we do a safe type assertion here - // args.length === 3 + // In TypeScript `args.length > 2` is not same as `args.lenth === 3`. + // We do a safe type assertion here. const fn = (args.length > 2 ? args[1] : args.length === 2 && typeof args[1] === 'function' ? args[1] : /** - pass fn as null will disable revalidate - https://paco.sh/blog/shared-hook-state-with-swr - */ + * Pass fn as null will disable revalidate + * https://paco.sh/blog/shared-hook-state-with-swr + */ args[1] === null ? args[1] : config.fetcher) as Fetcher | null - // we assume `key` as the identifier of the request - // `key` can change but `fn` shouldn't - // (because `revalidate` only depends on `key`) - // `keyErr` is the cache key for error objects + // `key` is the identifier of the SWR `data` state. + // `keyErr` and `keyValidating` are indentifiers of `error` and `isValidating` + // which are derived from `key`. + // `fnArgs` is a list of arguments for `fn`. const [key, fnArgs, keyErr, keyValidating] = cache.serializeKey(_key) const configRef = useRef(config) @@ -243,123 +261,69 @@ function useSWR( configRef.current = config }) - const willRevalidateOnMount = () => { - return ( - config.revalidateOnMount || - (!config.initialData && config.revalidateOnMount === undefined) - ) - } + // If it's the first render of this hook. + const initialMountedRef = useRef(false) + + // error ref inside revalidate (is last request errored?) + const unmountedRef = useRef(false) + const keyRef = useRef(key) + // Get the current state that SWR should return. const resolveData = () => { const cachedData = cache.get(key) - return typeof cachedData === 'undefined' ? config.initialData : cachedData + return cachedData === undefined ? config.initialData : cachedData } - - const resolveIsValidating = () => { - return !!cache.get(keyValidating) || (key && willRevalidateOnMount()) + const data = resolveData() + const error = cache.get(keyErr) + + // A revalidation must be triggered when mounted if: + // - `revalidateOnMount` is explicitly set to `true`. + // - Suspense mode and there's stale data for the inital render. + // - Not suspense mode and there is no `initialData`. + const shouldRevalidateOnMount = () => { + if (config.revalidateOnMount !== undefined) return config.revalidateOnMount + + return config.suspense + ? !initialMountedRef.current && data !== undefined + : config.initialData === undefined } - const initialData = resolveData() - const initialError = cache.get(keyErr) - const initialIsValidating = resolveIsValidating() - - // if a state is accessed (data, error or isValidating), - // we add the state to dependencies so if the state is - // updated in the future, we can trigger a rerender - const stateDependencies = useRef({ - data: false, - error: false, - isValidating: false - }) - const stateRef = useRef({ - data: initialData, - error: initialError, - isValidating: initialIsValidating - }) - - // display the data label in the React DevTools next to SWR hooks - useDebugValue(stateRef.current.data) - - const rerender = useState({})[1] - - let dispatch = useCallback( - (payload: Action) => { - let shouldUpdateState = false - for (let k in payload) { - // @ts-ignore - if (stateRef.current[k] === payload[k]) { - continue - } - // @ts-ignore - stateRef.current[k] = payload[k] - // @ts-ignore - if (stateDependencies.current[k]) { - shouldUpdateState = true - } - } - - if (shouldUpdateState) { - // if component is unmounted, should skip rerender - // if component is not mounted, should skip rerender - if (unmountedRef.current || !initialMountedRef.current) return - rerender({}) - } - }, - // config.suspense isn't allowed to change during the lifecycle - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ) - - // error ref inside revalidate (is last request errored?) - const unmountedRef = useRef(false) - const keyRef = useRef(key) + // Resolve the current validating state. + const resolveValidating = () => { + if (!key) return false + if (cache.get(keyValidating)) return true - // check if component is mounted in suspense mode - const initialMountedRef = useRef(false) + // If it's not mounted yet and it should revalidate on mount, revalidate. + return !initialMountedRef.current && shouldRevalidateOnMount() + } + const isValidating = resolveValidating() // do unmount check for callbacks - const eventsCallback = useCallback( - (event, ...params) => { + // if key changed during the revalidation, old dispatch and config callback should not take effect. + const safeCallback = useCallback( + (callback: () => void) => { if (unmountedRef.current) return - if (!initialMountedRef.current) return if (key !== keyRef.current) return - // @ts-ignore - configRef.current[event](...params) + if (!initialMountedRef.current) return + callback() }, [key] ) - const boundMutate: SWRResponse['mutate'] = useCallback( - (data, shouldRevalidate) => { - return mutate(keyRef.current, data, shouldRevalidate) + const [stateRef, stateDependenciesRef, setState] = useStateWithDeps< + Data, + Error + >( + { + data, + error, + isValidating }, - [] + unmountedRef ) - const addRevalidator = ( - revalidators: Record, - callback: Revalidator - ) => { - if (!revalidators[key]) { - revalidators[key] = [callback] - } else { - revalidators[key].push(callback) - } - - return () => { - const keyedRevalidators = revalidators[key] - const index = keyedRevalidators.indexOf(callback) - - if (index >= 0) { - // O(1): faster than splice - keyedRevalidators[index] = - keyedRevalidators[keyedRevalidators.length - 1] - keyedRevalidators.pop() - } - } - } - - // start a revalidation + // The revalidation function is a carefully crafted wrapper of the original + // `fetcher`, to correctly handle the many edge cases. const revalidate = useCallback( async (revalidateOpts: RevalidatorOptions = {}): Promise => { if (!key || !fn) return false @@ -373,10 +337,10 @@ function useSWR( // start fetching try { - dispatch({ + cache.set(keyValidating, true) + setState({ isValidating: true }) - cache.set(keyValidating, true) if (!shouldDeduping) { // also update other hooks broadcastState( @@ -387,7 +351,7 @@ function useSWR( ) } - let newData + let newData: Data let startAt if (shouldDeduping) { @@ -400,7 +364,8 @@ function useSWR( // we trigger the loading slow event. if (config.loadingTimeout && !cache.get(key)) { setTimeout(() => { - if (loading) eventsCallback('onLoadingSlow', key, config) + if (loading) + safeCallback(() => configRef.current.onLoadingSlow(key, config)) }, config.loadingTimeout) } @@ -421,7 +386,7 @@ function useSWR( // trigger the success event, // only do this for the original request. - eventsCallback('onSuccess', newData, key, config) + safeCallback(() => configRef.current.onSuccess(newData, key, config)) } // if there're other ongoing request(s), started after the current one, @@ -454,33 +419,34 @@ function useSWR( // case 3 MUTATION_END_TS[key] === 0) ) { - dispatch({ isValidating: false }) + setState({ isValidating: false }) return false } cache.set(keyErr, undefined) cache.set(keyValidating, false) - // new state for the reducer - const newState: Action = { + const newState: State = { isValidating: false } - if (typeof stateRef.current.error !== 'undefined') { - // we don't have an error + if (stateRef.current.error !== undefined) { newState.error = undefined } + + // Deep compare with latest state to avoid extra re-renders. + // For local state, compare and assign. if (!config.compare(stateRef.current.data, newData)) { - // deep compare to avoid extra re-render - // data changed newState.data = newData } - + // For global state, it's possible that the key has changed. + // https://github.com/vercel/swr/pull/1058 if (!config.compare(cache.get(key), newData)) { cache.set(key, newData) } + // merge the new state - dispatch(newState) + setState(newState) if (!shouldDeduping) { // also update other hooks @@ -490,23 +456,22 @@ function useSWR( delete CONCURRENT_PROMISES[key] delete CONCURRENT_PROMISES_TS[key] if (configRef.current.isPaused()) { - dispatch({ + setState({ isValidating: false }) return false } - cache.set(keyErr, err) - // get a new error // don't use deep equal for errors + cache.set(keyErr, err) + if (stateRef.current.error !== err) { // we keep the stale data - dispatch({ + setState({ isValidating: false, error: err }) - if (!shouldDeduping) { // also broadcast to update other hooks broadcastState(key, undefined, err, false) @@ -514,65 +479,63 @@ function useSWR( } // events and retry - eventsCallback('onError', err, key, config) + safeCallback(() => configRef.current.onError(err, key, config)) if (config.shouldRetryOnError) { // when retrying, we always enable deduping - eventsCallback('onErrorRetry', err, key, config, revalidate, { - retryCount: retryCount + 1, - dedupe: true - }) + safeCallback(() => + configRef.current.onErrorRetry(err, key, config, revalidate, { + retryCount: retryCount + 1, + dedupe: true + }) + ) } } loading = false return true }, - // dispatch is immutable, and `eventsCallback`, `fnArgs`, `keyErr`, and `keyValidating` are based on `key`, - // so we can them from the deps array. + // `setState` is immutable, and `eventsCallback`, `fnArgs`, `keyErr`, + // and `keyValidating` are depending on `key`, so we can exclude them from + // the deps array. // // FIXME: // `fn` and `config` might be changed during the lifecycle, // but they might be changed every render like this. - // useSWR('key', () => fetch('/api/'), { suspense: true }) + // `useSWR('key', () => fetch('/api/'), { suspense: true })` // So we omit the values from the deps array // even though it might cause unexpected behaviors. // eslint-disable-next-line react-hooks/exhaustive-deps [key] ) - // mounted (client side rendering) + // After mounted or key changed. useIsomorphicLayoutEffect(() => { if (!key) return undefined - // after `key` updates, we need to mark it as mounted - unmountedRef.current = false - - const isUpdating = initialMountedRef.current - initialMountedRef.current = true - - // after the component is mounted (hydrated), - // we need to update the data from the cache - // and trigger a revalidation + // Not the inital render. + const keyChanged = initialMountedRef.current - const currentHookData = stateRef.current.data - const latestKeyedData = resolveData() - - // update the state if the key changed (not the inital render) or cache updated + // Mark the component as mounted and update corresponding refs. + unmountedRef.current = false keyRef.current = key - if (!config.compare(currentHookData, latestKeyedData)) { - dispatch({ data: latestKeyedData }) + // When `key` updates, reset the state to the initial value + // and trigger a rerender if necessary. + if (keyChanged) { + setState({ + data, + error, + isValidating + }) } - // revalidate with deduping const softRevalidate = () => revalidate({ dedupe: true }) - // trigger a revalidation - if (isUpdating || willRevalidateOnMount()) { - if (typeof latestKeyedData !== 'undefined' && !IS_SERVER) { - // delay revalidate if there's cache - // to not block the rendering - + // Trigger a revalidation. + if (keyChanged || shouldRevalidateOnMount()) { + if (data !== undefined && !IS_SERVER) { + // Delay the revalidate if we have data to return so we won't block + // rendering. // @ts-ignore it's safe to use requestAnimationFrame in browser rAF(softRevalidate) } else { @@ -580,6 +543,8 @@ function useSWR( } } + // Add event listeners + let pending = false const onFocus = () => { if (pending || !configRef.current.revalidateOnFocus) return @@ -605,64 +570,41 @@ function useSWR( updatedIsValidating, dedupe = true ) => { - // update hook state - const newState: Action = {} - let needUpdate = false - - if ( - typeof updatedData !== 'undefined' && - !config.compare(stateRef.current.data, updatedData) - ) { - newState.data = updatedData - needUpdate = true - } - - // always update error - // because it can be `undefined` - if (stateRef.current.error !== updatedError) { - newState.error = updatedError - needUpdate = true - } - - if ( - typeof updatedIsValidating !== 'undefined' && - stateRef.current.isValidating !== updatedIsValidating - ) { - newState.isValidating = updatedIsValidating - needUpdate = true - } - - if (needUpdate) { - dispatch(newState) - } + setState({ + error: updatedError, + isValidating: updatedIsValidating, + // if data is undefined we should not update stateRef.current.data + ...(!config.compare(updatedData, stateRef.current.data) + ? { + data: updatedData + } + : null) + }) if (shouldRevalidate) { - if (dedupe) { - return softRevalidate() - } else { - return revalidate() - } + return (dedupe ? softRevalidate : revalidate)() } return false } - const unsubFocus = addRevalidator(FOCUS_REVALIDATORS, onFocus) - const unsubReconnect = addRevalidator(RECONNECT_REVALIDATORS, onReconnect) - const unsubUpdate = addRevalidator(CACHE_REVALIDATORS, onUpdate) + const unsubFocus = addRevalidator(FOCUS_REVALIDATORS, key, onFocus) + const unsubReconn = addRevalidator(RECONNECT_REVALIDATORS, key, onReconnect) + const unsubUpdate = addRevalidator(CACHE_REVALIDATORS, key, onUpdate) - return () => { - // cleanup - dispatch = () => null + // Finally, the component is mounted. + initialMountedRef.current = true + return () => { // mark it as unmounted unmountedRef.current = true unsubFocus() - unsubReconnect() + unsubReconn() unsubUpdate() } }, [key, revalidate]) + // Polling useIsomorphicLayoutEffect(() => { let timer: any = null const tick = async () => { @@ -698,116 +640,61 @@ function useSWR( revalidate ]) - // suspense - let latestData: Data | undefined - let latestError: unknown - if (config.suspense) { - // in suspense mode, we can't return empty state - // (it should be suspended) - - // try to get data and error from cache - latestData = cache.get(key) - latestError = cache.get(keyErr) - - if (typeof latestData === 'undefined') { - latestData = initialData - } - if (typeof latestError === 'undefined') { - latestError = initialError - } - - if ( - typeof latestData === 'undefined' && - typeof latestError === 'undefined' - ) { - // need to start the request if it hasn't - if (!CONCURRENT_PROMISES[key]) { - // trigger revalidate immediately - // to get the promise - // in this revalidate, should not rerender - revalidate() - } - - if ( - CONCURRENT_PROMISES[key] && - typeof CONCURRENT_PROMISES[key].then === 'function' - ) { - // if it is a promise - throw CONCURRENT_PROMISES[key] - } - - // it's a value, return it directly (override) - latestData = CONCURRENT_PROMISES[key] - } - - if (typeof latestData === 'undefined' && latestError) { - // in suspense mode, throw error if there's no content - throw latestError + // In Suspense mode, we can't return the empty `data` state. + // If there is `error`, the `error` needs to be thrown to the error boundary. + // If there is no `error`, the `revalidation` promise needs to be thrown to + // the suspense boundary. + if (config.suspense && data === undefined) { + if (error === undefined) { + throw revalidate({ dedupe: true }) } + throw error } - // define returned state - // can be memorized since the state is a ref - const memoizedState = useMemo(() => { - // revalidate will be deprecated in the 1.x release - // because mutate() covers the same use case of revalidate(). - // This remains only for backward compatibility - const state = { revalidate, mutate: boundMutate } as SWRResponse< - Data, - Error - > - Object.defineProperties(state, { - error: { - // `key` might be changed in the upcoming hook re-render, - // but the previous state will stay - // so we need to match the latest key and data (fallback to `initialData`) - get: function() { - stateDependencies.current.error = true - if (config.suspense) { - return latestError - } - return keyRef.current === key ? stateRef.current.error : initialError - }, - enumerable: true + // `mutate`, but bound to the current key. + const boundMutate: SWRResponse['mutate'] = useCallback( + (newData, shouldRevalidate) => { + return mutate(keyRef.current, newData, shouldRevalidate) + }, + [] + ) + + // Define the SWR state. + // `revalidate` will be deprecated in the 1.x release + // because `mutate()` covers the same use case of `revalidate()`. + // This remains only for backward compatibility + const state = { + revalidate, + mutate: boundMutate + } as SWRResponse + Object.defineProperties(state, { + data: { + get: function() { + stateDependenciesRef.current.data = true + return data }, - data: { - get: function() { - stateDependencies.current.data = true - if (config.suspense) { - return latestData - } - return keyRef.current === key ? stateRef.current.data : initialData - }, - enumerable: true + enumerable: true + }, + error: { + get: function() { + stateDependenciesRef.current.error = true + return error }, - isValidating: { - get: function() { - stateDependencies.current.isValidating = true - return key ? stateRef.current.isValidating : false - }, - enumerable: true - } - }) + enumerable: true + }, + isValidating: { + get: function() { + stateDependenciesRef.current.isValidating = true + return isValidating + }, + enumerable: true + } + }) - return state - // `config.suspense` isn't allowed to change during the lifecycle. - // `boundMutate` is immutable, and the immutability of `revalidate` depends on `key` - // so we can omit them from the deps array, - // but we put it to enable react-hooks/exhaustive-deps rule. - // `initialData` and `initialError` are not initial values - // because they are changed during the lifecycle - // so we should add them in the deps array. - }, [ - revalidate, - initialData, - initialError, - boundMutate, - key, - config.suspense, - latestError, - latestData - ]) - return memoizedState + // Display debug info in React DevTools. + useDebugValue(data) + + return state } Object.defineProperty(SWRConfigContext.Provider, 'default', { diff --git a/test/use-swr-concurrent-mode.test.tsx b/test/use-swr-concurrent-mode.test.tsx new file mode 100644 index 000000000..a40ccac2d --- /dev/null +++ b/test/use-swr-concurrent-mode.test.tsx @@ -0,0 +1,97 @@ +// This file includes some basic test cases for React Concurrent Mode. +// Due to the nature of global cache, the current SWR implementation will not +// be perfectly consistent in Concurrent Mode in every intermediate state. +// Only eventual consistency is guaranteed. + +import { screen, fireEvent } from '@testing-library/react' +import { createResponse, sleep } from './utils' + +describe('useSWR - concurrent mode', () => { + let React, ReactDOM, act, useSWR + + beforeEach(() => { + jest.resetModules() + jest.mock('scheduler', () => require('scheduler/unstable_mock')) + jest.mock('react', () => require('react-experimental')) + jest.mock('react-dom', () => require('react-dom-experimental')) + jest.mock('react-dom/test-utils', () => + require('react-dom-experimental/test-utils') + ) + React = require('react') + ReactDOM = require('react-dom') + act = require('react-dom/test-utils').act + useSWR = require('../src').default + }) + + it('should fetch data in concurrent mode', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + const reactRoot = ReactDOM.unstable_createRoot(root) + + function Page() { + const { data } = useSWR( + 'concurrent-1', + () => createResponse('0', { delay: 50 }), + { + dedupingInterval: 0 + } + ) + return
data:{data}
+ } + + act(() => reactRoot.render()) + + screen.getByText('data:') + await act(() => sleep(100)) + screen.getByText('data:0') + + act(() => reactRoot.unmount()) + }) + + it('should pause when changing the key inside a transition', async () => { + const root = document.createElement('div') + document.body.appendChild(root) + const reactRoot = ReactDOM.unstable_createRoot(root) + + const fetcher = (k: string) => createResponse(k, { delay: 100 }) + // eslint-disable-next-line react/prop-types + function Component({ swrKey }) { + const { data } = useSWR(swrKey, fetcher, { + dedupingInterval: 0, + suspense: true + }) + + return <>data:{data} + } + function Page() { + const [startTransition, isPending] = React.unstable_useTransition() + const [key, setKey] = React.useState('concurrent-2') + + return ( +
startTransition(() => setKey('new-key'))}> + isPending:{isPending ? 1 : 0}, + + + +
+ ) + } + + act(() => reactRoot.render()) + + screen.getByText('isPending:0,loading') + await act(() => sleep(120)) + screen.getByText('isPending:0,data:concurrent-2') + fireEvent.click(screen.getByText('isPending:0,data:concurrent-2')) + await act(() => sleep(10)) + + // Pending state + screen.getByText('isPending:1,data:concurrent-2') + + // Transition end + await act(() => sleep(120)) + screen.getByText('isPending:0,data:new-key') + + act(() => reactRoot.unmount()) + }) +}) diff --git a/test/use-swr-key.test.tsx b/test/use-swr-key.test.tsx index 58124230c..18ac76a67 100644 --- a/test/use-swr-key.test.tsx +++ b/test/use-swr-key.test.tsx @@ -163,4 +163,29 @@ describe('useSWR - key', () => { await act(() => sleep(10)) screen.getByText('false') }) + + it('should keep data in sync when key updates', async () => { + const fetcher = () => createResponse('test', { delay: 100 }) + const values = [] + + function Page() { + const [key, setKey] = useState(null) + + const { data: v1 } = useSWR(key, fetcher) + const { data: v2 } = useSWR(key, fetcher) + + values.push([v1, v2]) + + return + } + + render() + screen.getByText('update key') + + fireEvent.click(screen.getByText('update key')) + await act(() => sleep(120)) + + // All values should equal because they're sharing the same key + expect(values.some(([a, b]) => a !== b)).toBeFalsy() + }) }) diff --git a/test/use-swr-refresh.test.tsx b/test/use-swr-refresh.test.tsx index 2849f7722..640a0b994 100644 --- a/test/use-swr-refresh.test.tsx +++ b/test/use-swr-refresh.test.tsx @@ -35,8 +35,8 @@ describe('useSWR - refresh', () => { function Page() { const { data } = useSWR('dynamic-2', () => count++, { - refreshInterval: 200, - dedupingInterval: 300 + refreshInterval: 100, + dedupingInterval: 150 }) return
count: {data}
} @@ -48,26 +48,26 @@ describe('useSWR - refresh', () => { // mount await screen.findByText('count: 0') - await act(() => sleep(210)) // no update (deduped) + await act(() => sleep(110)) // no update (deduped) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 0"`) - await act(() => sleep(200)) // update + await act(() => sleep(100)) // update expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 1"`) - await act(() => sleep(200)) // no update (deduped) + await act(() => sleep(100)) // no update (deduped) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 1"`) - await act(() => sleep(200)) // update + await act(() => sleep(100)) // update expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 2"`) }) it('should update data upon interval changes', async () => { let count = 0 function Page() { - const [int, setInt] = React.useState(200) + const [int, setInt] = React.useState(100) const { data } = useSWR('/api', () => count++, { refreshInterval: int, - dedupingInterval: 100 + dedupingInterval: 50 }) return ( -
setInt(num => (num < 400 ? num + 100 : 0))}> +
setInt(num => (num < 200 ? num + 50 : 0))}> count: {data}
) @@ -78,39 +78,39 @@ describe('useSWR - refresh', () => { // mount await screen.findByText('count: 0') - await act(() => sleep(210)) + await act(() => sleep(110)) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 1"`) - await act(() => sleep(50)) + await act(() => sleep(25)) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 1"`) - await act(() => sleep(150)) + await act(() => sleep(75)) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 2"`) fireEvent.click(container.firstElementChild) - await act(() => sleep(200)) + await act(() => sleep(100)) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 2"`) - await act(() => sleep(110)) + await act(() => sleep(60)) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 3"`) - await act(() => sleep(310)) + await act(() => sleep(160)) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 4"`) fireEvent.click(container.firstElementChild) await act(() => { - // it will clear 300ms timer and setup a new 400ms timer - return sleep(300) + // it will clear 150ms timer and setup a new 200ms timer + return sleep(150) }) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 4"`) - await act(() => sleep(110)) + await act(() => sleep(60)) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 5"`) fireEvent.click(container.firstElementChild) await act(() => { - // it will clear 400ms timer and stop - return sleep(110) + // it will clear 200ms timer and stop + return sleep(60) }) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 5"`) - await act(() => sleep(110)) + await act(() => sleep(60)) expect(container.firstChild.textContent).toMatchInlineSnapshot(`"count: 5"`) }) @@ -124,8 +124,8 @@ describe('useSWR - refresh', () => { '/interval-changes-during-revalidate', () => count++, { - refreshInterval: shouldPoll ? 200 : 0, - dedupingInterval: 100, + refreshInterval: shouldPoll ? 100 : 0, + dedupingInterval: 50, onSuccess() { setFlag(value => value + 1) } @@ -144,23 +144,23 @@ describe('useSWR - refresh', () => { await screen.findByText('count: 0 1') - await act(() => sleep(200)) + await act(() => sleep(100)) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 1 2"` ) - await act(() => sleep(200)) + await act(() => sleep(100)) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 1 2"` ) - await act(() => sleep(200)) + await act(() => sleep(100)) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 1 2"` ) - await act(() => sleep(200)) + await act(() => sleep(100)) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 1 2"` ) @@ -168,33 +168,33 @@ describe('useSWR - refresh', () => { fireEvent.click(container.firstElementChild) await act(() => { - // it will setup a new 200ms timer - return sleep(100) + // it will setup a new 100ms timer + return sleep(50) }) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 1 0"` ) - await act(() => sleep(100)) + await act(() => sleep(50)) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 2 1"` ) - await act(() => sleep(200)) + await act(() => sleep(100)) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 3 2"` ) - await act(() => sleep(200)) + await act(() => sleep(100)) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 3 2"` ) - await act(() => sleep(200)) + await act(() => sleep(100)) expect(container.firstChild.textContent).toMatchInlineSnapshot( `"count: 3 2"` @@ -301,7 +301,7 @@ describe('useSWR - refresh', () => { it('the previous interval timer should not call onSuccess callback if key changes too fast', async () => { const fetcherWithToken = jest.fn(async token => { - await sleep(200) + await sleep(100) return token }) const onSuccess = jest.fn((data, key) => { @@ -310,8 +310,8 @@ describe('useSWR - refresh', () => { function Page() { const [count, setCount] = useState(0) const { data } = useSWR(`${count.toString()}-hash`, fetcherWithToken, { - refreshInterval: 100, - dedupingInterval: 50, + refreshInterval: 50, + dedupingInterval: 25, onSuccess }) return ( @@ -323,20 +323,20 @@ describe('useSWR - refresh', () => { const { container } = render() // initial revalidate - await act(() => sleep(200)) + await act(() => sleep(100)) expect(fetcherWithToken).toBeCalledTimes(1) expect(onSuccess).toBeCalledTimes(1) expect(onSuccess).toHaveLastReturnedWith(`0-hash 0-hash`) // first refresh - await act(() => sleep(100)) + await act(() => sleep(50)) expect(fetcherWithToken).toBeCalledTimes(2) expect(fetcherWithToken).toHaveBeenLastCalledWith('0-hash') - await act(() => sleep(200)) + await act(() => sleep(100)) expect(onSuccess).toBeCalledTimes(2) expect(onSuccess).toHaveLastReturnedWith(`0-hash 0-hash`) // second refresh start - await act(() => sleep(100)) + await act(() => sleep(50)) expect(fetcherWithToken).toBeCalledTimes(3) expect(fetcherWithToken).toHaveBeenLastCalledWith('0-hash') // change the key during revalidation @@ -344,10 +344,10 @@ describe('useSWR - refresh', () => { fireEvent.click(container.firstElementChild) // first refresh with new key 1 - await act(() => sleep(100)) + await act(() => sleep(50)) expect(fetcherWithToken).toBeCalledTimes(4) expect(fetcherWithToken).toHaveBeenLastCalledWith('1-hash') - await act(() => sleep(210)) + await act(() => sleep(110)) expect(onSuccess).toBeCalledTimes(3) expect(onSuccess).toHaveLastReturnedWith(`1-hash 1-hash`) diff --git a/test/use-swr-suspense.test.tsx b/test/use-swr-suspense.test.tsx index 54a5503c6..d4d46ffff 100644 --- a/test/use-swr-suspense.test.tsx +++ b/test/use-swr-suspense.test.tsx @@ -23,6 +23,7 @@ describe('useSWR - suspense', () => { jest.clearAllMocks() jest.restoreAllMocks() }) + it('should render fallback', async () => { function Section() { const { data } = useSWR( @@ -243,6 +244,7 @@ describe('useSWR - suspense', () => { `"hello, Initial"` ) }) + it('should avoid unnecessary re-renders', async () => { let renderCount = 0 let startRenderCount = 0 diff --git a/yarn.lock b/yarn.lock index 954eff377..211c76443 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4262,7 +4262,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.3" -prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -4299,15 +4299,31 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -react-dom@16.11.0: - version "16.11.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.11.0.tgz#7e7c4a5a85a569d565c2462f5d345da2dd849af5" - integrity sha512-nrRyIUE1e7j8PaXSPtyRKtz+2y9ubW/ghNgqKFHHAHaeP0fpF5uXR+sq8IMRHC+ZUxw7W9NyCDTBtwWxvkb0iA== +"react-dom-experimental@npm:react-dom@experimental": + version "0.0.0-experimental-7d06b80af" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-0.0.0-experimental-7d06b80af.tgz#a61a2e4463a37703a6bf0a204310f0bf7e040e18" + integrity sha512-iI7GbXqDJVNN8RBY3f1WYMKKhBKya8q9yQ7CK/TmYIv+cvZps8+Up4DcKnH7pNDmQRK8hmBT0PChRAfbLRnn0A== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "0.0.0-experimental-7d06b80af" + +react-dom@17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6" + integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.1" + +"react-experimental@npm:react@experimental": + version "0.0.0-experimental-7d06b80af" + resolved "https://registry.yarnpkg.com/react/-/react-0.0.0-experimental-7d06b80af.tgz#c32103deafbf00205f707c9d92c087adbcbfc19b" + integrity sha512-NgOfYj+Pflocms/wd+MoVQWuA1epleBvAx4ElrSXDmt4CgqAzYYdFhjsovl7jvUBaU7Vn5Pb1qhocCWyaUQNgw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.17.0" react-is@^16.12.0: version "16.13.1" @@ -4324,14 +4340,13 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== -react@16.11.0: - version "16.11.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.11.0.tgz#d294545fe62299ccee83363599bf904e4a07fdbb" - integrity sha512-M5Y8yITaLmU0ynd0r1Yvfq98Rmll6q8AxaEe88c8e7LxO8fZ2cNgmFt0aGAS9wzf1Ao32NKXtCl+/tVVtkxq6g== +react@17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127" + integrity sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" - prop-types "^15.6.2" read-pkg-up@^7.0.1: version "7.0.1" @@ -4631,10 +4646,18 @@ saxes@^3.1.9: dependencies: xmlchars "^2.1.1" -scheduler@^0.17.0: - version "0.17.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.17.0.tgz#7c9c673e4ec781fac853927916d1c426b6f3ddfe" - integrity sha512-7rro8Io3tnCPuY4la/NuI5F2yfESpnfZyT6TtkXnSWVkcu0BCDJ+8gk5ozUaFaxpIyNuWAPXrH0yFcSi28fnDA== +scheduler@0.0.0-experimental-7d06b80af: + version "0.0.0-experimental-7d06b80af" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.0.0-experimental-7d06b80af.tgz#dc4d56ba912eb55197481783409a95edef9b323e" + integrity sha512-KPMZqd8Un0TZF3tyG3WJCAAAklj0pF86wQh/m1V3C2WYsHSkH2FoGrMmF8au4JvohpFK9026SYIiVwgCgzxPrw== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +scheduler@^0.20.1: + version "0.20.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c" + integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1"