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
6 changes: 6 additions & 0 deletions .changeset/moody-cities-stand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/react-query': patch
'@tanstack/query-core': patch
---

prevent registered useQueries from skipping hydration
73 changes: 73 additions & 0 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { QueryClient } from '../queryClient'
import { QueryCache } from '../queryCache'
import { QueryObserver } from '../queryObserver'
import { dehydrate, hydrate } from '../hydration'
import { MutationCache } from '../mutationCache'
import { executeMutation, mockOnlineManagerIsOnline } from './utils'
Expand Down Expand Up @@ -1804,4 +1805,76 @@ describe('dehydration and rehydration', () => {
clientQueryClient.clear()
serverQueryClient.clear()
})

it('should not transition to a fetching/pending state when hydrating an already resolved promise into an idle pending query', async () => {
const key = queryKey()
// --- server ---
const serverQueryClient = new QueryClient({
defaultOptions: {
dehydrate: { shouldDehydrateQuery: () => true },
},
})

let resolvePrefetch: undefined | ((value?: unknown) => void)
const prefetchPromise = new Promise((res) => {
resolvePrefetch = res
})
void serverQueryClient.prefetchQuery({
queryKey: key,
queryFn: () => prefetchPromise,
})
const dehydrated = dehydrate(serverQueryClient)

// Simulate a synchronous thenable - the promise was already resolved
// before we hydrate on the client.
resolvePrefetch?.('server data')
Object.assign(dehydrated.queries[0]!.promise!, {
then: (cb: ((value: unknown) => unknown) | undefined) => {
cb?.('server data')
return dehydrated.queries[0]!.promise!
},
})

// --- client ---
// This matches a useQuery({ enabled: false }) shell that exists before
// HydrationBoundary hydrates the streamed query.
const clientQueryClient = new QueryClient()
const observer = new QueryObserver(clientQueryClient, {
queryKey: key,
enabled: false,
})
const unsubscribeObserver = observer.subscribe(() => undefined)
const query = clientQueryClient.getQueryCache().find({ queryKey: key })!
expect(query.state).toMatchObject({
dataUpdatedAt: 0,
status: 'pending',
fetchStatus: 'idle',
})

const states: Array<{ status: string; fetchStatus: string }> = []
const unsubscribeCache = clientQueryClient
.getQueryCache()
.subscribe((event) => {
if (event.type === 'updated') {
const { status, fetchStatus } = event.query.state
states.push({ status, fetchStatus })
}
})

hydrate(clientQueryClient, dehydrated)
await vi.advanceTimersByTimeAsync(0)
unsubscribeCache()
unsubscribeObserver()

expect(clientQueryClient.getQueryData(key)).toBe('server data')
expect(states).not.toContainEqual(
expect.objectContaining({ fetchStatus: 'fetching' }),
)
expect(states).not.toContainEqual(
expect.objectContaining({ status: 'pending' }),
)

clientQueryClient.clear()
serverQueryClient.clear()
})
})
34 changes: 21 additions & 13 deletions packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,17 @@ export function hydrate(
const syncData = promise ? tryResolveSync(promise) : undefined
const rawData = state.data === undefined ? syncData?.data : state.data
const data = rawData === undefined ? rawData : deserializeData(rawData)
const pendingQueryResolvedSync =
state.status === 'pending' && data !== undefined

let query = queryCache.get(queryHash)
const existingQueryIsUndefined = !query
const existingQueryIsPending = query?.state.status === 'pending'
const existingQueryIsFetching = query?.state.fetchStatus === 'fetching'
const existingQueryIsIdleUseQuery =
query?.state.dataUpdatedAt === 0 &&
query.state.status === 'pending' &&
query.state.fetchStatus === 'idle'

// Do not hydrate if an existing query exists with newer data
if (query) {
Expand All @@ -250,14 +257,14 @@ export function hydrate(
//
// Since you can opt into dehydrating failed queries, and those can have data from
// previous successful fetches, we make sure we only do this for pending queries.
...(state.status === 'pending' &&
data !== undefined && {
status: 'success' as const,
// Preserve existing fetchStatus if the existing query is actively fetching.
...(!existingQueryIsFetching && {
fetchStatus: 'idle' as const,
}),
...(pendingQueryResolvedSync && {
status: 'success' as const,
dataUpdatedAt: dehydratedAt ?? Date.now(),
// Preserve existing fetchStatus if the existing query is actively fetching.
...(!existingQueryIsFetching && {
fetchStatus: 'idle' as const,
}),
}),
})
}
} else {
Expand All @@ -280,10 +287,10 @@ export function hydrate(
fetchStatus: 'idle',
// Like above, if the query was pending at the moment of dehydration but has data,
// we can assume it should be hydrated as successful.
status:
state.status === 'pending' && data !== undefined
? 'success'
: state.status,
status: pendingQueryResolvedSync ? 'success' : state.status,
...(pendingQueryResolvedSync && {
dataUpdatedAt: dehydratedAt ?? Date.now(),
}),
},
)
}
Expand All @@ -293,8 +300,9 @@ export function hydrate(
// If the data was synchronously available, there is no need to set up
// a retryer and thus no reason to call fetch
!syncData &&
!existingQueryIsPending &&
!existingQueryIsFetching &&
(existingQueryIsIdleUseQuery ||
existingQueryIsUndefined ||
(!existingQueryIsPending && !existingQueryIsFetching)) &&
// Only hydrate if dehydration is newer than any existing data,
// this is always true for new queries
(dehydratedAt === undefined || dehydratedAt > query.state.dataUpdatedAt)
Expand Down
6 changes: 5 additions & 1 deletion packages/react-query/src/HydrationBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ export const HydrationBoundary = ({
const existingQueries: DehydratedState['queries'] = []
for (const dehydratedQuery of queries) {
const existingQuery = queryCache.get(dehydratedQuery.queryHash)
const existingQueryIsIdleUseQuery =
existingQuery?.state.dataUpdatedAt === 0 &&
existingQuery.state.status === 'pending' &&
existingQuery.state.fetchStatus === 'idle'

if (!existingQuery) {
if (!existingQuery || existingQueryIsIdleUseQuery) {
newQueries.push(dehydratedQuery)
} else {
const hydrationIsNewer =
Expand Down
146 changes: 146 additions & 0 deletions packages/react-query/src/__tests__/HydrationBoundary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
HydrationBoundary,
QueryClient,
QueryClientProvider,
defaultShouldDehydrateQuery,
dehydrate,
useQuery,
useSuspenseQuery,
} from '..'
import type { hydrate } from '@tanstack/query-core'

Expand Down Expand Up @@ -481,6 +483,150 @@ describe('React hydration', () => {
clientQueryClient.clear()
})

it('should hydrate pending idle queries in render to avoid suspense refetches', async () => {
const queryKey = ['string'] as const

const makeQueryClient = () =>
new QueryClient({
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
shouldRedactErrors: () => false,
},
},
})

const prefetchClient = makeQueryClient()
void prefetchClient.prefetchQuery({
queryKey,
queryFn: () => Promise.resolve(['stringCached']),
staleTime: Infinity,
})
const dehydratedState = dehydrate(prefetchClient)

const queryFn = vi.fn(() => Promise.resolve(['string']))
const suspenseQueryFn = vi.fn(() => Promise.resolve(['string']))
const queryClient = new QueryClient()

function Header() {
useQuery({
queryKey,
queryFn,
enabled: false,
})
return null
}

function Page() {
const { data } = useSuspenseQuery({
queryKey,
queryFn: suspenseQueryFn,
})
return <div>{data}</div>
}

render(
<QueryClientProvider client={queryClient}>
<Header />
<HydrationBoundary state={dehydratedState}>
<React.Suspense fallback="loading">
<Page />
</React.Suspense>
</HydrationBoundary>
</QueryClientProvider>,
)

await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(1)
expect(queryClient.getQueryData(queryKey)).toEqual(['stringCached'])
expect(suspenseQueryFn).toHaveBeenCalledTimes(0)

queryClient.clear()
})

it('should hydrate synchronously resolved pending idle queries in render', () => {
const queryKey = ['string'] as const

const makeQueryClient = () =>
new QueryClient({
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
shouldRedactErrors: () => false,
},
},
})

const prefetchClient = makeQueryClient()
let resolvePrefetch: undefined | ((value: Array<string>) => void)
const prefetchPromise = new Promise<Array<string>>((resolve) => {
resolvePrefetch = resolve
})
void prefetchClient.prefetchQuery({
queryKey,
queryFn: () => prefetchPromise,
staleTime: Infinity,
})
const dehydratedState = dehydrate(prefetchClient)

resolvePrefetch?.(['stringCached'])
// Simulate a synchronously resolved thenable, like React can provide for
// an already resolved streamed promise.
// @ts-expect-error
dehydratedState.queries[0].promise.then = (cb) => {
cb?.(['stringCached'])
// @ts-expect-error
return dehydratedState.queries[0].promise
}

const queryFn = vi.fn(() => Promise.resolve(['string']))
const suspenseQueryFn = vi.fn(() => Promise.resolve(['string']))
const queryClient = new QueryClient()

function Header() {
useQuery({
queryKey,
queryFn,
enabled: false,
})
return null
}

function Page() {
const { data } = useSuspenseQuery({
queryKey,
queryFn: suspenseQueryFn,
})
return <div>{data}</div>
}

const rendered = render(
<QueryClientProvider client={queryClient}>
<Header />
<HydrationBoundary state={dehydratedState}>
<React.Suspense fallback="loading">
<Page />
</React.Suspense>
</HydrationBoundary>
</QueryClientProvider>,
)

expect(rendered.queryByText('loading')).not.toBeInTheDocument()
expect(rendered.getByText('stringCached')).toBeInTheDocument()
expect(queryClient.getQueryData(queryKey)).toEqual(['stringCached'])
expect(queryFn).toHaveBeenCalledTimes(0)
expect(suspenseQueryFn).toHaveBeenCalledTimes(0)

queryClient.clear()
prefetchClient.clear()
})

it('should not refetch when query has enabled set to false', async () => {
const queryFn = vi.fn()
const queryClient = new QueryClient()
Expand Down
Loading