diff --git a/.changeset/hungry-planes-tease.md b/.changeset/hungry-planes-tease.md new file mode 100644 index 00000000000..e551be1a23b --- /dev/null +++ b/.changeset/hungry-planes-tease.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': patch +--- + +Hydrate deferred queries in a layout effect so a remounting `useQuery` no longer refetches data the dehydrated state already contains. diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index 901c8e9686c..bca61ca6e79 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { hydrate } from '@tanstack/query-core' +import { hydrate, isServer } from '@tanstack/query-core' import { useQueryClient } from './QueryClientProvider' import type { DehydratedState, @@ -10,6 +10,12 @@ import type { QueryClient, } from '@tanstack/query-core' +// Hook choice has to be static, so this intentionally uses the static +// isServer check instead of environmentManager +const useIsomorphicLayoutEffect = isServer + ? React.useEffect + : React.useLayoutEffect + export interface HydrationBoundaryProps { state: DehydratedState | null | undefined options?: OmitKeyof & { @@ -31,7 +37,7 @@ export const HydrationBoundary = ({ const client = useQueryClient(queryClient) const optionsRef = React.useRef(options) - React.useEffect(() => { + useIsomorphicLayoutEffect(() => { optionsRef.current = options }) @@ -101,7 +107,14 @@ export const HydrationBoundary = ({ return undefined }, [client, state]) - React.useEffect(() => { + // This must be a layout effect so the queue is hydrated before any + // useSyncExternalStore subscriptions in children run in their passive + // effects. A remounting observer that subscribes before hydration would + // see the old, possibly stale data and kick off a redundant refetch of + // the data the dehydrated state already contains. Layout effects still + // only run when the tree commits, so aborted transitions keep discarding + // the queue. + useIsomorphicLayoutEffect(() => { if (hydrationQueue) { hydrate(client, { queries: hydrationQueue }, optionsRef.current) } diff --git a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx index 25f481a22d0..a72889d5611 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -149,14 +149,15 @@ describe('React hydration', () => { , ) - // Existing observer should not have updated at this point, - // as that would indicate a side effect in the render phase - expect(rendered.getByText('string')).toBeInTheDocument() + // The existing observer picks up the hydrated data once effects have + // flushed, but not during the render phase (the aborted transition + // test guards the render phase) + expect(rendered.getByText('should change')).toBeInTheDocument() // New query data should be available immediately expect(rendered.getByText('added')).toBeInTheDocument() await vi.advanceTimersByTimeAsync(0) - // After effects phase has had time to run, the observer should have updated + // Nothing changes after the effects phase has had time to run expect(rendered.queryByText('string')).not.toBeInTheDocument() expect(rendered.getByText('should change')).toBeInTheDocument() @@ -481,6 +482,65 @@ describe('React hydration', () => { clientQueryClient.clear() }) + it('should not refetch an inactive query when hydrated data is fresh', async () => { + const queryClient = new QueryClient() + const queryFn = vi.fn(() => sleep(10).then(() => 'client')) + + function Page() { + const { data } = useQuery({ + queryKey: ['data'], + queryFn, + staleTime: 1000, + }) + return
{data}
+ } + + // First visit fetches and caches the data + const rendered = render( + + + , + ) + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('client')).toBeInTheDocument() + + // Navigate away; the cached data goes stale while the page is unmounted + rendered.rerender( + +
+ , + ) + await vi.advanceTimersByTimeAsync(2000) + + // A loader fetches fresh data on the revisit and dehydrates it + const loaderClient = new QueryClient() + loaderClient.prefetchQuery({ + queryKey: ['data'], + queryFn: () => sleep(10).then(() => 'loader'), + }) + await vi.advanceTimersByTimeAsync(10) + const dehydratedState = dehydrate(loaderClient) + loaderClient.clear() + + queryFn.mockClear() + rendered.rerender( + + + + + , + ) + + // Hydration lands before the remounted useQuery subscribes, so the + // fresh data is used as is instead of triggering a refetch + expect(rendered.getByText('loader')).toBeInTheDocument() + await vi.advanceTimersByTimeAsync(11) + expect(queryFn).toHaveBeenCalledTimes(0) + expect(rendered.getByText('loader')).toBeInTheDocument() + + queryClient.clear() + }) + it('should not refetch when query has enabled set to false', async () => { const queryFn = vi.fn() const queryClient = new QueryClient()