Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hungry-planes-tease.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 16 additions & 3 deletions packages/react-query/src/HydrationBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<HydrateOptions, 'defaultOptions'> & {
Expand All @@ -31,7 +37,7 @@ export const HydrationBoundary = ({
const client = useQueryClient(queryClient)

const optionsRef = React.useRef(options)
React.useEffect(() => {
useIsomorphicLayoutEffect(() => {
optionsRef.current = options
})

Expand Down Expand Up @@ -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)
}
Expand Down
68 changes: 64 additions & 4 deletions packages/react-query/src/__tests__/HydrationBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,15 @@ describe('React hydration', () => {
</QueryClientProvider>,
)

// 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()

Expand Down Expand Up @@ -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 <div>{data}</div>
}

// First visit fetches and caches the data
const rendered = render(
<QueryClientProvider client={queryClient}>
<Page />
</QueryClientProvider>,
)
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('client')).toBeInTheDocument()

// Navigate away; the cached data goes stale while the page is unmounted
rendered.rerender(
<QueryClientProvider client={queryClient}>
<div />
</QueryClientProvider>,
)
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(
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
<Page />
</HydrationBoundary>
</QueryClientProvider>,
)

// 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()
Expand Down