Skip to content

Commit fca2f4c

Browse files
committed
fix(react-query): hydrate deferred queries before observers subscribe to avoid a redundant refetch
1 parent 46d7f02 commit fca2f4c

3 files changed

Lines changed: 136 additions & 7 deletions

File tree

.changeset/hungry-planes-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/react-query': patch
3+
---
4+
5+
Hydrate deferred queries in a layout effect so a remounting `useQuery` no longer refetches data the dehydrated state already contains.

packages/react-query/src/HydrationBoundary.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22
import * as React from 'react'
33

4-
import { hydrate } from '@tanstack/query-core'
4+
import { hydrate, isServer } from '@tanstack/query-core'
55
import { useQueryClient } from './QueryClientProvider'
66
import type {
77
DehydratedState,
@@ -10,6 +10,12 @@ import type {
1010
QueryClient,
1111
} from '@tanstack/query-core'
1212

13+
// Hook choice has to be static, so this intentionally uses the static
14+
// isServer check instead of environmentManager
15+
const useIsomorphicLayoutEffect = isServer
16+
? React.useEffect
17+
: React.useLayoutEffect
18+
1319
export interface HydrationBoundaryProps {
1420
state: DehydratedState | null | undefined
1521
options?: OmitKeyof<HydrateOptions, 'defaultOptions'> & {
@@ -31,7 +37,7 @@ export const HydrationBoundary = ({
3137
const client = useQueryClient(queryClient)
3238

3339
const optionsRef = React.useRef(options)
34-
React.useEffect(() => {
40+
useIsomorphicLayoutEffect(() => {
3541
optionsRef.current = options
3642
})
3743

@@ -101,7 +107,14 @@ export const HydrationBoundary = ({
101107
return undefined
102108
}, [client, state])
103109

104-
React.useEffect(() => {
110+
// This must be a layout effect so the queue is hydrated before any
111+
// useSyncExternalStore subscriptions in children run in their passive
112+
// effects. A remounting observer that subscribes before hydration would
113+
// see the old, possibly stale data and kick off a redundant refetch of
114+
// the data the dehydrated state already contains. Layout effects still
115+
// only run when the tree commits, so aborted transitions keep discarding
116+
// the queue.
117+
useIsomorphicLayoutEffect(() => {
105118
if (hydrationQueue) {
106119
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
107120
}

packages/react-query/src/__tests__/HydrationBoundary.test.tsx

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,14 +149,15 @@ describe('React hydration', () => {
149149
</QueryClientProvider>,
150150
)
151151

152-
// Existing observer should not have updated at this point,
153-
// as that would indicate a side effect in the render phase
154-
expect(rendered.getByText('string')).toBeInTheDocument()
152+
// The existing observer picks up the hydrated data once effects have
153+
// flushed, but not during the render phase (the aborted transition
154+
// test guards the render phase)
155+
expect(rendered.getByText('should change')).toBeInTheDocument()
155156
// New query data should be available immediately
156157
expect(rendered.getByText('added')).toBeInTheDocument()
157158

158159
await vi.advanceTimersByTimeAsync(0)
159-
// After effects phase has had time to run, the observer should have updated
160+
// Nothing changes after the effects phase has had time to run
160161
expect(rendered.queryByText('string')).not.toBeInTheDocument()
161162
expect(rendered.getByText('should change')).toBeInTheDocument()
162163

@@ -481,6 +482,116 @@ describe('React hydration', () => {
481482
clientQueryClient.clear()
482483
})
483484

485+
it('should not refetch an inactive query when hydrated data is fresh', async () => {
486+
const queryClient = new QueryClient()
487+
const queryFn = vi.fn(() => sleep(10).then(() => 'client'))
488+
489+
function Page() {
490+
const { data } = useQuery({
491+
queryKey: ['data'],
492+
queryFn,
493+
staleTime: 1000,
494+
})
495+
return <div>{data}</div>
496+
}
497+
498+
// First visit fetches and caches the data
499+
const rendered = render(
500+
<QueryClientProvider client={queryClient}>
501+
<Page />
502+
</QueryClientProvider>,
503+
)
504+
await vi.advanceTimersByTimeAsync(11)
505+
expect(rendered.getByText('client')).toBeInTheDocument()
506+
507+
// Navigate away; the cached data goes stale while the page is unmounted
508+
rendered.rerender(
509+
<QueryClientProvider client={queryClient}>
510+
<div />
511+
</QueryClientProvider>,
512+
)
513+
await vi.advanceTimersByTimeAsync(2000)
514+
515+
// A loader fetches fresh data on the revisit and dehydrates it
516+
const loaderClient = new QueryClient()
517+
loaderClient.prefetchQuery({
518+
queryKey: ['data'],
519+
queryFn: () => sleep(10).then(() => 'loader'),
520+
})
521+
await vi.advanceTimersByTimeAsync(10)
522+
const dehydratedState = dehydrate(loaderClient)
523+
loaderClient.clear()
524+
525+
queryFn.mockClear()
526+
rendered.rerender(
527+
<QueryClientProvider client={queryClient}>
528+
<HydrationBoundary state={dehydratedState}>
529+
<Page />
530+
</HydrationBoundary>
531+
</QueryClientProvider>,
532+
)
533+
534+
// Hydration lands before the remounted useQuery subscribes, so the
535+
// fresh data is used as is instead of triggering a refetch
536+
expect(rendered.getByText('loader')).toBeInTheDocument()
537+
await vi.advanceTimersByTimeAsync(11)
538+
expect(queryFn).toHaveBeenCalledTimes(0)
539+
expect(rendered.getByText('loader')).toBeInTheDocument()
540+
541+
queryClient.clear()
542+
})
543+
544+
it('should not refetch a query that remounts in the same commit as the boundary', async () => {
545+
const queryClient = new QueryClient()
546+
const queryFn = vi.fn(() => sleep(10).then(() => 'client'))
547+
548+
function Page() {
549+
const { data } = useQuery({
550+
queryKey: ['data'],
551+
queryFn,
552+
staleTime: 1000,
553+
})
554+
return <div>{data}</div>
555+
}
556+
557+
const rendered = render(
558+
<QueryClientProvider client={queryClient}>
559+
<Page />
560+
</QueryClientProvider>,
561+
)
562+
await vi.advanceTimersByTimeAsync(11)
563+
expect(rendered.getByText('client')).toBeInTheDocument()
564+
await vi.advanceTimersByTimeAsync(2000)
565+
566+
const loaderClient = new QueryClient()
567+
loaderClient.prefetchQuery({
568+
queryKey: ['data'],
569+
queryFn: () => sleep(10).then(() => 'loader'),
570+
})
571+
await vi.advanceTimersByTimeAsync(10)
572+
const dehydratedState = dehydrate(loaderClient)
573+
loaderClient.clear()
574+
575+
queryFn.mockClear()
576+
// Wrapping the page in the boundary remounts it, so the old observer is
577+
// torn down and a new one subscribes in one go. The old subscription is
578+
// only cleaned up in the passive phase, so it is still on the query while
579+
// this commit's layout effects run
580+
rendered.rerender(
581+
<QueryClientProvider client={queryClient}>
582+
<HydrationBoundary state={dehydratedState}>
583+
<Page />
584+
</HydrationBoundary>
585+
</QueryClientProvider>,
586+
)
587+
588+
expect(rendered.getByText('loader')).toBeInTheDocument()
589+
await vi.advanceTimersByTimeAsync(11)
590+
expect(queryFn).toHaveBeenCalledTimes(0)
591+
592+
queryClient.clear()
593+
})
594+
484595
it('should not refetch when query has enabled set to false', async () => {
485596
const queryFn = vi.fn()
486597
const queryClient = new QueryClient()

0 commit comments

Comments
 (0)