Skip to content

Commit

Permalink
fix: router stream injection (#3216)
Browse files Browse the repository at this point in the history
Co-authored-by: Manuel Schiller <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Jan 24, 2025
1 parent a0a5949 commit bb1a14a
Show file tree
Hide file tree
Showing 54 changed files with 1,708 additions and 1,605 deletions.
17 changes: 0 additions & 17 deletions docs/framework/react/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,23 +276,6 @@ const router = createRouter({
- Type: `(err: TSerializedError) => unknown`
- This method is called to define how errors are deserialized from the router's dehydrated state.

### `transformer` property

- Type: `RouterTransformer`
- Optional
- The transformer that will be used when sending data between the server and the client during SSR.
- Defaults to a very lightweight transformer that supports a few basic types. See the [SSR guide](../../guide/ssr.md) for more information.

#### `transformer.stringify` method

- Type: `(obj: unknown) => string`
- This method is called when stringifying data to be sent to the client.

#### `transformer.parse` method

- Type: `(str: string) => unknown`
- This method is called when parsing the string encoded by the server.

### `trailingSlash` property

- Type: `'always' | 'never' | 'preserve'`
Expand Down
18 changes: 11 additions & 7 deletions docs/framework/react/guide/ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,28 +201,32 @@ This pattern can be useful for pages that have slow or high-latency data fetchin

Streaming dehydration/hydration is an advanced pattern that goes beyond markup and allows you to dehydrate and stream any supporting data from the server to the client and rehydrate it on arrival. This is useful for applications that may need to further use/manage the underlying data that was used to render the initial markup on the server.

## Data Transformers
## Data Serialization

When using SSR, data passed between the server and the client must be serialized before it is sent across network-boundaries. By default, TanStack Router will serialize data using a very lightweight serializer that supports a few basic types beyond JSON.stringify/JSON.parse.
When using SSR, data passed between the server and the client must be serialized before it is sent across network-boundaries. TanStack Router handles this serialization using a very lightweight serializer that supports common data types beyond JSON.stringify/JSON.parse.

Out of the box, the following types are supported:

- `Date`
- `undefined`
- `Date`
- `Error`
- `FormData`

If you feel that there are other types that should be supported by default, please open an issue on the TanStack Router repository.

If you are using more complex data types like `Map`, `Set`, `BigInt`, etc, you may need to use a custom serializer to ensure that your type-definitions are accurate and your data is correctly serialized and deserialized. This is where the `transformer` option on `createRouter` comes in.
If you are using more complex data types like `Map`, `Set`, `BigInt`, etc, you may need to use a custom serializer to ensure that your type-definitions are accurate and your data is correctly serialized and deserialized. We are currently working on both a more robust serializer and a way to customize the serializer for your application. Open an issue if you are interested in helping out!

<!-- This is where the `serializer` option on `createRouter` comes in. -->

The Data Transformer API allows the usage of a custom serializer that can allow us to transparently use these data types when communicating across the network.
The Data Serialization API allows the usage of a custom serializer that can allow us to transparently use these data types when communicating across the network.

The following example shows usage with [SuperJSON](https://github.com/blitz-js/superjson), however, anything that implements [`Router Transformer`](../api/router/RouterOptionsType.md#transformer-property) can be used.
<!-- The following example shows usage with [SuperJSON](https://github.com/blitz-js/superjson), however, anything that implements [`Start Serializer`](../api/router/RouterOptionsType.md#serializer-property) can be used. -->

```tsx
import { SuperJSON } from 'superjson'

const router = createRouter({
transformer: SuperJSON,
serializer: SuperJSON,
})
```

Expand Down
52 changes: 28 additions & 24 deletions e2e/start/basic/app/routes/stream.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Await, createFileRoute } from '@tanstack/react-router'
import React, { useEffect, useState } from 'react'
import { useEffect, useState } from 'react'

export const Route = createFileRoute('/stream')({
component: Home,
Expand All @@ -9,49 +9,53 @@ export const Route = createFileRoute('/stream')({
setTimeout(() => resolve('promise-data'), 150),
),
stream: new ReadableStream({
start(controller) {
controller.enqueue('stream-data')
async start(controller) {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 200))
controller.enqueue(`stream-data-${i} `)
}
controller.close()
},
}),
}
},
})

const decoder = new TextDecoder('utf-8')

function Home() {
const { promise, stream } = Route.useLoaderData()

const [streamData, setStreamData] = useState('')
const [streamData, setStreamData] = useState([])

useEffect(() => {
async function fetchStream() {
const reader = stream.getReader()
const decoder = new TextDecoder('utf-8')
let result = ''
let done = false
let chunk

while (!done) {
const { value, done: readerDone } = await reader.read()
done = readerDone
if (value) {
result += decoder.decode(value, { stream: !done })
}
while (!(chunk = await reader.read()).done) {
const decoded = decoder.decode(chunk.value, { stream: !chunk.done })
setStreamData((prev) => [...prev, decoded])
}
setStreamData(result)
}

fetchStream()
}, [])

return (
<Await
promise={promise}
children={(promiseData) => (
<div className="p-2">
<h3 data-testid="promise-data">{promiseData}</h3>
<h3 data-testid="stream-data">{streamData}</h3>
</div>
)}
></Await>
<>
<Await
promise={promise}
children={(promiseData) => (
<div className="p-2" data-testid="promise-data">
{promiseData}
<div data-testid="stream-data">
{streamData.map((d) => (
<div key={d}>{d}</div>
))}
</div>
</div>
)}
/>
</>
)
}
4 changes: 2 additions & 2 deletions examples/react/basic-ssr-streaming-file-based/src/router.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createRouter as createReactRouter } from '@tanstack/react-router'

import { routeTree } from './routeTree.gen'
import SuperJSON from 'superjson'
// import SuperJSON from 'superjson'

export function createRouter() {
return createReactRouter({
Expand All @@ -10,7 +10,7 @@ export function createRouter() {
head: '',
},
defaultPreload: 'intent',
transformer: SuperJSON,
// serializer: SuperJSON,
})
}

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"eslint-plugin-unused-imports": "^4.1.4",
"tinyglobby": "^0.2.10",
"jsdom": "^25.0.1",
"nx": "^20.3.0",
"nx": "^20.3.3",
"prettier": "^3.4.2",
"publint": "^0.2.12",
"react": "^18.3.1",
Expand All @@ -65,6 +65,8 @@
},
"pnpm": {
"overrides": {
"react": "$react",
"react-dom": "$react-dom",
"eslint": "$eslint",
"vite": "$vite",
"@tanstack/react-query": "5.62.3",
Expand Down
4 changes: 2 additions & 2 deletions packages/react-router-with-query/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function routerWithQueryClient<TRouter extends AnyRouter>(
}
} else {
// On the client, pick up the deferred data from the stream
const dehydratedClient = router.getStreamedValue<any>(
const dehydratedClient = router.clientSsr!.getStreamedValue<any>(
'__QueryClient__' + hash(options.queryKey),
)

Expand All @@ -75,7 +75,7 @@ export function routerWithQueryClient<TRouter extends AnyRouter>(
) {
streamedQueryKeys.add(hash(options.queryKey))

router.streamValue(
router.serverSsr!.streamValue(
'__QueryClient__' + hash(options.queryKey),
dehydrate(queryClient, {
shouldDehydrateMutation: () => false,
Expand Down
33 changes: 4 additions & 29 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { createControlledPromise, pick } from './utils'
import { CatchNotFound, isNotFound } from './not-found'
import { isRedirect } from './redirects'
import { matchContext } from './matchContext'
import { defaultDeserializeError, isServerSideError } from './isServerSideError'
import { SafeFragment } from './SafeFragment'
import { renderRouteNotFound } from './renderRouteNotFound'
import { rootRouteId } from './root'
Expand Down Expand Up @@ -157,19 +156,8 @@ export const MatchInner = React.memo(function MatchInnerImpl({
ErrorComponent

if (match.status === 'notFound') {
let error: unknown
if (isServerSideError(match.error)) {
const deserializeError =
router.options.errorSerializer?.deserialize ?? defaultDeserializeError

error = deserializeError(match.error.data)
} else {
error = match.error
}

invariant(isNotFound(error), 'Expected a notFound error')

return renderRouteNotFound(router, route, error)
invariant(isNotFound(match.error), 'Expected a notFound error')
return renderRouteNotFound(router, route, match.error)
}

if (match.status === 'redirected') {
Expand Down Expand Up @@ -201,13 +189,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
)
}

if (isServerSideError(match.error)) {
const deserializeError =
router.options.errorSerializer?.deserialize ?? defaultDeserializeError
throw deserializeError(match.error.data)
} else {
throw match.error
}
throw match.error
}

if (match.status === 'pending') {
Expand Down Expand Up @@ -241,14 +223,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
throw router.getMatch(match.id)?.loadPromise
}

return (
<>
{out}
{router.AfterEachMatch ? (
<router.AfterEachMatch match={match} matchIndex={matchIndex} />
) : null}
</>
)
return out
})

export const Outlet = React.memo(function OutletImpl() {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/Matches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export function Matches() {

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

Expand Down
6 changes: 3 additions & 3 deletions packages/react-router/src/RouterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ export type BuildLocationFn = <
},
) => ParsedLocation

export type InjectedHtmlEntry = string | (() => Promise<string> | string)

export function RouterContextProvider<
TRouter extends AnyRouter = RegisteredRouter,
TDehydrated extends Record<string, any> = Record<string, any>,
Expand All @@ -79,7 +77,9 @@ export function RouterContextProvider<
const routerContext = getRouterContext()

const provider = (
<routerContext.Provider value={router}>{children}</routerContext.Provider>
<routerContext.Provider value={router as AnyRouter}>
{children}
</routerContext.Provider>
)

if (router.options.Wrap) {
Expand Down
8 changes: 0 additions & 8 deletions packages/react-router/src/ScriptOnce.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
import jsesc from 'jsesc'
import { useRouter } from './useRouter'

export function ScriptOnce({
children,
log,
sync,
}: {
children: string
log?: boolean
sync?: boolean
}) {
const router = useRouter()
if (typeof document !== 'undefined') {
return null
}

if (!sync) {
router.injectScript(children, { logScript: log })
return null
}

return (
<script
className="tsr-once"
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/Transitioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function Transitioner() {
// Try to load the initial location
useLayoutEffect(() => {
if (
(typeof window !== 'undefined' && window.__TSR__?.dehydrated) ||
(typeof window !== 'undefined' && router.clientSsr) ||
(mountLoadForRouter.current.router === router &&
mountLoadForRouter.current.mounted)
) {
Expand Down
28 changes: 3 additions & 25 deletions packages/react-router/src/awaited.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import * as React from 'react'
import warning from 'tiny-warning'
import { useRouter } from './useRouter'
import { defaultSerializeError } from './router'

import { TSR_DEFERRED_PROMISE, defer } from './defer'
import { defaultDeserializeError, isServerSideError } from './isServerSideError'
import type { DeferredPromise } from './defer'

export type AwaitOptions<T> = {
Expand All @@ -13,35 +10,16 @@ export type AwaitOptions<T> = {
export function useAwaited<T>({
promise: _promise,
}: AwaitOptions<T>): [T, DeferredPromise<T>] {
const router = useRouter()
const promise = defer(_promise)

if (promise[TSR_DEFERRED_PROMISE].status === 'pending') {
throw promise
}

if (promise[TSR_DEFERRED_PROMISE].status === 'error') {
if (typeof document !== 'undefined') {
if (isServerSideError(promise[TSR_DEFERRED_PROMISE].error)) {
throw (
router.options.errorSerializer?.deserialize ?? defaultDeserializeError
)(promise[TSR_DEFERRED_PROMISE].error.data as any)
} else {
warning(
false,
"Encountered a server-side error that doesn't fit the expected shape",
)
throw promise[TSR_DEFERRED_PROMISE].error
}
} else {
throw {
data: (
router.options.errorSerializer?.serialize ?? defaultSerializeError
)(promise[TSR_DEFERRED_PROMISE].error),
__isServerError: true,
}
}
throw promise[TSR_DEFERRED_PROMISE].error
}

return [promise[TSR_DEFERRED_PROMISE].data, promise]
}

Expand Down
Loading

0 comments on commit bb1a14a

Please sign in to comment.