Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})
}
} 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