Skip to content
Open
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
22 changes: 22 additions & 0 deletions .changeset/steady-streams-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@tanstack/router-core': patch
'@tanstack/router-ssr-query-core': patch
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/solid-start-server': patch
'@tanstack/start-client-core': patch
'@tanstack/start-server-core': patch
'@tanstack/vue-router': patch
'@tanstack/vue-router-ssr-query': patch
'@tanstack/vue-start-server': patch
---

Stream large deferred SSR hydration payloads through a backpressure-aware router transport, fail known setup errors before response creation, and close cancelled or expired transforms safely.

Start now cancels discarded middleware and HEAD response bodies, including plain streams and derived branches.

Server-function raw streams now use bounded transport backpressure instead of buffering unread raw data on the client.

Solid SSR now emits one document type, preserves blocking Await values, and renders late lazy errors through route boundaries.

SSR Query integrations now keep request cleanup and stream ownership aligned with the router lifecycle.
5 changes: 1 addition & 4 deletions docs/router/api/router/RouterEventsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,6 @@ type RouterEvents = {
hrefChanged: boolean
hashChanged: boolean
}
onInjectedHtml: {
type: 'onInjectedHtml'
}
onRendered: {
type: 'onRendered'
fromLocation?: ParsedLocation
Expand All @@ -67,7 +64,7 @@ Once an event is emitted, the following properties will be present on the event

### `type` property

- Type: `onBeforeNavigate | onBeforeLoad | onLoad | onBeforeRouteMount | onResolved | onRendered | onInjectedHtml`
- Type: `onBeforeNavigate | onBeforeLoad | onLoad | onBeforeRouteMount | onResolved | onRendered`
- The type of the event
- This is useful for discriminating between events in a listener function.

Expand Down
24 changes: 21 additions & 3 deletions e2e/react-start/static-server-functions/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as PostsRouteImport } from './routes/posts'
import { Route as RawStreamRouteImport } from './routes/raw-stream'
import { Route as PostsIndexRouteImport } from './routes/posts.index'

