Skip to content
Merged
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.
it('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()
})

it('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()
})

it('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()
})
})
28 changes: 23 additions & 5 deletions packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,12 +230,24 @@ export function hydrate(
state.dataUpdatedAt > query.state.dataUpdatedAt ||
hasNewerSyncData
) {
// omit fetchStatus from dehydrated state
// so that query stays in its current fetchStatus
// Omit fetchStatus from dehydrated state so that query stays in its current fetchStatus
const { fetchStatus: _ignored, ...serializedState } = state
query.setState({
...serializedState,
data,
// If the query was pending at the moment of dehydration, but resolved to have data
// before hydration, we can assume the query should be hydrated as successful.
//
// 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,
}),
}),
})
}
} else {
Expand All @@ -255,13 +267,21 @@ export function hydrate(
...state,
data,
fetchStatus: 'idle',
status: data !== undefined ? 'success' : state.status,
// 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,
},
)
}

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 +290,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