Skip to content

Commit ab12b64

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

3 files changed

Lines changed: 137 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: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,14 +149,16 @@ 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 has the hydrated data as soon as the tree
153+
// commits, the layout effect has already run by this line. It still
154+
// doesn't happen during render, that's what the aborted transition
155+
// test guards
156+
expect(rendered.getByText('should change')).toBeInTheDocument()
155157
// New query data should be available immediately
156158
expect(rendered.getByText('added')).toBeInTheDocument()
157159

158160
await vi.advanceTimersByTimeAsync(0)
159-
// After effects phase has had time to run, the observer should have updated
161+
// Nothing changes after the effects phase has had time to run
160162
expect(rendered.queryByText('string')).not.toBeInTheDocument()
161163
expect(rendered.getByText('should change')).toBeInTheDocument()
162164

@@ -481,6 +483,116 @@ describe('React hydration', () => {
481483
clientQueryClient.clear()
482484
})
483485

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

0 commit comments

Comments
 (0)