Skip to content

Commit

Permalink
feat: hydrate.transformPromise (#7538)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
juliusmarminge and TkDodo committed Jun 12, 2024
1 parent bd094f8 commit 9a030b6
Show file tree
Hide file tree
Showing 8 changed files with 174 additions and 6 deletions.
63 changes: 63 additions & 0 deletions docs/framework/react/guides/advanced-ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,69 @@ export default function Posts() {

> 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.
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:

```tsx
// app/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
import { deserialize } from './transformer'

export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
hydrate: {
/**
* Called when the query is rebuilt from a prefetched
* promise, before the query data is put into the cache.
*/
transformPromise: (promise) => promise.then(deserialize),
},
// ...
},
})
}
```

```tsx
// app/posts/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { serialize } from './transformer'
import Posts from './posts'

export default function PostsPage() {
const queryClient = new QueryClient()

// look ma, no await
queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: () => getPosts().then(serialize), // <-- serilize the data on the server
})

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
</HydrationBoundary>
)
}
```

```tsx
// app/posts/posts.tsx
'use client'

export default function Posts() {
const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })

// ...
}
```

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.

For more information, check out the [Next.js App with Prefetching Example](../../examples/nextjs-app-prefetching).

## Experimental streaming without prefetching in Next.js
Expand Down
12 changes: 10 additions & 2 deletions integrations/react-next-15/app/client-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { Temporal } from '@js-temporal/polyfill'

export function ClientComponent() {
const query = useQuery({
queryKey: ['data'],
queryFn: async () => {
await new Promise((r) => setTimeout(r, 1000))
return 'data from client'
return {
text: 'data from client',
date: Temporal.PlainDate.from('2023-01-01'),
}
},
})

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

return <div>{query.data}</div>
return (
<div>
{query.data.text} - {query.data.date.toJSON()}
</div>
)
}
21 changes: 21 additions & 0 deletions integrations/react-next-15/app/make-query-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
import { Temporal } from '@js-temporal/polyfill'
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
import { createTson } from 'tupleson'
import type { TsonType } from 'tupleson'

const plainDate = {
deserialize: (v) => Temporal.PlainDate.from(v),
key: 'PlainDate',
serialize: (v) => v.toJSON(),
test: (v) => v instanceof Temporal.PlainDate,
} satisfies TsonType<Temporal.PlainDate, string>

export const tson = createTson({
types: [plainDate],
})

export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
hydrate: {
/**
* Called when the query is rebuilt from a prefetched
* promise, before the query data is put into the cache.
*/
transformPromise: (promise) => promise.then(tson.deserialize),
},
queries: {
staleTime: 60 * 1000,
},
Expand Down
8 changes: 6 additions & 2 deletions integrations/react-next-15/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react'
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { makeQueryClient } from '@/app/make-query-client'
import { Temporal } from '@js-temporal/polyfill'
import { ClientComponent } from './client-component'
import { makeQueryClient, tson } from './make-query-client'

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

Expand All @@ -12,7 +13,10 @@ export default async function Home() {
queryKey: ['data'],
queryFn: async () => {
await sleep(2000)
return 'data from server'
return tson.serialize({
text: 'data from server',
date: Temporal.PlainDate.from('2024-01-01'),
})
},
})

Expand Down
4 changes: 3 additions & 1 deletion integrations/react-next-15/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
"build": "next build"
},
"dependencies": {
"@js-temporal/polyfill": "^0.4.4",
"@tanstack/react-query": "workspace:*",
"@tanstack/react-query-devtools": "workspace:*",
"next": "^15.0.0-rc.0",
"react": "^19.0.0-rc-4c2e457c7c-20240522",
"react-dom": "^19.0.0-rc-4c2e457c7c-20240522"
"react-dom": "^19.0.0-rc-4c2e457c7c-20240522",
"tupleson": "0.23.1"
},
"devDependencies": {
"@types/node": "^20.12.12",
Expand Down
35 changes: 35 additions & 0 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -908,4 +908,39 @@ describe('dehydration and rehydration', () => {
}),
)
})

test('should transform promise result', async () => {
const queryClient = createQueryClient({
defaultOptions: {
dehydrate: {
shouldDehydrateQuery: () => true,
},
},
})

const promise = queryClient.prefetchQuery({
queryKey: ['transformedStringToDate'],
queryFn: () => fetchData('2024-01-01T00:00:00.000Z', 20),
})
const dehydrated = dehydrate(queryClient)
expect(dehydrated.queries[0]?.promise).toBeInstanceOf(Promise)

const hydrationClient = createQueryClient({
defaultOptions: {
hydrate: {
transformPromise: (p) => p.then((d) => new Date(d)),
},
},
})

hydrate(hydrationClient, dehydrated)
await promise
await waitFor(() =>
expect(
hydrationClient.getQueryData(['transformedStringToDate']),
).toBeInstanceOf(Date),
)

queryClient.clear()
})
})
11 changes: 10 additions & 1 deletion packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface DehydrateOptions {

export interface HydrateOptions {
defaultOptions?: {
transformPromise?: (promise: Promise<any>) => Promise<any>
queries?: QueryOptions
mutations?: MutationOptions<unknown, DefaultError, unknown, unknown>
}
Expand Down Expand Up @@ -178,9 +179,17 @@ export function hydrate(
}

if (promise) {
const transformPromise =
client.getDefaultOptions().hydrate?.transformPromise

// Note: `Promise.resolve` required cause
// RSC transformed promises are not thenable
const initialPromise =
transformPromise?.(Promise.resolve(promise)) ?? promise

// this doesn't actually fetch - it just creates a retryer
// which will re-use the passed `initialPromise`
void query.fetch(undefined, { initialPromise: promise })
void query.fetch(undefined, { initialPromise })
}
})
}
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 9a030b6

Please sign in to comment.