Skip to content

Commit 6ca0eb7

Browse files
juliusmarmingeTkDodoautofix-ci[bot]
authored
fix: no hydration when new promise comes in (#8383)
* add failing repro test * update assertinos * add logg * ehm - maybe fix? * rm -only * make example * upd * ad debug logs * more debugging * push * maybe??? * rm log * revert * fix: ? * fix: check for pending status again otherwise, we risk including promises that happen because of background updates (think persistQueryClient) * fix: clear serverQueryClient between "requests" otherwise, we are re-using the cache and the query won't be in "pending" state the second time around * chore: remove logs * rethrow next build error * kick off ci again * add `shouldRedactError` option * pluralize * docs * chore: more memory * chore: revert more memory * don't compare statuses if they don't exist * ci: apply automated fixes * lint --------- Co-authored-by: Dominik Dorfmeister <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent bb25d06 commit 6ca0eb7

File tree

11 files changed

+207
-19
lines changed

11 files changed

+207
-19
lines changed

docs/framework/react/guides/advanced-ssr.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,14 @@ function makeQueryClient() {
392392
shouldDehydrateQuery: (query) =>
393393
defaultShouldDehydrateQuery(query) ||
394394
query.state.status === 'pending',
395+
shouldRedactErrors: (error) => {
396+
// We should not catch Next.js server errors
397+
// as that's how Next.js detects dynamic pages
398+
// so we cannot redact them.
399+
// Next.js also automatically redacts errors for us
400+
// with better digests.
401+
return false
402+
},
395403
},
396404
},
397405
})

