diff --git a/docs/framework/react/guides/advanced-ssr.md b/docs/framework/react/guides/advanced-ssr.md
index 515f1461da..93dc0e6a55 100644
--- a/docs/framework/react/guides/advanced-ssr.md
+++ b/docs/framework/react/guides/advanced-ssr.md
@@ -392,6 +392,14 @@ function makeQueryClient() {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
+ shouldRedactErrors: (error) => {
+ // We should not catch Next.js server errors
+ // as that's how Next.js detects dynamic pages
+ // so we cannot redact them.
+ // Next.js also automatically redacts errors for us
+ // with better digests.
+ return false
+ },
},
},
})
diff --git a/docs/framework/react/reference/hydration.md b/docs/framework/react/reference/hydration.md
index 7e2ab468e8..0c91545687 100644
--- a/docs/framework/react/reference/hydration.md
+++ b/docs/framework/react/reference/hydration.md
@@ -38,6 +38,12 @@ const dehydratedState = dehydrate(queryClient, {
- Defaults to only including successful queries
- If you would like to extend the function while retaining the default behavior, import and execute `defaultShouldDehydrateQuery` as part of the return statement
- `serializeData?: (data: any) => any` A function to transform (serialize) data during dehydration.
+ - `shouldRedactErrors?: (error: unknown) => boolean`
+ - Optional
+ - Whether to redact errors from the server during dehydration.
+ - The function is called for each error in the cache
+ - Return `true` to redact this error, or `false` otherwise
+ - Defaults to redacting all errors
**Returns**
diff --git a/integrations/react-next-15/app/_action.ts b/integrations/react-next-15/app/_action.ts
new file mode 100644
index 0000000000..5930be2e08
--- /dev/null
+++ b/integrations/react-next-15/app/_action.ts
@@ -0,0 +1,11 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { countRef } from './make-query-client'
+
+export async function queryExampleAction() {
+ await Promise.resolve()
+ countRef.current++
+ revalidatePath('/', 'page')
+ return undefined
+}
diff --git a/integrations/react-next-15/app/client-component.tsx b/integrations/react-next-15/app/client-component.tsx
index 29dd7b33c9..f795255ecb 100644
--- a/integrations/react-next-15/app/client-component.tsx
+++ b/integrations/react-next-15/app/client-component.tsx
@@ -8,10 +8,14 @@ export function ClientComponent() {
const query = useQuery({
queryKey: ['data'],
queryFn: async () => {
- await new Promise((r) => setTimeout(r, 1000))
+ const { count } = await (
+ await fetch('http://localhost:3000/count')
+ ).json()
+
return {
text: 'data from client',
date: Temporal.PlainDate.from('2023-01-01'),
+ count,
}
},
})
@@ -26,7 +30,7 @@ export function ClientComponent() {
return (
- {query.data.text} - {query.data.date.toJSON()}
+ {query.data.text} - {query.data.date.toJSON()} - {query.data.count}
)
}
diff --git a/integrations/react-next-15/app/count/route.ts b/integrations/react-next-15/app/count/route.ts
new file mode 100644
index 0000000000..f56c243ad9
--- /dev/null
+++ b/integrations/react-next-15/app/count/route.ts
@@ -0,0 +1,5 @@
+import { countRef } from '../make-query-client'
+
+export const GET = () => {
+ return Response.json({ count: countRef.current })
+}
diff --git a/integrations/react-next-15/app/make-query-client.ts b/integrations/react-next-15/app/make-query-client.ts
index 3d0ff40cb8..a71affe77f 100644
--- a/integrations/react-next-15/app/make-query-client.ts
+++ b/integrations/react-next-15/app/make-query-client.ts
@@ -10,6 +10,10 @@ const plainDate = {
test: (v) => v instanceof Temporal.PlainDate,
} satisfies TsonType
+export const countRef = {
+ current: 0,
+}
+
export const tson = createTson({
types: [plainDate],
})
@@ -22,16 +26,27 @@ export function makeQueryClient() {
* Called when the query is rebuilt from a prefetched
* promise, before the query data is put into the cache.
*/
- deserializeData: tson.deserialize,
+ deserializeData: (data) => {
+ return tson.deserialize(data)
+ },
},
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
- serializeData: tson.serialize,
- shouldDehydrateQuery: (query) =>
- defaultShouldDehydrateQuery(query) ||
- query.state.status === 'pending',
+ serializeData: (data) => {
+ return tson.serialize(data)
+ },
+ shouldDehydrateQuery: (query) => {
+ return (
+ defaultShouldDehydrateQuery(query) ||
+ query.state.status === 'pending'
+ )
+ },
+ shouldRedactErrors: (error) => {
+ // Next.js automatically redacts errors for us
+ return false
+ },
},
},
})
diff --git a/integrations/react-next-15/app/page.tsx b/integrations/react-next-15/app/page.tsx
index 2382ab540f..6752ff7375 100644
--- a/integrations/react-next-15/app/page.tsx
+++ b/integrations/react-next-15/app/page.tsx
@@ -1,30 +1,41 @@
+import { headers } from 'next/headers'
import React from 'react'
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { Temporal } from '@js-temporal/polyfill'
import { ClientComponent } from './client-component'
-import { makeQueryClient, tson } from './make-query-client'
+import { makeQueryClient } from './make-query-client'
+import { queryExampleAction } from './_action'
-const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
-
-export default async function Home() {
+export default function Home() {
const queryClient = makeQueryClient()
- void queryClient.prefetchQuery({
+ queryClient.prefetchQuery({
queryKey: ['data'],
queryFn: async () => {
- await sleep(2000)
+ const { count } = await (
+ await fetch('http://localhost:3000/count', {
+ headers: await headers(),
+ })
+ ).json()
+
return {
text: 'data from server',
date: Temporal.PlainDate.from('2024-01-01'),
+ count,
}
},
})
+ const state = dehydrate(queryClient)
+
return (
-
+
+
)
}
diff --git a/integrations/react-next-15/app/providers.tsx b/integrations/react-next-15/app/providers.tsx
index 25a9217ff9..aa52fc1d35 100644
--- a/integrations/react-next-15/app/providers.tsx
+++ b/integrations/react-next-15/app/providers.tsx
@@ -1,11 +1,34 @@
+// In Next.js, this file would be called: app/providers.tsx
'use client'
-import { QueryClientProvider } from '@tanstack/react-query'
+
+// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top
+import { QueryClientProvider, isServer } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
-import * as React from 'react'
+import type { QueryClient } from '@tanstack/react-query'
import { makeQueryClient } from '@/app/make-query-client'
+let browserQueryClient: QueryClient | undefined = undefined
+
+function getQueryClient() {
+ if (isServer) {
+ // Server: always make a new query client
+ return makeQueryClient()
+ } else {
+ // Browser: make a new query client if we don't already have one
+ // This is very important, so we don't re-make a new client if React
+ // suspends during the initial render. This may not be needed if we
+ // have a suspense boundary BELOW the creation of the query client
+ if (!browserQueryClient) browserQueryClient = makeQueryClient()
+ return browserQueryClient
+ }
+}
+
export default function Providers({ children }: { children: React.ReactNode }) {
- const [queryClient] = React.useState(() => makeQueryClient())
+ // NOTE: Avoid useState when initializing the query client if you don't
+ // have a suspense boundary between this and the code that may
+ // suspend because React will throw away the client on the initial
+ // render if it suspends and there is no boundary
+ const queryClient = getQueryClient()
return (
diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx
index 182a46b57b..1fdb4c327a 100644
--- a/packages/query-core/src/__tests__/hydration.test.tsx
+++ b/packages/query-core/src/__tests__/hydration.test.tsx
@@ -1066,4 +1066,80 @@ describe('dehydration and rehydration', () => {
clientQueryClient.clear()
serverQueryClient.clear()
})
+
+ 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 = createQueryClient({
+ defaultOptions: {
+ dehydrate: {
+ shouldDehydrateQuery: () => true,
+ serializeData: serializeDataMock,
+ },
+ },
+ })
+
+ const query = {
+ queryKey: ['data'],
+ queryFn: async () => {
+ await sleep(10)
+ return countRef.current
+ },
+ }
+
+ const promise = serverQueryClient.prefetchQuery(query)
+
+ let dehydrated = dehydrate(serverQueryClient)
+
+ // --- client ---
+
+ const clientQueryClient = createQueryClient({
+ defaultOptions: {
+ hydrate: {
+ deserializeData: deserializeDataMock,
+ },
+ },
+ })
+
+ hydrate(clientQueryClient, dehydrated)
+
+ await promise
+ await waitFor(() =>
+ 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()
+ const promise2 = serverQueryClient.prefetchQuery(query)
+
+ dehydrated = dehydrate(serverQueryClient)
+
+ // --- client ---
+
+ hydrate(clientQueryClient, dehydrated)
+
+ await promise2
+ await waitFor(() =>
+ 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()
+ })
})
diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts
index a3dfd0e482..316dd69a6d 100644
--- a/packages/query-core/src/hydration.ts
+++ b/packages/query-core/src/hydration.ts
@@ -22,6 +22,7 @@ export interface DehydrateOptions {
serializeData?: TransformerFn
shouldDehydrateMutation?: (mutation: Mutation) => boolean
shouldDehydrateQuery?: (query: Query) => boolean
+ shouldRedactErrors?: (error: unknown) => boolean
}
export interface HydrateOptions {
@@ -70,6 +71,7 @@ function dehydrateMutation(mutation: Mutation): DehydratedMutation {
function dehydrateQuery(
query: Query,
serializeData: TransformerFn,
+ shouldRedactErrors: (error: unknown) => boolean,
): DehydratedQuery {
return {
state: {
@@ -82,6 +84,11 @@ function dehydrateQuery(
queryHash: query.queryHash,
...(query.state.status === 'pending' && {
promise: query.promise?.then(serializeData).catch((error) => {
+ if (!shouldRedactErrors(error)) {
+ // Reject original error if it should not be redacted
+ return Promise.reject(error)
+ }
+ // If not in production, log original error before rejecting redacted error
if (process.env.NODE_ENV !== 'production') {
console.error(
`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) {
return query.state.status === 'success'
}
+export function defaultshouldRedactErrors(_: unknown) {
+ return true
+}
+
export function dehydrate(
client: QueryClient,
options: DehydrateOptions = {},
@@ -123,6 +134,11 @@ export function dehydrate(
client.getDefaultOptions().dehydrate?.shouldDehydrateQuery ??
defaultShouldDehydrateQuery
+ const shouldRedactErrors =
+ options.shouldRedactErrors ??
+ client.getDefaultOptions().dehydrate?.shouldRedactErrors ??
+ defaultshouldRedactErrors
+
const serializeData =
options.serializeData ??
client.getDefaultOptions().dehydrate?.serializeData ??
@@ -132,7 +148,9 @@ export function dehydrate(
.getQueryCache()
.getAll()
.flatMap((query) =>
- filterQuery(query) ? [dehydrateQuery(query, serializeData)] : [],
+ filterQuery(query)
+ ? [dehydrateQuery(query, serializeData, shouldRedactErrors)]
+ : [],
)
return { mutations, queries }
diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx
index 407933fc5c..e70c8c6aab 100644
--- a/packages/react-query/src/HydrationBoundary.tsx
+++ b/packages/react-query/src/HydrationBoundary.tsx
@@ -24,6 +24,13 @@ export interface HydrationBoundaryProps {
queryClient?: QueryClient
}
+const hasProperty = (
+ obj: unknown,
+ key: TKey,
+): obj is { [k in TKey]: unknown } => {
+ return typeof obj === 'object' && obj !== null && key in obj
+}
+
export const HydrationBoundary = ({
children,
options = {},
@@ -73,7 +80,11 @@ export const HydrationBoundary = ({
} else {
const hydrationIsNewer =
dehydratedQuery.state.dataUpdatedAt >
- existingQuery.state.dataUpdatedAt
+ existingQuery.state.dataUpdatedAt || // RSC special serialized then-able chunks
+ (hasProperty(dehydratedQuery.promise, 'status') &&
+ hasProperty(existingQuery.promise, 'status') &&
+ dehydratedQuery.promise.status !== existingQuery.promise.status)
+
const queryAlreadyQueued = hydrationQueue?.find(
(query) => query.queryHash === dehydratedQuery.queryHash,
)