diff --git a/src/types.ts b/src/types.ts index 3a30349c5..0dea512cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,7 @@ export interface PublicConfiguration< revalidateOnMount?: boolean revalidateIfStale: boolean shouldRetryOnError: boolean | ((err: Error) => boolean) + keepPreviousData?: boolean suspense?: boolean fallbackData?: Data fetcher?: Fn diff --git a/src/use-swr.ts b/src/use-swr.ts index bd6fe8fea..0e78a6c1c 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -55,12 +55,13 @@ export const useSWRHandler = ( const { cache, compare, - fallbackData, suspense, + fallbackData, revalidateOnMount, refreshInterval, refreshWhenHidden, - refreshWhenOffline + refreshWhenOffline, + keepPreviousData } = config const [EVENT_REVALIDATORS, STATE_UPDATERS, MUTATION, FETCH] = @@ -98,7 +99,15 @@ export const useSWRHandler = ( const data = isUndefined(cachedData) ? fallback : cachedData const error = cached.error + // Use a ref to store previous returned data. Use the inital data as its inital value. + const laggyDataRef = useRef(data) + const isInitialMount = !initialMountedRef.current + const returnedData = keepPreviousData + ? isUndefined(cachedData) + ? laggyDataRef.current + : cachedData + : data // - Suspense mode and there's stale data for the initial render. // - Not suspense mode and there is no fallback data and `revalidateIfStale` is enabled. @@ -179,8 +188,10 @@ export const useSWRHandler = ( isLoading: false } const finishRequestAndUpdateState = () => { + // Set the global cache. setCache(finalState) - // We can only set state if it's safe (still mounted with the same key). + + // We can only set the local state if it's safe (still mounted with the same key). if (isCurrentKeyMounted()) { setState(finalState) } @@ -387,11 +398,17 @@ export const useSWRHandler = ( [] ) - // Always update fetcher, config and state refs. + // Logic for updating refs. useIsomorphicLayoutEffect(() => { fetcherRef.current = fetcher configRef.current = config stateRef.current = currentState + + // Handle laggy data updates. If there's cached data of the current key, + // it'll be the correct reference. + if (!isUndefined(cachedData)) { + laggyDataRef.current = cachedData + } }) // After mounted or key changed. @@ -518,7 +535,7 @@ export const useSWRHandler = ( }, [refreshInterval, refreshWhenHidden, refreshWhenOffline, key]) // Display debug info in React DevTools. - useDebugValue(data) + useDebugValue(returnedData) // 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. @@ -536,7 +553,7 @@ export const useSWRHandler = ( mutate: boundMutate, get data() { stateDependencies.data = true - return data + return returnedData }, get error() { stateDependencies.error = true diff --git a/test/use-swr-laggy.test.tsx b/test/use-swr-laggy.test.tsx new file mode 100644 index 000000000..0f770c1df --- /dev/null +++ b/test/use-swr-laggy.test.tsx @@ -0,0 +1,197 @@ +import { screen, act, fireEvent } from '@testing-library/react' +import React, { useState } from 'react' +import useSWR from 'swr' +import useSWRInfinite from 'swr/infinite' + +import { createKey, createResponse, renderWithConfig, sleep } from './utils' + +describe('useSWR - keep previous data', () => { + it('should keep previous data when key changes when `keepPreviousData` is enabled', async () => { + const loggedData = [] + const fetcher = k => createResponse(k, { delay: 50 }) + function App() { + const [key, setKey] = useState(createKey()) + const { data: laggedData } = useSWR(key, fetcher, { + keepPreviousData: true + }) + loggedData.push([key, laggedData]) + return + } + + renderWithConfig() + await act(() => sleep(100)) + fireEvent.click(screen.getByText('change key')) + await act(() => sleep(100)) + + const key1 = loggedData[0][0] + const key2 = loggedData[2][0] + expect(loggedData).toEqual([ + [key1, undefined], + [key1, key1], + [key2, key1], + [key2, key2] + ]) + }) + + it('should keep previous data when sharing the cache', async () => { + const loggedData = [] + const fetcher = k => createResponse(k, { delay: 50 }) + function App() { + const [key, setKey] = useState(createKey()) + + const { data } = useSWR(key, fetcher) + const { data: laggedData } = useSWR(key, fetcher, { + keepPreviousData: true + }) + + loggedData.push([key, data, laggedData]) + return + } + + renderWithConfig() + await act(() => sleep(100)) + fireEvent.click(screen.getByText('change key')) + await act(() => sleep(100)) + + const key1 = loggedData[0][0] + const key2 = loggedData[2][0] + expect(loggedData).toEqual([ + [key1, undefined, undefined], + [key1, key1, key1], + [key2, undefined, key1], + [key2, key2, key2] + ]) + }) + + it('should keep previous data even if there is fallback data', async () => { + const loggedData = [] + const fetcher = k => createResponse(k, { delay: 50 }) + function App() { + const [key, setKey] = useState(createKey()) + + const { data } = useSWR(key, fetcher, { + fallbackData: 'fallback' + }) + const { data: laggedData } = useSWR(key, fetcher, { + keepPreviousData: true, + fallbackData: 'fallback' + }) + + loggedData.push([key, data, laggedData]) + return + } + + renderWithConfig() + await act(() => sleep(100)) + fireEvent.click(screen.getByText('change key')) + await act(() => sleep(100)) + + const key1 = loggedData[0][0] + const key2 = loggedData[2][0] + expect(loggedData).toEqual([ + [key1, 'fallback', 'fallback'], + [key1, key1, key1], + [key2, 'fallback', key1], + [key2, key2, key2] + ]) + }) + + it('should always return the latest data', async () => { + const loggedData = [] + const fetcher = k => createResponse(k, { delay: 50 }) + function App() { + const [key, setKey] = useState(createKey()) + const { data: laggedData, mutate } = useSWR(key, fetcher, { + keepPreviousData: true + }) + loggedData.push([key, laggedData]) + return ( + <> + + + + ) + } + + renderWithConfig() + await act(() => sleep(100)) + fireEvent.click(screen.getByText('change key')) + await act(() => sleep(100)) + fireEvent.click(screen.getByText('mutate')) + await act(() => sleep(100)) + + const key1 = loggedData[0][0] + const key2 = loggedData[2][0] + expect(loggedData).toEqual([ + [key1, undefined], + [key1, key1], + [key2, key1], + [key2, key2], + [key2, 'mutate'], + [key2, key2] + ]) + }) + + it('should keep previous data for the useSWRInfinite hook', async () => { + const loggedData = [] + const fetcher = k => createResponse(k, { delay: 50 }) + function App() { + const [key, setKey] = useState(createKey()) + + const { data } = useSWRInfinite(() => key, fetcher, { + keepPreviousData: true + }) + + loggedData.push([key, data]) + return + } + + renderWithConfig() + await act(() => sleep(100)) + fireEvent.click(screen.getByText('change key')) + await act(() => sleep(100)) + + const key1 = loggedData[0][0] + const key2 = loggedData[2][0] + expect(loggedData).toEqual([ + [key1, undefined], + [key1, [key1]], + [key2, [key1]], + [key2, [key2]] + ]) + }) + + it('should support changing the `keepPreviousData` option', async () => { + const loggedData = [] + const fetcher = k => createResponse(k, { delay: 50 }) + let keepPreviousData = false + function App() { + const [key, setKey] = useState(createKey()) + const { data: laggedData } = useSWR(key, fetcher, { + keepPreviousData + }) + loggedData.push([key, laggedData]) + return + } + + renderWithConfig() + await act(() => sleep(100)) + fireEvent.click(screen.getByText('change key')) + await act(() => sleep(100)) + keepPreviousData = true + fireEvent.click(screen.getByText('change key')) + await act(() => sleep(100)) + + const key1 = loggedData[0][0] + const key2 = loggedData[2][0] + const key3 = loggedData[4][0] + expect(loggedData).toEqual([ + [key1, undefined], + [key1, key1], + [key2, undefined], + [key2, key2], + [key3, key2], + [key3, key3] + ]) + }) +})