diff --git a/.changeset/quiet-ssr-hotspots.md b/.changeset/quiet-ssr-hotspots.md
new file mode 100644
index 00000000000..c4a36e065d9
--- /dev/null
+++ b/.changeset/quiet-ssr-hotspots.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/router-core': patch
+'@tanstack/start-plugin-core': patch
+---
+
+Reduce per-request SSR overhead: abort settled route matches with one shared reason instead of building a stack-capturing `DOMException` per match, skip `JSON.parse` for search values that cannot start JSON, wait on request signals with one listener per wait, and keep resolved server-function modules in production builds instead of re-importing them on every call.
diff --git a/.changeset/steady-streams-flow.md b/.changeset/steady-streams-flow.md
new file mode 100644
index 00000000000..6f6286d46f3
--- /dev/null
+++ b/.changeset/steady-streams-flow.md
@@ -0,0 +1,29 @@
+---
+'@tanstack/router-core': patch
+'@tanstack/router-ssr-query-core': patch
+'@tanstack/react-router': patch
+'@tanstack/react-router-ssr-query': patch
+'@tanstack/solid-router': patch
+'@tanstack/solid-router-ssr-query': patch
+'@tanstack/solid-start-server': patch
+'@tanstack/start-client-core': patch
+'@tanstack/start-server-core': patch
+'@tanstack/start-static-server-functions': 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 share one ordered response. Arbitrary or sequential consumption can require potentially unbounded buffering of unread data on the client. Cancelling one raw stream discards it locally, while aborting the whole call cancels the response and server work. Consume streams concurrently, cancel unused streams promptly, or use separate calls when independent backpressure is required.
+
+The JSON wire shape of a `RawStream` server-function argument changed. Clients and servers must run matching versions for requests that pass a `RawStream`.
+
+Solid SSR now emits one document type and renders late lazy errors through route boundaries. A Solid `` without a `fallback` no longer holds the streamed shell; it renders inside the nearest `` boundary like React and Vue, and now renders falsy resolved values.
+
+Static server functions decode cached `RawStream` values with the client deserializer plugins.
+
+SSR Query integrations now keep request cleanup and stream ownership aligned with the router lifecycle.
diff --git a/benchmarks/ssr/scenarios/control-flow/vue/src/routeTree.gen.ts b/benchmarks/ssr/scenarios/control-flow/vue/src/routeTree.gen.ts
index f5063d1c09d..6dcf0d095e3 100644
--- a/benchmarks/ssr/scenarios/control-flow/vue/src/routeTree.gen.ts
+++ b/benchmarks/ssr/scenarios/control-flow/vue/src/routeTree.gen.ts
@@ -10,25 +10,25 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
-import { Route as TargetIdRouteImport } from './routes/target.$id'
-import { Route as MissingIdRouteImport } from './routes/missing.$id'
-import { Route as HeadersIdRouteImport } from './routes/headers.$id'
-import { Route as FromIdRouteImport } from './routes/from.$id'
import { Route as BoomIdRouteImport } from './routes/boom.$id'
+import { Route as FromIdRouteImport } from './routes/from.$id'
+import { Route as HeadersIdRouteImport } from './routes/headers.$id'
+import { Route as MissingIdRouteImport } from './routes/missing.$id'
+import { Route as TargetIdRouteImport } from './routes/target.$id'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
-const TargetIdRoute = TargetIdRouteImport.update({
- id: '/target/$id',
- path: '/target/$id',
+const BoomIdRoute = BoomIdRouteImport.update({
+ id: '/boom/$id',
+ path: '/boom/$id',
getParentRoute: () => rootRouteImport,
} as any)
-const MissingIdRoute = MissingIdRouteImport.update({
- id: '/missing/$id',
- path: '/missing/$id',
+const FromIdRoute = FromIdRouteImport.update({
+ id: '/from/$id',
+ path: '/from/$id',
getParentRoute: () => rootRouteImport,
} as any)
const HeadersIdRoute = HeadersIdRouteImport.update({
@@ -36,14 +36,14 @@ const HeadersIdRoute = HeadersIdRouteImport.update({
path: '/headers/$id',
getParentRoute: () => rootRouteImport,
} as any)
-const FromIdRoute = FromIdRouteImport.update({
- id: '/from/$id',
- path: '/from/$id',
+const MissingIdRoute = MissingIdRouteImport.update({
+ id: '/missing/$id',
+ path: '/missing/$id',
getParentRoute: () => rootRouteImport,
} as any)
-const BoomIdRoute = BoomIdRouteImport.update({
- id: '/boom/$id',
- path: '/boom/$id',
+const TargetIdRoute = TargetIdRouteImport.update({
+ id: '/target/$id',
+ path: '/target/$id',
getParentRoute: () => rootRouteImport,
} as any)
@@ -117,18 +117,18 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
- '/target/$id': {
- id: '/target/$id'
- path: '/target/$id'
- fullPath: '/target/$id'
- preLoaderRoute: typeof TargetIdRouteImport
+ '/boom/$id': {
+ id: '/boom/$id'
+ path: '/boom/$id'
+ fullPath: '/boom/$id'
+ preLoaderRoute: typeof BoomIdRouteImport
parentRoute: typeof rootRouteImport
}
- '/missing/$id': {
- id: '/missing/$id'
- path: '/missing/$id'
- fullPath: '/missing/$id'
- preLoaderRoute: typeof MissingIdRouteImport
+ '/from/$id': {
+ id: '/from/$id'
+ path: '/from/$id'
+ fullPath: '/from/$id'
+ preLoaderRoute: typeof FromIdRouteImport
parentRoute: typeof rootRouteImport
}
'/headers/$id': {
@@ -138,18 +138,18 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof HeadersIdRouteImport
parentRoute: typeof rootRouteImport
}
- '/from/$id': {
- id: '/from/$id'
- path: '/from/$id'
- fullPath: '/from/$id'
- preLoaderRoute: typeof FromIdRouteImport
+ '/missing/$id': {
+ id: '/missing/$id'
+ path: '/missing/$id'
+ fullPath: '/missing/$id'
+ preLoaderRoute: typeof MissingIdRouteImport
parentRoute: typeof rootRouteImport
}
- '/boom/$id': {
- id: '/boom/$id'
- path: '/boom/$id'
- fullPath: '/boom/$id'
- preLoaderRoute: typeof BoomIdRouteImport
+ '/target/$id': {
+ id: '/target/$id'
+ path: '/target/$id'
+ fullPath: '/target/$id'
+ preLoaderRoute: typeof TargetIdRouteImport
parentRoute: typeof rootRouteImport
}
}
diff --git a/benchmarks/ssr/scenarios/global-middleware/bench.ts b/benchmarks/ssr/scenarios/global-middleware/bench.ts
index a89d03cabb7..d2784aed385 100644
--- a/benchmarks/ssr/scenarios/global-middleware/bench.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/bench.ts
@@ -7,10 +7,13 @@ import {
import {
expectedFunctionTotal,
expectedRequestTotal,
+ getExpectedRequestContext,
makeDocumentMarker,
makeServerFnMarker,
makeServerRouteMarker,
+ setRequestMiddlewareCount,
} from './shared'
+import type { RequestMiddlewareCount } from './shared'
import type { StartRequestHandler } from '../../bench-utils'
export type { StartRequestHandler }
@@ -140,6 +143,7 @@ function buildPostRequest(urls: FnUrls, bodies: Array, index: number) {
export async function setupGlobalMiddlewareBench(
handler: StartRequestHandler,
): Promise {
+ setRequestMiddlewareCount(3)
const urls = await discoverUrls(handler)
const payloads = createPayloads()
const bodies = await createBodies(payloads)
@@ -154,6 +158,7 @@ export async function setupGlobalMiddlewareBench(
}
async function assertDocumentResponse(handler: StartRequestHandler) {
+ setRequestMiddlewareCount(3)
const id = 'page-sanity'
const response = await handler.fetch(
new Request(`${origin}/page/${id}`, documentRequestInit),
@@ -175,7 +180,11 @@ async function assertDocumentResponse(handler: StartRequestHandler) {
}
}
-async function assertServerRouteResponse(handler: StartRequestHandler) {
+async function assertServerRouteResponseForCount(
+ handler: StartRequestHandler,
+ count: RequestMiddlewareCount,
+) {
+ setRequestMiddlewareCount(count)
const id = 'route-sanity'
const response = await handler.fetch(
new Request(`${origin}/api/ping/${id}`, apiRequestInit),
@@ -199,10 +208,8 @@ async function assertServerRouteResponse(handler: StartRequestHandler) {
marker?: string
requestTotal?: number
}
- const expectedMarker = makeServerRouteMarker(id, {
- requestTrace: 'req.r1.r2.r3',
- requestTotal: expectedRequestTotal,
- })
+ const expectedContext = getExpectedRequestContext(count)
+ const expectedMarker = makeServerRouteMarker(id, expectedContext)
if (body.marker !== expectedMarker) {
throw new Error(
@@ -210,9 +217,9 @@ async function assertServerRouteResponse(handler: StartRequestHandler) {
)
}
- if (body.requestTotal !== expectedRequestTotal) {
+ if (body.requestTotal !== expectedContext.requestTotal) {
throw new Error(
- `Expected server route requestTotal ${expectedRequestTotal}, received ${body.requestTotal}`,
+ `Expected server route requestTotal ${expectedContext.requestTotal}, received ${body.requestTotal}`,
)
}
}
@@ -221,6 +228,7 @@ async function assertServerFnResponse(
handler: StartRequestHandler,
context: GlobalMiddlewareBenchContext,
) {
+ setRequestMiddlewareCount(3)
const response = await handler.fetch(
buildPostRequest(context.urls, context.bodies, 0),
)
@@ -257,10 +265,14 @@ export async function assertGlobalMiddlewareScenario(
) {
await assertDocumentResponse(handler)
await assertServerFnResponse(handler, context)
- await assertServerRouteResponse(handler)
+ for (const count of [0, 1, 2, 3] as const) {
+ await assertServerRouteResponseForCount(handler, count)
+ }
+ setRequestMiddlewareCount(3)
}
export function runGlobalMiddlewareDocumentLoop(handler: StartRequestHandler) {
+ setRequestMiddlewareCount(3)
return runRequestLoop(handler, {
seed: benchmarkSeed,
concurrency: 16,
@@ -273,6 +285,7 @@ export function runGlobalMiddlewareServerFnLoop(
handler: StartRequestHandler,
context: GlobalMiddlewareBenchContext,
) {
+ setRequestMiddlewareCount(3)
return runRequestLoop(handler, {
seed: benchmarkSeed,
concurrency: 16,
@@ -284,7 +297,9 @@ export function runGlobalMiddlewareServerFnLoop(
export function runGlobalMiddlewareServerRouteLoop(
handler: StartRequestHandler,
+ count: RequestMiddlewareCount = 3,
) {
+ setRequestMiddlewareCount(count)
return runRequestLoop(handler, {
seed: benchmarkSeed,
concurrency: 16,
diff --git a/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-0-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-0-middleware.bench.ts
new file mode 100644
index 00000000000..2a784ec9870
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-0-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 0 middleware (react)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 0),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-1-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-1-middleware.bench.ts
new file mode 100644
index 00000000000..64e18cfe611
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-1-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 1 middleware (react)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 1),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-2-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-2-middleware.bench.ts
new file mode 100644
index 00000000000..4418f0867ef
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route-2-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 2 middlewares (react)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 2),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route.bench.ts b/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route.bench.ts
index 9dc19cf943d..189f3b7ef67 100644
--- a/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route.bench.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/react/speed.global-mw-server-route.bench.ts
@@ -20,8 +20,8 @@ await assertGlobalMiddlewareScenario(handler, context)
describe('ssr', () => {
bench(
- 'ssr global-mw server-route (react)',
- () => runGlobalMiddlewareServerRouteLoop(handler),
+ 'ssr global-mw server-route 3 middlewares (react)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 3),
globalMiddlewareBenchOptions,
)
})
diff --git a/benchmarks/ssr/scenarios/global-middleware/react/src/start.ts b/benchmarks/ssr/scenarios/global-middleware/react/src/start.ts
index 6b59e8035b5..8dfb2dd3c43 100644
--- a/benchmarks/ssr/scenarios/global-middleware/react/src/start.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/react/src/start.ts
@@ -1,4 +1,5 @@
import { createMiddleware, createStart } from '@tanstack/react-start'
+import { getRequestMiddlewareCount } from '../../shared'
import type { GlobalMiddlewareContext } from '../../shared'
function appendTrace(
@@ -48,6 +49,13 @@ const requestMiddlewareC = createMiddleware({ type: 'request' }).server(
},
)
+const requestMiddlewareSets: Array> = [
+ [],
+ [requestMiddlewareA],
+ [requestMiddlewareA, requestMiddlewareB],
+ [requestMiddlewareA, requestMiddlewareB, requestMiddlewareC],
+]
+
const functionMiddlewareA = createMiddleware({ type: 'function' }).server(
({ next, context }) => {
const ctx = (context ?? {}) as GlobalMiddlewareContext
@@ -75,10 +83,6 @@ const functionMiddlewareB = createMiddleware({ type: 'function' }).server(
)
export const startInstance = createStart(() => ({
- requestMiddleware: [
- requestMiddlewareA,
- requestMiddlewareB,
- requestMiddlewareC,
- ],
+ requestMiddleware: requestMiddlewareSets[getRequestMiddlewareCount()],
functionMiddleware: [functionMiddlewareA, functionMiddlewareB],
}))
diff --git a/benchmarks/ssr/scenarios/global-middleware/shared.ts b/benchmarks/ssr/scenarios/global-middleware/shared.ts
index f90fa9b233d..8fbe788911b 100644
--- a/benchmarks/ssr/scenarios/global-middleware/shared.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/shared.ts
@@ -10,11 +10,41 @@ export interface GlobalMiddlewareRouteContext extends GlobalMiddlewareContext {
globalMiddlewareContext?: GlobalMiddlewareContext
}
+export type RequestMiddlewareCount = 0 | 1 | 2 | 3
+
+const requestMiddlewareCountKey = '__TSR_SSR_BENCH_REQUEST_MIDDLEWARE_COUNT__'
+
+type BenchmarkGlobal = typeof globalThis & {
+ [requestMiddlewareCountKey]?: RequestMiddlewareCount
+}
+
+const expectedRequestContexts: ReadonlyArray = [
+ {},
+ { requestTrace: 'req.r1', requestTotal: 1 },
+ { requestTrace: 'req.r1.r2', requestTotal: 3 },
+ { requestTrace: 'req.r1.r2.r3', requestTotal: 6 },
+]
+
export const expectedRequestTrace = 'req.r1.r2.r3'
export const expectedRequestTotal = 6
export const expectedFunctionTrace = 'fn.f1.f2'
export const expectedFunctionTotal = 30
+export function setRequestMiddlewareCount(count: RequestMiddlewareCount) {
+ const benchmarkGlobal = globalThis as BenchmarkGlobal
+ benchmarkGlobal[requestMiddlewareCountKey] = count
+}
+
+export function getRequestMiddlewareCount(): RequestMiddlewareCount {
+ return (globalThis as BenchmarkGlobal)[requestMiddlewareCountKey] ?? 3
+}
+
+export function getExpectedRequestContext(
+ count: RequestMiddlewareCount,
+): GlobalMiddlewareContext {
+ return expectedRequestContexts[count]!
+}
+
export function makeDocumentMarker(
id: string,
context: GlobalMiddlewareContext,
diff --git a/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-0-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-0-middleware.bench.ts
new file mode 100644
index 00000000000..c7485b4ef44
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-0-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 0 middleware (solid)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 0),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-1-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-1-middleware.bench.ts
new file mode 100644
index 00000000000..ac18e0ca79b
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-1-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 1 middleware (solid)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 1),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-2-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-2-middleware.bench.ts
new file mode 100644
index 00000000000..7c2be97b015
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route-2-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 2 middlewares (solid)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 2),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route.bench.ts b/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route.bench.ts
index 495c57112a0..7d21d720671 100644
--- a/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route.bench.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/solid/speed.global-mw-server-route.bench.ts
@@ -20,8 +20,8 @@ await assertGlobalMiddlewareScenario(handler, context)
describe('ssr', () => {
bench(
- 'ssr global-mw server-route (solid)',
- () => runGlobalMiddlewareServerRouteLoop(handler),
+ 'ssr global-mw server-route 3 middlewares (solid)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 3),
globalMiddlewareBenchOptions,
)
})
diff --git a/benchmarks/ssr/scenarios/global-middleware/solid/src/start.ts b/benchmarks/ssr/scenarios/global-middleware/solid/src/start.ts
index 8c4168d574f..a6f6214b829 100644
--- a/benchmarks/ssr/scenarios/global-middleware/solid/src/start.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/solid/src/start.ts
@@ -1,4 +1,5 @@
import { createMiddleware, createStart } from '@tanstack/solid-start'
+import { getRequestMiddlewareCount } from '../../shared'
import type { GlobalMiddlewareContext } from '../../shared'
function appendTrace(
@@ -48,6 +49,13 @@ const requestMiddlewareC = createMiddleware({ type: 'request' }).server(
},
)
+const requestMiddlewareSets: Array> = [
+ [],
+ [requestMiddlewareA],
+ [requestMiddlewareA, requestMiddlewareB],
+ [requestMiddlewareA, requestMiddlewareB, requestMiddlewareC],
+]
+
const functionMiddlewareA = createMiddleware({ type: 'function' }).server(
({ next, context }) => {
const ctx = (context ?? {}) as GlobalMiddlewareContext
@@ -75,10 +83,6 @@ const functionMiddlewareB = createMiddleware({ type: 'function' }).server(
)
export const startInstance = createStart(() => ({
- requestMiddleware: [
- requestMiddlewareA,
- requestMiddlewareB,
- requestMiddlewareC,
- ],
+ requestMiddleware: requestMiddlewareSets[getRequestMiddlewareCount()],
functionMiddleware: [functionMiddlewareA, functionMiddlewareB],
}))
diff --git a/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-0-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-0-middleware.bench.ts
new file mode 100644
index 00000000000..c334af8b65d
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-0-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 0 middleware (vue)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 0),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-1-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-1-middleware.bench.ts
new file mode 100644
index 00000000000..7c1b91a0eab
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-1-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 1 middleware (vue)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 1),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-2-middleware.bench.ts b/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-2-middleware.bench.ts
new file mode 100644
index 00000000000..5b00bd3ca9f
--- /dev/null
+++ b/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route-2-middleware.bench.ts
@@ -0,0 +1,27 @@
+import { bench, describe } from 'vitest'
+import {
+ assertGlobalMiddlewareScenario,
+ globalMiddlewareBenchOptions,
+ runGlobalMiddlewareServerRouteLoop,
+ setupGlobalMiddlewareBench,
+} from '../bench'
+import type { StartRequestHandler } from '../bench'
+
+const appModuleUrl = new URL('./dist/server/server.js', import.meta.url).href
+
+const { default: handler } = (await import(
+ /* @vite-ignore */ appModuleUrl
+)) as {
+ default: StartRequestHandler
+}
+const context = await setupGlobalMiddlewareBench(handler)
+
+await assertGlobalMiddlewareScenario(handler, context)
+
+describe('ssr', () => {
+ bench(
+ 'ssr global-mw server-route 2 middlewares (vue)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 2),
+ globalMiddlewareBenchOptions,
+ )
+})
diff --git a/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route.bench.ts b/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route.bench.ts
index e7fd4cd2054..6cdc34241eb 100644
--- a/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route.bench.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/vue/speed.global-mw-server-route.bench.ts
@@ -20,8 +20,8 @@ await assertGlobalMiddlewareScenario(handler, context)
describe('ssr', () => {
bench(
- 'ssr global-mw server-route (vue)',
- () => runGlobalMiddlewareServerRouteLoop(handler),
+ 'ssr global-mw server-route 3 middlewares (vue)',
+ () => runGlobalMiddlewareServerRouteLoop(handler, 3),
globalMiddlewareBenchOptions,
)
})
diff --git a/benchmarks/ssr/scenarios/global-middleware/vue/src/routeTree.gen.ts b/benchmarks/ssr/scenarios/global-middleware/vue/src/routeTree.gen.ts
index aae3329420d..3118848e4d5 100644
--- a/benchmarks/ssr/scenarios/global-middleware/vue/src/routeTree.gen.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/vue/src/routeTree.gen.ts
@@ -10,8 +10,8 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
-import { Route as PageIdRouteImport } from './routes/page.$id'
import { Route as ApiFnUrlsRouteImport } from './routes/api.fn-urls'
+import { Route as PageIdRouteImport } from './routes/page.$id'
import { Route as ApiPingIdRouteImport } from './routes/api.ping.$id'
const IndexRoute = IndexRouteImport.update({
@@ -19,16 +19,16 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
-const PageIdRoute = PageIdRouteImport.update({
- id: '/page/$id',
- path: '/page/$id',
- getParentRoute: () => rootRouteImport,
-} as any)
const ApiFnUrlsRoute = ApiFnUrlsRouteImport.update({
id: '/api/fn-urls',
path: '/api/fn-urls',
getParentRoute: () => rootRouteImport,
} as any)
+const PageIdRoute = PageIdRouteImport.update({
+ id: '/page/$id',
+ path: '/page/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiPingIdRoute = ApiPingIdRouteImport.update({
id: '/api/ping/$id',
path: '/api/ping/$id',
@@ -78,13 +78,6 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
- '/page/$id': {
- id: '/page/$id'
- path: '/page/$id'
- fullPath: '/page/$id'
- preLoaderRoute: typeof PageIdRouteImport
- parentRoute: typeof rootRouteImport
- }
'/api/fn-urls': {
id: '/api/fn-urls'
path: '/api/fn-urls'
@@ -92,6 +85,13 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof ApiFnUrlsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/page/$id': {
+ id: '/page/$id'
+ path: '/page/$id'
+ fullPath: '/page/$id'
+ preLoaderRoute: typeof PageIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/ping/$id': {
id: '/api/ping/$id'
path: '/api/ping/$id'
diff --git a/benchmarks/ssr/scenarios/global-middleware/vue/src/start.ts b/benchmarks/ssr/scenarios/global-middleware/vue/src/start.ts
index dc3f142e27f..6e4eb2c5ef1 100644
--- a/benchmarks/ssr/scenarios/global-middleware/vue/src/start.ts
+++ b/benchmarks/ssr/scenarios/global-middleware/vue/src/start.ts
@@ -1,4 +1,5 @@
import { createMiddleware, createStart } from '@tanstack/vue-start'
+import { getRequestMiddlewareCount } from '../../shared'
import type { GlobalMiddlewareContext } from '../../shared'
function appendTrace(
@@ -48,6 +49,13 @@ const requestMiddlewareC = createMiddleware({ type: 'request' }).server(
},
)
+const requestMiddlewareSets: Array> = [
+ [],
+ [requestMiddlewareA],
+ [requestMiddlewareA, requestMiddlewareB],
+ [requestMiddlewareA, requestMiddlewareB, requestMiddlewareC],
+]
+
const functionMiddlewareA = createMiddleware({ type: 'function' }).server(
({ next, context }) => {
const ctx = (context ?? {}) as GlobalMiddlewareContext
@@ -75,10 +83,6 @@ const functionMiddlewareB = createMiddleware({ type: 'function' }).server(
)
export const startInstance = createStart(() => ({
- requestMiddleware: [
- requestMiddlewareA,
- requestMiddlewareB,
- requestMiddlewareC,
- ],
+ requestMiddleware: requestMiddlewareSets[getRequestMiddlewareCount()],
functionMiddleware: [functionMiddlewareA, functionMiddlewareB],
}))
diff --git a/benchmarks/ssr/scenarios/serialization/vue/src/routeTree.gen.ts b/benchmarks/ssr/scenarios/serialization/vue/src/routeTree.gen.ts
index 02268c2c3c6..cd7b0b449b2 100644
--- a/benchmarks/ssr/scenarios/serialization/vue/src/routeTree.gen.ts
+++ b/benchmarks/ssr/scenarios/serialization/vue/src/routeTree.gen.ts
@@ -9,19 +9,19 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as RichIdRouteImport } from './routes/rich.$id'
import { Route as PlainIdRouteImport } from './routes/plain.$id'
+import { Route as RichIdRouteImport } from './routes/rich.$id'
-const RichIdRoute = RichIdRouteImport.update({
- id: '/rich/$id',
- path: '/rich/$id',
- getParentRoute: () => rootRouteImport,
-} as any)
const PlainIdRoute = PlainIdRouteImport.update({
id: '/plain/$id',
path: '/plain/$id',
getParentRoute: () => rootRouteImport,
} as any)
+const RichIdRoute = RichIdRouteImport.update({
+ id: '/rich/$id',
+ path: '/rich/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/plain/$id': typeof PlainIdRoute
@@ -51,13 +51,6 @@ export interface RootRouteChildren {
declare module '@tanstack/vue-router' {
interface FileRoutesByPath {
- '/rich/$id': {
- id: '/rich/$id'
- path: '/rich/$id'
- fullPath: '/rich/$id'
- preLoaderRoute: typeof RichIdRouteImport
- parentRoute: typeof rootRouteImport
- }
'/plain/$id': {
id: '/plain/$id'
path: '/plain/$id'
@@ -65,6 +58,13 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof PlainIdRouteImport
parentRoute: typeof rootRouteImport
}
+ '/rich/$id': {
+ id: '/rich/$id'
+ path: '/rich/$id'
+ fullPath: '/rich/$id'
+ preLoaderRoute: typeof RichIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
diff --git a/benchmarks/ssr/scenarios/server-fns/vue/src/routeTree.gen.ts b/benchmarks/ssr/scenarios/server-fns/vue/src/routeTree.gen.ts
index fa27fb1a5d3..0e24cd9c150 100644
--- a/benchmarks/ssr/scenarios/server-fns/vue/src/routeTree.gen.ts
+++ b/benchmarks/ssr/scenarios/server-fns/vue/src/routeTree.gen.ts
@@ -10,24 +10,24 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
-import { Route as SsrCallIdRouteImport } from './routes/ssr-call.$id'
import { Route as ApiFnUrlsRouteImport } from './routes/api.fn-urls'
+import { Route as SsrCallIdRouteImport } from './routes/ssr-call.$id'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
-const SsrCallIdRoute = SsrCallIdRouteImport.update({
- id: '/ssr-call/$id',
- path: '/ssr-call/$id',
- getParentRoute: () => rootRouteImport,
-} as any)
const ApiFnUrlsRoute = ApiFnUrlsRouteImport.update({
id: '/api/fn-urls',
path: '/api/fn-urls',
getParentRoute: () => rootRouteImport,
} as any)
+const SsrCallIdRoute = SsrCallIdRouteImport.update({
+ id: '/ssr-call/$id',
+ path: '/ssr-call/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -68,13 +68,6 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
- '/ssr-call/$id': {
- id: '/ssr-call/$id'
- path: '/ssr-call/$id'
- fullPath: '/ssr-call/$id'
- preLoaderRoute: typeof SsrCallIdRouteImport
- parentRoute: typeof rootRouteImport
- }
'/api/fn-urls': {
id: '/api/fn-urls'
path: '/api/fn-urls'
@@ -82,6 +75,13 @@ declare module '@tanstack/vue-router' {
preLoaderRoute: typeof ApiFnUrlsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/ssr-call/$id': {
+ id: '/ssr-call/$id'
+ path: '/ssr-call/$id'
+ fullPath: '/ssr-call/$id'
+ preLoaderRoute: typeof SsrCallIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
diff --git a/docs/router/api/router/RouterEventsType.md b/docs/router/api/router/RouterEventsType.md
index c79bb47f817..c27887fba1f 100644
--- a/docs/router/api/router/RouterEventsType.md
+++ b/docs/router/api/router/RouterEventsType.md
@@ -47,9 +47,6 @@ type RouterEvents = {
hrefChanged: boolean
hashChanged: boolean
}
- onInjectedHtml: {
- type: 'onInjectedHtml'
- }
onRendered: {
type: 'onRendered'
fromLocation?: ParsedLocation
@@ -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.
diff --git a/docs/router/guide/ssr.md b/docs/router/guide/ssr.md
index f87849f8a1f..dae190a7099 100644
--- a/docs/router/guide/ssr.md
+++ b/docs/router/guide/ssr.md
@@ -156,6 +156,8 @@ export async function render({ request }: { request: Request }) {
using `renderRouterToString`
+React and Solid use their native synchronous string renderers. Suspended components render their Suspense fallback without waiting for the component to resolve. The router still finishes serializing hydration data before returning the response. Use `renderRouterToStream` when you need the server to render suspended content after it resolves.
+
# React
@@ -258,6 +260,9 @@ This pattern can be useful for pages that have slow or high-latency data fetchin
> [!NOTE]
> This streaming pattern is all automatic as long as you are using either `defaultStreamHandler` or `renderRouterToStream`.
+> [!IMPORTANT]
+> Render [``](./document-head-management.md#scripts) inside `` of your root route. It emits the route scripts and marks where the router inserts streamed hydration data. Without it the response still completes, but the page cannot hydrate.
+
using `defaultStreamHandler`
diff --git a/e2e/react-start/static-server-functions/src/routeTree.gen.ts b/e2e/react-start/static-server-functions/src/routeTree.gen.ts
index ea6a6a38061..558b1ab4d95 100644
--- a/e2e/react-start/static-server-functions/src/routeTree.gen.ts
+++ b/e2e/react-start/static-server-functions/src/routeTree.gen.ts
@@ -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({
@@ -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: '/',
@@ -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' {
@@ -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: '/'
@@ -96,6 +113,7 @@ const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
PostsRoute: PostsRouteWithChildren,
+ RawStreamRoute: RawStreamRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/e2e/react-start/static-server-functions/src/routes/__root.tsx b/e2e/react-start/static-server-functions/src/routes/__root.tsx
index b768e8b9a14..a300613fa87 100644
--- a/e2e/react-start/static-server-functions/src/routes/__root.tsx
+++ b/e2e/react-start/static-server-functions/src/routes/__root.tsx
@@ -30,6 +30,9 @@ function RootComponent() {
Posts
+
+ Raw Stream
+
diff --git a/e2e/react-start/static-server-functions/src/routes/raw-stream.tsx b/e2e/react-start/static-server-functions/src/routes/raw-stream.tsx
new file mode 100644
index 00000000000..df898d29074
--- /dev/null
+++ b/e2e/react-start/static-server-functions/src/routes/raw-stream.tsx
@@ -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
+ return new Response(
+ stream instanceof RawStream ? stream.stream : stream,
+ ).text()
+ },
+ component: RawStreamComponent,
+})
+
+function RawStreamComponent() {
+ return {Route.useLoaderData()}
+}
diff --git a/e2e/react-start/static-server-functions/tests/app.spec.ts b/e2e/react-start/static-server-functions/tests/app.spec.ts
index 895566aa353..d86fd61a757 100644
--- a/e2e/react-start/static-server-functions/tests/app.spec.ts
+++ b/e2e/react-start/static-server-functions/tests/app.spec.ts
@@ -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',
+ )
+ })
})
})
diff --git a/e2e/react-start/streaming-ssr/package.json b/e2e/react-start/streaming-ssr/package.json
index fcf78262d54..271a48013b1 100644
--- a/e2e/react-start/streaming-ssr/package.json
+++ b/e2e/react-start/streaming-ssr/package.json
@@ -10,7 +10,8 @@
"start": "pnpx srvx --prod -s ../client dist/server/server.js",
"preview": "vite preview",
"test:e2e": "rm -rf port*.txt; playwright test --project=chromium",
- "test:e2e:preview": "rm -rf port*.txt; MODE=preview playwright test preview-streaming.spec.ts --project=chromium"
+ "test:e2e:preview": "rm -rf port*.txt; MODE=preview playwright test preview-streaming.spec.ts --project=chromium",
+ "test:e2e:buffer-repro": "rm -rf port*.txt; RUN_SSR_ROUTER_HTML_BUFFER_REPRO=true playwright test router-html-buffer.spec.ts --project=chromium"
},
"dependencies": {
"@tanstack/react-query": "^5.80.7",
@@ -26,9 +27,45 @@
"@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"
+ ]
+ },
+ "test:e2e:buffer-repro": {
+ "inputs": [
+ "default",
+ "^production",
+ "dependentTaskOutputs",
+ "streamingSsrTests"
+ ]
+ }
+ }
}
}
diff --git a/e2e/react-start/streaming-ssr/src/routeTree.gen.ts b/e2e/react-start/streaming-ssr/src/routeTree.gen.ts
index c1a51b62f8f..169121003dc 100644
--- a/e2e/react-start/streaming-ssr/src/routeTree.gen.ts
+++ b/e2e/react-start/streaming-ssr/src/routeTree.gen.ts
@@ -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'
@@ -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',
@@ -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
@@ -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
@@ -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
@@ -128,6 +137,7 @@ export interface FileRouteTypes {
| '/many-promises'
| '/nested-deferred'
| '/query-heavy'
+ | '/router-html-buffer'
| '/slow-render'
| '/stream'
| '/sync-only'
@@ -141,6 +151,7 @@ export interface FileRouteTypes {
| '/many-promises'
| '/nested-deferred'
| '/query-heavy'
+ | '/router-html-buffer'
| '/slow-render'
| '/stream'
| '/sync-only'
@@ -154,6 +165,7 @@ export interface FileRouteTypes {
| '/many-promises'
| '/nested-deferred'
| '/query-heavy'
+ | '/router-html-buffer'
| '/slow-render'
| '/stream'
| '/sync-only'
@@ -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
@@ -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'
@@ -264,6 +284,7 @@ const rootRouteChildren: RootRouteChildren = {
ManyPromisesRoute: ManyPromisesRoute,
NestedDeferredRoute: NestedDeferredRoute,
QueryHeavyRoute: QueryHeavyRoute,
+ RouterHtmlBufferRoute: RouterHtmlBufferRoute,
SlowRenderRoute: SlowRenderRoute,
StreamRoute: StreamRoute,
SyncOnlyRoute: SyncOnlyRoute,
diff --git a/e2e/react-start/streaming-ssr/src/routes/router-html-buffer.tsx b/e2e/react-start/streaming-ssr/src/routes/router-html-buffer.tsx
new file mode 100644
index 00000000000..bf22923b906
--- /dev/null
+++ b/e2e/react-start/streaming-ssr/src/routes/router-html-buffer.tsx
@@ -0,0 +1,73 @@
+import { Suspense, useState } from 'react'
+import { Await, createFileRoute } from '@tanstack/react-router'
+
+// Keep the expensive allocation request-driven. The corresponding E2E test is
+// opt-in, so the normal streaming suite never creates this payload.
+const ROUTER_HTML_PAYLOAD_CHARS = 17 * 1024 * 1024
+
+export const Route = createFileRoute('/router-html-buffer')({
+ loader: () => {
+ return {
+ // Resolving after the shell starts streaming makes this value arrive in
+ // a router hydration script instead of ordinary rendered HTML.
+ payload: new Promise<{
+ value: string
+ source: 'server' | 'client'
+ }>((resolve) => {
+ setTimeout(() => {
+ resolve({
+ value: 'x'.repeat(ROUTER_HTML_PAYLOAD_CHARS),
+ source: typeof window === 'undefined' ? 'server' : 'client',
+ })
+ }, 100)
+ }),
+ }
+ },
+ component: RouterHtmlBufferRoute,
+})
+
+function RouterHtmlBufferRoute() {
+ const { payload } = Route.useLoaderData()
+
+ return (
+
+ Router HTML Buffer Reproduction
+ Waiting for the deferred payload...
}>
+ (
+
+ )}
+ />
+
+
+ )
+}
+
+function HydratedPayload({
+ payload,
+ source,
+}: {
+ payload: string
+ source: 'server' | 'client'
+}) {
+ const [hydrationResult, setHydrationResult] = useState('not checked')
+
+ return (
+ <>
+ {payload.length}
+
+ {hydrationResult}
+ >
+ )
+}
diff --git a/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts b/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts
index 91138987ada..8b9b3a8dcc5 100644
--- a/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts
@@ -1,372 +1 @@
-import { expect, test, testWithHydration } from './fixtures'
-
-test.describe('Client-side navigation between all routes', () => {
- test.beforeEach(async ({ page }) => {
- // Start from home page
- await page.goto('/')
- await page.waitForLoadState('networkidle')
- })
-
- test('home -> sync-only -> home works', async ({ page }) => {
- // Navigate to sync-only
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Sync Only' })
- .click()
- await expect(page).toHaveURL('/sync-only')
- await expect(page.getByTestId('sync-message')).toBeVisible()
-
- // Navigate back to home
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- await expect(page.getByTestId('index-title')).toBeVisible()
- })
-
- test('home -> deferred -> home works', async ({ page }) => {
- // Navigate to deferred (use exact: true to avoid matching "Nested Deferred")
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Deferred', exact: true })
- .click()
- await expect(page).toHaveURL('/deferred')
- await expect(page.getByTestId('immediate-data')).toBeVisible()
-
- // Wait for deferred data
- await expect(page.getByTestId('deferred-data')).toBeVisible({
- timeout: 5000,
- })
-
- // Navigate back to home
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- })
-
- test('home -> stream -> home works (no stream locking error)', async ({
- page,
- }) => {
- // This test specifically validates the ReadableStream locking fix
- // Console errors are monitored by the fixture automatically
-
- // Navigate to stream
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Stream' })
- .click()
- await expect(page).toHaveURL('/stream')
-
- // Wait for stream to start (at least one chunk or promise resolved)
- await expect(page.getByTestId('promise-data')).toBeVisible({
- timeout: 5000,
- })
-
- // Navigate back to home before stream completes
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- })
-
- test('home -> stream -> wait for completion -> home works', async ({
- page,
- }) => {
- // Navigate to stream
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Stream' })
- .click()
- await expect(page).toHaveURL('/stream')
-
- // Wait for stream to complete
- await expect(page.getByTestId('stream-complete')).toBeVisible({
- timeout: 10000,
- })
-
- // Navigate back to home
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- await expect(page.getByTestId('index-title')).toBeVisible()
- })
-
- test('home -> stream -> home -> stream again works (fresh stream each time)', async ({
- page,
- }) => {
- // Console errors are monitored by the fixture automatically
-
- // First navigation to stream
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Stream' })
- .click()
- await expect(page).toHaveURL('/stream')
- await expect(page.getByTestId('promise-data')).toBeVisible({
- timeout: 5000,
- })
-
- // Navigate back to home
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
-
- // Second navigation to stream - should get fresh stream without errors
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Stream' })
- .click()
- await expect(page).toHaveURL('/stream')
-
- // Wait for stream to complete
- await expect(page.getByTestId('stream-complete')).toBeVisible({
- timeout: 10000,
- })
-
- // Verify all chunks are present
- await expect(page.getByTestId('stream-chunk-0')).toBeVisible()
- await expect(page.getByTestId('stream-chunk-4')).toBeVisible()
- })
-
- test('home -> fast-serial -> home works', async ({ page }) => {
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Fast Serial' })
- .click()
- await expect(page).toHaveURL('/fast-serial')
- await expect(page.getByTestId('server-data')).toBeVisible()
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- })
-
- test('home -> slow-render -> home works', async ({ page }) => {
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Slow Render' })
- .click()
- await expect(page).toHaveURL('/slow-render')
- await expect(page.getByTestId('quick-data')).toBeVisible()
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- })
-
- test('home -> nested-deferred -> home works', async ({ page }) => {
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Nested Deferred' })
- .click()
- await expect(page).toHaveURL('/nested-deferred')
-
- // Wait for all levels to load
- await expect(page.getByTestId('level3-data')).toBeVisible({ timeout: 5000 })
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- })
-
- test('rapid navigation between routes works', async ({ page }) => {
- // Console errors are monitored by the fixture automatically
-
- // Rapid navigation sequence
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Sync Only' })
- .click()
- await expect(page).toHaveURL('/sync-only')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Deferred', exact: true })
- .click()
- await expect(page).toHaveURL('/deferred')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Stream' })
- .click()
- await expect(page).toHaveURL('/stream')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Fast Serial' })
- .click()
- await expect(page).toHaveURL('/fast-serial')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Slow Render' })
- .click()
- await expect(page).toHaveURL('/slow-render')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Nested Deferred' })
- .click()
- await expect(page).toHaveURL('/nested-deferred')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- })
-})
-
-test.describe('Direct navigation followed by client navigation', () => {
- test('direct to stream -> client nav to deferred works', async ({ page }) => {
- // Direct navigation to stream
- await page.goto('/stream')
- await expect(page.getByTestId('promise-data')).toBeVisible({
- timeout: 5000,
- })
-
- // Client navigation to deferred (use exact: true)
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Deferred', exact: true })
- .click()
- await expect(page).toHaveURL('/deferred')
- await expect(page.getByTestId('immediate-data')).toBeVisible()
- })
-
- test('direct to deferred -> client nav to stream works', async ({ page }) => {
- // Console errors are monitored by the fixture automatically
-
- // Direct navigation to deferred
- await page.goto('/deferred')
- await expect(page.getByTestId('immediate-data')).toBeVisible()
-
- // Client navigation to stream
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Stream' })
- .click()
- await expect(page).toHaveURL('/stream')
- await expect(page.getByTestId('stream-complete')).toBeVisible({
- timeout: 10000,
- })
- })
-
- test('direct to sync-only -> client nav to all routes works', async ({
- page,
- }) => {
- await page.goto('/sync-only')
- await expect(page.getByTestId('sync-message')).toBeVisible()
-
- // Navigate through all routes
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Deferred', exact: true })
- .click()
- await expect(page).toHaveURL('/deferred')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Stream' })
- .click()
- await expect(page).toHaveURL('/stream')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Fast Serial' })
- .click()
- await expect(page).toHaveURL('/fast-serial')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Home' })
- .click()
- await expect(page).toHaveURL('/')
- })
-})
-
-testWithHydration.describe('Hydration after client navigation', () => {
- testWithHydration(
- 'interactive elements work after navigating to deferred',
- async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Deferred', exact: true })
- .click()
- await expect(page).toHaveURL('/deferred')
-
- // Wait for the page to be fully loaded
- await expect(page.getByTestId('immediate-data')).toBeVisible()
- },
- )
-
- testWithHydration(
- 'interactive elements work after navigating to fast-serial',
- async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Fast Serial' })
- .click()
- await expect(page).toHaveURL('/fast-serial')
-
- // Wait for page to load
- await expect(page.getByTestId('server-data')).toBeVisible()
- },
- )
-
- testWithHydration(
- 'interactive elements work after navigating to nested-deferred',
- async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Nested Deferred' })
- .click()
- await expect(page).toHaveURL('/nested-deferred')
-
- // Wait for page to load
- await expect(page.getByTestId('plain-deferred')).toBeVisible({
- timeout: 5000,
- })
- },
- )
-
- testWithHydration(
- 'interactive elements work after navigating to slow-render',
- async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- await page
- .getByRole('navigation')
- .getByRole('link', { name: 'Slow Render' })
- .click()
- await expect(page).toHaveURL('/slow-render')
-
- // Wait for page to load (slow-render has blocking loops)
- await expect(page.getByTestId('quick-data')).toBeVisible()
- },
- )
-})
+import '../../../streaming-ssr-specs/client-navigation'
diff --git a/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts b/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts
index 10e32c68097..8625473a58e 100644
--- a/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts
@@ -1,100 +1 @@
-import { expect, test, testWithHydration } from './fixtures'
-
-test.describe('Concurrent promise resolution (15 promises in 3 batches)', () => {
- test('all concurrent promises resolve correctly', async ({ page }) => {
- await page.goto('/concurrent')
-
- // Batch 1 (5 promises at 100ms)
- await expect(page.getByTestId('concurrent-1-1')).toContainText(
- 'concurrent-1',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-1-2')).toContainText(
- 'concurrent-2',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-1-3')).toContainText(
- 'concurrent-3',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-1-4')).toContainText(
- 'concurrent-4',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-1-5')).toContainText(
- 'concurrent-5',
- { timeout: 5000 },
- )
-
- // Batch 2 (5 promises at 200ms)
- await expect(page.getByTestId('concurrent-2-1')).toContainText(
- 'concurrent-1',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-2-2')).toContainText(
- 'concurrent-2',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-2-3')).toContainText(
- 'concurrent-3',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-2-4')).toContainText(
- 'concurrent-4',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-2-5')).toContainText(
- 'concurrent-5',
- { timeout: 5000 },
- )
-
- // Batch 3 (5 promises at 300ms)
- await expect(page.getByTestId('concurrent-3-1')).toContainText(
- 'concurrent-1',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-3-2')).toContainText(
- 'concurrent-2',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-3-3')).toContainText(
- 'concurrent-3',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-3-4')).toContainText(
- 'concurrent-4',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('concurrent-3-5')).toContainText(
- 'concurrent-5',
- { timeout: 5000 },
- )
- })
-
- test('batch 1 resolves before batch 3', async ({ page }) => {
- await page.goto('/concurrent', { waitUntil: 'commit' })
-
- // Batch 1 should be visible before batch 3
- await expect(page.getByTestId('concurrent-1-1')).toBeVisible({
- timeout: 3000,
- })
-
- // Eventually batch 3 should also be visible
- await expect(page.getByTestId('concurrent-3-5')).toBeVisible({
- timeout: 5000,
- })
- })
-
- testWithHydration(
- 'hydration works with concurrent resolutions',
- async ({ page }) => {
- await page.goto('/concurrent')
- await page.waitForLoadState('networkidle')
-
- // Wait for all batches
- await expect(page.getByTestId('concurrent-3-5')).toBeVisible({
- timeout: 5000,
- })
- },
- )
-})
+import '../../../streaming-ssr-specs/concurrent'
diff --git a/e2e/react-start/streaming-ssr/tests/deferred-rejection.spec.ts b/e2e/react-start/streaming-ssr/tests/deferred-rejection.spec.ts
index 3b8144072c4..355b5019bc9 100644
--- a/e2e/react-start/streaming-ssr/tests/deferred-rejection.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/deferred-rejection.spec.ts
@@ -1,21 +1 @@
-import { expect, test } from './fixtures'
-
-test.use({
- whitelistErrors: ['Error in deferred object'],
-})
-
-test('rejected deferred Await renders the route error boundary without killing SSR', async ({
- page,
-}) => {
- await page.goto('/deferred-rejection')
-
- await expect(page.getByTestId('deferred-error-boundary')).toContainText(
- 'Error in deferred object',
- { timeout: 5000 },
- )
-
- await page.goto('/sync-only')
- await expect(page.getByTestId('sync-message')).toContainText(
- 'Hello from sync loader!',
- )
-})
+import '../../../streaming-ssr-specs/deferred-rejection'
diff --git a/e2e/react-start/streaming-ssr/tests/deferred.spec.ts b/e2e/react-start/streaming-ssr/tests/deferred.spec.ts
index 90ee16edc06..3145897d7be 100644
--- a/e2e/react-start/streaming-ssr/tests/deferred.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/deferred.spec.ts
@@ -1,120 +1 @@
-import { expect, test, testWithHydration } from './fixtures'
-
-test.describe('Deferred data streaming', () => {
- test('shows immediate data right away and deferred data after loading', async ({
- page,
- }) => {
- await page.goto('/deferred')
-
- // Immediate data should be available right away
- await expect(page.getByTestId('immediate-data')).toBeVisible()
- await expect(page.getByTestId('immediate-data')).toContainText(
- 'Immediate: Fast User',
- )
-
- // Verify immediate data came from server
- await expect(page.getByTestId('immediate-source')).toContainText(
- 'Immediate source: server',
- )
- await expect(page.getByTestId('loader-source')).toContainText(
- 'Loader source: server',
- )
-
- // Deferred data should eventually appear with server source
- await expect(page.getByTestId('deferred-data')).toContainText(
- 'Deferred data loaded!',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('deferred-data')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('deferred-server-data')).toContainText(
- 'Server: Slow User',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('deferred-server-data')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- })
-
- test('shows loading states for deferred content', async ({ page }) => {
- // Navigate with cache disabled to ensure fresh load
- await page.goto('/deferred', { waitUntil: 'commit' })
-
- // Should see loading states initially (may be very brief)
- // We check that deferred content eventually shows
- await expect(page.getByTestId('deferred-data')).toBeVisible({
- timeout: 5000,
- })
- await expect(page.getByTestId('deferred-server-data')).toBeVisible({
- timeout: 5000,
- })
- })
-
- testWithHydration(
- 'hydration works - interactive elements respond',
- async ({ page }) => {
- await page.goto('/deferred')
- await page.waitForLoadState('networkidle')
-
- // Wait for all deferred content to load
- await expect(page.getByTestId('deferred-data')).toBeVisible({
- timeout: 5000,
- })
-
- // Verify all data came from server after hydration
- await expect(page.getByTestId('loader-source')).toContainText(
- 'Loader source: server',
- )
- await expect(page.getByTestId('deferred-data')).toContainText(
- 'source: server',
- )
- },
- )
-
- test('client-side navigation to deferred route works', async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- // Navigate via client-side routing using nav link
- await page.getByRole('link', { name: 'Deferred' }).first().click()
- await expect(page).toHaveURL('/deferred')
-
- // Data should load
- await expect(page.getByTestId('immediate-data')).toContainText('Fast User')
- await expect(page.getByTestId('deferred-data')).toContainText(
- 'Deferred data loaded!',
- { timeout: 5000 },
- )
- })
-
- test('all data sources are server - proves SSR streaming works', async ({
- page,
- }) => {
- await page.goto('/deferred')
-
- // Wait for all deferred content
- await expect(page.getByTestId('deferred-data')).toBeVisible({
- timeout: 5000,
- })
- await expect(page.getByTestId('deferred-server-data')).toBeVisible({
- timeout: 5000,
- })
-
- // Count all elements showing 'server' source - should be 4:
- // 1. immediate-source
- // 2. loader-source
- // 3. deferred-data (contains "source: server")
- // 4. deferred-server-data (contains "source: server")
- await expect(page.getByTestId('immediate-source')).toContainText('server')
- await expect(page.getByTestId('loader-source')).toContainText('server')
- await expect(page.getByTestId('deferred-data')).toContainText(
- 'source: server',
- )
- await expect(page.getByTestId('deferred-server-data')).toContainText(
- 'source: server',
- )
- })
-})
+import '../../../streaming-ssr-specs/deferred'
diff --git a/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts b/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts
index 339d6408ea7..95df0555e26 100644
--- a/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts
@@ -1,72 +1 @@
-import { expect, test, testWithHydration } from './fixtures'
-
-test.describe('Fast serialization (serialization completes before render)', () => {
- test('initial HTML contains router bootstrap + barrier', async ({ page }) => {
- let responseHtml = ''
- await page.route('/fast-serial', async (route) => {
- const response = await route.fetch()
- responseHtml = await response.text()
- await route.fulfill({ response })
- })
-
- await page.goto('/fast-serial')
- await expect(page.getByTestId('server-data')).toBeVisible()
-
- expect(responseHtml).toContain('$_TSR')
- expect(responseHtml).toContain('$_TSR.router')
- expect(responseHtml).toContain('$_TSR.e()')
- expect(responseHtml).toContain('$tsr-stream-barrier')
- })
-
- test('all data is available immediately', async ({ page }) => {
- await page.goto('/fast-serial')
- await page.waitForLoadState('networkidle')
-
- // All data should be visible
- await expect(page.getByTestId('server-data')).toContainText('small-data')
- await expect(page.getByTestId('static-data')).toContainText(
- 'This is static data',
- )
- await expect(page.getByTestId('loader-timestamp')).toBeVisible()
-
- // Verify data came from server (proves SSR streaming worked)
- await expect(page.getByTestId('loader-source')).toContainText(
- 'Loader source: server',
- )
- await expect(page.getByTestId('server-fn-source')).toContainText(
- 'Server function source: server',
- )
- })
-
- testWithHydration('hydration works correctly', async ({ page }) => {
- await page.goto('/fast-serial')
- await page.waitForLoadState('networkidle')
-
- // Verify data came from server after hydration
- await expect(page.getByTestId('loader-source')).toContainText(
- 'Loader source: server',
- )
- })
-
- test('direct navigation renders correctly', async ({ page }) => {
- // Direct navigation (SSR)
- await page.goto('/fast-serial')
-
- // Should render without errors and show server source
- await expect(page.getByTestId('server-data')).toBeVisible()
- await expect(page.getByTestId('loader-source')).toContainText(
- 'Loader source: server',
- )
- })
-
- test('client-side navigation works', async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- // Navigate via nav link
- await page.getByRole('link', { name: 'Fast Serial' }).first().click()
- await expect(page).toHaveURL('/fast-serial')
-
- await expect(page.getByTestId('server-data')).toContainText('small-data')
- })
-})
+import '../../../streaming-ssr-specs/fast-serial'
diff --git a/e2e/react-start/streaming-ssr/tests/fixtures.ts b/e2e/react-start/streaming-ssr/tests/fixtures.ts
deleted file mode 100644
index 71c564aebea..00000000000
--- a/e2e/react-start/streaming-ssr/tests/fixtures.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { test as base, expect, type Page } from '@playwright/test'
-
-/**
- * Verifies that React hydration has completed by clicking the global
- * hydration check button and verifying the status changes.
- *
- * This is the canonical way to verify hydration in streaming-ssr tests.
- * The HydrationCheck component is rendered in the root layout (__root.tsx).
- *
- * The function retries clicking until the status changes to 'hydrated',
- * which handles the case where the button is visible from SSR before
- * React has finished hydrating.
- */
-async function verifyHydration(
- page: Page,
- options: { timeout?: number } = {},
-): Promise {
- const timeout = options.timeout ?? 10000
- const button = page.getByTestId('hydration-check-btn')
- const status = page.getByTestId('hydration-status')
-
- // Ensure the button is visible
- await expect(button).toBeVisible()
-
- // Retry clicking until hydration succeeds
- // This handles the case where SSR renders the button before React hydrates
- await expect(async () => {
- // Click the button to trigger hydration verification
- await button.click()
-
- // Check if the status changed to 'hydrated'
- await expect(status).toHaveText('hydrated', { timeout: 100 })
- }).toPass({ timeout })
-}
-
-export interface StreamingSsrOptions {
- /**
- * List of error message patterns to ignore in console output.
- */
- whitelistErrors: Array
-}
-
-/**
- * Base test fixture for streaming-ssr e2e tests.
- * Provides console error monitoring.
- */
-export const test = base.extend({
- whitelistErrors: [[], { option: true }],
-
- page: async ({ page, whitelistErrors }, use) => {
- const errorMessages: Array = []
-
- page.on('console', (m) => {
- if (m.type() === 'error') {
- const text = m.text()
- for (const whitelistError of whitelistErrors) {
- if (
- (typeof whitelistError === 'string' &&
- text.includes(whitelistError)) ||
- (whitelistError instanceof RegExp && whitelistError.test(text))
- ) {
- return
- }
- }
- errorMessages.push(text)
- }
- })
-
- await use(page)
-
- // Assert no unexpected console errors
- expect(errorMessages).toEqual([])
- },
-})
-
-/**
- * Extended test fixture that automatically verifies hydration at the end.
- * Use this for tests where you want to confirm React hydration succeeded.
- */
-export const testWithHydration = test.extend({
- page: async ({ page }, use) => {
- await use(page)
-
- // Automatically verify hydration at the end of the test
- await verifyHydration(page)
- },
-})
-
-// Re-export expect for convenience
-export { expect }
diff --git a/e2e/react-start/streaming-ssr/tests/home.spec.ts b/e2e/react-start/streaming-ssr/tests/home.spec.ts
index e231d007fb5..5d49d34a565 100644
--- a/e2e/react-start/streaming-ssr/tests/home.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/home.spec.ts
@@ -1,34 +1 @@
-import { expect, test, testWithHydration } from './fixtures'
-
-test.describe('Home page', () => {
- testWithHydration(
- 'renders index page with all navigation links',
- async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- await expect(page.getByTestId('index-title')).toContainText(
- 'Streaming SSR Test Scenarios',
- )
- // Check links exist (they're in the nav and the body)
- await expect(
- page.getByRole('link', { name: 'Deferred' }).first(),
- ).toBeVisible()
- await expect(
- page.getByRole('link', { name: 'Stream' }).first(),
- ).toBeVisible()
- },
- )
-
- testWithHydration(
- 'navigation from home to routes works',
- async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- // Use the link in the content (not nav)
- await page.getByTestId('link-deferred').click()
- await expect(page).toHaveURL('/deferred')
- },
- )
-})
+import '../../../streaming-ssr-specs/home'
diff --git a/e2e/react-start/streaming-ssr/tests/many-promises.spec.ts b/e2e/react-start/streaming-ssr/tests/many-promises.spec.ts
index 034ab742206..b747bd7d0aa 100644
--- a/e2e/react-start/streaming-ssr/tests/many-promises.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/many-promises.spec.ts
@@ -1,93 +1 @@
-import { expect, test, testWithHydration } from './fixtures'
-
-test.describe('Many promises streaming (15 deferred)', () => {
- test('all 15 promises eventually resolve', async ({ page }) => {
- await page.goto('/many-promises')
-
- // Immediate group (0-20ms)
- await expect(page.getByTestId('immediate-1')).toContainText('immediate-1', {
- timeout: 5000,
- })
- await expect(page.getByTestId('immediate-2')).toContainText('immediate-2', {
- timeout: 5000,
- })
- await expect(page.getByTestId('immediate-3')).toContainText('immediate-3', {
- timeout: 5000,
- })
-
- // Fast group (50-125ms)
- await expect(page.getByTestId('fast-1')).toContainText('fast-1', {
- timeout: 5000,
- })
- await expect(page.getByTestId('fast-2')).toContainText('fast-2', {
- timeout: 5000,
- })
- await expect(page.getByTestId('fast-3')).toContainText('fast-3', {
- timeout: 5000,
- })
- await expect(page.getByTestId('fast-4')).toContainText('fast-4', {
- timeout: 5000,
- })
-
- // Medium group (150-250ms)
- await expect(page.getByTestId('medium-1')).toContainText('medium-1', {
- timeout: 5000,
- })
- await expect(page.getByTestId('medium-2')).toContainText('medium-2', {
- timeout: 5000,
- })
- await expect(page.getByTestId('medium-3')).toContainText('medium-3', {
- timeout: 5000,
- })
-
- // Slow group (300-500ms)
- await expect(page.getByTestId('slow-1')).toContainText('slow-1', {
- timeout: 5000,
- })
- await expect(page.getByTestId('slow-2')).toContainText('slow-2', {
- timeout: 5000,
- })
- await expect(page.getByTestId('slow-3')).toContainText('slow-3', {
- timeout: 5000,
- })
-
- // Very slow group (600-800ms)
- await expect(page.getByTestId('very-slow-1')).toContainText('very-slow-1', {
- timeout: 5000,
- })
- await expect(page.getByTestId('very-slow-2')).toContainText('very-slow-2', {
- timeout: 5000,
- })
- })
-
- testWithHydration('hydration works with many promises', async ({ page }) => {
- await page.goto('/many-promises')
- await page.waitForLoadState('networkidle')
-
- // Wait for all promises to resolve
- await expect(page.getByTestId('very-slow-2')).toBeVisible({ timeout: 5000 })
- })
-
- test('client-side navigation works', async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- // Navigate via nav link
- await page.getByRole('link', { name: 'Many Promises' }).click()
- await expect(page).toHaveURL('/many-promises')
-
- // All promises should eventually resolve
- await expect(page.getByTestId('very-slow-2')).toBeVisible({ timeout: 5000 })
- })
-
- test('fast promises resolve before slow ones', async ({ page }) => {
- // Navigate and check ordering - faster promises should be visible first
- await page.goto('/many-promises', { waitUntil: 'commit' })
-
- // Immediate promises should appear first
- await expect(page.getByTestId('immediate-1')).toBeVisible({ timeout: 2000 })
-
- // By the time we check very-slow, all should be visible
- await expect(page.getByTestId('very-slow-2')).toBeVisible({ timeout: 5000 })
- })
-})
+import '../../../streaming-ssr-specs/many-promises'
diff --git a/e2e/react-start/streaming-ssr/tests/nested-deferred.spec.ts b/e2e/react-start/streaming-ssr/tests/nested-deferred.spec.ts
index dbf6df4265e..8df20a8a958 100644
--- a/e2e/react-start/streaming-ssr/tests/nested-deferred.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/nested-deferred.spec.ts
@@ -1,77 +1 @@
-import { expect, test, testWithHydration } from './fixtures'
-
-test.describe('Nested deferred (multiple levels of deferred data)', () => {
- test('all levels of deferred data eventually resolve', async ({ page }) => {
- await page.goto('/nested-deferred')
-
- // Plain deferred should resolve first (300ms)
- await expect(page.getByTestId('plain-deferred')).toContainText(
- 'Plain deferred resolved!',
- { timeout: 5000 },
- )
-
- // Level 1 should resolve (200ms)
- await expect(page.getByTestId('level1-data')).toContainText('Level 1:', {
- timeout: 5000,
- })
-
- // Level 2 should resolve (400ms)
- await expect(page.getByTestId('level2-data')).toContainText('Level 2:', {
- timeout: 5000,
- })
-
- // Level 3 should resolve (600ms)
- await expect(page.getByTestId('level3-data')).toContainText('Level 3:', {
- timeout: 5000,
- })
- })
-
- test('shows loading states while data is loading', async ({ page }) => {
- // Use fast navigation to catch loading states
- await page.goto('/nested-deferred', { waitUntil: 'commit' })
-
- // Eventually all data should be visible
- await expect(page.getByTestId('level3-data')).toBeVisible({
- timeout: 10000,
- })
- })
-
- testWithHydration(
- 'hydration works with nested deferred',
- async ({ page }) => {
- await page.goto('/nested-deferred')
- await page.waitForLoadState('networkidle')
-
- // Wait for all data
- await expect(page.getByTestId('level3-data')).toBeVisible({
- timeout: 10000,
- })
- },
- )
-
- test('client-side navigation works', async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- // Navigate via nav link
- await page.getByRole('link', { name: 'Nested Deferred' }).first().click()
- await expect(page).toHaveURL('/nested-deferred')
-
- // All levels should eventually render
- await expect(page.getByTestId('level3-data')).toBeVisible({
- timeout: 10000,
- })
- })
-
- test('data resolves in expected order (fastest first)', async ({ page }) => {
- await page.goto('/nested-deferred')
-
- // Wait for all to be visible
- await expect(page.getByTestId('level1-data')).toBeVisible({ timeout: 5000 })
- await expect(page.getByTestId('level2-data')).toBeVisible({ timeout: 5000 })
- await expect(page.getByTestId('level3-data')).toBeVisible({ timeout: 5000 })
- await expect(page.getByTestId('plain-deferred')).toBeVisible({
- timeout: 5000,
- })
- })
-})
+import '../../../streaming-ssr-specs/nested-deferred'
diff --git a/e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts b/e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts
index 319219c5195..34241cfbaa7 100644
--- a/e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/preview-streaming.spec.ts
@@ -1,21 +1 @@
-import { expect, test } from './fixtures'
-
-test('vite preview streams HTML incrementally', async ({ page }) => {
- // /deferred has immediate data + deferred data with a ~1s delay.
- // Without streaming, compression buffers the entire response.
- await page.goto('/deferred', { waitUntil: 'commit' })
-
- // Immediate data should arrive in the first chunk
- await expect(page.getByTestId('immediate-data')).toBeVisible({
- timeout: 3000,
- })
-
- // Deferred data (1s delay) should still be loading at this point
- await expect(page.getByTestId('deferred-loading')).toBeVisible()
-
- // Wait for it to resolve
- await expect(page.getByTestId('deferred-data')).toContainText(
- 'Deferred data loaded!',
- { timeout: 5000 },
- )
-})
+import '../../../streaming-ssr-specs/preview-streaming'
diff --git a/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts b/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts
index 8e735040c2e..e0d617106c5 100644
--- a/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts
+++ b/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts
@@ -1,168 +1,18 @@
-import { expect, test, testWithHydration } from './fixtures'
-
-test.describe('Query heavy route (9 useSuspenseQuery)', () => {
- test('all queries resolve with server data', async ({ page }) => {
- await page.goto('/query-heavy')
-
- // Sync queries should show server source
- await expect(page.getByTestId('sync-query-1')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('sync-query-2')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('sync-query-3')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
-
- // Fast async queries should show server source
- await expect(page.getByTestId('fast-async-query-1')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('fast-async-query-2')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('fast-async-query-3')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
-
- // Slow async queries should show server source
- await expect(page.getByTestId('slow-async-query-1')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('slow-async-query-2')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('slow-async-query-3')).toContainText(
- 'source: server',
- { timeout: 5000 },
- )
- })
-
- test('sync queries have correct values', async ({ page }) => {
- await page.goto('/query-heavy')
-
- await expect(page.getByTestId('sync-query-1')).toContainText('sync-value-1')
- await expect(page.getByTestId('sync-query-2')).toContainText('sync-value-2')
- await expect(page.getByTestId('sync-query-3')).toContainText('sync-value-3')
- })
-
- test('async queries have correct values', async ({ page }) => {
- await page.goto('/query-heavy')
-
- await expect(page.getByTestId('fast-async-query-1')).toContainText(
- 'fast-async-1',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('fast-async-query-2')).toContainText(
- 'fast-async-2',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('fast-async-query-3')).toContainText(
- 'fast-async-3',
- { timeout: 5000 },
- )
-
- await expect(page.getByTestId('slow-async-query-1')).toContainText(
- 'slow-async-1',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('slow-async-query-2')).toContainText(
- 'slow-async-2',
- { timeout: 5000 },
- )
- await expect(page.getByTestId('slow-async-query-3')).toContainText(
- 'slow-async-3',
- { timeout: 5000 },
- )
- })
-
- testWithHydration('hydration works with many queries', async ({ page }) => {
- await page.goto('/query-heavy')
- await page.waitForLoadState('networkidle')
-
- // Wait for all queries to resolve
- await expect(page.getByTestId('slow-async-query-3')).toBeVisible({
- timeout: 5000,
- })
- })
-
- test('client-side navigation works', async ({ page }) => {
- await page.goto('/')
- await page.waitForLoadState('networkidle')
-
- // Navigate via nav link
- await page.getByRole('link', { name: 'Query Heavy' }).click()
- await expect(page).toHaveURL('/query-heavy')
-
- // All queries should eventually resolve (on client)
- await expect(page.getByTestId('slow-async-query-3')).toBeVisible({
- timeout: 5000,
- })
- })
-
- test('no hydration mismatch - queries streamed from server', async ({
- page,
- }) => {
- // This test verifies that query data is streamed from server
- // If it wasn't, the queries would re-execute on client and show 'client' as source
- await page.goto('/query-heavy')
-
- // Wait for all queries
- await expect(page.getByTestId('slow-async-query-3')).toBeVisible({
- timeout: 5000,
- })
-
- // Verify all show 'server' - this proves data was streamed, not re-fetched
- const serverSourceCount = await page
- .locator('[data-testid*="query-"]')
- .filter({ hasText: 'source: server' })
- .count()
- expect(serverSourceCount).toBe(9)
- })
-
- test('emits query stream data before stream end marker', async ({
- request,
- }) => {
- const response = await request.get('/query-heavy')
- const html = await response.text()
- const endMarker = '$_TSR.e()'
- const endIndex = html.indexOf(endMarker)
- const slowAsyncPayloadIndex = html.indexOf('"slow-async-3"')
- const lastScriptOpen = html.lastIndexOf('', endIndex)
+ const scriptCloseIndex = html.indexOf('', endIndex)
+ const bodyCloseIndex = html.indexOf('', scriptCloseIndex)
+ const htmlCloseIndex = html.indexOf('