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 ( <div> - {query.data.text} - {query.data.date.toJSON()} + {query.data.text} - {query.data.date.toJSON()} - {query.data.count} </div> ) } 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<Temporal.PlainDate, string> +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 ( <main> - <HydrationBoundary state={dehydrate(queryClient)}> + <HydrationBoundary state={state}> <ClientComponent /> </HydrationBoundary> + <form action={queryExampleAction}> + <button type="submit">Increment</button> + </form> </main> ) } 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 ( <QueryClientProvider client={queryClient}> 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 = <TKey extends string>( + 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, )