Skip to content
3 changes: 2 additions & 1 deletion packages/query-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
],
"devDependencies": {
"@tanstack/query-test-utils": "workspace:*",
"npm-run-all2": "^5.0.0"
"npm-run-all2": "^5.0.0",
"superjson": "^2.2.2"
}
}
91 changes: 59 additions & 32 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import assert from 'node:assert'
import { sleep } from '@tanstack/query-test-utils'
import { QueryClient } from '../queryClient'
import { QueryCache } from '../queryCache'
import superjson from 'superjson'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { dehydrate, hydrate } from '../hydration'
import { MutationCache } from '../mutationCache'
import { QueryCache } from '../queryCache'
import { QueryClient } from '../queryClient'
import { executeMutation, mockOnlineManagerIsOnline } from './utils'

describe('dehydration and rehydration', () => {
Expand Down Expand Up @@ -40,6 +42,7 @@ describe('dehydration and rehydration', () => {
await vi.waitFor(() =>
queryClient.prefetchQuery({
queryKey: ['null'],

queryFn: () => sleep(0).then(() => null),
}),
)
Expand Down Expand Up @@ -971,7 +974,7 @@ describe('dehydration and rehydration', () => {
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
serializeData: (data) => data.toISOString(),
serializeData: superjson.serialize,
},
},
})
Expand All @@ -986,7 +989,7 @@ describe('dehydration and rehydration', () => {
const hydrationClient = new QueryClient({
defaultOptions: {
hydrate: {
deserializeData: (data) => new Date(data),
deserializeData: superjson.deserialize,
},
},
})
Expand All @@ -1007,7 +1010,7 @@ describe('dehydration and rehydration', () => {
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
serializeData: (data) => data.toISOString(),
serializeData: superjson.serialize,
},
},
})
Expand All @@ -1022,7 +1025,7 @@ describe('dehydration and rehydration', () => {
const hydrationClient = new QueryClient({
defaultOptions: {
hydrate: {
deserializeData: (data) => new Date(data),
deserializeData: superjson.deserialize,
},
},
})
Expand All @@ -1042,7 +1045,7 @@ describe('dehydration and rehydration', () => {
const hydrationClient = new QueryClient({
defaultOptions: {
hydrate: {
deserializeData: (data) => new Date(data),
deserializeData: superjson.deserialize,
},
},
})
Expand All @@ -1060,7 +1063,7 @@ describe('dehydration and rehydration', () => {
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
serializeData: (data) => data.toISOString(),
serializeData: superjson.serialize,
},
},
})
Expand Down Expand Up @@ -1178,16 +1181,12 @@ describe('dehydration and rehydration', () => {
})

test('should overwrite data when a new promise is streamed in', async () => {
const serializeDataMock = vi.fn((data: any) => data)
const deserializeDataMock = vi.fn((data: any) => data)

const countRef = { current: 0 }
// --- server ---
const serverQueryClient = new QueryClient({
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
serializeData: serializeDataMock,
},
},
})
Expand All @@ -1206,13 +1205,7 @@ describe('dehydration and rehydration', () => {

// --- client ---

const clientQueryClient = new QueryClient({
defaultOptions: {
hydrate: {
deserializeData: deserializeDataMock,
},
},
})
const clientQueryClient = new QueryClient()

hydrate(clientQueryClient, dehydrated)

Expand All @@ -1221,12 +1214,6 @@ describe('dehydration and rehydration', () => {
expect(clientQueryClient.getQueryData(query.queryKey)).toBe(0),
)

expect(serializeDataMock).toHaveBeenCalledTimes(1)
expect(serializeDataMock).toHaveBeenCalledWith(0)

expect(deserializeDataMock).toHaveBeenCalledTimes(1)
expect(deserializeDataMock).toHaveBeenCalledWith(0)

// --- server ---
countRef.current++
serverQueryClient.clear()
Expand All @@ -1243,12 +1230,6 @@ describe('dehydration and rehydration', () => {
expect(clientQueryClient.getQueryData(query.queryKey)).toBe(1),
)

expect(serializeDataMock).toHaveBeenCalledTimes(2)
expect(serializeDataMock).toHaveBeenCalledWith(1)

expect(deserializeDataMock).toHaveBeenCalledTimes(2)
expect(deserializeDataMock).toHaveBeenCalledWith(1)

clientQueryClient.clear()
serverQueryClient.clear()
})
Expand Down Expand Up @@ -1400,4 +1381,50 @@ describe('dehydration and rehydration', () => {
// error and test will fail
await originalPromise
})

test('should serialize and deserialize query keys', () => {
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
dehydrate: {
serializeData: superjson.serialize,
},
hydrate: {
deserializeData: superjson.deserialize,
},
},
})

const getFirstEntry = (client: QueryClient) => {
const [entry] = client.getQueryCache().getAll()
assert(entry, 'cache should not be empty')
return entry
}

const serverClient = createQueryClient()

// Make a query key that isn't plain javascript object
const queryKey = ['date', new Date('2024-01-01T00:00:00.000Z')] as const

serverClient.setQueryData(queryKey, {
foo: 'bar',
})

const serverEntry = getFirstEntry(serverClient)

// use JSON.parse(JSON.stringify()) to mock a http roundtrip
const dehydrated = JSON.parse(JSON.stringify(dehydrate(serverClient)))

const frontendClient = createQueryClient()

hydrate(frontendClient, dehydrated)

const clientEntry = getFirstEntry(frontendClient)

expect(clientEntry.queryKey).toEqual(queryKey)
expect(clientEntry.queryKey).toEqual(serverEntry.queryKey)
expect(clientEntry.queryHash).toEqual(serverEntry.queryHash)

expect(clientEntry).toMatchObject(serverEntry)
})
})
132 changes: 65 additions & 67 deletions packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function dehydrateQuery(
data: serializeData(query.state.data),
}),
},
queryKey: query.queryKey,
queryKey: serializeData(query.queryKey),
queryHash: query.queryHash,
...(query.state.status === 'pending' && {
promise: query.promise?.then(serializeData).catch((error) => {
Expand Down Expand Up @@ -195,75 +195,73 @@ export function hydrate(
)
})

queries.forEach(
({ queryKey, state, queryHash, meta, promise, dehydratedAt }) => {
const syncData = promise ? tryResolveSync(promise) : undefined
const rawData = state.data === undefined ? syncData?.data : state.data
const data = rawData === undefined ? rawData : deserializeData(rawData)
queries.forEach((nextQuery) => {
const { state, queryHash, meta, promise, dehydratedAt } = nextQuery

let query = queryCache.get(queryHash)
const existingQueryIsPending = query?.state.status === 'pending'
const existingQueryIsFetching = query?.state.fetchStatus === 'fetching'
const syncData = promise ? tryResolveSync(promise) : undefined
const rawData = state.data === undefined ? syncData?.data : state.data
const data = rawData === undefined ? rawData : deserializeData(rawData)
const queryKey = deserializeData(nextQuery.queryKey)

// Do not hydrate if an existing query exists with newer data
if (query) {
const hasNewerSyncData =
syncData &&
// We only need this undefined check to handle older dehydration
// payloads that might not have dehydratedAt
dehydratedAt !== undefined &&
dehydratedAt > query.state.dataUpdatedAt
if (
state.dataUpdatedAt > query.state.dataUpdatedAt ||
hasNewerSyncData
) {
// omit fetchStatus from dehydrated state
// so that query stays in its current fetchStatus
const { fetchStatus: _ignored, ...serializedState } = state
query.setState({
...serializedState,
data,
})
}
} else {
// Restore query
query = queryCache.build(
client,
{
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
},
// Reset fetch status to idle to avoid
// query being stuck in fetching state upon hydration
{
...state,
data,
fetchStatus: 'idle',
status: data !== undefined ? 'success' : state.status,
},
)
}
let query = queryCache.get(queryHash)
const existingQueryIsPending = query?.state.status === 'pending'
const existingQueryIsFetching = query?.state.fetchStatus === 'fetching'

if (
promise &&
!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)
) {
// 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
void query.fetch(undefined, {
// RSC transformed promises are not thenable
initialPromise: Promise.resolve(promise).then(deserializeData),
// Do not hydrate if an existing query exists with newer data
if (query) {
const hasNewerSyncData =
syncData &&
// We only need this undefined check to handle older dehydration
// payloads that might not have dehydratedAt
dehydratedAt !== undefined &&
dehydratedAt > query.state.dataUpdatedAt
if (state.dataUpdatedAt > query.state.dataUpdatedAt || hasNewerSyncData) {
// omit fetchStatus from dehydrated state
// so that query stays in its current fetchStatus
const { fetchStatus: _ignored, ...serializedState } = state
query.setState({
...serializedState,
data,
})
}
},
)
} else {
// Restore query
query = queryCache.build(
client,
{
...client.getDefaultOptions().hydrate?.queries,
...options?.defaultOptions?.queries,
queryKey,
queryHash,
meta,
},
// Reset fetch status to idle to avoid
// query being stuck in fetching state upon hydration
{
...state,
data,
fetchStatus: 'idle',
status: data !== undefined ? 'success' : state.status,
},
)
}

if (
promise &&
!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)
) {
// 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
void query.fetch(undefined, {
// RSC transformed promises are not thenable
initialPromise: Promise.resolve(promise).then(deserializeData),
})
}
})
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading