diff --git a/src/types.ts b/src/types.ts index 541267709..dc37e2e53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -156,13 +156,15 @@ export type Broadcaster = ( error?: Error, isValidating?: boolean, revalidate?: boolean, - populateCache?: boolean + populateCache?: boolean, + updatedAt?: number ) => Promise export type State = { data?: Data error?: Error isValidating?: boolean + updatedAt?: number } export type MutatorFn = ( @@ -218,6 +220,7 @@ export interface SWRResponse { data?: Data error?: Error mutate: KeyedMutator + updatedAt?: number isValidating: boolean } @@ -251,7 +254,8 @@ export type RevalidateCallback = ( export type StateUpdateCallback = ( data?: Data, error?: Error, - isValidating?: boolean + isValidating?: boolean, + updatedAt?: number ) => void export interface Cache { diff --git a/src/use-swr.ts b/src/use-swr.ts index 53573f00b..438a6b53e 100644 --- a/src/use-swr.ts +++ b/src/use-swr.ts @@ -117,11 +117,16 @@ export const useSWRHandler = ( } const isValidating = resolveValidating() + const updatedAtRef = useRef() + const getUpdatedAt = () => updatedAtRef.current + const updatedAt = getUpdatedAt() + const [stateRef, stateDependencies, setState] = useStateWithDeps( { data, error, - isValidating + isValidating, + updatedAt }, unmountedRef ) @@ -214,6 +219,9 @@ export const useSWRHandler = ( newData = await newData if (shouldStartNewRequest) { + const now = Date.now() + updatedAtRef.current = now + newState.updatedAt = now // If the request isn't interrupted, clean it up after the // deduplication interval. setTimeout(cleanupState, config.dedupingInterval) @@ -337,7 +345,16 @@ export const useSWRHandler = ( // Here is the source of the request, need to tell all other hooks to // update their states. if (isCurrentKeyMounted() && shouldStartNewRequest) { - broadcastState(cache, key, newState.data, newState.error, false) + broadcastState( + cache, + key, + newState.data, + newState.error, + false, + false, + undefined, + newState.updatedAt + ) } return true @@ -386,13 +403,19 @@ export const useSWRHandler = ( const onStateUpdate: StateUpdateCallback = ( updatedData, updatedError, - updatedIsValidating + updatedIsValidating, + nextUpdatedAt ) => { + updatedAtRef.current = isUndefined(nextUpdatedAt) + ? getUpdatedAt() + : nextUpdatedAt + setState( mergeObjects( { error: updatedError, - isValidating: updatedIsValidating + isValidating: updatedIsValidating, + updatedAt: getUpdatedAt() }, // Since `setState` only shallowly compares states, we do a deep // comparison here. @@ -443,7 +466,8 @@ export const useSWRHandler = ( setState({ data, error, - isValidating + isValidating, + updatedAt: UNDEFINED }) } @@ -540,6 +564,10 @@ export const useSWRHandler = ( get isValidating() { stateDependencies.isValidating = true return isValidating + }, + get updatedAt() { + stateDependencies.updatedAt = true + return getUpdatedAt() } } as SWRResponse } diff --git a/src/utils/broadcast-state.ts b/src/utils/broadcast-state.ts index eccf8b141..757b5ab3b 100644 --- a/src/utils/broadcast-state.ts +++ b/src/utils/broadcast-state.ts @@ -9,7 +9,8 @@ export const broadcastState: Broadcaster = ( error, isValidating, revalidate, - broadcast = true + broadcast = true, + updatedAt ) => { const [EVENT_REVALIDATORS, STATE_UPDATERS, , FETCH] = SWRGlobalState.get( cache @@ -20,7 +21,7 @@ export const broadcastState: Broadcaster = ( // Cache was populated, update states of all hooks. if (broadcast && updaters) { for (let i = 0; i < updaters.length; ++i) { - updaters[i](data, error, isValidating) + updaters[i](data, error, isValidating, updatedAt) } } diff --git a/src/utils/state.ts b/src/utils/state.ts index 9e1eed449..79b109574 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -23,7 +23,8 @@ export const useStateWithDeps = >( const stateDependenciesRef = useRef({ data: false, error: false, - isValidating: false + isValidating: false, + updatedAt: false }) /** diff --git a/test/use-swr-loading.test.tsx b/test/use-swr-loading.test.tsx index 8d47bfa98..aef69712f 100644 --- a/test/use-swr-loading.test.tsx +++ b/test/use-swr-loading.test.tsx @@ -138,7 +138,7 @@ describe('useSWR - loading', () => { } renderWithConfig() - screen.getByText('data,error,isValidating,mutate') + screen.getByText('data,error,isValidating,mutate,updatedAt') }) it('should sync loading states', async () => { diff --git a/test/use-swr-updated-at.test.tsx b/test/use-swr-updated-at.test.tsx new file mode 100644 index 000000000..f8c067daf --- /dev/null +++ b/test/use-swr-updated-at.test.tsx @@ -0,0 +1,267 @@ +import { act, fireEvent, screen } from '@testing-library/react' +import React, { useEffect, useReducer } from 'react' +import useSWR from 'swr' +import { + createKey, + renderWithConfig, + nextTick as waitForNextTick, + sleep, + focusOn +} from './utils' + +const focusWindow = () => focusOn(window) + +describe('useSWR - updatedAt', () => { + it('should be initially undefined', async () => { + const key = createKey() + + const fetcher = () => { + return 'data' + } + + function Page() { + const { updatedAt } = useSWR(key, fetcher) + + return {updatedAt} + } + + renderWithConfig() + + expect(screen.getByTestId('updatedAt')).toBeEmptyDOMElement() + + await waitForNextTick() + + expect(screen.getByTestId('updatedAt')).not.toBeEmptyDOMElement() + }) + + it('should not trigger re-render if not consumed', async () => { + const key = createKey() + + const fetcher = () => { + return 'data' + } + + const renderSpy = jest.fn() + + function Page() { + const { data, isValidating } = useSWR(key, fetcher) + + // reading is validating just to trigger more renders + useEffect(() => {}, [isValidating]) + + renderSpy() + + return data: {data} + } + + renderWithConfig() + + expect(screen.getByText(/data/i)).toHaveTextContent('data:') + + expect(renderSpy).toHaveBeenCalledTimes(1) + + await waitForNextTick() + + expect(screen.getByText(/data/i)).toHaveTextContent('data: data') + + expect(renderSpy).toHaveBeenCalledTimes(2) + + await focusWindow() + + await waitForNextTick() + + expect(screen.getByText(/data/i)).toHaveTextContent('data: data') + + expect(renderSpy).toHaveBeenCalledTimes(4) + }) + + it('should updated when the fetcher is called', async () => { + const key = createKey() + + let fetcherCallTime: number + + const fetcher = () => { + fetcherCallTime = Date.now() + return 'data' + } + + const updateSpy = jest.fn() + + const TIME_INTERVAL = Math.floor(100 + Math.random() * 100) + + const config = { + dedupingInterval: TIME_INTERVAL + } + + function Page() { + const { data, updatedAt } = useSWR(key, fetcher, config) + + useEffect(() => { + updateSpy(data, updatedAt) + }, [data, updatedAt]) + + return ( +
+ data: {data} + updatedAt: {updatedAt} +
+ ) + } + + renderWithConfig() + + expect(updateSpy).toHaveBeenCalledTimes(1) + expect(updateSpy).toHaveBeenLastCalledWith(undefined, undefined) + + await waitForNextTick() + + expect(updateSpy).toHaveBeenCalledTimes(2) + const [data, updatedAt] = updateSpy.mock.calls[1] + + expect(data).toEqual('data') + expect(updatedAt).toBeDefined() + + expect(updatedAt).toBeGreaterThanOrEqual(fetcherCallTime) + + await sleep(config.dedupingInterval) + + await focusWindow() + await waitForNextTick() + + expect(updateSpy).toHaveBeenCalledTimes(3) + const [, lastUpdatedAt] = updateSpy.mock.calls[2] + + expect(lastUpdatedAt).toBeGreaterThanOrEqual(fetcherCallTime) + expect(lastUpdatedAt).toBeGreaterThan(updatedAt) + }) + + it('should be consistent in all hooks using the same key', async () => { + const key = createKey() + + const fetcher = () => 'data' + + const TIME_INTERVAL = Math.floor(100 + Math.random() * 100) + + const config = { + dedupingInterval: TIME_INTERVAL, + refreshInterval: TIME_INTERVAL * 2 + } + + const Dashboard = ({ + testId = 'testId', + children = null, + revalidateOnMount = false + }) => { + const { updatedAt } = useSWR(key, fetcher, { + ...config, + revalidateOnMount + }) + + return ( +
+ {updatedAt} + +
{children}
+
+ ) + } + + function Page() { + const { updatedAt } = useSWR(key, fetcher, config) + const [show, toggle] = useReducer(x => !x, false) + + return ( +
+ {updatedAt} + + + +
+ +
+ + {show && } +
+ ) + } + + renderWithConfig() + + // assert emptiness because the `updatedAt` prop is undefined + expect(screen.getByTestId('zero')).toBeEmptyDOMElement() + expect(screen.getByTestId('first')).toBeEmptyDOMElement() + + await waitForNextTick() + + expect(screen.getByTestId('zero')).not.toBeEmptyDOMElement() + expect(screen.getByTestId('first')).not.toBeEmptyDOMElement() + + const firstUpdatedAt = Number(screen.getByTestId('zero').textContent) + + // Assert that `first` agrees with `zero` + expect(screen.getByTestId('first')).toHaveTextContent( + screen.getByTestId('zero').textContent + ) + + fireEvent.click(screen.getByRole('button', { name: 'show' })) + + // Assert that when it mounts, `second` has no knowledge of `updatedAt` + expect(screen.getByTestId('second')).toBeEmptyDOMElement() + + // wait for the refresh interval + await act(async () => { + await sleep(config.refreshInterval) + }) + + expect(Number(screen.getByTestId('zero').textContent)).toBeGreaterThan( + firstUpdatedAt + ) + + expect(Number(screen.getByTestId('first').textContent)).toBeGreaterThan( + firstUpdatedAt + ) + + expect(Number(screen.getByTestId('second').textContent)).toBeGreaterThan( + firstUpdatedAt + ) + + // transitively check that all hooks continue to agree on the `updatedAt` value for `key` + expect(screen.getByTestId('zero')).toHaveTextContent( + screen.getByTestId('first').textContent + ) + + expect(screen.getByTestId('first')).toHaveTextContent( + screen.getByTestId('second').textContent + ) + + const secondUpdateAt = Number(screen.getByTestId('zero').textContent) + + // let the deduping interval run + await sleep(config.dedupingInterval) + + // trigger revalidation by focus - all `updatedAt` keys should continue to agree + await focusWindow() + await waitForNextTick() + + expect(Number(screen.getByTestId('zero').textContent)).toBeGreaterThan( + secondUpdateAt + ) + + expect(Number(screen.getByTestId('first').textContent)).toBeGreaterThan( + secondUpdateAt + ) + + expect(Number(screen.getByTestId('second').textContent)).toBeGreaterThan( + secondUpdateAt + ) + + // transitively check that all hooks continue to agree on the `updatedAt` value for `key` + expect(screen.getByTestId('zero')).toHaveTextContent( + screen.getByTestId('first').textContent + ) + + expect(screen.getByTestId('first')).toHaveTextContent( + screen.getByTestId('second').textContent + ) + }) +})