const IndexRoute = IndexRouteImport.update({
Expand All @@ -23,6 +24,11 @@ const PostsRoute = PostsRouteImport.update({
path: '/posts',
getParentRoute: () => rootRouteImport,
} as any)
const RawStreamRoute = RawStreamRouteImport.update({
id: '/raw-stream',
path: '/raw-stream',
getParentRoute: () => rootRouteImport,
} as any)
const PostsIndexRoute = PostsIndexRouteImport.update({
id: '/',
path: '/',
Expand All @@ -32,29 +38,33 @@ const PostsIndexRoute = PostsIndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/posts': typeof PostsRouteWithChildren
'/raw-stream': typeof RawStreamRoute
'/posts/': typeof PostsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/raw-stream': typeof RawStreamRoute
'/posts': typeof PostsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/posts': typeof PostsRouteWithChildren
'/raw-stream': typeof RawStreamRoute
'/posts/': typeof PostsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/posts' | '/posts/'
fullPaths: '/' | '/posts' | '/raw-stream' | '/posts/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/posts'
id: '__root__' | '/' | '/posts' | '/posts/'
to: '/' | '/raw-stream' | '/posts'
id: '__root__' | '/' | '/posts' | '/raw-stream' | '/posts/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
PostsRoute: typeof PostsRouteWithChildren
RawStreamRoute: typeof RawStreamRoute
}

declare module '@tanstack/react-router' {
Expand All @@ -73,6 +83,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PostsRouteImport
parentRoute: typeof rootRouteImport
}
'/raw-stream': {
id: '/raw-stream'
path: '/raw-stream'
fullPath: '/raw-stream'
preLoaderRoute: typeof RawStreamRouteImport
parentRoute: typeof rootRouteImport
}
'/posts/': {
id: '/posts/'
path: '/'
Expand All @@ -96,6 +113,7 @@ const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
PostsRoute: PostsRouteWithChildren,
RawStreamRoute: RawStreamRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
3 changes: 3 additions & 0 deletions e2e/react-start/static-server-functions/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ function RootComponent() {
<Link to="/posts" data-testid="link-posts">
Posts
</Link>
<Link to="/raw-stream" data-testid="link-raw-stream">
Raw Stream
</Link>
</div>
<hr />
<Outlet />
Expand Down
29 changes: 29 additions & 0 deletions e2e/react-start/static-server-functions/src/routes/raw-stream.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createFileRoute } from '@tanstack/react-router'
import { RawStream, createServerFn } from '@tanstack/react-start'
import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions'

const fetchRawStream = createServerFn({ method: 'GET' })
.middleware([staticFunctionMiddleware])
.handler(async () => {
return new RawStream(new Response('Static cache stream').body!, {
hint: 'text',
})
})

export const Route = createFileRoute('/raw-stream')({
loader: async () => {
const result = await fetchRawStream()
if (typeof document === 'undefined') {
return ''
}
const stream = result as RawStream | ReadableStream<Uint8Array>
return new Response(
stream instanceof RawStream ? stream.stream : stream,
).text()
},
component: RawStreamComponent,
})

function RawStreamComponent() {
return <p data-testid="raw-stream">{Route.useLoaderData()}</p>
}
27 changes: 27 additions & 0 deletions e2e/react-start/static-server-functions/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,32 @@ test.describe('Static Server Functions with Nitro', () => {
await expect(page.getByTestId('post-2')).toHaveText('Second Post')
await expect(page.getByTestId('post-3')).toHaveText('Third Post')
})

test('should decode a cached RawStream during client navigation', async ({
page,
}) => {
await page.goto('/')
// Nitro snapshots public assets before Start prerenders (see #6787).
// Serve the generated file as a static host would to isolate decoding.
await page.route('**/__tsr/staticServerFnCache/*.json', (route) =>
route.fulfill({
path: join(
process.cwd(),
'.output',
'public',
new URL(route.request().url()).pathname,
),
}),
)
const cacheResponse = page.waitForResponse((response) =>
response.url().includes('/__tsr/staticServerFnCache/'),
)
await page.getByTestId('link-raw-stream').click()
const response = await cacheResponse
expect(response.status(), response.url()).toBe(200)
await expect(page.getByTestId('raw-stream')).toHaveText(
'Static cache stream',
)
})
})
})
28 changes: 28 additions & 0 deletions e2e/react-start/streaming-ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,37 @@
"@types/node": "^22.10.2",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^6.0.1",
"srvx": "^0.11.9",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.0.14"
},
"nx": {
"targets": {
"build": {
"inputs": [
"buildProduction",
"^buildProduction",
"dependentTaskOutputs",
"streamingSsrTests"
]
},
"test:e2e": {
"inputs": [
"default",
"^production",
"dependentTaskOutputs",
"streamingSsrTests"
]
},
"test:e2e:preview": {
"inputs": [
"default",
"^production",
"streamingSsrTests"
]
}
}
}
}
21 changes: 21 additions & 0 deletions e2e/react-start/streaming-ssr/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Route as FastSerialRouteImport } from './routes/fast-serial'
import { Route as ManyPromisesRouteImport } from './routes/many-promises'
import { Route as NestedDeferredRouteImport } from './routes/nested-deferred'
import { Route as QueryHeavyRouteImport } from './routes/query-heavy'
import { Route as RouterHtmlBufferRouteImport } from './routes/router-html-buffer'
import { Route as SlowRenderRouteImport } from './routes/slow-render'
import { Route as StreamRouteImport } from './routes/stream'
import { Route as SyncOnlyRouteImport } from './routes/sync-only'
Expand Down Expand Up @@ -61,6 +62,11 @@ const QueryHeavyRoute = QueryHeavyRouteImport.update({
path: '/query-heavy',
getParentRoute: () => rootRouteImport,
} as any)
const RouterHtmlBufferRoute = RouterHtmlBufferRouteImport.update({
id: '/router-html-buffer',
path: '/router-html-buffer',
getParentRoute: () => rootRouteImport,
} as any)
const SlowRenderRoute = SlowRenderRouteImport.update({
id: '/slow-render',
path: '/slow-render',
Expand All @@ -86,6 +92,7 @@ export interface FileRoutesByFullPath {
'/many-promises': typeof ManyPromisesRoute
'/nested-deferred': typeof NestedDeferredRoute
'/query-heavy': typeof QueryHeavyRoute
'/router-html-buffer': typeof RouterHtmlBufferRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
'/sync-only': typeof SyncOnlyRoute
Expand All @@ -99,6 +106,7 @@ export interface FileRoutesByTo {
'/many-promises': typeof ManyPromisesRoute
'/nested-deferred': typeof NestedDeferredRoute
'/query-heavy': typeof QueryHeavyRoute
'/router-html-buffer': typeof RouterHtmlBufferRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
'/sync-only': typeof SyncOnlyRoute
Expand All @@ -113,6 +121,7 @@ export interface FileRoutesById {
'/many-promises': typeof ManyPromisesRoute
'/nested-deferred': typeof NestedDeferredRoute
'/query-heavy': typeof QueryHeavyRoute
'/router-html-buffer': typeof RouterHtmlBufferRoute
'/slow-render': typeof SlowRenderRoute
'/stream': typeof StreamRoute
'/sync-only': typeof SyncOnlyRoute
Expand All @@ -128,6 +137,7 @@ export interface FileRouteTypes {
| '/many-promises'
| '/nested-deferred'
| '/query-heavy'
| '/router-html-buffer'
| '/slow-render'
| '/stream'
| '/sync-only'
Expand All @@ -141,6 +151,7 @@ export interface FileRouteTypes {
| '/many-promises'
| '/nested-deferred'
| '/query-heavy'
| '/router-html-buffer'
| '/slow-render'
| '/stream'
| '/sync-only'
Expand All @@ -154,6 +165,7 @@ export interface FileRouteTypes {
| '/many-promises'
| '/nested-deferred'
| '/query-heavy'
| '/router-html-buffer'
| '/slow-render'
| '/stream'
| '/sync-only'
Expand All @@ -168,6 +180,7 @@ export interface RootRouteChildren {
ManyPromisesRoute: typeof ManyPromisesRoute
NestedDeferredRoute: typeof NestedDeferredRoute
QueryHeavyRoute: typeof QueryHeavyRoute
RouterHtmlBufferRoute: typeof RouterHtmlBufferRoute
SlowRenderRoute: typeof SlowRenderRoute
StreamRoute: typeof StreamRoute
SyncOnlyRoute: typeof SyncOnlyRoute
Expand Down Expand Up @@ -231,6 +244,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof QueryHeavyRouteImport
parentRoute: typeof rootRouteImport
}
'/router-html-buffer': {
id: '/router-html-buffer'
path: '/router-html-buffer'
fullPath: '/router-html-buffer'
preLoaderRoute: typeof RouterHtmlBufferRouteImport
parentRoute: typeof rootRouteImport
}
'/slow-render': {
id: '/slow-render'
path: '/slow-render'
Expand Down Expand Up @@ -264,6 +284,7 @@ const rootRouteChildren: RootRouteChildren = {
ManyPromisesRoute: ManyPromisesRoute,
NestedDeferredRoute: NestedDeferredRoute,
QueryHeavyRoute: QueryHeavyRoute,
RouterHtmlBufferRoute: RouterHtmlBufferRoute,
SlowRenderRoute: SlowRenderRoute,
StreamRoute: StreamRoute,
SyncOnlyRoute: SyncOnlyRoute,
Expand Down
Loading
Loading