Skip to content

Commit 6475b35

Browse files
feat: use seroval for SSR (#4600)
1 parent d0ed7ce commit 6475b35

34 files changed

+482
-947
lines changed

docs/router/framework/react/api/router/RouterOptionsType.md

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -260,22 +260,6 @@ const router = createRouter({
260260
- Optional
261261
- A route that will be used as the default not found route for every branch of the route tree. This can be overridden on a per-branch basis by providing a not found route to the `NotFoundRoute` option on the root route of the branch.
262262

263-
### `errorSerializer` property
264-
265-
- Type: [`RouterErrorSerializer`]
266-
- Optional
267-
- The serializer object that will be used to determine how errors are serialized and deserialized between the server and the client.
268-
269-
#### `errorSerializer.serialize` method
270-
271-
- Type: `(err: unknown) => TSerializedError`
272-
- This method is called to define how errors are serialized when they are stored in the router's dehydrated state.
273-
274-
#### `errorSerializer.deserialize` method
275-
276-
- Type: `(err: TSerializedError) => unknown`
277-
- This method is called to define how errors are deserialized from the router's dehydrated state.
278-
279263
### `trailingSlash` property
280264

281265
- Type: `'always' | 'never' | 'preserve'`

docs/router/framework/react/guide/external-data-loading.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Tools that are able can integrate with TanStack Router's convenient Dehydration/
141141

142142
**For critical data needed for the first render/paint**, TanStack Router supports **`dehydrate` and `hydrate`** options when configuring the `Router`. These callbacks are functions that are automatically called on the server and client when the router dehydrates and hydrates normally and allow you to augment the dehydrated data with your own data.
143143

144-
The `dehydrate` function can return any serializable JSON data which will get merged and injected into the dehydrated payload that is sent to the client. This payload is delivered via the `DehydrateRouter` component which, when rendered, provides the data back to you in the `hydrate` function on the client.
144+
The `dehydrate` function can return any serializable JSON data which will get merged and injected into the dehydrated payload that is sent to the client.
145145

146146
For example, let's dehydrate and hydrate a TanStack Query `QueryClient` so that our data we fetched on the server will be available for hydration on the client.
147147

Lines changed: 152 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
import { Fragment } from 'react'
22
import {
33
QueryClientProvider,
4-
dehydrate,
5-
hashKey,
6-
hydrate,
4+
dehydrate as queryDehydrate,
5+
hydrate as queryHydrate,
76
} from '@tanstack/react-query'
87
import { isRedirect } from '@tanstack/router-core'
8+
import '@tanstack/router-core/ssr/client'
99
import type { AnyRouter } from '@tanstack/react-router'
1010
import type {
1111
QueryClient,
12-
QueryObserverResult,
13-
UseQueryOptions,
12+
DehydratedState as QueryDehydratedState,
1413
} from '@tanstack/react-query'
1514

1615
type AdditionalOptions = {
@@ -24,6 +23,10 @@ type AdditionalOptions = {
2423
handleRedirects?: boolean
2524
}
2625

26+
type DehydratedRouterQueryState = {
27+
dehydratedQueryClient: QueryDehydratedState
28+
queryStream: ReadableStream<QueryDehydratedState>
29+
}
2730
export type ValidateRouter<TRouter extends AnyRouter> =
2831
NonNullable<TRouter['options']['context']> extends {
2932
queryClient: QueryClient
@@ -36,129 +39,10 @@ export function routerWithQueryClient<TRouter extends AnyRouter>(
3639
queryClient: QueryClient,
3740
additionalOpts?: AdditionalOptions,
3841
): TRouter {
39-
const seenQueryKeys = new Set<string>()
40-
const streamedQueryKeys = new Set<string>()
41-
42-
const ogClientOptions = queryClient.getDefaultOptions()
43-
queryClient.setDefaultOptions({
44-
...ogClientOptions,
45-
queries: {
46-
...ogClientOptions.queries,
47-
_experimental_beforeQuery: (options: UseQueryOptions) => {
48-
// Call the original beforeQuery
49-
;(ogClientOptions.queries as any)?._experimental_beforeQuery?.(options)
50-
51-
const hash = options.queryKeyHashFn || hashKey
52-
// On the server, check if we've already seen the query before
53-
if (router.isServer) {
54-
if (seenQueryKeys.has(hash(options.queryKey))) {
55-
return
56-
}
57-
58-
seenQueryKeys.add(hash(options.queryKey))
59-
60-
// If we haven't seen the query and we have data for it,
61-
// That means it's going to get dehydrated with critical
62-
// data, so we can skip the injection
63-
if (queryClient.getQueryData(options.queryKey) !== undefined) {
64-
;(options as any).__skipInjection = true
65-
return
66-
}
67-
} else {
68-
// On the client, pick up the deferred data from the stream
69-
const dehydratedClient = router.clientSsr!.getStreamedValue<any>(
70-
'__QueryClient__' + hash(options.queryKey),
71-
)
72-
73-
// If we have data, hydrate it into the query client
74-
if (dehydratedClient && !dehydratedClient.hydrated) {
75-
dehydratedClient.hydrated = true
76-
hydrate(queryClient, dehydratedClient)
77-
}
78-
}
79-
},
80-
_experimental_afterQuery: (
81-
options: UseQueryOptions,
82-
_result: QueryObserverResult,
83-
) => {
84-
// On the server (if we're not skipping injection)
85-
// send down the dehydrated query
86-
const hash = options.queryKeyHashFn || hashKey
87-
if (
88-
router.isServer &&
89-
!(options as any).__skipInjection &&
90-
queryClient.getQueryData(options.queryKey) !== undefined &&
91-
!streamedQueryKeys.has(hash(options.queryKey))
92-
) {
93-
streamedQueryKeys.add(hash(options.queryKey))
94-
95-
router.serverSsr!.streamValue(
96-
'__QueryClient__' + hash(options.queryKey),
97-
dehydrate(queryClient, {
98-
shouldDehydrateMutation: () => false,
99-
shouldDehydrateQuery: (query) =>
100-
hash(query.queryKey) === hash(options.queryKey),
101-
}),
102-
)
103-
}
104-
105-
// Call the original afterQuery
106-
;(ogClientOptions.queries as any)?._experimental_afterQuery?.(
107-
options,
108-
_result,
109-
)
110-
},
111-
} as any,
112-
})
113-
114-
if (additionalOpts?.handleRedirects ?? true) {
115-
const ogMutationCacheConfig = queryClient.getMutationCache().config
116-
queryClient.getMutationCache().config = {
117-
...ogMutationCacheConfig,
118-
onError: (error, _variables, _context, _mutation) => {
119-
if (isRedirect(error)) {
120-
error.options._fromLocation = router.state.location
121-
return router.navigate(router.resolveRedirect(error).options)
122-
}
123-
124-
return ogMutationCacheConfig.onError?.(
125-
error,
126-
_variables,
127-
_context,
128-
_mutation,
129-
)
130-
},
131-
}
132-
133-
const ogQueryCacheConfig = queryClient.getQueryCache().config
134-
queryClient.getQueryCache().config = {
135-
...ogQueryCacheConfig,
136-
onError: (error, _query) => {
137-
if (isRedirect(error)) {
138-
error.options._fromLocation = router.state.location
139-
return router.navigate(router.resolveRedirect(error).options)
140-
}
141-
142-
return ogQueryCacheConfig.onError?.(error, _query)
143-
},
144-
}
145-
}
146-
14742
const ogOptions = router.options
43+
14844
router.options = {
14945
...router.options,
150-
dehydrate: () => {
151-
return {
152-
...ogOptions.dehydrate?.(),
153-
// When critical data is dehydrated, we also dehydrate the query client
154-
dehydratedQueryClient: dehydrate(queryClient),
155-
}
156-
},
157-
hydrate: (dehydrated: any) => {
158-
ogOptions.hydrate?.(dehydrated)
159-
// On the client, hydrate the query client with the dehydrated data
160-
hydrate(queryClient, dehydrated.dehydratedQueryClient)
161-
},
16246
context: {
16347
...ogOptions.context,
16448
// Pass the query client to the context, so we can access it in loaders
@@ -178,5 +62,148 @@ export function routerWithQueryClient<TRouter extends AnyRouter>(
17862
},
17963
}
18064

65+
if (router.isServer) {
66+
const queryStream = createPushableStream()
67+
68+
router.options.dehydrate =
69+
async (): Promise<DehydratedRouterQueryState> => {
70+
const ogDehydrated = await ogOptions.dehydrate?.()
71+
const dehydratedQueryClient = queryDehydrate(queryClient)
72+
73+
router.serverSsr!.onRenderFinished(() => queryStream.close())
74+
75+
const dehydratedRouter = {
76+
...ogDehydrated,
77+
// When critical data is dehydrated, we also dehydrate the query client
78+
dehydratedQueryClient,
79+
// prepare the stream for queries coming up during rendering
80+
queryStream: queryStream.stream,
81+
}
82+
83+
return dehydratedRouter
84+
}
85+
86+
const ogClientOptions = queryClient.getDefaultOptions()
87+
queryClient.setDefaultOptions({
88+
...ogClientOptions,
89+
dehydrate: {
90+
shouldDehydrateQuery: () => true,
91+
...ogClientOptions.dehydrate,
92+
},
93+
})
94+
95+
queryClient.getQueryCache().subscribe((event) => {
96+
if (event.type === 'added') {
97+
// before rendering starts, we do not stream individual queries
98+
// instead we dehydrate the entire query client in router's dehydrate()
99+
if (!router.serverSsr!.isDehydrated()) {
100+
return
101+
}
102+
if (queryStream.isClosed()) {
103+
console.warn(
104+
`tried to stream query ${event.query.queryHash} after stream was already closed`,
105+
)
106+
return
107+
}
108+
queryStream.enqueue(
109+
queryDehydrate(queryClient, {
110+
shouldDehydrateQuery: (query) => {
111+
if (query.queryHash === event.query.queryHash) {
112+
return (
113+
ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ??
114+
true
115+
)
116+
}
117+
return false
118+
},
119+
}),
120+
)
121+
}
122+
})
123+
// on the client
124+
} else {
125+
router.options.hydrate = async (dehydrated: DehydratedRouterQueryState) => {
126+
await ogOptions.hydrate?.(dehydrated)
127+
// On the client, hydrate the query client with the dehydrated data
128+
queryHydrate(queryClient, dehydrated.dehydratedQueryClient)
129+
130+
const reader = dehydrated.queryStream.getReader()
131+
reader
132+
.read()
133+
.then(async function handle({ done, value }) {
134+
queryHydrate(queryClient, value)
135+
if (done) {
136+
return
137+
}
138+
const result = await reader.read()
139+
return handle(result)
140+
})
141+
.catch((err) => {
142+
console.error('Error reading query stream:', err)
143+
})
144+
}
145+
if (additionalOpts?.handleRedirects ?? true) {
146+
const ogMutationCacheConfig = queryClient.getMutationCache().config
147+
queryClient.getMutationCache().config = {
148+
...ogMutationCacheConfig,
149+
onError: (error, _variables, _context, _mutation) => {
150+
if (isRedirect(error)) {
151+
error.options._fromLocation = router.state.location
152+
return router.navigate(router.resolveRedirect(error).options)
153+
}
154+
155+
return ogMutationCacheConfig.onError?.(
156+
error,
157+
_variables,
158+
_context,
159+
_mutation,
160+
)
161+
},
162+
}
163+
164+
const ogQueryCacheConfig = queryClient.getQueryCache().config
165+
queryClient.getQueryCache().config = {
166+
...ogQueryCacheConfig,
167+
onError: (error, _query) => {
168+
if (isRedirect(error)) {
169+
error.options._fromLocation = router.state.location
170+
return router.navigate(router.resolveRedirect(error).options)
171+
}
172+
173+
return ogQueryCacheConfig.onError?.(error, _query)
174+
},
175+
}
176+
}
177+
}
178+
181179
return router
182180
}
181+
182+
type PushableStream = {
183+
stream: ReadableStream
184+
enqueue: (chunk: unknown) => void
185+
close: () => void
186+
isClosed: () => boolean
187+
error: (err: unknown) => void
188+
}
189+
190+
function createPushableStream(): PushableStream {
191+
let controllerRef: ReadableStreamDefaultController
192+
const stream = new ReadableStream({
193+
start(controller) {
194+
controllerRef = controller
195+
},
196+
})
197+
let _isClosed = false
198+
199+
return {
200+
stream,
201+
enqueue: (chunk) => controllerRef.enqueue(chunk),
202+
close: () => {
203+
controllerRef.close()
204+
_isClosed = true
205+
},
206+
isClosed: () => _isClosed,
207+
error: (err: unknown) => controllerRef.error(err),
208+
}
209+
}

packages/react-router/src/Matches.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function Matches() {
4848

4949
// Do not render a root Suspense during SSR or hydrating from SSR
5050
const ResolvedSuspense =
51-
router.isServer || (typeof document !== 'undefined' && router.clientSsr)
51+
router.isServer || (typeof document !== 'undefined' && router.ssr)
5252
? SafeFragment
5353
: React.Suspense
5454

packages/react-router/src/ScriptOnce.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ export function ScriptOnce({
1414

1515
return (
1616
<script
17-
className="tsr-once"
17+
className="$tsr"
1818
dangerouslySetInnerHTML={{
1919
__html: [
2020
children,
2121
(log ?? true) && process.env.NODE_ENV === 'development'
2222
? `console.info(\`Injected From Server:
2323
${jsesc(children.toString(), { quotes: 'backtick' })}\`)`
2424
: '',
25-
'if (typeof __TSR_SSR__ !== "undefined") __TSR_SSR__.cleanScripts()',
25+
'if (typeof $_TSR !== "undefined") $_TSR.c()',
2626
]
2727
.filter(Boolean)
2828
.join('\n'),

packages/react-router/src/Transitioner.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export function Transitioner() {
6969
// Try to load the initial location
7070
useLayoutEffect(() => {
7171
if (
72-
(typeof window !== 'undefined' && router.clientSsr) ||
72+
// if we are hydrating from SSR, loading is triggered in ssr-client
73+
(typeof window !== 'undefined' && router.ssr) ||
7374
(mountLoadForRouter.current.router === router &&
7475
mountLoadForRouter.current.mounted)
7576
) {

0 commit comments

Comments
 (0)