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..02d29562f2 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,23 @@ 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' + ) + }, }, }, }) diff --git a/integrations/react-next-15/app/page.tsx b/integrations/react-next-15/app/page.tsx index 2382ab540f..eabd856629 100644 --- a/integrations/react-next-15/app/page.tsx +++ b/integrations/react-next-15/app/page.tsx @@ -2,29 +2,39 @@ 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({ queryKey: ['data'], queryFn: async () => { - await sleep(2000) + const { count } = await ( + await fetch('http://localhost:3000/count') + ).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..461d88b6dd 100644 --- a/integrations/react-next-15/app/providers.tsx +++ b/integrations/react-next-15/app/providers.tsx @@ -1,16 +1,39 @@ +// 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 ( {children} - + {} ) } diff --git a/packages/query-core/package.json b/packages/query-core/package.json index 1df8d4d7e1..34135870d5 100644 --- a/packages/query-core/package.json +++ b/packages/query-core/package.json @@ -29,7 +29,8 @@ "test:lib": "vitest", "test:lib:dev": "pnpm run test:lib --watch", "test:build": "publint --strict && attw --pack", - "build": "tsup" + "build": "tsup", + "build:dev": "tsup --watch" }, "type": "module", "types": "build/legacy/index.d.ts", 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/react-query/package.json b/packages/react-query/package.json index ddc2836eaf..58cc07fa71 100644 --- a/packages/react-query/package.json +++ b/packages/react-query/package.json @@ -31,6 +31,7 @@ "test:build": "publint --strict && attw --pack", "build": "pnpm build:tsup && pnpm build:codemods", "build:tsup": "tsup", + "build:tsup:dev": "tsup --watch", "build:codemods": "cpy ../query-codemods/* ./build/codemods" }, "type": "module", diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index 407933fc5c..e656ef2fdf 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -73,7 +73,10 @@ export const HydrationBoundary = ({ } else { const hydrationIsNewer = dehydratedQuery.state.dataUpdatedAt > - existingQuery.state.dataUpdatedAt + existingQuery.state.dataUpdatedAt || + // @ts-expect-error + dehydratedQuery.promise?.status !== existingQuery.promise?.status + const queryAlreadyQueued = hydrationQueue?.find( (query) => query.queryHash === dehydratedQuery.queryHash, )