Skip to content

fix: serialize queryKey #9308

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
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.1"
}
}
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 @@ -970,7 +973,7 @@ describe('dehydration and rehydration', () => {
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
serializeData: (data) => data.toISOString(),
serializeData: superjson.serialize,
},
},
})
Expand All @@ -985,7 +988,7 @@ describe('dehydration and rehydration', () => {
const hydrationClient = new QueryClient({
defaultOptions: {
hydrate: {
deserializeData: (data) => new Date(data),
deserializeData: superjson.deserialize,
},
},
})
Expand All @@ -1006,7 +1009,7 @@ describe('dehydration and rehydration', () => {
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
serializeData: (data) => data.toISOString(),
serializeData: superjson.serialize,
},
},
})
Expand All @@ -1021,7 +1024,7 @@ describe('dehydration and rehydration', () => {
const hydrationClient = new QueryClient({
defaultOptions: {
hydrate: {
deserializeData: (data) => new Date(data),
deserializeData: superjson.deserialize,
},
},
})
Expand All @@ -1041,7 +1044,7 @@ describe('dehydration and rehydration', () => {
const hydrationClient = new QueryClient({
defaultOptions: {
hydrate: {
deserializeData: (data) => new Date(data),
deserializeData: superjson.deserialize,
},
},
})
Expand All @@ -1059,7 +1062,7 @@ describe('dehydration and rehydration', () => {
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
serializeData: (data) => data.toISOString(),
serializeData: superjson.serialize,
},
},
})
Expand Down Expand Up @@ -1177,16 +1180,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 @@ -1205,13 +1204,7 @@ describe('dehydration and rehydration', () => {

// --- client ---

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

hydrate(clientQueryClient, dehydrated)

Expand All @@ -1220,12 +1213,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 ---
Comment on lines -1223 to -1228
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed these as the test isn't about whether if serialization is about and the amount of times the mock gets called doubled so it gets a bit weird and unintuitive anyway

countRef.current++
serverQueryClient.clear()
Expand All @@ -1242,12 +1229,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 @@ -1402,4 +1383,50 @@ describe('dehydration and rehydration', () => {
clientQueryClient.clear()
serverQueryClient.clear()
})

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),
})
}
})
}
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

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

Loading