docs/framework/react/reference/hydration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ const dehydratedState = dehydrate(queryClient, {
3838
- Defaults to only including successful queries
3939
- If you would like to extend the function while retaining the default behavior, import and execute `defaultShouldDehydrateQuery` as part of the return statement
4040
- `serializeData?: (data: any) => any` A function to transform (serialize) data during dehydration.
41+
- `shouldRedactErrors?: (error: unknown) => boolean`
42+
- Optional
43+
- Whether to redact errors from the server during dehydration.
44+
- The function is called for each error in the cache
45+
- Return `true` to redact this error, or `false` otherwise
46+
- Defaults to redacting all errors
4147

4248
**Returns**
4349

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use server'
2+
3+
import { revalidatePath } from 'next/cache'
4+
import { countRef } from './make-query-client'
5+
6+
export async function queryExampleAction() {
7+
await Promise.resolve()
8+
countRef.current++
9+
revalidatePath('/', 'page')
10+
return undefined
11+
}

integrations/react-next-15/app/client-component.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ export function ClientComponent() {
88
const query = useQuery({
99
queryKey: ['data'],
1010
queryFn: async () => {
11-
await new Promise((r) => setTimeout(r, 1000))
11+
const { count } = await (
12+
await fetch('http://localhost:3000/count')
13+
).json()
14+
1215
return {
1316
text: 'data from client',
1417
date: Temporal.PlainDate.from('2023-01-01'),
18+
count,
1519
}
1620
},
1721
})
@@ -26,7 +30,7 @@ export function ClientComponent() {
2630

2731
return (
2832
<div>
29-
{query.data.text} - {query.data.date.toJSON()}
33+
{query.data.text} - {query.data.date.toJSON()} - {query.data.count}
3034
</div>
3135
)
3236
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { countRef } from '../make-query-client'
2+
3+
export const GET = () => {
4+
return Response.json({ count: countRef.current })
5+
}

integrations/react-next-15/app/make-query-client.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const plainDate = {
1010
test: (v) => v instanceof Temporal.PlainDate,
1111
} satisfies TsonType<Temporal.PlainDate, string>
1212

13+
export const countRef = {
14+
current: 0,
15+
}
16+
1317
export const tson = createTson({
1418
types: [plainDate],
1519
})
@@ -22,16 +26,27 @@ export function makeQueryClient() {
2226
* Called when the query is rebuilt from a prefetched
2327
* promise, before the query data is put into the cache.
2428
*/
25-
deserializeData: tson.deserialize,
29+
deserializeData: (data) => {
30+
return tson.deserialize(data)
31+
},
2632
},
2733
queries: {
2834
staleTime: 60 * 1000,
2935
},
3036
dehydrate: {
31-
serializeData: tson.serialize,
32-
shouldDehydrateQuery: (query) =>
33-
defaultShouldDehydrateQuery(query) ||
34-
query.state.status === 'pending',
37+
serializeData: (data) => {
38+
return tson.serialize(data)
39+
},
40+
shouldDehydrateQuery: (query) => {
41+
return (
42+
defaultShouldDehydrateQuery(query) ||
43+
query.state.status === 'pending'
44+
)
45+
},
46+
shouldRedactErrors: (error) => {
47+
// Next.js automatically redacts errors for us
48+
return false
49+
},
3550
},
3651
},
3752
})
Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,41 @@
1+
import { headers } from 'next/headers'
12
import React from 'react'
23
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
34
import { Temporal } from '@js-temporal/polyfill'
45
import { ClientComponent } from './client-component'
5-
import { makeQueryClient, tson } from './make-query-client'
6+
import { makeQueryClient } from './make-query-client'
7+
import { queryExampleAction } from './_action'
68

7-
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
8-
9-
export default async function Home() {
9+
export default function Home() {
1010
const queryClient = makeQueryClient()
1111

12-
void queryClient.prefetchQuery({
12+
queryClient.prefetchQuery({
1313
queryKey: ['data'],
1414
queryFn: async () => {
15-
await sleep(2000)
15+
const { count } = await (
16+
await fetch('http://localhost:3000/count', {
17+
headers: await headers(),
18+
})
19+
).json()
20+
1621
return {
1722
text: 'data from server',
1823
date: Temporal.PlainDate.from('2024-01-01'),
24+
count,
1925
}
2026
},
2127
})
2228

29+
const state = dehydrate(queryClient)
30+
2331
return (
2432
<main>
25-
<HydrationBoundary state={dehydrate(queryClient)}>
33+
<HydrationBoundary state={state}>
2634
<ClientComponent />
2735
</HydrationBoundary>
36+
<form action={queryExampleAction}>
37+
<button type="submit">Increment</button>
38+
</form>
2839
</main>
2940
)
3041
}

integrations/react-next-15/app/providers.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
1+
// In Next.js, this file would be called: app/providers.tsx
12
'use client'
2-
import { QueryClientProvider } from '@tanstack/react-query'
3+
4+
// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
5+
import { QueryClientProvider, isServer } from '@tanstack/react-query'
36
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
4-
import * as React from 'react'
7+
import type { QueryClient } from '@tanstack/react-query'
58
import { makeQueryClient } from '@/app/make-query-client'
69

10+
let browserQueryClient: QueryClient | undefined = undefined
11+
12+
function getQueryClient() {
13+
if (isServer) {
14+
// Server: always make a new query client
15+
return makeQueryClient()
16+
} else {
17+
// Browser: make a new query client if we don't already have one
18+
// This is very important, so we don't re-make a new client if React
19+
// suspends during the initial render. This may not be needed if we
20+
// have a suspense boundary BELOW the creation of the query client
21+
if (!browserQueryClient) browserQueryClient = makeQueryClient()
22+
return browserQueryClient
23+
}
24+
}
25+
726
export default function Providers({ children }: { children: React.ReactNode }) {
8-
const [queryClient] = React.useState(() => makeQueryClient())
27+
// NOTE: Avoid useState when initializing the query client if you don't
28+
// have a suspense boundary between this and the code that may
29+
// suspend because React will throw away the client on the initial
30+
// render if it suspends and there is no boundary
31+
const queryClient = getQueryClient()
932

1033
return (
1134
<QueryClientProvider client={queryClient}>

packages/query-core/src/__tests__/hydration.test.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,4 +1066,80 @@ describe('dehydration and rehydration', () => {
10661066
clientQueryClient.clear()
10671067
serverQueryClient.clear()
10681068
})
1069+
1070+
test('should overwrite data when a new promise is streamed in', async () => {
1071+
const serializeDataMock = vi.fn((data: any) => data)
1072+
const deserializeDataMock = vi.fn((data: any) => data)
1073+
1074+
const countRef = { current: 0 }
1075+
// --- server ---
1076+
const serverQueryClient = createQueryClient({
1077+
defaultOptions: {
1078+
dehydrate: {
1079+
shouldDehydrateQuery: () => true,
1080+
serializeData: serializeDataMock,
1081+
},
1082+
},
1083+
})
1084+
1085+
const query = {
1086+
queryKey: ['data'],
1087+
queryFn: async () => {
1088+
await sleep(10)
1089+
return countRef.current
1090+
},
1091+
}
1092+
1093+
const promise = serverQueryClient.prefetchQuery(query)
1094+
1095+
let dehydrated = dehydrate(serverQueryClient)
1096+
1097+
// --- client ---
1098+
1099+
const clientQueryClient = createQueryClient({
1100+
defaultOptions: {
1101+
hydrate: {
1102+
deserializeData: deserializeDataMock,
1103+
},
1104+
},
1105+
})
1106+
1107+
hydrate(clientQueryClient, dehydrated)
1108+
1109+
await promise
1110+
await waitFor(() =>
1111+
expect(clientQueryClient.getQueryData(query.queryKey)).toBe(0),
1112+
)
1113+
1114+
expect(serializeDataMock).toHaveBeenCalledTimes(1)
1115+
expect(serializeDataMock).toHaveBeenCalledWith(0)
1116+
1117+
expect(deserializeDataMock).toHaveBeenCalledTimes(1)
1118+
expect(deserializeDataMock).toHaveBeenCalledWith(0)
1119+
1120+
// --- server ---
1121+
countRef.current++
1122+
serverQueryClient.clear()
1123+
const promise2 = serverQueryClient.prefetchQuery(query)
1124+
1125+
dehydrated = dehydrate(serverQueryClient)
1126+
1127+
// --- client ---
1128+
1129+
hydrate(clientQueryClient, dehydrated)
1130+
1131+
await promise2
1132+
await waitFor(() =>
1133+
expect(clientQueryClient.getQueryData(query.queryKey)).toBe(1),
1134+
)
1135+
1136+
expect(serializeDataMock).toHaveBeenCalledTimes(2)
1137+
expect(serializeDataMock).toHaveBeenCalledWith(1)
1138+
1139+
expect(deserializeDataMock).toHaveBeenCalledTimes(2)
1140+
expect(deserializeDataMock).toHaveBeenCalledWith(1)
1141+
1142+
clientQueryClient.clear()
1143+
serverQueryClient.clear()
1144+
})
10691145
})

packages/query-core/src/hydration.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface DehydrateOptions {
2222
serializeData?: TransformerFn
2323
shouldDehydrateMutation?: (mutation: Mutation) => boolean
2424
shouldDehydrateQuery?: (query: Query) => boolean
25+
shouldRedactErrors?: (error: unknown) => boolean
2526
}
2627

2728
export interface HydrateOptions {
@@ -70,6 +71,7 @@ function dehydrateMutation(mutation: Mutation): DehydratedMutation {
7071
function dehydrateQuery(
7172
query: Query,
7273
serializeData: TransformerFn,
74+
shouldRedactErrors: (error: unknown) => boolean,
7375
): DehydratedQuery {
7476
return {
7577
state: {
@@ -82,6 +84,11 @@ function dehydrateQuery(
8284
queryHash: query.queryHash,
8385
...(query.state.status === 'pending' && {
8486
promise: query.promise?.then(serializeData).catch((error) => {
87+
if (!shouldRedactErrors(error)) {
88+
// Reject original error if it should not be redacted
89+
return Promise.reject(error)
90+
}
91+
// If not in production, log original error before rejecting redacted error
8592
if (process.env.NODE_ENV !== 'production') {
8693
console.error(
8794
`A query that was dehydrated as pending ended up rejecting. [${query.queryHash}]: ${error}; The error will be redacted in production builds`,
@@ -102,6 +109,10 @@ export function defaultShouldDehydrateQuery(query: Query) {
102109
return query.state.status === 'success'
103110
}
104111

112+
export function defaultshouldRedactErrors(_: unknown) {
113+
return true
114+
}
115+
105116
export function dehydrate(
106117
client: QueryClient,
107118
options: DehydrateOptions = {},
@@ -123,6 +134,11 @@ export function dehydrate(
123134
client.getDefaultOptions().dehydrate?.shouldDehydrateQuery ??
124135
defaultShouldDehydrateQuery
125136

137+
const shouldRedactErrors =
138+
options.shouldRedactErrors ??
139+
client.getDefaultOptions().dehydrate?.shouldRedactErrors ??
140+
defaultshouldRedactErrors
141+
126142
const serializeData =
127143
options.serializeData ??
128144
client.getDefaultOptions().dehydrate?.serializeData ??
@@ -132,7 +148,9 @@ export function dehydrate(
132148
.getQueryCache()
133149
.getAll()
134150
.flatMap((query) =>
135-
filterQuery(query) ? [dehydrateQuery(query, serializeData)] : [],
151+
filterQuery(query)
152+
? [dehydrateQuery(query, serializeData, shouldRedactErrors)]
153+
: [],
136154
)
137155

138156
return { mutations, queries }

packages/react-query/src/HydrationBoundary.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export interface HydrationBoundaryProps {
2424
queryClient?: QueryClient
2525
}
2626

27+
const hasProperty = <TKey extends string>(
28+
obj: unknown,
29+
key: TKey,
30+
): obj is { [k in TKey]: unknown } => {
31+
return typeof obj === 'object' && obj !== null && key in obj
32+
}
33+
2734
export const HydrationBoundary = ({
2835
children,
2936
options = {},
@@ -73,7 +80,11 @@ export const HydrationBoundary = ({
7380
} else {
7481
const hydrationIsNewer =
7582
dehydratedQuery.state.dataUpdatedAt >
76-
existingQuery.state.dataUpdatedAt
83+
existingQuery.state.dataUpdatedAt || // RSC special serialized then-able chunks
84+
(hasProperty(dehydratedQuery.promise, 'status') &&
85+
hasProperty(existingQuery.promise, 'status') &&
86+
dehydratedQuery.promise.status !== existingQuery.promise.status)
87+
7788
const queryAlreadyQueued = hydrationQueue?.find(
7889
(query) => query.queryHash === dehydratedQuery.queryHash,
7990
)

0 commit comments

Comments
 (0)