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,
)