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/shy-wings-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-core': patch
---

Fix bugs where hydrating queries with promises that had already resolved could cause queries to briefly and incorrectly show as pending/fetching
173 changes: 173 additions & 0 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1385,4 +1385,177 @@ describe('dehydration and rehydration', () => {
// error and test will fail
await originalPromise
})

// Companion to the test above: when the query already exists in the cache
// (e.g. after an initial render or a first hydration pass), the same
// synchronous thenable resolution must also produce status: 'success'.
// Previously the if (query) branch would spread status: 'pending' from the
// server state without correcting it for the resolved data.
test('should set status to success when rehydrating an existing pending query with a synchronously resolved promise', 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
})
// Keep the query pending so it dehydrates with status: 'pending' and a promise
void serverQueryClient.prefetchQuery({
queryKey: key,
queryFn: () => prefetchPromise,
})

const dehydrated = dehydrate(serverQueryClient)
expect(dehydrated.queries[0]?.state.status).toBe('pending')

// Simulate a synchronous thenable – models a React streaming promise that
// resolved before the second hydrate() call.
resolvePrefetch?.('server data')
// @ts-expect-error
dehydrated.queries[0].promise.then = (cb) => {
cb?.('server data')
// @ts-expect-error
return dehydrated.queries[0].promise
}

// --- client ---
// Query already exists in the cache in a pending state, as it would after
// a first hydration pass or an initial render.
const clientQueryClient = new QueryClient()
void clientQueryClient.prefetchQuery({
queryKey: key,
queryFn: () => {
throw new Error('QueryFn on client should not be called')
},
})

const query = clientQueryClient.getQueryCache().find({ queryKey: key })!
expect(query.state.status).toBe('pending')

hydrate(clientQueryClient, dehydrated)

expect(clientQueryClient.getQueryData(key)).toBe('server data')
expect(query.state.status).toBe('success')

clientQueryClient.clear()
serverQueryClient.clear()
})

test('should not transition to a fetching/pending state when hydrating an already resolved promise into a new 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')
// @ts-expect-error
dehydrated.queries[0].promise.then = (cb) => {
cb?.('server data')
// @ts-expect-error
return dehydrated.queries[0].promise
}

// --- client ---
const clientQueryClient = new QueryClient()

const states: Array<{ status: string; fetchStatus: string }> = []
const unsubscribe = 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)
unsubscribe()

expect(states).not.toContainEqual(
expect.objectContaining({ fetchStatus: 'fetching' }),
)
expect(states).not.toContainEqual(
expect.objectContaining({ status: 'pending' }),
)

clientQueryClient.clear()
serverQueryClient.clear()
})

test('should not transition to a fetching/pending state when hydrating an already resolved promise into an existing 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')
// @ts-expect-error
dehydrated.queries[0].promise.then = (cb) => {
cb?.('server data')
// @ts-expect-error
return dehydrated.queries[0].promise
}

// --- client ---
// Pre-populate with old data (updatedAt: 0 ensures dehydratedAt is newer)
const clientQueryClient = new QueryClient()
clientQueryClient.setQueryData(key, 'old data', { updatedAt: 0 })

const states: Array<{ status: string; fetchStatus: string }> = []
const unsubscribe = 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)
unsubscribe()

expect(states).not.toContainEqual(
expect.objectContaining({ fetchStatus: 'fetching' }),
)
expect(states).not.toContainEqual(
expect.objectContaining({ status: 'pending' }),
)

clientQueryClient.clear()
serverQueryClient.clear()
})
})
14 changes: 12 additions & 2 deletions packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,15 @@ export function hydrate(
query.setState({
...serializedState,
data,
// if data was resolved synchronously, transition to success
// (mirrors the new-query branch below), but preserve fetchStatus
// if the query is already actively fetching
...(data !== undefined && {
status: 'success' as const,
...(!existingQueryIsFetching && {
fetchStatus: 'idle' as const,
}),
}),
Comment on lines +239 to +247
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope the success override to resolved pending queries only.

This now rewrites any hydrated existing query with data !== undefined to status: 'success'. Queries can legitimately be status: 'error' while still holding prior data after a background refetch failure, and callers can opt into dehydrating those. In that case hydration will silently clear the error state. Limit the override to the pending-promise case that this PR is fixing.

Suggested fix
-            ...(data !== undefined && {
+            ...(state.status === 'pending' && data !== undefined && {
               status: 'success' as const,
               ...(!existingQueryIsFetching && {
                 fetchStatus: 'idle' as const,
               }),
             }),
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// if data was resolved synchronously, transition to success
// (mirrors the new-query branch below), but preserve fetchStatus
// if the query is already actively fetching
...(data !== undefined && {
status: 'success' as const,
...(!existingQueryIsFetching && {
fetchStatus: 'idle' as const,
}),
}),
// if data was resolved synchronously, transition to success
// (mirrors the new-query branch below), but preserve fetchStatus
// if the query is already actively fetching
...(state.status === 'pending' && data !== undefined && {
status: 'success' as const,
...(!existingQueryIsFetching && {
fetchStatus: 'idle' as const,
}),
}),
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/query-core/src/hydration.ts` around lines 239 - 247, The current
hydration always forces status: 'success' whenever data !== undefined, which
wipes error states; restrict that override to only when the existing query was a
resolved pending promise by adding a guard (e.g., compute an
existingQueryIsPending boolean by checking the existing query's status ===
'pending') and apply the status: 'success' branch only when data !== undefined
&& existingQueryIsPending (keeping the existing fetchStatus preservation via
existingQueryIsFetching). Locate the block in hydration.ts where data and
existingQueryIsFetching are used and add the pending-status check before setting
status: 'success'.

})
}
} else {
Expand All @@ -262,6 +271,9 @@ export function hydrate(

if (
promise &&
// 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 &&
// Only hydrate if dehydration is newer than any existing data,
Expand All @@ -270,8 +282,6 @@ export function hydrate(
) {
// This doesn't actually fetch - it just creates a retryer
// which will re-use the passed `initialPromise`
// Note that we need to call these even when data was synchronously
// available, as we still need to set up the retryer
query
.fetch(undefined, {
// RSC transformed promises are not thenable
Expand Down
Loading