Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: router stream injection #3216

Merged
merged 32 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
172ed40
fix: router stream injection
tannerlinsley Jan 22, 2025
49b0716
Merge remote-tracking branch 'origin/main' into fix-router-stream-inj…
tannerlinsley Jan 22, 2025
8d8ebf8
fix: use web streams instead
tannerlinsley Jan 22, 2025
450043d
no log
tannerlinsley Jan 22, 2025
78c4af9
fix: more explicit stream timings
tannerlinsley Jan 22, 2025
b4f0cd5
fix: router stream lifecycle
tannerlinsley Jan 22, 2025
3c44011
fix: router stream lifecycel
tannerlinsley Jan 22, 2025
ec1eac7
checkpoint
tannerlinsley Jan 23, 2025
d9a5fac
checkpoint
schiller-manuel Jan 23, 2025
c97ca14
ci: apply automated fixes
autofix-ci[bot] Jan 23, 2025
a3e315a
so close!
tannerlinsley Jan 24, 2025
779efb0
fixes
tannerlinsley Jan 24, 2025
ae21023
readable streams
tannerlinsley Jan 24, 2025
75e94dd
inject promises
tannerlinsley Jan 24, 2025
94a245d
almost there
tannerlinsley Jan 24, 2025
df99ace
please
tannerlinsley Jan 24, 2025
96927c1
yEssssss
tannerlinsley Jan 24, 2025
a5e9861
YESSSSS
tannerlinsley Jan 24, 2025
7bceb8a
almost there
tannerlinsley Jan 24, 2025
95d1c6c
move scripts into head
schiller-manuel Jan 24, 2025
8c9e856
fix lockfile
schiller-manuel Jan 24, 2025
a1b1956
override react version in monorepo
schiller-manuel Jan 24, 2025
1163814
fix buffering
schiller-manuel Jan 24, 2025
7075aab
matrix build for react
schiller-manuel Jan 24, 2025
ced2fd0
fix stream demo
tannerlinsley Jan 24, 2025
1c80465
Revert "matrix build for react"
schiller-manuel Jan 24, 2025
abf9aae
Merge branch 'main' into fix-router-stream-injection
schiller-manuel Jan 24, 2025
a992b2c
updates
tannerlinsley Jan 24, 2025
768b6c3
remove unnecessary TSR queue
tannerlinsley Jan 24, 2025
8a541fe
bump nx
schiller-manuel Jan 24, 2025
89de5c1
fix lint
schiller-manuel Jan 24, 2025
91bf350
adding back ScriptOnce for clerk compat
schiller-manuel Jan 24, 2025
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
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,
Copy link
Member

Choose a reason for hiding this comment

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

As far as I can see, at this current time, this action of setting serializer: SuperJSON is not possible since the serializer property (previously named transformer) no longer exists on RouterOptions.

Copy link
Contributor

Choose a reason for hiding this comment

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

the whole non-start SSR story needs a major overhaul. let's do this separately

Copy link
Member

@SeanCassiere SeanCassiere Jan 24, 2025

Choose a reason for hiding this comment

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

👍🏼

Are we committing to removing user-configurable transformer/serializer though?

If not, I think it'd make sense to introduce a bug where we leave the RouterOptions interface "as-is" but disable functionality in code. Then wire it back up in another PR with any renaming that needs to be done.

Copy link
Contributor

Choose a reason for hiding this comment

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

for now it cant be configured. but this functionality should be re-added at some point.

})
```

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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 0 additions & 40 deletions packages/react-router/src/ScriptOnce.tsx

This file was deleted.

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
Loading