Skip to content

Commit 9a030b6

Browse files
juliusmarmingeTkDodo
andauthoredJun 12, 2024··
feat: hydrate.transformPromise (#7538)
* hydrate.deserialize * rm logs * test * commit what i got * async await is king * rev * rev * rm unused * make thenable work * comment usage * docs * simplify * fix docs * tsx * Update packages/query-core/src/hydration.ts Co-authored-by: Dominik Dorfmeister <office@dorfmeister.cc> --------- Co-authored-by: Dominik Dorfmeister <office@dorfmeister.cc>
1 parent bd094f8 commit 9a030b6

File tree

8 files changed

+174
-6
lines changed

8 files changed

+174
-6
lines changed
 

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

+63
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,69 @@ export default function Posts() {
427427

428428
> Note that you could also `useQuery` instead of `useSuspenseQuery`, and the Promise would still be picked up correctly. However, NextJs won't suspend in that case and the component will render in the `pending` status, which also opts out of server rendering the content.
429429
430+
If you're using non-JSON data types and serialize the query results on the server, you can specify the `hydrate.transformPromise` option to deserialize the data on the client after the promise is resolved, before the data is put into the cache:
431+
432+
```tsx
433+
// app/get-query-client.ts
434+
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
435+
import { deserialize } from './transformer'
436+
437+
export function makeQueryClient() {
438+
return new QueryClient({
439+
defaultOptions: {
440+
hydrate: {
441+
/**
442+
* Called when the query is rebuilt from a prefetched
443+
* promise, before the query data is put into the cache.
444+
*/
445+
transformPromise: (promise) => promise.then(deserialize),
446+
},
447+
// ...
448+
},
449+
})
450+
}
451+
```
452+
453+
```tsx
454+
// app/posts/page.tsx
455+
import {
456+
dehydrate,
457+
HydrationBoundary,
458+
QueryClient,
459+
} from '@tanstack/react-query'
460+
import { serialize } from './transformer'
461+
import Posts from './posts'
462+
463+
export default function PostsPage() {
464+
const queryClient = new QueryClient()
465+
466+
// look ma, no await
467+
queryClient.prefetchQuery({
468+
queryKey: ['posts'],
469+
queryFn: () => getPosts().then(serialize), // <-- serilize the data on the server
470+
})
471+
472+
return (
473+
<HydrationBoundary state={dehydrate(queryClient)}>
474+
<Posts />
475+
</HydrationBoundary>
476+
)
477+
}
478+
```
479+
480+
```tsx
481+
// app/posts/posts.tsx
482+
'use client'
483+
484+
export default function Posts() {
485+
const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })
486+
487+
// ...
488+
}
489+
```
490+
491+
Now, your `getPosts` function can return e.g. `Temporal` datetime objects and the data will be serialized and deserialized on the client, assuming your transformer can serialize and deserialize those data types.
492+
430493
For more information, check out the [Next.js App with Prefetching Example](../../examples/nextjs-app-prefetching).
431494

432495
## Experimental streaming without prefetching in Next.js

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

+10-2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22

33
import React from 'react'
44
import { useQuery } from '@tanstack/react-query'
5+
import { Temporal } from '@js-temporal/polyfill'
56

67
export function ClientComponent() {
78
const query = useQuery({
89
queryKey: ['data'],
910
queryFn: async () => {
1011
await new Promise((r) => setTimeout(r, 1000))
11-
return 'data from client'
12+
return {
13+
text: 'data from client',
14+
date: Temporal.PlainDate.from('2023-01-01'),
15+
}
1216
},
1317
})
1418

@@ -20,5 +24,9 @@ export function ClientComponent() {
2024
return <div>An error has occurred!</div>
2125
}
2226

23-
return <div>{query.data}</div>
27+
return (
28+
<div>
29+
{query.data.text} - {query.data.date.toJSON()}
30+
</div>
31+
)
2432
}

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

+21
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
1+
import { Temporal } from '@js-temporal/polyfill'
12
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
3+
import { createTson } from 'tupleson'
4+
import type { TsonType } from 'tupleson'
5+
6+
const plainDate = {
7+
deserialize: (v) => Temporal.PlainDate.from(v),
8+
key: 'PlainDate',
9+
serialize: (v) => v.toJSON(),
10+
test: (v) => v instanceof Temporal.PlainDate,
11+
} satisfies TsonType<Temporal.PlainDate, string>
12+
13+
export const tson = createTson({
14+
types: [plainDate],
15+
})
216

317
export function makeQueryClient() {
418
return new QueryClient({
519
defaultOptions: {
20+
hydrate: {
21+
/**
22+
* Called when the query is rebuilt from a prefetched
23+
* promise, before the query data is put into the cache.
24+
*/
25+
transformPromise: (promise) => promise.then(tson.deserialize),
26+
},
627
queries: {
728
staleTime: 60 * 1000,
829
},

‎integrations/react-next-15/app/page.tsx

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React from 'react'
22
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
3-
import { makeQueryClient } from '@/app/make-query-client'
3+
import { Temporal } from '@js-temporal/polyfill'
44
import { ClientComponent } from './client-component'
5+
import { makeQueryClient, tson } from './make-query-client'
56

67
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
78

@@ -12,7 +13,10 @@ export default async function Home() {
1213
queryKey: ['data'],
1314
queryFn: async () => {
1415
await sleep(2000)
15-
return 'data from server'
16+
return tson.serialize({
17+
text: 'data from server',
18+
date: Temporal.PlainDate.from('2024-01-01'),
19+
})
1620
},
1721
})
1822

‎integrations/react-next-15/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
"build": "next build"
77
},
88
"dependencies": {
9+
"@js-temporal/polyfill": "^0.4.4",
910
"@tanstack/react-query": "workspace:*",
1011
"@tanstack/react-query-devtools": "workspace:*",
1112
"next": "^15.0.0-rc.0",
1213
"react": "^19.0.0-rc-4c2e457c7c-20240522",
13-
"react-dom": "^19.0.0-rc-4c2e457c7c-20240522"
14+
"react-dom": "^19.0.0-rc-4c2e457c7c-20240522",
15+
"tupleson": "0.23.1"
1416
},
1517
"devDependencies": {
1618
"@types/node": "^20.12.12",

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

+35
Original file line numberDiff line numberDiff line change
@@ -908,4 +908,39 @@ describe('dehydration and rehydration', () => {
908908
}),
909909
)
910910
})
911+
912+
test('should transform promise result', async () => {
913+
const queryClient = createQueryClient({
914+
defaultOptions: {
915+
dehydrate: {
916+
shouldDehydrateQuery: () => true,
917+
},
918+
},
919+
})
920+
921+
const promise = queryClient.prefetchQuery({
922+
queryKey: ['transformedStringToDate'],
923+
queryFn: () => fetchData('2024-01-01T00:00:00.000Z', 20),
924+
})
925+
const dehydrated = dehydrate(queryClient)
926+
expect(dehydrated.queries[0]?.promise).toBeInstanceOf(Promise)
927+
928+
const hydrationClient = createQueryClient({
929+
defaultOptions: {
930+
hydrate: {
931+
transformPromise: (p) => p.then((d) => new Date(d)),
932+
},
933+
},
934+
})
935+
936+
hydrate(hydrationClient, dehydrated)
937+
await promise
938+
await waitFor(() =>
939+
expect(
940+
hydrationClient.getQueryData(['transformedStringToDate']),
941+
).toBeInstanceOf(Date),
942+
)
943+
944+
queryClient.clear()
945+
})
911946
})

‎packages/query-core/src/hydration.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface DehydrateOptions {
2121

2222
export interface HydrateOptions {
2323
defaultOptions?: {
24+
transformPromise?: (promise: Promise<any>) => Promise<any>
2425
queries?: QueryOptions
2526
mutations?: MutationOptions<unknown, DefaultError, unknown, unknown>
2627
}
@@ -178,9 +179,17 @@ export function hydrate(
178179
}
179180

180181
if (promise) {
182+
const transformPromise =
183+
client.getDefaultOptions().hydrate?.transformPromise
184+
185+
// Note: `Promise.resolve` required cause
186+
// RSC transformed promises are not thenable
187+
const initialPromise =
188+
transformPromise?.(Promise.resolve(promise)) ?? promise
189+
181190
// this doesn't actually fetch - it just creates a retryer
182191
// which will re-use the passed `initialPromise`
183-
void query.fetch(undefined, { initialPromise: promise })
192+
void query.fetch(undefined, { initialPromise })
184193
}
185194
})
186195
}

‎pnpm-lock.yaml

+26
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.