Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions docs/router/framework/react/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,22 +260,6 @@ const router = createRouter({
- Optional
- 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.

### `errorSerializer` property

- Type: [`RouterErrorSerializer`]
- Optional
- The serializer object that will be used to determine how errors are serialized and deserialized between the server and the client.

#### `errorSerializer.serialize` method

- Type: `(err: unknown) => TSerializedError`
- This method is called to define how errors are serialized when they are stored in the router's dehydrated state.

#### `errorSerializer.deserialize` method

- Type: `(err: TSerializedError) => unknown`
- This method is called to define how errors are deserialized from the router's dehydrated state.

### `trailingSlash` property

- Type: `'always' | 'never' | 'preserve'`
Expand Down
2 changes: 1 addition & 1 deletion docs/router/framework/react/guide/external-data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Tools that are able can integrate with TanStack Router's convenient Dehydration/

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

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

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.

Expand Down
277 changes: 152 additions & 125 deletions packages/react-router-with-query/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { Fragment } from 'react'
import {
QueryClientProvider,
dehydrate,
hashKey,
hydrate,
dehydrate as queryDehydrate,
hydrate as queryHydrate,
} from '@tanstack/react-query'
import { isRedirect } from '@tanstack/router-core'
import '@tanstack/router-core/ssr/client'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like extra import

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the issue here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended to import this module like side effect without import specific functions, etc?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the import is necessary to get the type import. is there any problem with that import?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for disturbing.
I expected to see something like:

import { type SomeType } from '@tanstack/router-core/ssr/client'

instead of import '@tanstack/router-core/ssr/client'

import type { AnyRouter } from '@tanstack/react-router'
import type {
QueryClient,
QueryObserverResult,
UseQueryOptions,
DehydratedState as QueryDehydratedState,
} from '@tanstack/react-query'

type AdditionalOptions = {
Expand All @@ -24,6 +23,10 @@ type AdditionalOptions = {
handleRedirects?: boolean
}

type DehydratedRouterQueryState = {
dehydratedQueryClient: QueryDehydratedState
queryStream: ReadableStream<QueryDehydratedState>
}
export type ValidateRouter<TRouter extends AnyRouter> =
NonNullable<TRouter['options']['context']> extends {
queryClient: QueryClient
Expand All @@ -36,129 +39,10 @@ export function routerWithQueryClient<TRouter extends AnyRouter>(
queryClient: QueryClient,
additionalOpts?: AdditionalOptions,
): TRouter {
const seenQueryKeys = new Set<string>()
const streamedQueryKeys = new Set<string>()

const ogClientOptions = queryClient.getDefaultOptions()
queryClient.setDefaultOptions({
...ogClientOptions,
queries: {
...ogClientOptions.queries,
_experimental_beforeQuery: (options: UseQueryOptions) => {
// Call the original beforeQuery
;(ogClientOptions.queries as any)?._experimental_beforeQuery?.(options)

const hash = options.queryKeyHashFn || hashKey
// On the server, check if we've already seen the query before
if (router.isServer) {
if (seenQueryKeys.has(hash(options.queryKey))) {
return
}

seenQueryKeys.add(hash(options.queryKey))

// If we haven't seen the query and we have data for it,
// That means it's going to get dehydrated with critical
// data, so we can skip the injection
if (queryClient.getQueryData(options.queryKey) !== undefined) {
;(options as any).__skipInjection = true
return
}
} else {
// On the client, pick up the deferred data from the stream
const dehydratedClient = router.clientSsr!.getStreamedValue<any>(
'__QueryClient__' + hash(options.queryKey),
)

// If we have data, hydrate it into the query client
if (dehydratedClient && !dehydratedClient.hydrated) {
dehydratedClient.hydrated = true
hydrate(queryClient, dehydratedClient)
}
}
},
_experimental_afterQuery: (
options: UseQueryOptions,
_result: QueryObserverResult,
) => {
// On the server (if we're not skipping injection)
// send down the dehydrated query
const hash = options.queryKeyHashFn || hashKey
if (
router.isServer &&
!(options as any).__skipInjection &&
queryClient.getQueryData(options.queryKey) !== undefined &&
!streamedQueryKeys.has(hash(options.queryKey))
) {
streamedQueryKeys.add(hash(options.queryKey))

router.serverSsr!.streamValue(
'__QueryClient__' + hash(options.queryKey),
dehydrate(queryClient, {
shouldDehydrateMutation: () => false,
shouldDehydrateQuery: (query) =>
hash(query.queryKey) === hash(options.queryKey),
}),
)
}

// Call the original afterQuery
;(ogClientOptions.queries as any)?._experimental_afterQuery?.(
options,
_result,
)
},
} as any,
})

if (additionalOpts?.handleRedirects ?? true) {
const ogMutationCacheConfig = queryClient.getMutationCache().config
queryClient.getMutationCache().config = {
...ogMutationCacheConfig,
onError: (error, _variables, _context, _mutation) => {
if (isRedirect(error)) {
error.options._fromLocation = router.state.location
return router.navigate(router.resolveRedirect(error).options)
}

return ogMutationCacheConfig.onError?.(
error,
_variables,
_context,
_mutation,
)
},
}

const ogQueryCacheConfig = queryClient.getQueryCache().config
queryClient.getQueryCache().config = {
...ogQueryCacheConfig,
onError: (error, _query) => {
if (isRedirect(error)) {
error.options._fromLocation = router.state.location
return router.navigate(router.resolveRedirect(error).options)
}

return ogQueryCacheConfig.onError?.(error, _query)
},
}
}

const ogOptions = router.options

router.options = {
...router.options,
dehydrate: () => {
return {
...ogOptions.dehydrate?.(),
// When critical data is dehydrated, we also dehydrate the query client
dehydratedQueryClient: dehydrate(queryClient),
}
},
hydrate: (dehydrated: any) => {
ogOptions.hydrate?.(dehydrated)
// On the client, hydrate the query client with the dehydrated data
hydrate(queryClient, dehydrated.dehydratedQueryClient)
},
context: {
...ogOptions.context,
// Pass the query client to the context, so we can access it in loaders
Expand All @@ -178,5 +62,148 @@ export function routerWithQueryClient<TRouter extends AnyRouter>(
},
}

if (router.isServer) {
const queryStream = createPushableStream()

router.options.dehydrate =
async (): Promise<DehydratedRouterQueryState> => {
const ogDehydrated = await ogOptions.dehydrate?.()
const dehydratedQueryClient = queryDehydrate(queryClient)

router.serverSsr!.onRenderFinished(() => queryStream.close())

const dehydratedRouter = {
...ogDehydrated,
// When critical data is dehydrated, we also dehydrate the query client
dehydratedQueryClient,
// prepare the stream for queries coming up during rendering
queryStream: queryStream.stream,
}

return dehydratedRouter
}

const ogClientOptions = queryClient.getDefaultOptions()
queryClient.setDefaultOptions({
...ogClientOptions,
dehydrate: {
shouldDehydrateQuery: () => true,
...ogClientOptions.dehydrate,
},
})

queryClient.getQueryCache().subscribe((event) => {
if (event.type === 'added') {
// before rendering starts, we do not stream individual queries
// instead we dehydrate the entire query client in router's dehydrate()
if (!router.serverSsr!.isDehydrated()) {
return
}
if (queryStream.isClosed()) {
console.warn(
`tried to stream query ${event.query.queryHash} after stream was already closed`,
)
return
}
queryStream.enqueue(
queryDehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
if (query.queryHash === event.query.queryHash) {
return (
ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ??
true
)
}
return false
},
}),
)
}
})
// on the client
} else {
router.options.hydrate = async (dehydrated: DehydratedRouterQueryState) => {
await ogOptions.hydrate?.(dehydrated)
// On the client, hydrate the query client with the dehydrated data
queryHydrate(queryClient, dehydrated.dehydratedQueryClient)

const reader = dehydrated.queryStream.getReader()
reader
.read()
.then(async function handle({ done, value }) {
queryHydrate(queryClient, value)
if (done) {
return
}
const result = await reader.read()
return handle(result)
})
.catch((err) => {
console.error('Error reading query stream:', err)
})
}
if (additionalOpts?.handleRedirects ?? true) {
const ogMutationCacheConfig = queryClient.getMutationCache().config
queryClient.getMutationCache().config = {
...ogMutationCacheConfig,
onError: (error, _variables, _context, _mutation) => {
if (isRedirect(error)) {
error.options._fromLocation = router.state.location
return router.navigate(router.resolveRedirect(error).options)
}

return ogMutationCacheConfig.onError?.(
error,
_variables,
_context,
_mutation,
)
},
}

const ogQueryCacheConfig = queryClient.getQueryCache().config
queryClient.getQueryCache().config = {
...ogQueryCacheConfig,
onError: (error, _query) => {
if (isRedirect(error)) {
error.options._fromLocation = router.state.location
return router.navigate(router.resolveRedirect(error).options)
}

return ogQueryCacheConfig.onError?.(error, _query)
},
}
}
}

return router
}

type PushableStream = {
stream: ReadableStream
enqueue: (chunk: unknown) => void
close: () => void
isClosed: () => boolean
error: (err: unknown) => void
}

function createPushableStream(): PushableStream {
let controllerRef: ReadableStreamDefaultController
const stream = new ReadableStream({
start(controller) {
controllerRef = controller
},
})
let _isClosed = false

return {
stream,
enqueue: (chunk) => controllerRef.enqueue(chunk),
close: () => {
controllerRef.close()
_isClosed = true
},
isClosed: () => _isClosed,
error: (err: unknown) => controllerRef.error(err),
}
}
2 changes: 1 addition & 1 deletion packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function Matches() {

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

Expand Down
4 changes: 2 additions & 2 deletions packages/react-router/src/ScriptOnce.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ export function ScriptOnce({

return (
<script
className="tsr-once"
className="$tsr"
dangerouslySetInnerHTML={{
__html: [
children,
(log ?? true) && process.env.NODE_ENV === 'development'
? `console.info(\`Injected From Server:
${jsesc(children.toString(), { quotes: 'backtick' })}\`)`
: '',
'if (typeof __TSR_SSR__ !== "undefined") __TSR_SSR__.cleanScripts()',
'if (typeof $_TSR !== "undefined") $_TSR.c()',
]
.filter(Boolean)
.join('\n'),
Expand Down
3 changes: 2 additions & 1 deletion packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ export function Transitioner() {
// Try to load the initial location
useLayoutEffect(() => {
if (
(typeof window !== 'undefined' && router.clientSsr) ||
// if we are hydrating from SSR, loading is triggered in ssr-client
(typeof window !== 'undefined' && router.ssr) ||
(mountLoadForRouter.current.router === router &&
mountLoadForRouter.current.mounted)
) {
Expand Down
Loading