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) - - expect(endIndex).toBeGreaterThan(-1) - expect(lastScriptOpen).toBeGreaterThan(lastScriptClose) - expect(slowAsyncPayloadIndex).toBeGreaterThan(-1) - expect(slowAsyncPayloadIndex).toBeLessThan(endIndex) - expect(html.slice(lastScriptOpen, endIndex)).toContain('.return(void 0)') - expect(endIndex).toBeLessThan(html.indexOf('')) - }) - - test('batches same-turn queries into one stream chunk', async ({ - request, - }) => { - const response = await request.get('/query-heavy') - const html = await response.text() - const scripts = Array.from( - html.matchAll(/]*>([\s\S]*?)<\/script>/g), - (match) => match[1]!, - ) - const syncQueryChunk = scripts.find( - (script) => script.includes('.next(') && script.includes('sync-value-1'), - ) - - expect(syncQueryChunk).toBeDefined() - expect(syncQueryChunk).toContain('sync-value-2') - expect(syncQueryChunk).toContain('sync-value-3') - }) +import '../../../streaming-ssr-specs/query-heavy' +import { expect, test } from '../../../streaming-ssr-assertions' + +test('batches same-turn queries into one stream chunk', async ({ request }) => { + const response = await request.get('/query-heavy') + const html = await response.text() + const scripts = Array.from( + html.matchAll(/]*>([\s\S]*?)<\/script>/g), + (match) => match[1]!, + ) + const syncQueryChunk = scripts.find((script) => + script.includes('sync-value-1'), + ) + + expect(syncQueryChunk).toBeDefined() + expect(syncQueryChunk).toContain('sync-value-2') + expect(syncQueryChunk).toContain('sync-value-3') }) diff --git a/e2e/react-start/streaming-ssr/tests/router-html-buffer.spec.ts b/e2e/react-start/streaming-ssr/tests/router-html-buffer.spec.ts new file mode 100644 index 00000000000..a9cf8a8142d --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/router-html-buffer.spec.ts @@ -0,0 +1,34 @@ +import { + STREAM_PART_SELECTOR, + expect, + test, + testWithHydration, +} from '../../../streaming-ssr-assertions' + +const runBufferReproduction = + process.env.RUN_SSR_ROUTER_HTML_BUFFER_REPRO === 'true' + +test.describe('SSR router HTML buffer', () => { + test.skip( + !runBufferReproduction, + 'The 17 MiB ASCII reproduction is intentionally opt-in: pnpm test:e2e:buffer-repro', + ) + + testWithHydration( + 'hydrates a large deferred loader payload from server state', + async ({ page }) => { + const response = await page.goto('/router-html-buffer') + + expect(response?.ok()).toBe(true) + await expect(page.getByTestId('router-html-payload-length')).toHaveText( + '17825792', + ) + await expect(page.locator(STREAM_PART_SELECTOR)).toHaveCount(0) + + await page.getByTestId('router-html-payload-check').click() + await expect(page.getByTestId('router-html-payload-result')).toHaveText( + 'server:17825792:x', + ) + }, + ) +}) diff --git a/e2e/react-start/streaming-ssr/tests/slow-render.spec.ts b/e2e/react-start/streaming-ssr/tests/slow-render.spec.ts index c3631a7fc03..0d5a87363f5 100644 --- a/e2e/react-start/streaming-ssr/tests/slow-render.spec.ts +++ b/e2e/react-start/streaming-ssr/tests/slow-render.spec.ts @@ -1,73 +1 @@ -import { expect, test, testWithHydration } from './fixtures' - -test.describe('Slow render (render takes longer than serialization)', () => { - test('all data eventually renders with server source', async ({ page }) => { - await page.goto('/slow-render') - await page.waitForLoadState('networkidle') - - // Quick data should be available with server source - await expect(page.getByTestId('quick-data')).toContainText('Quick:') - await expect(page.getByTestId('quick-source')).toContainText( - 'Quick data source: server', - ) - await expect(page.getByTestId('loader-source')).toContainText( - 'Loader source: server', - ) - - // Deferred data should resolve with server source - await expect(page.getByTestId('deferred-resolved')).toContainText( - 'Deferred resolved!', - { timeout: 5000 }, - ) - await expect(page.getByTestId('deferred-resolved')).toContainText( - 'source: server', - { timeout: 5000 }, - ) - - // Slow components should have rendered - await expect(page.getByTestId('slow-component-1')).toBeVisible() - await expect(page.getByTestId('slow-component-2')).toBeVisible() - await expect(page.getByTestId('slow-component-3')).toBeVisible() - }) - - testWithHydration('hydration works after slow render', async ({ page }) => { - await page.goto('/slow-render') - await page.waitForLoadState('networkidle') - - // Wait for content and verify server source - await expect(page.getByTestId('slow-component-1')).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: 'Slow Render' }).first().click() - await expect(page).toHaveURL('/slow-render') - - await expect(page.getByTestId('quick-data')).toBeVisible({ timeout: 10000 }) - }) - - test('all data sources are server - proves SSR streaming works', async ({ - page, - }) => { - await page.goto('/slow-render') - await page.waitForLoadState('networkidle') - - // Wait for deferred content - await expect(page.getByTestId('deferred-resolved')).toBeVisible({ - timeout: 5000, - }) - - // Verify all sources are server - await expect(page.getByTestId('quick-source')).toContainText('server') - await expect(page.getByTestId('loader-source')).toContainText('server') - await expect(page.getByTestId('deferred-resolved')).toContainText( - 'source: server', - ) - }) -}) +import '../../../streaming-ssr-specs/slow-render' diff --git a/e2e/react-start/streaming-ssr/tests/stream.spec.ts b/e2e/react-start/streaming-ssr/tests/stream.spec.ts index 5e4c134f82d..a51614f09af 100644 --- a/e2e/react-start/streaming-ssr/tests/stream.spec.ts +++ b/e2e/react-start/streaming-ssr/tests/stream.spec.ts @@ -1,46 +1 @@ -import { expect, test, testWithHydration } from './fixtures' - -test.describe('ReadableStream streaming', () => { - testWithHydration('promise data resolves correctly', async ({ page }) => { - await page.goto('/stream') - - // Promise should resolve - await expect(page.getByTestId('promise-data')).toContainText( - 'promise-resolved', - { timeout: 5000 }, - ) - }) - - testWithHydration('stream chunks arrive incrementally', async ({ page }) => { - await page.goto('/stream') - - // Wait for stream to complete - await expect(page.getByTestId('stream-complete')).toBeVisible({ - timeout: 10000, - }) - - // All chunks should be present - await expect(page.getByTestId('stream-chunk-0')).toContainText('chunk-0') - await expect(page.getByTestId('stream-chunk-1')).toContainText('chunk-1') - await expect(page.getByTestId('stream-chunk-2')).toContainText('chunk-2') - await expect(page.getByTestId('stream-chunk-3')).toContainText('chunk-3') - await expect(page.getByTestId('stream-chunk-4')).toContainText('chunk-4') - }) - - testWithHydration( - 'client-side navigation to stream route works', - async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - - // Navigate via nav link - await page.getByRole('link', { name: 'Stream' }).first().click() - await expect(page).toHaveURL('/stream') - - // Wait for stream to complete - await expect(page.getByTestId('stream-complete')).toBeVisible({ - timeout: 10000, - }) - }, - ) -}) +import '../../../streaming-ssr-specs/stream' diff --git a/e2e/react-start/streaming-ssr/tests/sync-only.spec.ts b/e2e/react-start/streaming-ssr/tests/sync-only.spec.ts index a0060a07ee1..0e9278d3f2f 100644 --- a/e2e/react-start/streaming-ssr/tests/sync-only.spec.ts +++ b/e2e/react-start/streaming-ssr/tests/sync-only.spec.ts @@ -1,84 +1 @@ -import { expect } from '@playwright/test' -import { test, testWithHydration } from './fixtures' - -/** - * Tests for synchronous serialization - no deferred data, no streaming. - * This is the most common case where all loader data is immediately available. - * The hydration scripts should be included in the initial HTML response. - */ - -test('Sync-only route renders with loader data', async ({ page }) => { - await page.goto('/sync-only') - - // Verify the page content is rendered - await expect(page.getByTestId('sync-title')).toContainText( - 'Synchronous Serialization Test', - ) - await expect(page.getByTestId('sync-message')).toContainText( - 'Hello from sync loader!', - ) - - // Verify loader data items are rendered - await expect(page.getByTestId('sync-item-item-1')).toBeVisible() - await expect(page.getByTestId('sync-item-item-2')).toBeVisible() - await expect(page.getByTestId('sync-item-item-3')).toBeVisible() - - // Verify data came from server (proves SSR streaming worked) - await expect(page.getByTestId('sync-source')).toContainText('Source: server') -}) - -testWithHydration('Sync-only route hydrates correctly', async ({ page }) => { - await page.goto('/sync-only') - - // Verify client-side navigation works (proves hydration succeeded) - await page.getByRole('navigation').getByRole('link', { name: 'Home' }).click() - await expect(page.getByTestId('index-title')).toBeVisible() - - // Navigate back to sync-only via client-side navigation (use nav link to be specific) - await page - .getByRole('navigation') - .getByRole('link', { name: 'Sync Only' }) - .click() - await expect(page.getByTestId('sync-title')).toBeVisible() -}) - -test('Sync-only route has bootstrap scripts in initial HTML', async ({ - page, -}) => { - // Intercept the response to check the raw HTML - let responseHtml = '' - await page.route('/sync-only', async (route) => { - const response = await route.fetch() - responseHtml = await response.text() - await route.fulfill({ response }) - }) - - await page.goto('/sync-only') - - // Wait for page to load - await expect(page.getByTestId('sync-title')).toBeVisible() - - // The HTML should contain the bootstrap scripts - // $_TSR.router should be present (the dehydrated router state) - expect(responseHtml).toContain('$_TSR') - expect(responseHtml).toContain('$_TSR.router') - // The serialization end marker should be present - expect(responseHtml).toContain('$_TSR.e()') - - // SSR should include the barrier script tag in the HTML (rendered by ) - // This is the critical marker transformStreamWithRouter can scan for. - expect(responseHtml).toContain('$tsr-stream-barrier') -}) - -test('Navigating to sync-only from home page', async ({ page }) => { - await page.goto('/') - - await page.getByTestId('link-sync-only').click() - - await expect(page.getByTestId('sync-title')).toContainText( - 'Synchronous Serialization Test', - ) - await expect(page.getByTestId('sync-message')).toContainText( - 'Hello from sync loader!', - ) -}) +import '../../../streaming-ssr-specs/sync-only' diff --git a/e2e/react-start/streaming-ssr/vite.config.ts b/e2e/react-start/streaming-ssr/vite.config.ts index 275bfacde2a..0590c197a5d 100644 --- a/e2e/react-start/streaming-ssr/vite.config.ts +++ b/e2e/react-start/streaming-ssr/vite.config.ts @@ -1,9 +1,10 @@ import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' export default defineConfig({ server: { port: 3000, }, - plugins: [tanstackStart()], + plugins: [tanstackStart(), viteReact()], }) diff --git a/e2e/solid-start/streaming-ssr/package.json b/e2e/solid-start/streaming-ssr/package.json new file mode 100644 index 00000000000..89e1c00e0a0 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/package.json @@ -0,0 +1,59 @@ +{ + "name": "tanstack-solid-start-e2e-streaming-ssr", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "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" + }, + "dependencies": { + "@tanstack/solid-query": "^5.90.9", + "@tanstack/solid-router": "workspace:^", + "@tanstack/solid-router-ssr-query": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "solid-js": "^1.9.10" + }, + "devDependencies": { + "@playwright/test": "^1.61.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "srvx": "^0.11.9", + "@typescript/native": "npm:typescript@^7.0.2", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vite": "^8.0.14", + "vite-plugin-solid": "^2.11.11" + }, + "nx": { + "targets": { + "build": { + "inputs": [ + "buildProduction", + "^buildProduction", + "dependentTaskOutputs", + "streamingSsrTests" + ] + }, + "test:e2e": { + "inputs": [ + "default", + "^production", + "dependentTaskOutputs", + "streamingSsrTests" + ] + }, + "test:e2e:preview": { + "inputs": [ + "default", + "^production", + "streamingSsrTests" + ] + } + } + } +} diff --git a/e2e/solid-start/streaming-ssr/playwright.config.ts b/e2e/solid-start/streaming-ssr/playwright.config.ts new file mode 100644 index 00000000000..7d8cb2bef96 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const isPreview = process.env.MODE === 'preview' + +const PORT = await getTestServerPort( + `${packageJson.name}${isPreview ? '_preview' : ''}`, +) +const baseURL = `http://localhost:${PORT}` +const ssrCommand = `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start` +const previewCommand = `VITE_SERVER_PORT=${PORT} pnpm build && pnpm preview --port ${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: isPreview ? previewCommand : ssrCommand, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/solid-start/streaming-ssr/src/routeTree.gen.ts b/e2e/solid-start/streaming-ssr/src/routeTree.gen.ts new file mode 100644 index 00000000000..7410f165acf --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routeTree.gen.ts @@ -0,0 +1,282 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// 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 IndexRouteImport } from './routes/index' +import { Route as ConcurrentRouteImport } from './routes/concurrent' +import { Route as DeferredRouteImport } from './routes/deferred' +import { Route as DeferredRejectionRouteImport } from './routes/deferred-rejection' +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 SlowRenderRouteImport } from './routes/slow-render' +import { Route as StreamRouteImport } from './routes/stream' +import { Route as SyncOnlyRouteImport } from './routes/sync-only' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ConcurrentRoute = ConcurrentRouteImport.update({ + id: '/concurrent', + path: '/concurrent', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredRoute = DeferredRouteImport.update({ + id: '/deferred', + path: '/deferred', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredRejectionRoute = DeferredRejectionRouteImport.update({ + id: '/deferred-rejection', + path: '/deferred-rejection', + getParentRoute: () => rootRouteImport, +} as any) +const FastSerialRoute = FastSerialRouteImport.update({ + id: '/fast-serial', + path: '/fast-serial', + getParentRoute: () => rootRouteImport, +} as any) +const ManyPromisesRoute = ManyPromisesRouteImport.update({ + id: '/many-promises', + path: '/many-promises', + getParentRoute: () => rootRouteImport, +} as any) +const NestedDeferredRoute = NestedDeferredRouteImport.update({ + id: '/nested-deferred', + path: '/nested-deferred', + getParentRoute: () => rootRouteImport, +} as any) +const QueryHeavyRoute = QueryHeavyRouteImport.update({ + id: '/query-heavy', + path: '/query-heavy', + getParentRoute: () => rootRouteImport, +} as any) +const SlowRenderRoute = SlowRenderRouteImport.update({ + id: '/slow-render', + path: '/slow-render', + getParentRoute: () => rootRouteImport, +} as any) +const StreamRoute = StreamRouteImport.update({ + id: '/stream', + path: '/stream', + getParentRoute: () => rootRouteImport, +} as any) +const SyncOnlyRoute = SyncOnlyRouteImport.update({ + id: '/sync-only', + path: '/sync-only', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/concurrent': typeof ConcurrentRoute + '/deferred': typeof DeferredRoute + '/deferred-rejection': typeof DeferredRejectionRoute + '/fast-serial': typeof FastSerialRoute + '/many-promises': typeof ManyPromisesRoute + '/nested-deferred': typeof NestedDeferredRoute + '/query-heavy': typeof QueryHeavyRoute + '/slow-render': typeof SlowRenderRoute + '/stream': typeof StreamRoute + '/sync-only': typeof SyncOnlyRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/concurrent': typeof ConcurrentRoute + '/deferred': typeof DeferredRoute + '/deferred-rejection': typeof DeferredRejectionRoute + '/fast-serial': typeof FastSerialRoute + '/many-promises': typeof ManyPromisesRoute + '/nested-deferred': typeof NestedDeferredRoute + '/query-heavy': typeof QueryHeavyRoute + '/slow-render': typeof SlowRenderRoute + '/stream': typeof StreamRoute + '/sync-only': typeof SyncOnlyRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/concurrent': typeof ConcurrentRoute + '/deferred': typeof DeferredRoute + '/deferred-rejection': typeof DeferredRejectionRoute + '/fast-serial': typeof FastSerialRoute + '/many-promises': typeof ManyPromisesRoute + '/nested-deferred': typeof NestedDeferredRoute + '/query-heavy': typeof QueryHeavyRoute + '/slow-render': typeof SlowRenderRoute + '/stream': typeof StreamRoute + '/sync-only': typeof SyncOnlyRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/concurrent' + | '/deferred' + | '/deferred-rejection' + | '/fast-serial' + | '/many-promises' + | '/nested-deferred' + | '/query-heavy' + | '/slow-render' + | '/stream' + | '/sync-only' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/concurrent' + | '/deferred' + | '/deferred-rejection' + | '/fast-serial' + | '/many-promises' + | '/nested-deferred' + | '/query-heavy' + | '/slow-render' + | '/stream' + | '/sync-only' + id: + | '__root__' + | '/' + | '/concurrent' + | '/deferred' + | '/deferred-rejection' + | '/fast-serial' + | '/many-promises' + | '/nested-deferred' + | '/query-heavy' + | '/slow-render' + | '/stream' + | '/sync-only' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ConcurrentRoute: typeof ConcurrentRoute + DeferredRoute: typeof DeferredRoute + DeferredRejectionRoute: typeof DeferredRejectionRoute + FastSerialRoute: typeof FastSerialRoute + ManyPromisesRoute: typeof ManyPromisesRoute + NestedDeferredRoute: typeof NestedDeferredRoute + QueryHeavyRoute: typeof QueryHeavyRoute + SlowRenderRoute: typeof SlowRenderRoute + StreamRoute: typeof StreamRoute + SyncOnlyRoute: typeof SyncOnlyRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/concurrent': { + id: '/concurrent' + path: '/concurrent' + fullPath: '/concurrent' + preLoaderRoute: typeof ConcurrentRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred': { + id: '/deferred' + path: '/deferred' + fullPath: '/deferred' + preLoaderRoute: typeof DeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred-rejection': { + id: '/deferred-rejection' + path: '/deferred-rejection' + fullPath: '/deferred-rejection' + preLoaderRoute: typeof DeferredRejectionRouteImport + parentRoute: typeof rootRouteImport + } + '/fast-serial': { + id: '/fast-serial' + path: '/fast-serial' + fullPath: '/fast-serial' + preLoaderRoute: typeof FastSerialRouteImport + parentRoute: typeof rootRouteImport + } + '/many-promises': { + id: '/many-promises' + path: '/many-promises' + fullPath: '/many-promises' + preLoaderRoute: typeof ManyPromisesRouteImport + parentRoute: typeof rootRouteImport + } + '/nested-deferred': { + id: '/nested-deferred' + path: '/nested-deferred' + fullPath: '/nested-deferred' + preLoaderRoute: typeof NestedDeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/query-heavy': { + id: '/query-heavy' + path: '/query-heavy' + fullPath: '/query-heavy' + preLoaderRoute: typeof QueryHeavyRouteImport + parentRoute: typeof rootRouteImport + } + '/slow-render': { + id: '/slow-render' + path: '/slow-render' + fullPath: '/slow-render' + preLoaderRoute: typeof SlowRenderRouteImport + parentRoute: typeof rootRouteImport + } + '/stream': { + id: '/stream' + path: '/stream' + fullPath: '/stream' + preLoaderRoute: typeof StreamRouteImport + parentRoute: typeof rootRouteImport + } + '/sync-only': { + id: '/sync-only' + path: '/sync-only' + fullPath: '/sync-only' + preLoaderRoute: typeof SyncOnlyRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ConcurrentRoute: ConcurrentRoute, + DeferredRoute: DeferredRoute, + DeferredRejectionRoute: DeferredRejectionRoute, + FastSerialRoute: FastSerialRoute, + ManyPromisesRoute: ManyPromisesRoute, + NestedDeferredRoute: NestedDeferredRoute, + QueryHeavyRoute: QueryHeavyRoute, + SlowRenderRoute: SlowRenderRoute, + StreamRoute: StreamRoute, + SyncOnlyRoute: SyncOnlyRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/solid-start' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/solid-start/streaming-ssr/src/router.tsx b/e2e/solid-start/streaming-ssr/src/router.tsx new file mode 100644 index 00000000000..9e84d8316da --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/router.tsx @@ -0,0 +1,18 @@ +import { QueryClient } from '@tanstack/solid-query' +import { createRouter } from '@tanstack/solid-router' +import { setupRouterSsrQueryIntegration } from '@tanstack/solid-router-ssr-query' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const queryClient = new QueryClient() + const router = createRouter({ + routeTree, + context: { queryClient }, + scrollRestoration: true, + }) + setupRouterSsrQueryIntegration({ + router, + queryClient, + }) + return router +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/__root.tsx b/e2e/solid-start/streaming-ssr/src/routes/__root.tsx new file mode 100644 index 00000000000..6ddac9188ec --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/__root.tsx @@ -0,0 +1,99 @@ +import { createSignal } from 'solid-js' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRouteWithContext, +} from '@tanstack/solid-router' +import { HydrationScript } from 'solid-js/web' +import type { QueryClient } from '@tanstack/solid-query' + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient +}>()({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Streaming SSR Tests' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + + + + ) +} + +function HydrationCheck() { + const [status, setStatus] = createSignal<'pending' | 'hydrated'>('pending') + + return ( +
+ + + {status()} + +
+ ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/concurrent.tsx b/e2e/solid-start/streaming-ssr/src/routes/concurrent.tsx new file mode 100644 index 00000000000..e84d6d43979 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/concurrent.tsx @@ -0,0 +1,80 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { Suspense } from 'solid-js' + +function createConcurrentPromises( + count: number, + delayMs: number, +): Array> { + const sharedPromise = new Promise((resolve) => + setTimeout(resolve, delayMs), + ) + return Array.from({ length: count }, (_, i) => + sharedPromise.then(() => `concurrent-${i + 1}`), + ) +} + +export const Route = createFileRoute('/concurrent')({ + loader: async () => { + const batch1 = createConcurrentPromises(5, 100) + const batch2 = createConcurrentPromises(5, 200) + const batch3 = createConcurrentPromises(5, 300) + + return { + concurrent1_1: batch1[0], + concurrent1_2: batch1[1], + concurrent1_3: batch1[2], + concurrent1_4: batch1[3], + concurrent1_5: batch1[4], + concurrent2_1: batch2[0], + concurrent2_2: batch2[1], + concurrent2_3: batch2[2], + concurrent2_4: batch2[3], + concurrent2_5: batch2[4], + concurrent3_1: batch3[0], + concurrent3_2: batch3[1], + concurrent3_3: batch3[2], + concurrent3_4: batch3[3], + concurrent3_5: batch3[4], + } + }, + component: Concurrent, +}) + +function PromiseItem(props: { promise: Promise; testId: string }) { + return ( + Loading...} + > +
{value}
} + /> +
+ ) +} + +function Concurrent() { + const data = Route.useLoaderData() + + return ( +
+

Concurrent Resolution Test (15 promises in 3 batches)

+

Tests multiple promises resolving at the exact same instant.

+ + + + + + + + + + + + + + + +
+ ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/deferred-rejection.tsx b/e2e/solid-start/streaming-ssr/src/routes/deferred-rejection.tsx new file mode 100644 index 00000000000..b837303519a --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/deferred-rejection.tsx @@ -0,0 +1,38 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { Suspense } from 'solid-js' + +const deferredErrorMessage = 'Error in deferred object' + +export const Route = createFileRoute('/deferred-rejection')({ + loader: async () => { + return { + deferredData: new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error(deferredErrorMessage)) + }, 1000) + }), + } + }, + errorComponent: ({ error }) => ( +
{error.message}
+ ), + component: DeferredRejection, +}) + +function DeferredRejection() { + const data = Route.useLoaderData() + + return ( +
+

Deferred Rejection Test

+ Loading deferred...
} + > +
{value}
} + /> +
+ + ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/deferred.tsx b/e2e/solid-start/streaming-ssr/src/routes/deferred.tsx new file mode 100644 index 00000000000..db01a11ec30 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/deferred.tsx @@ -0,0 +1,93 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import { Suspense } from 'solid-js' + +const getImmediateData = createServerFn({ method: 'GET' }) + .validator((data: { name: string }) => data) + .handler(({ data }) => { + return { + name: data.name, + timestamp: Date.now(), + source: 'server' as const, + } + }) + +const getSlowData = createServerFn({ method: 'GET' }) + .validator((data: { name: string; delay: number }) => data) + .handler(async ({ data }) => { + await new Promise((r) => setTimeout(r, data.delay)) + return { + name: data.name, + timestamp: Date.now(), + source: 'server' as const, + } + }) + +export const Route = createFileRoute('/deferred')({ + loader: async () => { + return { + deferredData: new Promise<{ message: string; source: string }>((r) => + setTimeout( + () => + r({ + message: 'Deferred data loaded!', + source: typeof window === 'undefined' ? 'server' : 'client', + }), + 1000, + ), + ), + deferredServerData: getSlowData({ + data: { name: 'Slow User', delay: 800 }, + }), + immediateData: await getImmediateData({ data: { name: 'Fast User' } }), + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: Deferred, +}) + +function Deferred() { + const data = Route.useLoaderData() + + return ( +
+

Deferred Data Test

+
+ Immediate: {data().immediateData.name} @{' '} + {data().immediateData.timestamp} +
+
+ Immediate source: {data().immediateData.source} +
+
+ Loader source: {data().loaderSource} +
+ Loading deferred...
} + > + ( +
+ {value.message} (source: {value.source}) +
+ )} + /> + + Loading server data... + } + > + ( +
+ Server: {value.name} @ {value.timestamp} (source: {value.source}) +
+ )} + /> +
+ + ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/fast-serial.tsx b/e2e/solid-start/streaming-ssr/src/routes/fast-serial.tsx new file mode 100644 index 00000000000..7f6e0724829 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/fast-serial.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' + +const getSmallData = createServerFn({ method: 'GET' }).handler(() => { + return { + value: 'small-data', + timestamp: Date.now(), + source: 'server' as const, + } +}) + +export const Route = createFileRoute('/fast-serial')({ + loader: async () => { + const data = await getSmallData() + return { + serverData: data, + staticData: 'This is static data', + timestamp: Date.now(), + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: FastSerial, +}) + +function FastSerial() { + const data = Route.useLoaderData() + + return ( +
+

Fast Serialization Test

+

This route tests when serialization completes before render.

+
+ Server: {data().serverData.value} @ {data().serverData.timestamp} +
+
+ Server function source: {data().serverData.source} +
+
+ Loader source: {data().loaderSource} +
+
Static: {data().staticData}
+
+ Loader timestamp: {data().timestamp} +
+
+ ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/index.tsx b/e2e/solid-start/streaming-ssr/src/routes/index.tsx new file mode 100644 index 00000000000..f72998817a3 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/index.tsx @@ -0,0 +1,58 @@ +import { Link, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: Index, +}) + +function Index() { + return ( +
+

Streaming SSR Test Scenarios

+

This e2e project tests various SSR streaming scenarios:

+
    +
  • + + Sync Only + {' '} + - Tests synchronous serialization with no deferred/streaming data +
  • +
  • + + Deferred Data + {' '} + - Tests deferred promises resolving after initial render +
  • +
  • + + Deferred Rejection + {' '} + - Tests deferred promise rejections render through the error boundary +
  • +
  • + + ReadableStream + {' '} + - Tests streaming data via ReadableStream +
  • +
  • + + Fast Serialization + {' '} + - Tests when serialization completes before render finishes +
  • +
  • + + Slow Render + {' '} + - Tests when render takes longer than serialization +
  • +
  • + + Nested Deferred + {' '} + - Tests nested components with deferred data +
  • +
+
+ ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/many-promises.tsx b/e2e/solid-start/streaming-ssr/src/routes/many-promises.tsx new file mode 100644 index 00000000000..89fa8a3365c --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/many-promises.tsx @@ -0,0 +1,112 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { Suspense } from 'solid-js' + +function createDelayedPromise(value: T, delayMs: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), delayMs)) +} + +export const Route = createFileRoute('/many-promises')({ + loader: async () => { + return { + immediate1: createDelayedPromise('immediate-1', 0), + immediate2: createDelayedPromise('immediate-2', 10), + immediate3: createDelayedPromise('immediate-3', 20), + fast1: createDelayedPromise('fast-1', 50), + fast2: createDelayedPromise('fast-2', 75), + fast3: createDelayedPromise('fast-3', 100), + fast4: createDelayedPromise('fast-4', 125), + medium1: createDelayedPromise('medium-1', 150), + medium2: createDelayedPromise('medium-2', 200), + medium3: createDelayedPromise('medium-3', 250), + slow1: createDelayedPromise('slow-1', 300), + slow2: createDelayedPromise('slow-2', 400), + slow3: createDelayedPromise('slow-3', 500), + verySlow1: createDelayedPromise('very-slow-1', 600), + verySlow2: createDelayedPromise('very-slow-2', 800), + } + }, + component: ManyPromises, +}) + +function PromiseItem(props: { + promise: Promise + testId: string + label: string +}) { + return ( + + Loading {props.label}... + + } + > + ( +
+ {props.label}: {value} +
+ )} + /> +
+ ) +} + +function ManyPromises() { + const data = Route.useLoaderData() + + return ( +
+

Many Promises Test (15 deferred)

+

Tests streaming with many concurrent deferred promises.

+ + + + + + + + + + + + + + + +
+ ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/nested-deferred.tsx b/e2e/solid-start/streaming-ssr/src/routes/nested-deferred.tsx new file mode 100644 index 00000000000..6ffb171dbb0 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/nested-deferred.tsx @@ -0,0 +1,112 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import { Suspense } from 'solid-js' + +const getLevel1Data = createServerFn({ method: 'GET' }).handler(async () => { + await new Promise((r) => setTimeout(r, 200)) + return { level: 1, timestamp: Date.now() } +}) + +const getLevel2Data = createServerFn({ method: 'GET' }).handler(async () => { + await new Promise((r) => setTimeout(r, 400)) + return { level: 2, timestamp: Date.now() } +}) + +const getLevel3Data = createServerFn({ method: 'GET' }).handler(async () => { + await new Promise((r) => setTimeout(r, 600)) + return { level: 3, timestamp: Date.now() } +}) + +export const Route = createFileRoute('/nested-deferred')({ + loader: async () => { + return { + level1: getLevel1Data(), + level2: getLevel2Data(), + level3: getLevel3Data(), + plainDeferred: new Promise((r) => + setTimeout(() => r('Plain deferred resolved!'), 300), + ), + } + }, + component: NestedDeferred, +}) + +function Level2Content(props: { + level3: Promise<{ level: number; timestamp: number }> +}) { + return ( +
+ Loading level 3...
} + > + ( +
+ Level 3: {value.level} @ {value.timestamp} +
+ )} + /> + + + ) +} + +function Level1Content(props: { + level2: Promise<{ level: number; timestamp: number }> + level3: Promise<{ level: number; timestamp: number }> +}) { + return ( +
+ Loading level 2...
} + > + ( +
+ Level 2: {value.level} @ {value.timestamp} + +
+ )} + /> + + + ) +} + +function NestedDeferred() { + const data = Route.useLoaderData() + + return ( +
+

Nested Deferred Test

+

+ Tests multiple nested deferred promises resolving at different times. +

+ Loading plain...
} + > +
{value}
} + /> + +
+ Loading level 1...
} + > + ( +
+ Level 1: {value.level} @ {value.timestamp} + +
+ )} + /> + + + + ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/query-heavy.tsx b/e2e/solid-start/streaming-ssr/src/routes/query-heavy.tsx new file mode 100644 index 00000000000..a8b8ef93b11 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/query-heavy.tsx @@ -0,0 +1,162 @@ +import { queryOptions, useQuery } from '@tanstack/solid-query' +import { createFileRoute } from '@tanstack/solid-router' +import { Suspense } from 'solid-js' + +type QueryType = 'sync' | 'fast-async' | 'slow-async' + +type QueryData = { + type: QueryType + id: number + value: string + source: string +} + +function makeQueryOptions( + type: QueryType, + id: number, + value: string, + delayMs = 0, +) { + return queryOptions({ + queryKey: ['streaming-ssr-query-heavy', type, id], + queryFn: async (): Promise => { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + + return { + type, + id, + value, + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, + }) +} + +const syncQuery1 = makeQueryOptions('sync', 1, 'sync-value-1') +const syncQuery2 = makeQueryOptions('sync', 2, 'sync-value-2') +const syncQuery3 = makeQueryOptions('sync', 3, 'sync-value-3') +const fastAsyncQuery1 = makeQueryOptions('fast-async', 1, 'fast-async-1', 50) +const fastAsyncQuery2 = makeQueryOptions('fast-async', 2, 'fast-async-2', 75) +const fastAsyncQuery3 = makeQueryOptions('fast-async', 3, 'fast-async-3', 100) +const slowAsyncQuery1 = makeQueryOptions('slow-async', 1, 'slow-async-1', 200) +const slowAsyncQuery2 = makeQueryOptions('slow-async', 2, 'slow-async-2', 300) +const slowAsyncQuery3 = makeQueryOptions('slow-async', 3, 'slow-async-3', 400) + +type QueryOptions = ReturnType + +export const Route = createFileRoute('/query-heavy')({ + component: QueryHeavy, +}) + +function QueryDisplay(props: { queryOpts: QueryOptions; testId: string }) { + const query = useQuery(() => props.queryOpts) + + return +} + +function QueryDataView(props: { data: QueryData | undefined; testId: string }) { + return ( +
+ {props.data?.value ?? 'loading...'} (source:{' '} + {props.data?.source ?? 'loading...'}) +
+ ) +} + +function QuerySuspense(props: { + queryOpts: QueryOptions + testId: string + fallback: string +}) { + return ( + {props.fallback} + } + > + + + ) +} + +function QueryHeavy() { + return ( +
+

Query Heavy Test (9 useQuery calls)

+

Tests multiple useQuery with mixed sync/async queryFn.

+

+ All queries should show "source: server" if SSR streaming works + correctly. +

+ +
+
+

Sync Queries (immediate)

+ + + +
+ +
+

Fast Async Queries (50-100ms)

+ + + +
+ +
+

Slow Async Queries (200-400ms)

+ + + +
+
+
+ ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/slow-render.tsx b/e2e/solid-start/streaming-ssr/src/routes/slow-render.tsx new file mode 100644 index 00000000000..46d72c0e38a --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/slow-render.tsx @@ -0,0 +1,73 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { createServerFn } from '@tanstack/solid-start' +import { Suspense } from 'solid-js' + +const getQuickData = createServerFn({ method: 'GET' }).handler(() => { + return { + name: 'Quick data', + timestamp: Date.now(), + source: 'server' as const, + } +}) + +function SlowComponent(props: { data: string; index: number }) { + const startTime = Date.now() + while (Date.now() - startTime < 100) { + // Simulate slow render work. + } + return
{props.data}
+} + +export const Route = createFileRoute('/slow-render')({ + loader: async () => { + const quickData = await getQuickData() + return { + quickData, + deferredData: new Promise<{ message: string; source: string }>((r) => + setTimeout( + () => + r({ + message: 'Deferred resolved!', + source: typeof window === 'undefined' ? 'server' : 'client', + }), + 50, + ), + ), + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: SlowRender, +}) + +function SlowRender() { + const data = Route.useLoaderData() + + return ( +
+

Slow Render Test

+

Tests when render takes longer than serialization.

+
+ Quick: {data().quickData.name} @ {data().quickData.timestamp} +
+
+ Quick data source: {data().quickData.source} +
+
+ Loader source: {data().loaderSource} +
+ Loading...
}> + ( +
+ {value.message} (source: {value.source}) +
+ )} + /> + + + + + + ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/stream.tsx b/e2e/solid-start/streaming-ssr/src/routes/stream.tsx new file mode 100644 index 00000000000..7e058737ca4 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/stream.tsx @@ -0,0 +1,91 @@ +import { Await, createFileRoute } from '@tanstack/solid-router' +import { createEffect, createSignal, onCleanup, Suspense } from 'solid-js' + +export const Route = createFileRoute('/stream')({ + component: StreamRoute, + loader() { + return { + promise: new Promise((resolve) => + setTimeout(() => resolve('promise-resolved'), 150), + ), + stream: new ReadableStream({ + async start(controller) { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 200)) + controller.enqueue(`chunk-${i}`) + } + controller.close() + }, + }), + } + }, +}) + +const decoder = new TextDecoder('utf-8') + +function StreamRoute() { + const data = Route.useLoaderData() + const [streamData, setStreamData] = createSignal>([]) + const [streamComplete, setStreamComplete] = createSignal(false) + let reader: ReadableStreamDefaultReader | undefined + + createEffect(() => { + const stream = data().stream + if (stream.locked) { + return + } + + setStreamData([]) + setStreamComplete(false) + + async function readStream() { + try { + reader = stream.getReader() + let chunk + while (!(chunk = await reader.read()).done) { + let value = chunk.value + if (typeof value !== 'string') { + value = decoder.decode(value, { stream: !chunk.done }) + } + setStreamData((prev) => [...prev, value]) + } + setStreamComplete(true) + } catch (e) { + if (!(e instanceof TypeError && String(e).includes('cancelled'))) { + console.error('Stream error:', e) + } + } + } + + void readStream() + onCleanup(() => { + reader?.cancel().catch(() => {}) + reader = undefined + }) + }) + + return ( +
+

ReadableStream Test

+ Loading promise...
} + > +
{value}
} + /> + +
+

Stream chunks:

+
+ {streamData().map((chunk, i) => ( +
{chunk}
+ ))} +
+ {streamComplete() && ( +
Stream complete!
+ )} +
+ + ) +} diff --git a/e2e/solid-start/streaming-ssr/src/routes/sync-only.tsx b/e2e/solid-start/streaming-ssr/src/routes/sync-only.tsx new file mode 100644 index 00000000000..3ef015e599d --- /dev/null +++ b/e2e/solid-start/streaming-ssr/src/routes/sync-only.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/sync-only')({ + loader: async () => { + return { + message: 'Hello from sync loader!', + timestamp: Date.now(), + items: ['item-1', 'item-2', 'item-3'], + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: SyncOnly, +}) + +function SyncOnly() { + const data = Route.useLoaderData() + + return ( +
+

Synchronous Serialization Test

+

{data().message}

+

Loaded at: {data().timestamp}

+

Source: {data().source}

+
    + {data().items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) +} diff --git a/e2e/solid-start/streaming-ssr/tests/client-navigation.spec.ts b/e2e/solid-start/streaming-ssr/tests/client-navigation.spec.ts new file mode 100644 index 00000000000..8b9b3a8dcc5 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/client-navigation.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/client-navigation' diff --git a/e2e/solid-start/streaming-ssr/tests/concurrent.spec.ts b/e2e/solid-start/streaming-ssr/tests/concurrent.spec.ts new file mode 100644 index 00000000000..8625473a58e --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/concurrent.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/concurrent' diff --git a/e2e/solid-start/streaming-ssr/tests/deferred-rejection.spec.ts b/e2e/solid-start/streaming-ssr/tests/deferred-rejection.spec.ts new file mode 100644 index 00000000000..355b5019bc9 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/deferred-rejection.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/deferred-rejection' diff --git a/e2e/solid-start/streaming-ssr/tests/deferred.spec.ts b/e2e/solid-start/streaming-ssr/tests/deferred.spec.ts new file mode 100644 index 00000000000..3145897d7be --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/deferred.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/deferred' diff --git a/e2e/solid-start/streaming-ssr/tests/fast-serial.spec.ts b/e2e/solid-start/streaming-ssr/tests/fast-serial.spec.ts new file mode 100644 index 00000000000..95df0555e26 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/fast-serial.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/fast-serial' diff --git a/e2e/solid-start/streaming-ssr/tests/home.spec.ts b/e2e/solid-start/streaming-ssr/tests/home.spec.ts new file mode 100644 index 00000000000..5d49d34a565 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/home.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/home' diff --git a/e2e/solid-start/streaming-ssr/tests/many-promises.spec.ts b/e2e/solid-start/streaming-ssr/tests/many-promises.spec.ts new file mode 100644 index 00000000000..b747bd7d0aa --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/many-promises.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/many-promises' diff --git a/e2e/solid-start/streaming-ssr/tests/nested-deferred.spec.ts b/e2e/solid-start/streaming-ssr/tests/nested-deferred.spec.ts new file mode 100644 index 00000000000..8df20a8a958 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/nested-deferred.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/nested-deferred' diff --git a/e2e/solid-start/streaming-ssr/tests/preview-streaming.spec.ts b/e2e/solid-start/streaming-ssr/tests/preview-streaming.spec.ts new file mode 100644 index 00000000000..34241cfbaa7 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/preview-streaming.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/preview-streaming' diff --git a/e2e/solid-start/streaming-ssr/tests/query-heavy.spec.ts b/e2e/solid-start/streaming-ssr/tests/query-heavy.spec.ts new file mode 100644 index 00000000000..d15c092f6e3 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/query-heavy.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/query-heavy' diff --git a/e2e/solid-start/streaming-ssr/tests/slow-render.spec.ts b/e2e/solid-start/streaming-ssr/tests/slow-render.spec.ts new file mode 100644 index 00000000000..0d5a87363f5 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/slow-render.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/slow-render' diff --git a/e2e/solid-start/streaming-ssr/tests/stream.spec.ts b/e2e/solid-start/streaming-ssr/tests/stream.spec.ts new file mode 100644 index 00000000000..a51614f09af --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/stream.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/stream' diff --git a/e2e/solid-start/streaming-ssr/tests/sync-only.spec.ts b/e2e/solid-start/streaming-ssr/tests/sync-only.spec.ts new file mode 100644 index 00000000000..0e9278d3f2f --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tests/sync-only.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/sync-only' diff --git a/e2e/solid-start/streaming-ssr/tsconfig.json b/e2e/solid-start/streaming-ssr/tsconfig.json new file mode 100644 index 00000000000..ff0556e7a83 --- /dev/null +++ b/e2e/solid-start/streaming-ssr/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/solid-start/streaming-ssr/vite.config.ts b/e2e/solid-start/streaming-ssr/vite.config.ts new file mode 100644 index 00000000000..a9913da087e --- /dev/null +++ b/e2e/solid-start/streaming-ssr/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import viteSolid from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [tanstackStart(), viteSolid({ ssr: true })], +}) diff --git a/e2e/streaming-ssr-assertions.ts b/e2e/streaming-ssr-assertions.ts new file mode 100644 index 00000000000..51d2a7343e5 --- /dev/null +++ b/e2e/streaming-ssr-assertions.ts @@ -0,0 +1,143 @@ +import { expect } from '@playwright/test' +import { test } from './e2e-utils/src/fixture' +import type { Page } from '@playwright/test' + +// Router transport markers, mirrored from router-core's hydration scripts. +// Keep every spec's protocol knowledge in this one place. +export const ROUTER_BOOTSTRAP_MARKER = '$_TSR.router=' +export const ROUTER_STREAM_END_MARKER = '$_TSR.e()' +export const STREAM_BOUNDARY_MARKER = '$tsr-stream-boundary' +export const STREAM_PART_SELECTOR = '[data-tsr-stream-part]' +const LATE_RECORD_CLOSE = 'document.currentScript.remove()' + +const immediateDataMarker = 'data-testid="immediate-data"' +const renderedDeferredMarker = 'data-testid="deferred-data"' + +const browserUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' + +export async function expectDeferredRouteResponseOrder( + baseURL: string | undefined, +) { + if (!baseURL) { + throw new Error('Playwright baseURL is required') + } + + const response = await fetch(new URL('/deferred', baseURL), { + headers: { + accept: 'text/html', + 'accept-encoding': 'identity', + 'user-agent': browserUserAgent, + }, + }) + + expect(response.status).toBe(200) + expect(response.body).not.toBeNull() + + const html = await response.text() + + const responsePreview = + html.length > 2000 + ? `${html.slice(0, 1000)}\n...\n${html.slice(-1000)}` + : html + + const immediateDataIndex = html.indexOf(immediateDataMarker) + const renderedDeferredIndex = html.indexOf(renderedDeferredMarker) + expect(immediateDataIndex, responsePreview).toBeGreaterThanOrEqual(0) + expect(renderedDeferredIndex, responsePreview).toBeGreaterThan( + immediateDataIndex, + ) + + // SSR Query closes its request-local stream after renderer EOF. Its final + // record is the deterministic late router output for this test. + const boundaryIndex = html.indexOf(STREAM_BOUNDARY_MARKER) + const routerEndIndex = html.indexOf(ROUTER_STREAM_END_MARKER) + + expect(boundaryIndex).toBeGreaterThan(-1) + expect(boundaryIndex).toBeLessThan(routerEndIndex) + expect(renderedDeferredIndex).toBeLessThan(routerEndIndex) + + expectCompleteRouterStreamBeforeDocumentCloses(html, renderedDeferredMarker) +} + +export async function expectQueryHeavyRouteStreamOrder( + baseURL: string | undefined, +) { + if (!baseURL) { + throw new Error('Playwright baseURL is required') + } + + const response = await fetch(new URL('/query-heavy', baseURL), { + headers: { + accept: 'text/html', + 'accept-encoding': 'identity', + 'user-agent': browserUserAgent, + }, + }) + + expect(response.status).toBe(200) + const html = await response.text() + + expect(html).toContain('data-testid="fast-async-query-1"') + expect(html).toContain('data-testid="slow-async-query-3"') + expectCompleteRouterStreamBeforeDocumentCloses(html, '"slow-async-3"') +} + +/** Asserts a route response carries the bootstrap, the boundary, and the end record. */ +export function expectRouterHydrationMarkers(html: string) { + expect(html).toContain(ROUTER_BOOTSTRAP_MARKER) + expect(html).toContain(ROUTER_STREAM_END_MARKER) + expect(html).toContain(STREAM_BOUNDARY_MARKER) +} + +function expectCompleteRouterStreamBeforeDocumentCloses( + html: string, + payloadMarker: string, +) { + const endIndex = html.indexOf(ROUTER_STREAM_END_MARKER) + const payloadIndex = html.indexOf(payloadMarker) + const scriptOpenIndex = html.lastIndexOf('', endIndex) + const scriptCloseIndex = html.indexOf('', endIndex) + const bodyCloseIndex = html.indexOf('', scriptCloseIndex) + const htmlCloseIndex = html.indexOf('', bodyCloseIndex) + + expect(endIndex).toBeGreaterThan(-1) + expect(scriptOpenIndex).toBeGreaterThan(previousScriptCloseIndex) + expect(scriptCloseIndex).toBeGreaterThan(endIndex) + expect(payloadIndex).toBeGreaterThan(-1) + expect(payloadIndex).toBeLessThan(endIndex) + expect(html.slice(endIndex, scriptCloseIndex)).toContain(LATE_RECORD_CLOSE) + expect(scriptCloseIndex).toBeLessThan(bodyCloseIndex) + expect(bodyCloseIndex).toBeLessThan(htmlCloseIndex) +} + +async function verifyHydration( + page: Page, + { timeout = 10_000 }: { timeout?: number } = {}, +) { + const button = page.getByTestId('hydration-check-btn') + const status = page.getByTestId('hydration-status') + + await expect(button).toBeVisible() + await expect(async () => { + await button.click() + await expect(status).toHaveText('hydrated', { timeout: 100 }) + }).toPass({ timeout }) + + await expect(page.locator(STREAM_PART_SELECTOR)).toHaveCount(0) + await expect( + page.locator('script').filter({ hasText: STREAM_BOUNDARY_MARKER }), + ).toHaveCount(0) +} + +export { test } + +export const testWithHydration = test.extend({ + page: async ({ page }, use) => { + await use(page) + await verifyHydration(page) + }, +}) + +export { expect } diff --git a/e2e/streaming-ssr-specs/client-navigation.ts b/e2e/streaming-ssr-specs/client-navigation.ts new file mode 100644 index 00000000000..759f8e0282b --- /dev/null +++ b/e2e/streaming-ssr-specs/client-navigation.ts @@ -0,0 +1,372 @@ +import { expect, test, testWithHydration } from '../streaming-ssr-assertions' + +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 without waiting for stream completion. + 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('navigation without waiting for route data works', async ({ page }) => { + // Console errors are monitored by the fixture automatically + + // Change routes as soon as each URL updates, without waiting for route data. + 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() + }, + ) +}) diff --git a/e2e/streaming-ssr-specs/concurrent.ts b/e2e/streaming-ssr-specs/concurrent.ts new file mode 100644 index 00000000000..ef14f803677 --- /dev/null +++ b/e2e/streaming-ssr-specs/concurrent.ts @@ -0,0 +1,86 @@ +import { expect, test, testWithHydration } from '../streaming-ssr-assertions' + +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 }, + ) + }) + + 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, + }) + }, + ) +}) diff --git a/e2e/streaming-ssr-specs/deferred-rejection.ts b/e2e/streaming-ssr-specs/deferred-rejection.ts new file mode 100644 index 00000000000..3e944b1c0cb --- /dev/null +++ b/e2e/streaming-ssr-specs/deferred-rejection.ts @@ -0,0 +1,21 @@ +import { expect, test } from '../streaming-ssr-assertions' + +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!', + ) +}) diff --git a/e2e/streaming-ssr-specs/deferred.ts b/e2e/streaming-ssr-specs/deferred.ts new file mode 100644 index 00000000000..abece0b43e6 --- /dev/null +++ b/e2e/streaming-ssr-specs/deferred.ts @@ -0,0 +1,113 @@ +import { + expect, + expectDeferredRouteResponseOrder, + test, + testWithHydration, +} from '../streaming-ssr-assertions' + +test.describe('Deferred data streaming', () => { + test('finishes the router stream after renderer output', async ({ + baseURL, + }) => { + await expectDeferredRouteResponseOrder(baseURL) + }) + + test('renders immediate and deferred server data', async ({ page }) => { + await page.goto('/deferred') + + // Immediate loader data is rendered. + 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 }, + ) + }) + + 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', 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', + ) + }) +}) diff --git a/e2e/streaming-ssr-specs/fast-serial.ts b/e2e/streaming-ssr-specs/fast-serial.ts new file mode 100644 index 00000000000..03201f0a55b --- /dev/null +++ b/e2e/streaming-ssr-specs/fast-serial.ts @@ -0,0 +1,74 @@ +import { + expect, + expectRouterHydrationMarkers, + test, + testWithHydration, +} from '../streaming-ssr-assertions' + +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() + + expectRouterHydrationMarkers(responseHtml) + }) + + test('renders all server data', 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 the loader and server function ran during SSR. + 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') + }) +}) diff --git a/e2e/streaming-ssr-specs/home.ts b/e2e/streaming-ssr-specs/home.ts new file mode 100644 index 00000000000..d85e19bfa7e --- /dev/null +++ b/e2e/streaming-ssr-specs/home.ts @@ -0,0 +1,34 @@ +import { expect, test, testWithHydration } from '../streaming-ssr-assertions' + +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') + }, + ) +}) diff --git a/e2e/streaming-ssr-specs/many-promises.ts b/e2e/streaming-ssr-specs/many-promises.ts new file mode 100644 index 00000000000..768859c7e46 --- /dev/null +++ b/e2e/streaming-ssr-specs/many-promises.ts @@ -0,0 +1,82 @@ +import { expect, test, testWithHydration } from '../streaming-ssr-assertions' + +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 }) + }) +}) diff --git a/e2e/streaming-ssr-specs/nested-deferred.ts b/e2e/streaming-ssr-specs/nested-deferred.ts new file mode 100644 index 00000000000..0146a231620 --- /dev/null +++ b/e2e/streaming-ssr-specs/nested-deferred.ts @@ -0,0 +1,52 @@ +import { expect, test, testWithHydration } from '../streaming-ssr-assertions' + +test.describe('Nested deferred (multiple levels of deferred data)', () => { + test('all levels of deferred data eventually resolve', async ({ page }) => { + await page.goto('/nested-deferred') + + // All deferred values should eventually resolve. + await expect(page.getByTestId('plain-deferred')).toContainText( + 'Plain deferred resolved!', + { timeout: 5000 }, + ) + + await expect(page.getByTestId('level1-data')).toContainText('Level 1:', { + timeout: 5000, + }) + + await expect(page.getByTestId('level2-data')).toContainText('Level 2:', { + timeout: 5000, + }) + + await expect(page.getByTestId('level3-data')).toContainText('Level 3:', { + timeout: 5000, + }) + }) + + 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, + }) + }) +}) diff --git a/e2e/streaming-ssr-specs/preview-streaming.ts b/e2e/streaming-ssr-specs/preview-streaming.ts new file mode 100644 index 00000000000..5087dff7305 --- /dev/null +++ b/e2e/streaming-ssr-specs/preview-streaming.ts @@ -0,0 +1,13 @@ +import { + expectDeferredRouteResponseOrder, + test, +} from '../streaming-ssr-assertions' + +test.skip( + process.env.MODE !== 'preview', + 'Only runs against vite preview: pnpm test:e2e:preview', +) + +test('vite preview preserves deferred HTML order', async ({ baseURL }) => { + await expectDeferredRouteResponseOrder(baseURL) +}) diff --git a/e2e/streaming-ssr-specs/query-heavy.ts b/e2e/streaming-ssr-specs/query-heavy.ts new file mode 100644 index 00000000000..b33a64f3660 --- /dev/null +++ b/e2e/streaming-ssr-specs/query-heavy.ts @@ -0,0 +1,142 @@ +import { + expect, + expectQueryHeavyRouteStreamOrder, + test, + testWithHydration, +} from '../streaming-ssr-assertions' + +test.describe('Query heavy route (9 queries)', () => { + test('emits the complete router stream before document closes', async ({ + baseURL, + }) => { + await expectQueryHeavyRouteStreamOrder(baseURL) + }) + + 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('server query data hydrates without client refetch', async ({ + page, + }) => { + // If hydration missed the server data, these queries would re-execute on + // the client and show "client" as their source. + await page.goto('/query-heavy') + + // Wait for all queries + await expect(page.getByTestId('slow-async-query-3')).toBeVisible({ + timeout: 5000, + }) + + // Verify that all server results hydrated without a client refetch. + const serverSourceCount = await page + .locator('[data-testid*="query-"]') + .filter({ hasText: 'source: server' }) + .count() + expect(serverSourceCount).toBe(9) + }) +}) diff --git a/e2e/streaming-ssr-specs/slow-render.ts b/e2e/streaming-ssr-specs/slow-render.ts new file mode 100644 index 00000000000..117559701ec --- /dev/null +++ b/e2e/streaming-ssr-specs/slow-render.ts @@ -0,0 +1,71 @@ +import { expect, test, testWithHydration } from '../streaming-ssr-assertions' + +test.describe('Slow render (render takes longer than serialization)', () => { + test('all data eventually renders with server source', async ({ page }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Quick data should be available with server source + await expect(page.getByTestId('quick-data')).toContainText('Quick:') + await expect(page.getByTestId('quick-source')).toContainText( + 'Quick data source: server', + ) + await expect(page.getByTestId('loader-source')).toContainText( + 'Loader source: server', + ) + + // Deferred data should resolve with server source + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'Deferred resolved!', + { timeout: 5000 }, + ) + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'source: server', + { timeout: 5000 }, + ) + + // Slow components should have rendered + await expect(page.getByTestId('slow-component-1')).toBeVisible() + await expect(page.getByTestId('slow-component-2')).toBeVisible() + await expect(page.getByTestId('slow-component-3')).toBeVisible() + }) + + testWithHydration('hydration works after slow render', async ({ page }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Wait for content and verify server source + await expect(page.getByTestId('slow-component-1')).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: 'Slow Render' }).first().click() + await expect(page).toHaveURL('/slow-render') + + await expect(page.getByTestId('quick-data')).toBeVisible({ timeout: 10000 }) + }) + + test('all data sources are server', async ({ page }) => { + await page.goto('/slow-render') + await page.waitForLoadState('networkidle') + + // Wait for deferred content + await expect(page.getByTestId('deferred-resolved')).toBeVisible({ + timeout: 5000, + }) + + // Verify all sources are server + await expect(page.getByTestId('quick-source')).toContainText('server') + await expect(page.getByTestId('loader-source')).toContainText('server') + await expect(page.getByTestId('deferred-resolved')).toContainText( + 'source: server', + ) + }) +}) diff --git a/e2e/streaming-ssr-specs/stream.ts b/e2e/streaming-ssr-specs/stream.ts new file mode 100644 index 00000000000..ee61f170dec --- /dev/null +++ b/e2e/streaming-ssr-specs/stream.ts @@ -0,0 +1,55 @@ +import { expect, test, testWithHydration } from '../streaming-ssr-assertions' + +const expectedStreamChunks = [ + 'chunk-0', + 'chunk-1', + 'chunk-2', + 'chunk-3', + 'chunk-4', +] + +test.describe('ReadableStream deserialization', () => { + testWithHydration('promise data deserializes correctly', async ({ page }) => { + await page.goto('/stream') + + await expect(page.getByTestId('promise-data')).toContainText( + 'promise-resolved', + { timeout: 5000 }, + ) + }) + + testWithHydration( + 'stream chunks deserialize in source order', + async ({ page }) => { + await page.goto('/stream') + + await expect(page.getByTestId('stream-complete')).toBeVisible({ + timeout: 10000, + }) + + const chunks = page + .getByTestId('stream-data') + .locator('[data-testid^="stream-chunk-"]') + await expect(chunks).toHaveText(expectedStreamChunks) + + // This browser assertion covers deserialization and source order. The + // raw `/deferred` assertion covers server-response and router ordering. + }, + ) + + testWithHydration( + 'client-side navigation to stream route works', + async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Navigate via nav link + await page.getByRole('link', { name: 'Stream' }).first().click() + await expect(page).toHaveURL('/stream') + + await expect(page.getByTestId('stream-complete')).toBeVisible({ + timeout: 10000, + }) + }, + ) +}) diff --git a/e2e/streaming-ssr-specs/sync-only.ts b/e2e/streaming-ssr-specs/sync-only.ts new file mode 100644 index 00000000000..98502651090 --- /dev/null +++ b/e2e/streaming-ssr-specs/sync-only.ts @@ -0,0 +1,80 @@ +import { + expect, + expectRouterHydrationMarkers, + test, + testWithHydration, +} from '../streaming-ssr-assertions' + +/** + * Tests for synchronous serialization - no deferred data, no streaming. + * This is the most common case where all loader data is immediately available. + * The hydration scripts should be included in the initial HTML response. + */ + +test('Sync-only route renders with loader data', async ({ page }) => { + await page.goto('/sync-only') + + // Verify the page content is rendered + await expect(page.getByTestId('sync-title')).toContainText( + 'Synchronous Serialization Test', + ) + await expect(page.getByTestId('sync-message')).toContainText( + 'Hello from sync loader!', + ) + + // Verify loader data items are rendered + await expect(page.getByTestId('sync-item-item-1')).toBeVisible() + await expect(page.getByTestId('sync-item-item-2')).toBeVisible() + await expect(page.getByTestId('sync-item-item-3')).toBeVisible() + + // Verify the loader ran during SSR. + await expect(page.getByTestId('sync-source')).toContainText('Source: server') +}) + +testWithHydration('Sync-only route hydrates correctly', async ({ page }) => { + await page.goto('/sync-only') + + // Verify client-side navigation works (proves hydration succeeded) + await page.getByRole('navigation').getByRole('link', { name: 'Home' }).click() + await expect(page.getByTestId('index-title')).toBeVisible() + + // Navigate back to sync-only via client-side navigation (use nav link to be specific) + await page + .getByRole('navigation') + .getByRole('link', { name: 'Sync Only' }) + .click() + await expect(page.getByTestId('sync-title')).toBeVisible() +}) + +test('Sync-only route has bootstrap scripts in initial HTML', async ({ + page, +}) => { + // Intercept the response to check the raw HTML + let responseHtml = '' + await page.route('/sync-only', async (route) => { + const response = await route.fetch() + responseHtml = await response.text() + await route.fulfill({ response }) + }) + + await page.goto('/sync-only') + + // Wait for page to load + await expect(page.getByTestId('sync-title')).toBeVisible() + + // Bootstrap, end record, and the boundary rendered by . + expectRouterHydrationMarkers(responseHtml) +}) + +test('Navigating to sync-only from home page', async ({ page }) => { + await page.goto('/') + + await page.getByTestId('link-sync-only').click() + + await expect(page.getByTestId('sync-title')).toContainText( + 'Synchronous Serialization Test', + ) + await expect(page.getByTestId('sync-message')).toContainText( + 'Hello from sync loader!', + ) +}) diff --git a/e2e/vue-start/streaming-ssr/package.json b/e2e/vue-start/streaming-ssr/package.json new file mode 100644 index 00000000000..b810f827b91 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/package.json @@ -0,0 +1,59 @@ +{ + "name": "tanstack-vue-start-e2e-streaming-ssr", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "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" + }, + "dependencies": { + "@tanstack/vue-query": "^5.90.0", + "@tanstack/vue-router": "workspace:^", + "@tanstack/vue-router-ssr-query": "workspace:^", + "@tanstack/vue-start": "workspace:^", + "vue": "^3.5.16" + }, + "devDependencies": { + "@playwright/test": "^1.61.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@vitejs/plugin-vue-jsx": "^5.1.5", + "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" + ] + } + } + } +} diff --git a/e2e/vue-start/streaming-ssr/playwright.config.ts b/e2e/vue-start/streaming-ssr/playwright.config.ts new file mode 100644 index 00000000000..052ebe879d3 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const isPreview = process.env.MODE === 'preview' + +const PORT = await getTestServerPort( + `${packageJson.name}${isPreview ? '_preview' : ''}`, +) +const baseURL = `http://localhost:${PORT}` + +const ssrCommand = `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start` +const previewCommand = `VITE_SERVER_PORT=${PORT} pnpm build && pnpm preview --port ${PORT}` + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command: isPreview ? previewCommand : ssrCommand, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/vue-start/streaming-ssr/src/routeTree.gen.ts b/e2e/vue-start/streaming-ssr/src/routeTree.gen.ts new file mode 100644 index 00000000000..ac4c9ab1562 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routeTree.gen.ts @@ -0,0 +1,282 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// 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 IndexRouteImport } from './routes/index' +import { Route as ConcurrentRouteImport } from './routes/concurrent' +import { Route as DeferredRouteImport } from './routes/deferred' +import { Route as DeferredRejectionRouteImport } from './routes/deferred-rejection' +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 SlowRenderRouteImport } from './routes/slow-render' +import { Route as StreamRouteImport } from './routes/stream' +import { Route as SyncOnlyRouteImport } from './routes/sync-only' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ConcurrentRoute = ConcurrentRouteImport.update({ + id: '/concurrent', + path: '/concurrent', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredRoute = DeferredRouteImport.update({ + id: '/deferred', + path: '/deferred', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredRejectionRoute = DeferredRejectionRouteImport.update({ + id: '/deferred-rejection', + path: '/deferred-rejection', + getParentRoute: () => rootRouteImport, +} as any) +const FastSerialRoute = FastSerialRouteImport.update({ + id: '/fast-serial', + path: '/fast-serial', + getParentRoute: () => rootRouteImport, +} as any) +const ManyPromisesRoute = ManyPromisesRouteImport.update({ + id: '/many-promises', + path: '/many-promises', + getParentRoute: () => rootRouteImport, +} as any) +const NestedDeferredRoute = NestedDeferredRouteImport.update({ + id: '/nested-deferred', + path: '/nested-deferred', + getParentRoute: () => rootRouteImport, +} as any) +const QueryHeavyRoute = QueryHeavyRouteImport.update({ + id: '/query-heavy', + path: '/query-heavy', + getParentRoute: () => rootRouteImport, +} as any) +const SlowRenderRoute = SlowRenderRouteImport.update({ + id: '/slow-render', + path: '/slow-render', + getParentRoute: () => rootRouteImport, +} as any) +const StreamRoute = StreamRouteImport.update({ + id: '/stream', + path: '/stream', + getParentRoute: () => rootRouteImport, +} as any) +const SyncOnlyRoute = SyncOnlyRouteImport.update({ + id: '/sync-only', + path: '/sync-only', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/concurrent': typeof ConcurrentRoute + '/deferred': typeof DeferredRoute + '/deferred-rejection': typeof DeferredRejectionRoute + '/fast-serial': typeof FastSerialRoute + '/many-promises': typeof ManyPromisesRoute + '/nested-deferred': typeof NestedDeferredRoute + '/query-heavy': typeof QueryHeavyRoute + '/slow-render': typeof SlowRenderRoute + '/stream': typeof StreamRoute + '/sync-only': typeof SyncOnlyRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/concurrent': typeof ConcurrentRoute + '/deferred': typeof DeferredRoute + '/deferred-rejection': typeof DeferredRejectionRoute + '/fast-serial': typeof FastSerialRoute + '/many-promises': typeof ManyPromisesRoute + '/nested-deferred': typeof NestedDeferredRoute + '/query-heavy': typeof QueryHeavyRoute + '/slow-render': typeof SlowRenderRoute + '/stream': typeof StreamRoute + '/sync-only': typeof SyncOnlyRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/concurrent': typeof ConcurrentRoute + '/deferred': typeof DeferredRoute + '/deferred-rejection': typeof DeferredRejectionRoute + '/fast-serial': typeof FastSerialRoute + '/many-promises': typeof ManyPromisesRoute + '/nested-deferred': typeof NestedDeferredRoute + '/query-heavy': typeof QueryHeavyRoute + '/slow-render': typeof SlowRenderRoute + '/stream': typeof StreamRoute + '/sync-only': typeof SyncOnlyRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/concurrent' + | '/deferred' + | '/deferred-rejection' + | '/fast-serial' + | '/many-promises' + | '/nested-deferred' + | '/query-heavy' + | '/slow-render' + | '/stream' + | '/sync-only' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/concurrent' + | '/deferred' + | '/deferred-rejection' + | '/fast-serial' + | '/many-promises' + | '/nested-deferred' + | '/query-heavy' + | '/slow-render' + | '/stream' + | '/sync-only' + id: + | '__root__' + | '/' + | '/concurrent' + | '/deferred' + | '/deferred-rejection' + | '/fast-serial' + | '/many-promises' + | '/nested-deferred' + | '/query-heavy' + | '/slow-render' + | '/stream' + | '/sync-only' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ConcurrentRoute: typeof ConcurrentRoute + DeferredRoute: typeof DeferredRoute + DeferredRejectionRoute: typeof DeferredRejectionRoute + FastSerialRoute: typeof FastSerialRoute + ManyPromisesRoute: typeof ManyPromisesRoute + NestedDeferredRoute: typeof NestedDeferredRoute + QueryHeavyRoute: typeof QueryHeavyRoute + SlowRenderRoute: typeof SlowRenderRoute + StreamRoute: typeof StreamRoute + SyncOnlyRoute: typeof SyncOnlyRoute +} + +declare module '@tanstack/vue-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/concurrent': { + id: '/concurrent' + path: '/concurrent' + fullPath: '/concurrent' + preLoaderRoute: typeof ConcurrentRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred': { + id: '/deferred' + path: '/deferred' + fullPath: '/deferred' + preLoaderRoute: typeof DeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred-rejection': { + id: '/deferred-rejection' + path: '/deferred-rejection' + fullPath: '/deferred-rejection' + preLoaderRoute: typeof DeferredRejectionRouteImport + parentRoute: typeof rootRouteImport + } + '/fast-serial': { + id: '/fast-serial' + path: '/fast-serial' + fullPath: '/fast-serial' + preLoaderRoute: typeof FastSerialRouteImport + parentRoute: typeof rootRouteImport + } + '/many-promises': { + id: '/many-promises' + path: '/many-promises' + fullPath: '/many-promises' + preLoaderRoute: typeof ManyPromisesRouteImport + parentRoute: typeof rootRouteImport + } + '/nested-deferred': { + id: '/nested-deferred' + path: '/nested-deferred' + fullPath: '/nested-deferred' + preLoaderRoute: typeof NestedDeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/query-heavy': { + id: '/query-heavy' + path: '/query-heavy' + fullPath: '/query-heavy' + preLoaderRoute: typeof QueryHeavyRouteImport + parentRoute: typeof rootRouteImport + } + '/slow-render': { + id: '/slow-render' + path: '/slow-render' + fullPath: '/slow-render' + preLoaderRoute: typeof SlowRenderRouteImport + parentRoute: typeof rootRouteImport + } + '/stream': { + id: '/stream' + path: '/stream' + fullPath: '/stream' + preLoaderRoute: typeof StreamRouteImport + parentRoute: typeof rootRouteImport + } + '/sync-only': { + id: '/sync-only' + path: '/sync-only' + fullPath: '/sync-only' + preLoaderRoute: typeof SyncOnlyRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ConcurrentRoute: ConcurrentRoute, + DeferredRoute: DeferredRoute, + DeferredRejectionRoute: DeferredRejectionRoute, + FastSerialRoute: FastSerialRoute, + ManyPromisesRoute: ManyPromisesRoute, + NestedDeferredRoute: NestedDeferredRoute, + QueryHeavyRoute: QueryHeavyRoute, + SlowRenderRoute: SlowRenderRoute, + StreamRoute: StreamRoute, + SyncOnlyRoute: SyncOnlyRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/vue-start' +declare module '@tanstack/vue-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/vue-start/streaming-ssr/src/router.tsx b/e2e/vue-start/streaming-ssr/src/router.tsx new file mode 100644 index 00000000000..257eb8dac9b --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/router.tsx @@ -0,0 +1,18 @@ +import { QueryClient } from '@tanstack/vue-query' +import { createRouter } from '@tanstack/vue-router' +import { setupRouterSsrQueryIntegration } from '@tanstack/vue-router-ssr-query' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + const queryClient = new QueryClient() + const router = createRouter({ + routeTree, + context: { queryClient }, + scrollRestoration: true, + }) + setupRouterSsrQueryIntegration({ + router, + queryClient, + }) + return router +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/__root.tsx b/e2e/vue-start/streaming-ssr/src/routes/__root.tsx new file mode 100644 index 00000000000..d3b33397203 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/__root.tsx @@ -0,0 +1,103 @@ +import { + Body, + HeadContent, + Html, + Link, + Outlet, + Scripts, + createRootRouteWithContext, +} from '@tanstack/vue-router' +import { defineComponent, ref } from 'vue' +import type { QueryClient } from '@tanstack/vue-query' + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient +}>()({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Streaming SSR Tests' }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + + + ) +} + +const HydrationCheck = defineComponent({ + setup() { + const status = ref<'pending' | 'hydrated'>('pending') + + return () => ( +
+ + + {status.value} + +
+ ) + }, +}) diff --git a/e2e/vue-start/streaming-ssr/src/routes/concurrent.tsx b/e2e/vue-start/streaming-ssr/src/routes/concurrent.tsx new file mode 100644 index 00000000000..87ed2457683 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/concurrent.tsx @@ -0,0 +1,87 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { Suspense } from 'vue' + +function createConcurrentPromises( + count: number, + delayMs: number, +): Array> { + const sharedPromise = new Promise((resolve) => + setTimeout(resolve, delayMs), + ) + return Array.from({ length: count }, (_, i) => + sharedPromise.then(() => `concurrent-${i + 1}`), + ) +} + +export const Route = createFileRoute('/concurrent')({ + loader: async () => { + const batch1 = createConcurrentPromises(5, 100) + const batch2 = createConcurrentPromises(5, 200) + const batch3 = createConcurrentPromises(5, 300) + + return { + concurrent1_1: batch1[0], + concurrent1_2: batch1[1], + concurrent1_3: batch1[2], + concurrent1_4: batch1[3], + concurrent1_5: batch1[4], + concurrent2_1: batch2[0], + concurrent2_2: batch2[1], + concurrent2_3: batch2[2], + concurrent2_4: batch2[3], + concurrent2_5: batch2[4], + concurrent3_1: batch3[0], + concurrent3_2: batch3[1], + concurrent3_3: batch3[2], + concurrent3_4: batch3[3], + concurrent3_5: batch3[4], + } + }, + component: Concurrent, +}) + +function PromiseItem(props: { promise: Promise; testId: string }) { + return ( + + {{ + default: () => ( + ( +
{value}
+ )} + /> + ), + fallback: () => ( +
Loading...
+ ), + }} +
+ ) +} + +function Concurrent() { + const data = Route.useLoaderData() + + return ( +
+

Concurrent Resolution Test (15 promises in 3 batches)

+

Tests multiple promises resolving at the exact same instant.

+ + + + + + + + + + + + + + + +
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/deferred-rejection.tsx b/e2e/vue-start/streaming-ssr/src/routes/deferred-rejection.tsx new file mode 100644 index 00000000000..c7040850532 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/deferred-rejection.tsx @@ -0,0 +1,47 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { Suspense } from 'vue' + +const deferredErrorMessage = 'Error in deferred object' + +export const Route = createFileRoute('/deferred-rejection')({ + loader: async () => { + return { + deferredData: new Promise((_resolve, reject) => { + setTimeout(() => { + reject(new Error(deferredErrorMessage)) + }, 1000) + }), + } + }, + errorComponent: ({ error }) => ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ), + component: DeferredRejection, +}) + +function DeferredRejection() { + const data = Route.useLoaderData() + + return ( +
+

Deferred Rejection Test

+ + {{ + default: () => ( + ( +
{value}
+ )} + /> + ), + fallback: () => ( +
Loading deferred...
+ ), + }} +
+
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/deferred.tsx b/e2e/vue-start/streaming-ssr/src/routes/deferred.tsx new file mode 100644 index 00000000000..892f9928c90 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/deferred.tsx @@ -0,0 +1,106 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { Suspense } from 'vue' + +const getImmediateData = createServerFn({ method: 'GET' }) + .validator((data: { name: string }) => data) + .handler(({ data }) => { + return { + name: data.name, + timestamp: Date.now(), + source: 'server' as const, + } + }) + +const getSlowData = createServerFn({ method: 'GET' }) + .validator((data: { name: string; delay: number }) => data) + .handler(async ({ data }) => { + await new Promise((r) => setTimeout(r, data.delay)) + return { + name: data.name, + timestamp: Date.now(), + source: 'server' as const, + } + }) + +export const Route = createFileRoute('/deferred')({ + loader: async () => { + return { + deferredData: new Promise<{ message: string; source: string }>((r) => + setTimeout( + () => + r({ + message: 'Deferred data loaded!', + source: typeof window === 'undefined' ? 'server' : 'client', + }), + 1000, + ), + ), + deferredServerData: getSlowData({ + data: { name: 'Slow User', delay: 800 }, + }), + immediateData: await getImmediateData({ data: { name: 'Fast User' } }), + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: Deferred, +}) + +function Deferred() { + const data = Route.useLoaderData() + + return ( +
+

Deferred Data Test

+
+ Immediate: {data.value.immediateData.name} @{' '} + {data.value.immediateData.timestamp} +
+
+ Immediate source: {data.value.immediateData.source} +
+
+ Loader source: {data.value.loaderSource} +
+ + {{ + default: () => ( + ( +
+ {value.message} (source: {value.source}) +
+ )} + /> + ), + fallback: () => ( +
Loading deferred...
+ ), + }} +
+ + {{ + default: () => ( + ( +
+ Server: {value.name} @ {value.timestamp} (source:{' '} + {value.source}) +
+ )} + /> + ), + fallback: () => ( +
Loading server data...
+ ), + }} +
+
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/fast-serial.tsx b/e2e/vue-start/streaming-ssr/src/routes/fast-serial.tsx new file mode 100644 index 00000000000..ea976c7551d --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/fast-serial.tsx @@ -0,0 +1,48 @@ +import { createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' + +const getSmallData = createServerFn({ method: 'GET' }).handler(() => { + return { + value: 'small-data', + timestamp: Date.now(), + source: 'server' as const, + } +}) + +export const Route = createFileRoute('/fast-serial')({ + loader: async () => { + const data = await getSmallData() + return { + serverData: data, + staticData: 'This is static data', + timestamp: Date.now(), + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: FastSerial, +}) + +function FastSerial() { + const data = Route.useLoaderData() + + return ( +
+

Fast Serialization Test

+

This route tests when serialization completes before render.

+
+ Server: {data.value.serverData.value} @{' '} + {data.value.serverData.timestamp} +
+
+ Server function source: {data.value.serverData.source} +
+
+ Loader source: {data.value.loaderSource} +
+
Static: {data.value.staticData}
+
+ Loader timestamp: {data.value.timestamp} +
+
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/index.tsx b/e2e/vue-start/streaming-ssr/src/routes/index.tsx new file mode 100644 index 00000000000..0350eb5d4a3 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/index.tsx @@ -0,0 +1,58 @@ +import { Link, createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/')({ + component: Index, +}) + +function Index() { + return ( +
+

Streaming SSR Test Scenarios

+

This e2e project tests various SSR streaming scenarios:

+
    +
  • + + Sync Only + {' '} + - Tests synchronous serialization with no deferred/streaming data +
  • +
  • + + Deferred Data + {' '} + - Tests deferred promises resolving after initial render +
  • +
  • + + Deferred Rejection + {' '} + - Tests deferred promise rejections render through the error boundary +
  • +
  • + + ReadableStream + {' '} + - Tests streaming data via ReadableStream +
  • +
  • + + Fast Serialization + {' '} + - Tests when serialization completes before render finishes +
  • +
  • + + Slow Render + {' '} + - Tests when render takes longer than serialization +
  • +
  • + + Nested Deferred + {' '} + - Tests nested components with deferred data +
  • +
+
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/many-promises.tsx b/e2e/vue-start/streaming-ssr/src/routes/many-promises.tsx new file mode 100644 index 00000000000..4b666885f9a --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/many-promises.tsx @@ -0,0 +1,115 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { Suspense } from 'vue' + +function createDelayedPromise(value: T, delayMs: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), delayMs)) +} + +export const Route = createFileRoute('/many-promises')({ + loader: async () => { + return { + immediate1: createDelayedPromise('immediate-1', 0), + immediate2: createDelayedPromise('immediate-2', 10), + immediate3: createDelayedPromise('immediate-3', 20), + fast1: createDelayedPromise('fast-1', 50), + fast2: createDelayedPromise('fast-2', 75), + fast3: createDelayedPromise('fast-3', 100), + fast4: createDelayedPromise('fast-4', 125), + medium1: createDelayedPromise('medium-1', 150), + medium2: createDelayedPromise('medium-2', 200), + medium3: createDelayedPromise('medium-3', 250), + slow1: createDelayedPromise('slow-1', 300), + slow2: createDelayedPromise('slow-2', 400), + slow3: createDelayedPromise('slow-3', 500), + verySlow1: createDelayedPromise('very-slow-1', 600), + verySlow2: createDelayedPromise('very-slow-2', 800), + } + }, + component: ManyPromises, +}) + +function PromiseItem(props: { + promise: Promise + testId: string + label: string +}) { + return ( + + {{ + default: () => ( + ( +
+ {props.label}: {value} +
+ )} + /> + ), + fallback: () => ( +
+ Loading {props.label}... +
+ ), + }} +
+ ) +} + +function ManyPromises() { + const data = Route.useLoaderData() + + return ( +
+

Many Promises Test (15 deferred)

+

Tests streaming with many concurrent deferred promises.

+ + + + + + + + + + + + + + + +
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/nested-deferred.tsx b/e2e/vue-start/streaming-ssr/src/routes/nested-deferred.tsx new file mode 100644 index 00000000000..c79af191f1d --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/nested-deferred.tsx @@ -0,0 +1,137 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { Suspense } from 'vue' + +const getLevel1Data = createServerFn({ method: 'GET' }).handler(async () => { + await new Promise((r) => setTimeout(r, 200)) + return { level: 1, timestamp: Date.now() } +}) + +const getLevel2Data = createServerFn({ method: 'GET' }).handler(async () => { + await new Promise((r) => setTimeout(r, 400)) + return { level: 2, timestamp: Date.now() } +}) + +const getLevel3Data = createServerFn({ method: 'GET' }).handler(async () => { + await new Promise((r) => setTimeout(r, 600)) + return { level: 3, timestamp: Date.now() } +}) + +export const Route = createFileRoute('/nested-deferred')({ + loader: async () => { + return { + level1: getLevel1Data(), + level2: getLevel2Data(), + level3: getLevel3Data(), + plainDeferred: new Promise((r) => + setTimeout(() => r('Plain deferred resolved!'), 300), + ), + } + }, + component: NestedDeferred, +}) + +function Level2Content(props: { + level3: Promise<{ level: number; timestamp: number }> +}) { + return ( +
+ + {{ + default: () => ( + ( +
+ Level 3: {value.level} @ {value.timestamp} +
+ )} + /> + ), + fallback: () => ( +
Loading level 3...
+ ), + }} +
+
+ ) +} + +function Level1Content(props: { + level2: Promise<{ level: number; timestamp: number }> + level3: Promise<{ level: number; timestamp: number }> +}) { + return ( +
+ + {{ + default: () => ( + ( +
+ Level 2: {value.level} @ {value.timestamp} + +
+ )} + /> + ), + fallback: () => ( +
Loading level 2...
+ ), + }} +
+
+ ) +} + +function NestedDeferred() { + const data = Route.useLoaderData() + + return ( +
+

Nested Deferred Test

+

+ Tests multiple nested deferred promises resolving at different times. +

+ + {{ + default: () => ( + ( +
{value}
+ )} + /> + ), + fallback: () => ( +
Loading plain...
+ ), + }} +
+
+ + {{ + default: () => ( + ( +
+ Level 1: {value.level} @ {value.timestamp} + +
+ )} + /> + ), + fallback: () => ( +
Loading level 1...
+ ), + }} +
+
+
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/query-heavy.tsx b/e2e/vue-start/streaming-ssr/src/routes/query-heavy.tsx new file mode 100644 index 00000000000..13ec60b92a4 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/query-heavy.tsx @@ -0,0 +1,180 @@ +import { queryOptions, useQuery } from '@tanstack/vue-query' +import { createFileRoute } from '@tanstack/vue-router' +import { Suspense, defineComponent } from 'vue' +import type { PropType } from 'vue' + +type QueryType = 'sync' | 'fast-async' | 'slow-async' + +type QueryData = { + type: QueryType + id: number + value: string + source: string +} + +function makeQueryOptions( + type: QueryType, + id: number, + value: string, + delayMs = 0, +) { + return queryOptions({ + queryKey: ['streaming-ssr-query-heavy', type, id], + queryFn: async (): Promise => { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + + return { + type, + id, + value, + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, + }) +} + +const syncQuery1 = makeQueryOptions('sync', 1, 'sync-value-1') +const syncQuery2 = makeQueryOptions('sync', 2, 'sync-value-2') +const syncQuery3 = makeQueryOptions('sync', 3, 'sync-value-3') +const fastAsyncQuery1 = makeQueryOptions('fast-async', 1, 'fast-async-1', 50) +const fastAsyncQuery2 = makeQueryOptions('fast-async', 2, 'fast-async-2', 75) +const fastAsyncQuery3 = makeQueryOptions('fast-async', 3, 'fast-async-3', 100) +const slowAsyncQuery1 = makeQueryOptions('slow-async', 1, 'slow-async-1', 200) +const slowAsyncQuery2 = makeQueryOptions('slow-async', 2, 'slow-async-2', 300) +const slowAsyncQuery3 = makeQueryOptions('slow-async', 3, 'slow-async-3', 400) + +type QueryOptions = ReturnType + +const QueryDisplay = defineComponent({ + props: { + queryOpts: { + type: Object as PropType, + required: true, + }, + testId: { + type: String, + required: true, + }, + }, + async setup(props) { + const query = useQuery(props.queryOpts) + await query.suspense() + + return () => { + const data = query.data.value + + return ( +
+ {data?.value} (source: {data?.source}) +
+ ) + } + }, +}) + +function QuerySuspense(props: { + queryOpts: QueryOptions + testId: string + fallback: string +}) { + return ( + + {{ + default: () => ( + + ), + fallback: () => ( +
{props.fallback}
+ ), + }} +
+ ) +} + +const QueryHeavy = defineComponent({ + setup() { + return () => ( +
+

Query Heavy Test (9 useQuery calls)

+

Tests multiple useQuery with mixed sync/async queryFn.

+

+ All queries should show "source: server" if SSR streaming works + correctly. +

+ +
+
+

Sync Queries (immediate)

+ + + +
+ +
+

Fast Async Queries (50-100ms)

+ + + +
+ +
+

Slow Async Queries (200-400ms)

+ + + +
+
+
+ ) + }, +}) + +export const Route = createFileRoute('/query-heavy')({ + component: QueryHeavy, +}) diff --git a/e2e/vue-start/streaming-ssr/src/routes/slow-render.tsx b/e2e/vue-start/streaming-ssr/src/routes/slow-render.tsx new file mode 100644 index 00000000000..3edb0152901 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/slow-render.tsx @@ -0,0 +1,78 @@ +import { Await, createFileRoute } from '@tanstack/vue-router' +import { createServerFn } from '@tanstack/vue-start' +import { Suspense } from 'vue' + +const getQuickData = createServerFn({ method: 'GET' }).handler(() => { + return { + name: 'Quick data', + timestamp: Date.now(), + source: 'server' as const, + } +}) + +function SlowComponent(props: { data: string; index: number }) { + const startTime = Date.now() + while (Date.now() - startTime < 100) { + // Simulate slow render work. + } + return
{props.data}
+} + +export const Route = createFileRoute('/slow-render')({ + loader: async () => { + const quickData = await getQuickData() + return { + quickData, + deferredData: new Promise<{ message: string; source: string }>((r) => + setTimeout( + () => + r({ + message: 'Deferred resolved!', + source: typeof window === 'undefined' ? 'server' : 'client', + }), + 50, + ), + ), + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: SlowRender, +}) + +function SlowRender() { + const data = Route.useLoaderData() + + return ( +
+

Slow Render Test

+

Tests when render takes longer than serialization.

+
+ Quick: {data.value.quickData.name} @ {data.value.quickData.timestamp} +
+
+ Quick data source: {data.value.quickData.source} +
+
+ Loader source: {data.value.loaderSource} +
+ + {{ + default: () => ( + ( +
+ {value.message} (source: {value.source}) +
+ )} + /> + ), + fallback: () =>
Loading...
, + }} +
+ + + +
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/src/routes/stream.tsx b/e2e/vue-start/streaming-ssr/src/routes/stream.tsx new file mode 100644 index 00000000000..75eeed51388 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/stream.tsx @@ -0,0 +1,176 @@ +import { Await, createFileRoute, useRouter } from '@tanstack/vue-router' +import { + Suspense, + defineComponent, + onMounted, + onUnmounted, + ref, + watch, +} from 'vue' + +export const Route = createFileRoute('/stream')({ + component: () => , + loader() { + return { + promise: new Promise((resolve) => + setTimeout(() => resolve('promise-resolved'), 150), + ), + stream: new ReadableStream({ + async start(controller) { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 200)) + controller.enqueue(`chunk-${i}`) + } + controller.close() + }, + }), + } + }, +}) + +const decoder = new TextDecoder('utf-8') + +const StreamRoute = defineComponent({ + setup() { + const data = Route.useLoaderData() + const router = useRouter() + const streamData = ref>([]) + const streamComplete = ref(false) + const streamReadCount = ref(0) + let reader: ReadableStreamDefaultReader | undefined + let mounted = false + let activeStream: ReadableStream | undefined + let reading = false + let readTimer: ReturnType | undefined + + function clearReadTimer() { + if (readTimer) { + clearTimeout(readTimer) + readTimer = undefined + } + } + + function scheduleRead(delay = 0) { + if (!mounted || reading) { + return + } + + clearReadTimer() + readTimer = setTimeout(() => { + readTimer = undefined + void readStream() + }, delay) + } + + async function readStream() { + const stream = data.value.stream + if (!mounted || reading || activeStream === stream) { + return + } + + if (stream.locked) { + scheduleRead(10) + return + } + + streamData.value = [] + streamComplete.value = false + + let activeReader: ReadableStreamDefaultReader | undefined + + try { + activeReader = stream.getReader() + reader = activeReader + activeStream = stream + reading = true + streamReadCount.value++ + + let chunk + while (!(chunk = await activeReader.read()).done) { + let value = chunk.value + if (typeof value !== 'string') { + value = decoder.decode(value, { stream: !chunk.done }) + } + streamData.value = [...streamData.value, value] + } + streamComplete.value = true + } catch (e) { + const message = String(e) + if (e instanceof TypeError && message.includes('locked')) { + activeStream = undefined + scheduleRead(10) + } else if (!(e instanceof TypeError && message.includes('cancelled'))) { + console.error('Stream error:', e) + } + } finally { + activeReader?.releaseLock() + if (reader === activeReader) { + reader = undefined + } + reading = false + if (activeStream !== data.value.stream) { + scheduleRead() + } + } + } + + onMounted(() => { + mounted = true + scheduleRead() + }) + watch( + () => data.value.stream, + () => { + activeStream = undefined + scheduleRead() + }, + ) + onUnmounted(() => { + mounted = false + clearReadTimer() + reader?.cancel().catch(() => {}) + reader = undefined + }) + + return () => ( +
+

ReadableStream Test

+ + + {{ + default: () => ( + ( +
{value}
+ )} + /> + ), + fallback: () => ( +
Loading promise...
+ ), + }} +
+
+

Stream chunks:

+
+ {streamData.value.map((chunk, i) => ( +
{chunk}
+ ))} +
+ {streamComplete.value && ( +
Stream complete!
+ )} +
+
+ ) + }, +}) diff --git a/e2e/vue-start/streaming-ssr/src/routes/sync-only.tsx b/e2e/vue-start/streaming-ssr/src/routes/sync-only.tsx new file mode 100644 index 00000000000..c84b582f1f6 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/sync-only.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from '@tanstack/vue-router' + +export const Route = createFileRoute('/sync-only')({ + loader: async () => { + return { + message: 'Hello from sync loader!', + timestamp: Date.now(), + items: ['item-1', 'item-2', 'item-3'], + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: SyncOnly, +}) + +function SyncOnly() { + const data = Route.useLoaderData() + + return ( +
+

Synchronous Serialization Test

+

{data.value.message}

+

Loaded at: {data.value.timestamp}

+

Source: {data.value.source}

+
    + {data.value.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) +} diff --git a/e2e/vue-start/streaming-ssr/tests/client-navigation.spec.ts b/e2e/vue-start/streaming-ssr/tests/client-navigation.spec.ts new file mode 100644 index 00000000000..8b9b3a8dcc5 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/client-navigation.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/client-navigation' diff --git a/e2e/vue-start/streaming-ssr/tests/concurrent.spec.ts b/e2e/vue-start/streaming-ssr/tests/concurrent.spec.ts new file mode 100644 index 00000000000..8625473a58e --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/concurrent.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/concurrent' diff --git a/e2e/vue-start/streaming-ssr/tests/deferred-rejection.spec.ts b/e2e/vue-start/streaming-ssr/tests/deferred-rejection.spec.ts new file mode 100644 index 00000000000..355b5019bc9 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/deferred-rejection.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/deferred-rejection' diff --git a/e2e/vue-start/streaming-ssr/tests/deferred.spec.ts b/e2e/vue-start/streaming-ssr/tests/deferred.spec.ts new file mode 100644 index 00000000000..3145897d7be --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/deferred.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/deferred' diff --git a/e2e/vue-start/streaming-ssr/tests/fast-serial.spec.ts b/e2e/vue-start/streaming-ssr/tests/fast-serial.spec.ts new file mode 100644 index 00000000000..95df0555e26 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/fast-serial.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/fast-serial' diff --git a/e2e/vue-start/streaming-ssr/tests/home.spec.ts b/e2e/vue-start/streaming-ssr/tests/home.spec.ts new file mode 100644 index 00000000000..5d49d34a565 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/home.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/home' diff --git a/e2e/vue-start/streaming-ssr/tests/many-promises.spec.ts b/e2e/vue-start/streaming-ssr/tests/many-promises.spec.ts new file mode 100644 index 00000000000..b747bd7d0aa --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/many-promises.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/many-promises' diff --git a/e2e/vue-start/streaming-ssr/tests/nested-deferred.spec.ts b/e2e/vue-start/streaming-ssr/tests/nested-deferred.spec.ts new file mode 100644 index 00000000000..8df20a8a958 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/nested-deferred.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/nested-deferred' diff --git a/e2e/vue-start/streaming-ssr/tests/preview-streaming.spec.ts b/e2e/vue-start/streaming-ssr/tests/preview-streaming.spec.ts new file mode 100644 index 00000000000..34241cfbaa7 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/preview-streaming.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/preview-streaming' diff --git a/e2e/vue-start/streaming-ssr/tests/query-heavy.spec.ts b/e2e/vue-start/streaming-ssr/tests/query-heavy.spec.ts new file mode 100644 index 00000000000..d15c092f6e3 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/query-heavy.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/query-heavy' diff --git a/e2e/vue-start/streaming-ssr/tests/slow-render.spec.ts b/e2e/vue-start/streaming-ssr/tests/slow-render.spec.ts new file mode 100644 index 00000000000..0d5a87363f5 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/slow-render.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/slow-render' diff --git a/e2e/vue-start/streaming-ssr/tests/stream.spec.ts b/e2e/vue-start/streaming-ssr/tests/stream.spec.ts new file mode 100644 index 00000000000..9dfc6820ad8 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/stream.spec.ts @@ -0,0 +1,22 @@ +import { expect, testWithHydration } from '../../../streaming-ssr-assertions' +import '../../../streaming-ssr-specs/stream' + +testWithHydration( + 'reads a replacement stream after an active read', + async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + await page.getByRole('link', { name: 'Stream' }).first().click() + await expect(page.getByTestId('stream-chunk-0')).toHaveText('chunk-0') + await expect(page.getByTestId('stream-complete')).toHaveCount(0) + + await page.getByTestId('refresh-stream').click() + + const streamData = page.getByTestId('stream-data') + await expect(streamData).toHaveAttribute('data-read-count', '2') + await expect(page.getByTestId('stream-complete')).toBeVisible() + await expect( + streamData.locator('[data-testid^="stream-chunk-"]'), + ).toHaveText(['chunk-0', 'chunk-1', 'chunk-2', 'chunk-3', 'chunk-4']) + }, +) diff --git a/e2e/vue-start/streaming-ssr/tests/sync-only.spec.ts b/e2e/vue-start/streaming-ssr/tests/sync-only.spec.ts new file mode 100644 index 00000000000..b25572f6158 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/sync-only.spec.ts @@ -0,0 +1,7 @@ +import '../../../streaming-ssr-specs/sync-only' +import { expect, test } from '../../../streaming-ssr-assertions' + +test('Vue renders an unwrapped document root', async ({ request }) => { + const response = await request.get('/sync-only') + expect(await response.text()).toMatch(/^)/) +}) diff --git a/e2e/vue-start/streaming-ssr/tsconfig.json b/e2e/vue-start/streaming-ssr/tsconfig.json new file mode 100644 index 00000000000..390dad84c06 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "vue", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/vue-start/streaming-ssr/vite.config.ts b/e2e/vue-start/streaming-ssr/vite.config.ts new file mode 100644 index 00000000000..6519ec54c8f --- /dev/null +++ b/e2e/vue-start/streaming-ssr/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/vue-start/plugin/vite' +import vueJsx from '@vitejs/plugin-vue-jsx' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [tanstackStart(), vueJsx()], +}) diff --git a/nx.json b/nx.json index e521988aecb..ee5a31b29ec 100644 --- a/nx.json +++ b/nx.json @@ -21,6 +21,10 @@ "transitive": true } ], + "streamingSsrTests": [ + "{workspaceRoot}/e2e/streaming-ssr-assertions.ts", + "{workspaceRoot}/e2e/streaming-ssr-specs/**/*" + ], "default": [ "sharedGlobals", "{projectRoot}/**/*", diff --git a/packages/react-router-ssr-query/package.json b/packages/react-router-ssr-query/package.json index dba26ffbdfc..2669583f7e5 100644 --- a/packages/react-router-ssr-query/package.json +++ b/packages/react-router-ssr-query/package.json @@ -88,7 +88,7 @@ "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "@tanstack/query-core": ">=5.102.0", - "@tanstack/react-router": ">=1.127.0", + "@tanstack/react-router": ">=1.170.33", "@tanstack/react-query": ">=5.102.0" } } diff --git a/packages/react-router/src/Scripts.tsx b/packages/react-router/src/Scripts.tsx index a1b189d4c6a..8ebe7eb96f1 100644 --- a/packages/react-router/src/Scripts.tsx +++ b/packages/react-router/src/Scripts.tsx @@ -11,7 +11,8 @@ type ScriptRenderAsset = RouterManagedTag & { /** * Render body script tags collected from route matches and SSR manifests. - * Should be placed near the end of the document body. + * During streaming SSR, `` marks where late hydration scripts may + * begin to be inserted. */ export const Scripts = () => { const router = useRouter() @@ -65,26 +66,26 @@ export const Scripts = () => { if (isServer ?? router.isServer) { const activeMatches = router.stores.matches.get() const scripts = getScripts(activeMatches) - return renderScripts(router, scripts) + const initialHydrationScripts = + router.serverSsr?.takeInitialHydrationScriptTags() + return renderScripts( + initialHydrationScripts + ? [ + ...initialHydrationScripts.before, + ...scripts, + initialHydrationScripts.boundary, + ] + : scripts, + ) } // eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static const scripts = useStore(router.stores.matches, getScripts, deepEqual) - return renderScripts(router, scripts) + return renderScripts(scripts) } -function renderScripts( - router: ReturnType, - scripts: Array, -) { - if ((isServer ?? router.isServer) && router.serverSsr) { - const serverBufferedScript = router.serverSsr.takeBufferedScripts() - if (serverBufferedScript) { - scripts.unshift(serverBufferedScript) - } - } - +function renderScripts(scripts: Array) { return ( <> {scripts.map((asset, i) => ( diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 6637c7542bc..3a7eae70f2d 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -204,7 +204,6 @@ export type { RegisteredRouter, RouterContextOptions, ControllablePromise, - InjectedHtmlEntry, RouterOptions, RouterState, ListenerFn, diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index abfe36b86de..b55fc35405e 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -1,46 +1,15 @@ -import { PassThrough } from 'node:stream' +import { PassThrough, Readable } from 'node:stream' import ReactDOMServer from 'react-dom/server' import { isbot } from 'isbot' import { createSsrStreamResponse, - transformPipeableStreamWithRouter, + getSsrStatus, transformReadableStreamWithRouter, + waitForRequest, } from '@tanstack/router-core/ssr/server' import type { AnyRouter } from '@tanstack/router-core' -import type { ReadableStream } from 'node:stream/web' import type { ReactNode } from 'react' -const noop = () => {} - -// Bot responses wait for `allReady` so crawlers receive complete HTML. -// If the request disconnects during that wait, React may not settle quickly; -// unblock the wait so the response pipeline can abort and clean up. -async function waitForReadyOrAbort( - ready: Promise, - signal: AbortSignal, -) { - let cleanup = noop - try { - await Promise.race([ - ready, - new Promise((resolve) => { - const onAbort = () => resolve() - cleanup = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) resolve() - }), - ]) - } finally { - cleanup() - } -} - -// A client disconnecting mid-stream is normal operation, not a render -// failure; don't let React's onError log it as one. -const isAbortError = (request: Request, error: unknown) => - (request.signal.aborted && error === request.signal.reason) || - (error instanceof Error && error.name === 'AbortError') - export const renderRouterToStream = async ({ request, router, @@ -52,169 +21,116 @@ export const renderRouterToStream = async ({ responseHeaders: Headers children: ReactNode }) => { - if (typeof ReactDOMServer.renderToReadableStream === 'function') { - const stream = await ReactDOMServer.renderToReadableStream(children, { - signal: request.signal, - nonce: router.options.ssr?.nonce, - progressiveChunkSize: Number.POSITIVE_INFINITY, - onError: (error, info) => { - if (!isAbortError(request, error)) { - console.error('Error in renderToReadableStream:', error, info) - } - }, - }) - - if (isbot(request.headers.get('User-Agent'))) { - await waitForReadyOrAbort(stream.allReady, request.signal) - } - - const responseStream = transformReadableStreamWithRouter( - router, - stream as unknown as ReadableStream, - { - signal: request.signal, - onAbort: () => stream.cancel().catch(() => {}), - }, - ) - return createSsrStreamResponse( - router, - new Response(responseStream as any, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, - headers: responseHeaders, - }), - ) + const signal = request.signal + if (signal.aborted) { + router.serverSsr?.cleanup() + throw signal.reason } - - if (typeof ReactDOMServer.renderToPipeableStream === 'function') { - const reactAppPassthrough = new PassThrough() - - let pipeable: - | ReturnType - | undefined - let responseAttached = false - let aborted = false - let endedBeforeAttach = false - let pendingAbortReason: unknown - const toError = (reason: unknown) => - reason instanceof Error - ? reason - : new Error(String(reason ?? 'SSR aborted')) - const destroyError = (reason: unknown) => - reason === undefined ? undefined : toError(reason) - const pendingDestroyError = () => - pendingAbortReason === undefined - ? toError(pendingAbortReason) - : destroyError(pendingAbortReason) - const finishPassThrough = ( - reason: unknown, - opts?: { defaultError?: boolean }, - ) => { - if (reactAppPassthrough.destroyed) return - if (responseAttached) { - reactAppPassthrough.destroy( - opts?.defaultError ? toError(reason) : destroyError(reason), - ) - } else { - endedBeforeAttach = true - // onError can fire synchronously before React returns the pipeable - // handle and before Readable.toWeb() is attached. Defer touching the - // PassThrough until after the router transform can observe the error. - } - } - const abortPipeable = ( - reason?: unknown, - opts?: { defaultError?: boolean }, - ) => { - if (aborted) return - aborted = true - pendingAbortReason = reason - const err = toError(reason) - try { - pipeable?.abort(err) - } catch { - // ignore — React may throw if already aborted/finished + let rendererTeardown = false + const bot = isbot(request.headers.get('User-Agent')) + const onError = + ( + renderer: string, + ): ReactDOMServer.RenderToPipeableStreamOptions['onError'] => + (error, info) => { + if (!rendererTeardown && !signal.aborted) { + console.error(`Error in ${renderer}:`, error, info) } - finishPassThrough(reason, opts) } - // Register before attaching the router transform; the transform may - // synchronously cleanup/error, and cleanup must still remove this listener. - if (request.signal.aborted) { - abortPipeable(request.signal.reason) - } else { - const onRequestAbort = () => abortPipeable(request.signal.reason) - request.signal.addEventListener('abort', onRequestAbort, { once: true }) - router.serverSsr?.onCleanup(() => { - request.signal.removeEventListener('abort', onRequestAbort) - }) - } - - try { - pipeable = ReactDOMServer.renderToPipeableStream(children, { + try { + if (typeof ReactDOMServer.renderToReadableStream === 'function') { + const stream = await ReactDOMServer.renderToReadableStream(children, { + signal, nonce: router.options.ssr?.nonce, progressiveChunkSize: Number.POSITIVE_INFINITY, - ...(isbot(request.headers.get('User-Agent')) - ? { - onAllReady() { - pipeable!.pipe(reactAppPassthrough) - }, - } - : { - onShellReady() { - pipeable!.pipe(reactAppPassthrough) - }, - }), - onError: (error, info) => { - if (!isAbortError(request, error)) { - console.error('Error in renderToPipeableStream:', error, info) - } - abortPipeable(error, { defaultError: true }) + onError: onError('renderToReadableStream'), + }) + // The transform owns the reader and bounds bot readiness by its lifetime. + const rendererAbort = bot ? new AbortController() : undefined + const responseStream = transformReadableStreamWithRouter(router, stream, { + rendererSafePoint: 'script-close', + signal, + onAbort: (reason) => { + rendererTeardown = true + rendererAbort?.abort(reason) }, }) - } catch (e) { - console.error('Error in renderToPipeableStream:', e) - router.serverSsr?.cleanup() - throw e + if (rendererAbort) { + await waitForRequest(stream.allReady, rendererAbort.signal) + } + return createSsrStreamResponse( + router, + new Response(responseStream, { + status: getSsrStatus(router), + headers: responseHeaders, + }), + ) } - const responseStream = transformPipeableStreamWithRouter( - router, - reactAppPassthrough, - { signal: request.signal, onAbort: abortPipeable }, - ) - responseAttached = true + if (typeof ReactDOMServer.renderToPipeableStream === 'function') { + const reactAppPassthrough = new PassThrough() + let pipeable: + | ReturnType + | undefined + let resolveReady!: () => void + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + const rendererAbort = new AbortController() + const abortPipeable = (reason?: unknown) => { + if (rendererTeardown) { + return + } + rendererTeardown = true + rendererAbort.abort(reason) + try { + pipeable?.abort(reason) + } catch { + // ignore — React may throw if already aborted/finished + } + } + try { + pipeable = ReactDOMServer.renderToPipeableStream(children, { + nonce: router.options.ssr?.nonce, + progressiveChunkSize: Number.POSITIVE_INFINITY, + ...(bot + ? { onAllReady: resolveReady } + : { onShellReady: resolveReady }), + onError: onError('renderToPipeableStream'), + onShellError: (error) => rendererAbort.abort(error), + }) + const responseStream = transformReadableStreamWithRouter( + router, + Readable.toWeb(reactAppPassthrough), + { + rendererSafePoint: 'script-close', + signal, + onAbort: abortPipeable, + }, + ) - if (endedBeforeAttach) { - reactAppPassthrough.destroy(pendingDestroyError()) - } + await waitForRequest(ready, rendererAbort.signal) + pipeable.pipe(reactAppPassthrough) - // React's onError may have fired synchronously inside - // renderToPipeableStream before `pipeable` was assigned. If so, - // abortPipeable ran without a pipeable handle; re-apply the abort now. - if (aborted && pipeable) { - try { - pipeable.abort(toError(pendingAbortReason)) - } catch { - // ignore — React may throw if already aborted/finished + return createSsrStreamResponse( + router, + new Response(responseStream, { + status: getSsrStatus(router), + headers: responseHeaders, + }), + ) + } catch (error) { + abortPipeable(error) + throw error } } - return createSsrStreamResponse( - router, - new Response(responseStream as any, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, - headers: responseHeaders, - }), + throw new Error( + 'No renderToReadableStream or renderToPipeableStream found in react-dom/server. Ensure you are using a version of react-dom that supports streaming.', ) + } catch (error) { + router.serverSsr?.cleanup() + throw error } - - throw new Error( - 'No renderToReadableStream or renderToPipeableStream found in react-dom/server. Ensure you are using a version of react-dom that supports streaming.', - ) } diff --git a/packages/react-router/src/ssr/renderRouterToString.tsx b/packages/react-router/src/ssr/renderRouterToString.tsx index 5e299cc2159..b3529ebf96e 100644 --- a/packages/react-router/src/ssr/renderRouterToString.tsx +++ b/packages/react-router/src/ssr/renderRouterToString.tsx @@ -1,4 +1,8 @@ import ReactDOMServer from 'react-dom/server' +import { + getSsrStatus, + transformHtmlStringWithRouter, +} from '@tanstack/router-core/ssr/server' import type { ReactNode } from 'react' import type { AnyRouter } from '@tanstack/router-core' @@ -12,19 +16,13 @@ export const renderRouterToString = async ({ children: ReactNode }) => { try { - let html = ReactDOMServer.renderToString(children) - router.serverSsr!.setRenderFinished() + const html = await transformHtmlStringWithRouter( + router, + ReactDOMServer.renderToString(children), + ) - const injectedHtml = router.serverSsr!.takeBufferedHtml() - if (injectedHtml) { - html = html.replace(``, () => `${injectedHtml}`) - } - - return new Response(`${html}`, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, + return new Response(html, { + status: getSsrStatus(router), headers: responseHeaders, }) } catch (error) { diff --git a/packages/react-router/src/useRouter.tsx b/packages/react-router/src/useRouter.tsx index 925cba411ed..6708274c117 100644 --- a/packages/react-router/src/useRouter.tsx +++ b/packages/react-router/src/useRouter.tsx @@ -18,12 +18,18 @@ export function useRouter(opts?: { warn?: boolean }): TRouter { const value = React.useContext(routerContext) - if (process.env.NODE_ENV !== 'production') { - if ((opts?.warn ?? true) && !value) { - console.warn( - 'Warning: useRouter must be used inside a component!', - ) - } + if (!value) { + warnMissingRouter(opts) } return value as any } + +// Kept out of the hook body: this dev-only branch runs for every Link and +// route hook, and on the server each `process.env` read is a native call. +function warnMissingRouter(opts?: { warn?: boolean }) { + if (process.env.NODE_ENV !== 'production' && (opts?.warn ?? true)) { + console.warn( + 'Warning: useRouter must be used inside a component!', + ) + } +} diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 442a94bc103..441610a2e7d 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -8,6 +8,7 @@ import { Link, Outlet, RouterProvider, + Scripts, createBrowserHistory, createControlledPromise, createLazyRoute, @@ -528,6 +529,16 @@ test.each(['beforeLoad', 'loader'] as const)( test('SSR errorComponent receives primitive errors thrown from beforeLoad', async () => { const rootRoute = createRootRoute({ + shellComponent: function RootDocument({ children }) { + return ( + + + {children} + + + + ) + }, component: function Root() { return }, @@ -717,7 +728,19 @@ test('SSR renders a later fresh ancestor loader failure', async () => { const parentGate = createControlledPromise() const childGate = createControlledPromise() const childSettled = createControlledPromise() - const rootRoute = createRootRoute({ component: Outlet }) + const rootRoute = createRootRoute({ + shellComponent: function RootDocument({ children }) { + return ( + + + {children} + + + + ) + }, + component: Outlet, + }) const parentRoute = createRoute({ getParentRoute: () => rootRoute, path: '/parent', @@ -782,7 +805,10 @@ test('#4684: SSR renders head content when beforeLoad throws', async () => { - {children} + + {children} + + ) }, diff --git a/packages/react-router/tests/not-found.test.tsx b/packages/react-router/tests/not-found.test.tsx index f27057c7abc..436c3866948 100644 --- a/packages/react-router/tests/not-found.test.tsx +++ b/packages/react-router/tests/not-found.test.tsx @@ -5,6 +5,7 @@ import { Link, Outlet, RouterProvider, + Scripts, createBrowserHistory, createControlledPromise, createLazyRoute, @@ -111,6 +112,13 @@ test('a lazy route notFoundComponent handles an eager beforeLoad failure', async test('SSR uses a lazy route notFoundComponent for an eager beforeLoad failure', async () => { const rootRoute = createRootRoute({ + // Streaming SSR requires the boundary in the shell. + shellComponent: ({ children }) => ( + <> + {children} + + + ), component: Outlet, notFoundComponent: () =>
Root not found
, }) @@ -154,6 +162,14 @@ test.each(['client', 'server'] as const)( 'a lazy child boundary handles a fuzzy URL miss on the %s', async (environment) => { const rootRoute = createRootRoute({ + // Streaming SSR requires the boundary in the shell; the + // client render outputs no scripts here. + shellComponent: ({ children }) => ( + <> + {children} + + + ), component: Outlet, notFoundComponent: () =>
Root fuzzy boundary
, }) diff --git a/packages/react-router/tests/renderRouterToStream.react19.test.tsx b/packages/react-router/tests/renderRouterToStream.react19.test.tsx new file mode 100644 index 00000000000..426996d60e9 --- /dev/null +++ b/packages/react-router/tests/renderRouterToStream.react19.test.tsx @@ -0,0 +1,227 @@ +import { Suspense } from 'react' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' +import { + RouterContextProvider, + Scripts, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' +import type * as ReactDOMServer from 'react-dom/server' +import type { AnyRouter } from '@tanstack/router-core' + +/** + * Load the adapter against the real React 19 renderer. Node's `react-dom` + * exports both entry points; hiding `renderToReadableStream` exercises the + * pipeable path that every Node user on React < 19.2 runs. + */ +async function loadRenderRouterToStream(path: 'readable' | 'pipeable') { + vi.resetModules() + if (path === 'pipeable') { + vi.doMock('react-dom/server', async (importOriginal) => { + // The runtime module is CommonJS, so its interop `default` is the namespace. + const actual = await importOriginal<{ default: typeof ReactDOMServer }>() + return { + ...actual, + default: { ...actual.default, renderToReadableStream: undefined }, + } + }) + } else { + vi.doUnmock('react-dom/server') + } + const reactDomServer = (await import('react-dom/server')).default + const renderToPipeableStream = vi.spyOn( + reactDomServer, + 'renderToPipeableStream', + ) + const { renderRouterToStream } = + await import('../src/ssr/renderRouterToStream') + return { renderRouterToStream, renderToPipeableStream } +} + +const activeRouters: Array = [] + +afterEach(() => { + for (const router of activeRouters.splice(0)) { + router.serverSsr?.cleanup() + } +}) + +async function buildRouter(dehydratedData: { routerData: Promise }) { + const rootRoute = createRootRoute({ component: () => null }) + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + routeTree: rootRoute, + dehydrate: () => dehydratedData, + }) + activeRouters.push(router) + router.isServer = true + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr!.dehydrate() + return router +} + +function createSuspendingComponent(id: string, text: string) { + let ready = false + let resolve!: () => void + const pending = new Promise((done) => { + resolve = () => { + ready = true + done() + } + }) + + return { + Component() { + if (!ready) { + throw pending + } + return
{text}
+ }, + resolve, + } +} + +async function readWithTimeout( + reader: ReadableStreamDefaultReader, +) { + let timeout: ReturnType | undefined + try { + return await Promise.race([ + reader.read(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error('Timed out waiting for React SSR output')), + 2000, + ) + }), + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +async function readUntil( + reader: ReadableStreamDefaultReader, + decoder: TextDecoder, + state: { html: string }, + condition: (html: string) => boolean, +) { + while (!condition(state.html)) { + const { done, value } = await readWithTimeout(reader) + if (done) { + throw new Error('React SSR response ended before the expected output') + } + state.html += decoder.decode(value, { stream: true }) + } +} + +describe.each(['readable', 'pipeable'] as const)( + 'renderRouterToStream - real React 19 renderer (%s)', + (path) => { + test('emits router data after a real Suspense patch while React is still rendering', async () => { + const { renderRouterToStream, renderToPipeableStream } = + await loadRenderRouterToStream(path) + let resolveRouterData!: (value: string) => void + const routerData = new Promise((resolve) => { + resolveRouterData = resolve + }) + const router = await buildRouter({ routerData }) + const first = createSuspendingComponent('first-result', 'first-resolved') + const second = createSuspendingComponent( + 'second-result', + 'second-resolved', + ) + const requestController = new AbortController() + + const { response } = await renderRouterToStream({ + request: new Request('http://localhost/', { + signal: requestController.signal, + }), + router, + responseHeaders: new Headers(), + children: ( + + + + first-fallback

}> + +
+ second-fallback

}> + +
+ +
+ + + ), + }) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + const state = { html: '' } + + try { + // The shell ends with , whose last child is the boundary. + await readUntil(reader, decoder, state, (html) => { + const shell = html.indexOf('second-fallback') + return shell >= 0 && html.indexOf('', shell) >= 0 + }) + expect(state.html).toContain('first-fallback') + + first.resolve() + await readUntil(reader, decoder, state, (html) => { + const result = html.indexOf('first-resolved') + return result >= 0 && html.indexOf('', result) >= 0 + }) + + const firstResult = state.html.indexOf('first-resolved') + const reactPatchEnd = + state.html.indexOf('', firstResult) + ''.length + const reactPatch = state.html.slice(firstResult, reactPatchEnd) + expect(reactPatch).toContain('')).toBe(true) + expect(state.html).not.toContain('second-resolved') + expect(state.html).not.toContain('') + + resolveRouterData('late-react19-router-value') + await readUntil(reader, decoder, state, (html) => + html.includes('late-react19-router-value'), + ) + + // The second Suspense boundary still keeps React's renderer open. The + // router batch can therefore only have used React's completed patch + // script as its insertion point. + const routerValue = state.html.indexOf('late-react19-router-value') + expect(routerValue).toBeGreaterThan(reactPatchEnd) + expect(state.html).not.toContain('second-resolved') + expect(state.html).not.toContain('') + + second.resolve() + for (;;) { + const { done, value } = await readWithTimeout(reader) + if (done) { + break + } + state.html += decoder.decode(value, { stream: true }) + } + state.html += decoder.decode() + + expect(state.html).toContain('second-resolved') + expect(state.html.endsWith('')).toBe(true) + expect(router.serverSsr).toBeUndefined() + expect(renderToPipeableStream).toHaveBeenCalledTimes( + path === 'pipeable' ? 1 : 0, + ) + } finally { + first.resolve() + second.resolve() + requestController.abort(new Error('test-complete')) + await reader.cancel().catch(() => {}) + } + }) + }, +) diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index f42456b7bec..cde31d0b6a9 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -1,30 +1,63 @@ -import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' import { createMemoryHistory, createRootRoute, createRouter } from '../src' +import type { RouterManagedTag } from '@tanstack/router-core' +import type * as RouterSsrServer from '@tanstack/router-core/ssr/server' +import type * as ReactDomServer from 'react-dom/server' + +type ReadableRenderOptions = NonNullable< + Parameters[1] +> const reactDomServerMocks = vi.hoisted(() => ({ - renderToReadableStream: undefined as undefined | (() => unknown), + renderToReadableStream: undefined as + | undefined + | ((children: unknown, options: ReadableRenderOptions) => unknown), renderToPipeableStream: vi.fn(), })) +const routerTransformMocks = vi.hoisted(() => ({ + readableOptions: vi.fn(), +})) + +vi.mock('@tanstack/router-core/ssr/server', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + transformReadableStreamWithRouter: ( + ...args: Parameters + ) => { + routerTransformMocks.readableOptions(args[2]) + return actual.transformReadableStreamWithRouter(...args) + }, + } +}) vi.mock('react-dom/server', () => ({ default: reactDomServerMocks, ...reactDomServerMocks, })) +const actualReactDomServer = await vi.importActual( + 'react-dom/server.node', +) + const { renderRouterToStream } = await import('../src/ssr/renderRouterToStream') afterEach(() => { reactDomServerMocks.renderToReadableStream = undefined reactDomServerMocks.renderToPipeableStream.mockReset() + routerTransformMocks.readableOptions.mockReset() vi.restoreAllMocks() }) -async function buildRouter() { +async function buildRouter(dehydratedData?: { lateData: Promise }) { const rootRoute = createRootRoute({ component: () => null }) const router = createRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), routeTree: rootRoute, + ...(dehydratedData === undefined + ? {} + : { dehydrate: () => dehydratedData }), }) router.isServer = true attachRouterServerSsrUtils({ router, manifest: undefined }) @@ -34,15 +67,7 @@ async function buildRouter() { } async function expectBodyRejects(response: Response, message: string) { - const reader = response.body!.getReader() - await expect( - (async () => { - for (;;) { - const { done } = await reader.read() - if (done) return - } - })(), - ).rejects.toThrow(message) + await expect(response.text()).rejects.toThrow(message) } function unwrapResponse( @@ -51,47 +76,324 @@ function unwrapResponse( return result.response } -describe('renderRouterToStream - pipeable sync errors', () => { - test('request abort cancels readable rendering without consuming the response body', async () => { - const cancel = vi.fn() - const stream = Object.assign(new ReadableStream({ cancel }), { - allReady: Promise.resolve(), - }) - reactDomServerMocks.renderToReadableStream = vi.fn(() => stream) +function renderScript(tag: RouterManagedTag) { + const attrs = tag.attrs ?? {} + const id = attrs.id ? ` id="${attrs.id}"` : '' + const nonce = attrs.nonce ? ` nonce="${attrs.nonce}"` : '' + const streamPart = + 'data-tsr-stream-part' in attrs ? ' data-tsr-stream-part=""' : '' + return `${tag.children ?? ''}` +} + +function takeInitialScriptHtml( + router: Awaited>, +) { + const scripts = router.serverSsr!.takeInitialHydrationScriptTags()! + return [...scripts.before, scripts.boundary].map(renderScript).join('') +} + +async function readWithTimeout( + reader: ReadableStreamDefaultReader, +) { + let timeout: ReturnType | undefined + try { + return await Promise.race([ + reader.read(), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error('Timed out waiting for streamed HTML')), + 2000, + ) + }), + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +async function readUntil( + reader: ReadableStreamDefaultReader, + decoder: TextDecoder, + state: { html: string }, + needle: string, +) { + while (!state.html.includes(needle)) { + const { done, value } = await readWithTimeout(reader) + if (done) { + throw new Error(`Response ended before it contained ${needle}`) + } + state.html += decoder.decode(value, { stream: true }) + } +} +describe('renderRouterToStream - renderer selection and pipeable errors', () => { + test('already-aborted requests do not start the readable renderer', async () => { + const readable = vi.fn() + reactDomServerMocks.renderToReadableStream = readable + const controller = new AbortController() + controller.abort(new Error('already-gone-readable')) const router = await buildRouter() + + try { + await expect( + renderRouterToStream({ + request: new Request('http://localhost/', { + signal: controller.signal, + }), + router, + responseHeaders: new Headers(), + children: null, + }), + ).rejects.toThrow('already-gone-readable') + + expect(readable).not.toHaveBeenCalled() + expect(reactDomServerMocks.renderToPipeableStream).not.toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() + } finally { + router.serverSsr?.cleanup() + } + }) + + test('already-aborted requests do not start the pipeable renderer', async () => { const controller = new AbortController() - onTestFinished(() => { + controller.abort(new Error('already-gone-pipeable')) + const router = await buildRouter() + + try { + await expect( + renderRouterToStream({ + request: new Request('http://localhost/', { + signal: controller.signal, + }), + router, + responseHeaders: new Headers(), + children: null, + }), + ).rejects.toThrow('already-gone-pipeable') + + expect(reactDomServerMocks.renderToPipeableStream).not.toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() + } finally { + router.serverSsr?.cleanup() + } + }) + + test('disables React progressive chunking for the readable renderer', async () => { + const router = await buildRouter() + const renderedScripts = takeInitialScriptHtml(router) + const rendererStream = Object.assign( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(renderedScripts)) + controller.close() + }, + }), + { allReady: Promise.resolve() }, + ) + const renderToReadableStream = vi.fn(() => Promise.resolve(rendererStream)) + reactDomServerMocks.renderToReadableStream = renderToReadableStream + + try { + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }), + ) + await response.text() + + expect(renderToReadableStream).toHaveBeenCalledWith( + null, + expect.objectContaining({ + progressiveChunkSize: Number.POSITIVE_INFINITY, + }), + ) + } finally { router.serverSsr?.cleanup() + } + }) + + test('disables React progressive chunking for the pipeable renderer', async () => { + const router = await buildRouter() + const renderedScripts = takeInitialScriptHtml(router) + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + (_children, options) => { + queueMicrotask(options.onShellReady) + return { + abort: vi.fn(), + pipe(destination: NodeJS.WritableStream) { + destination.write(renderedScripts) + destination.end() + }, + } + }, + ) + + try { + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: null, + }), + ) + await response.text() + + expect( + reactDomServerMocks.renderToPipeableStream.mock.calls[0]![1], + ).toEqual( + expect.objectContaining({ + progressiveChunkSize: Number.POSITIVE_INFINITY, + }), + ) + } finally { + router.serverSsr?.cleanup() + } + }) + + test('emits late router data after a complete React patch while rendering is still open', async () => { + let resolveLateData!: (value: string) => void + const lateData = new Promise((resolve) => { + resolveLateData = resolve }) + const router = await buildRouter({ lateData }) + const renderedScripts = takeInitialScriptHtml(router) + + let rendererController!: ReadableStreamDefaultController + let rendererClosed = false + const rendererStream = Object.assign( + new ReadableStream({ + start(controller) { + rendererController = controller + }, + }), + { allReady: new Promise(() => {}) }, + ) + reactDomServerMocks.renderToReadableStream = vi.fn(() => + Promise.resolve(rendererStream), + ) + const patch = + '' + + '' const response = unwrapResponse( await renderRouterToStream({ - request: new Request('http://localhost/', { - signal: controller.signal, - }), + request: new Request('http://localhost/'), router, responseHeaders: new Headers(), children: null, }), ) + const reader = response.body!.getReader() + const decoder = new TextDecoder() + const state = { html: '' } + + try { + rendererController.enqueue( + new TextEncoder().encode( + `${renderedScripts}${patch}`, + ), + ) + await readUntil(reader, decoder, state, patch) + + resolveLateData('late-react-router-value') + await readUntil(reader, decoder, state, 'late-react-router-value') - expect(response.body).not.toBeNull() - controller.abort(new Error('request-gone')) - await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()) + // Close React only after its patch allowed the late router value through. + rendererClosed = true + rendererController.close() + for (;;) { + const { done, value } = await readWithTimeout(reader) + if (done) { + break + } + state.html += decoder.decode(value, { stream: true }) + } + state.html += decoder.decode() + + expect(state.html.indexOf(patch)).toBeLessThan( + state.html.indexOf('late-react-router-value'), + ) + expect(state.html.indexOf('late-react-router-value')).toBeLessThan( + state.html.indexOf(''), + ) + expect(routerTransformMocks.readableOptions).toHaveBeenCalledWith( + expect.objectContaining({ rendererSafePoint: 'script-close' }), + ) + } finally { + if (!rendererClosed) { + try { + rendererController.close() + } catch { + // The response may already have cancelled the renderer. + } + } + router.serverSsr?.cleanup() + } }) - test('sync onError before pipeable is assigned still aborts pipeable', async () => { - const abort = vi.fn() - reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( - (_children, opts) => { - opts.onError(new Error('sync-react-error'), { componentStack: '' }) - return { abort, pipe: vi.fn() } - }, + test('prefers the readable renderer when both APIs exist', async () => { + const cancelReadable = vi.fn() + const readableStream = Object.assign( + new ReadableStream({ + cancel: cancelReadable, + }), + { allReady: Promise.resolve() }, ) + const readable = vi.fn(() => Promise.resolve(readableStream)) + reactDomServerMocks.renderToReadableStream = readable + + const router = await buildRouter() + const requestController = new AbortController() + try { + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/', { + signal: requestController.signal, + }), + router, + responseHeaders: new Headers(), + children: null, + }), + ) + + expect(readable).toHaveBeenCalledOnce() + expect(reactDomServerMocks.renderToPipeableStream).not.toHaveBeenCalled() + expect(routerTransformMocks.readableOptions).toHaveBeenCalledWith( + expect.objectContaining({ rendererSafePoint: 'script-close' }), + ) + + requestController.abort(new Error('test-complete')) + await expectBodyRejects(response, 'test-complete') + expect(cancelReadable).toHaveBeenCalledOnce() + } finally { + router.serverSsr?.cleanup() + } + }) + + test('readable renderer cancellation does not log a render error', async () => { + const cancelReadable = vi.fn() + reactDomServerMocks.renderToReadableStream = vi.fn((_children, options) => { + const readableStream = Object.assign( + new ReadableStream({ + cancel(reason) { + cancelReadable(reason) + options.onError?.(reason, { componentStack: '' }) + }, + }), + { allReady: Promise.resolve() }, + ) + return Promise.resolve(readableStream) + }) const router = await buildRouter() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const reason = new Error('readable-consumer-gone') try { const response = unwrapResponse( await renderRouterToStream({ @@ -102,83 +404,313 @@ describe('renderRouterToStream - pipeable sync errors', () => { }), ) - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'sync-react-error') + await response.body!.cancel(reason) + expect(cancelReadable).toHaveBeenCalledWith(reason) + expect(errorSpy).not.toHaveBeenCalled() } finally { errorSpy.mockRestore() router.serverSsr?.cleanup() } }) - test('sync non-Error onError before pipeable assignment still errors body', async () => { - const abort = vi.fn() - reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( - (_children, opts) => { - opts.onError('string-react-error', { componentStack: '' }) - return { abort, pipe: vi.fn() } - }, - ) + test('request abort does not log a renderer cancellation error', async () => { + const controller = new AbortController() + const renderError = new Error('renderer cancellation') + reactDomServerMocks.renderToReadableStream = vi.fn((_children, options) => { + controller.signal.addEventListener( + 'abort', + () => options.onError?.(renderError, { componentStack: '' }), + { once: true }, + ) + return Promise.resolve( + Object.assign(new ReadableStream(), { + allReady: Promise.resolve(), + }), + ) + }) const router = await buildRouter() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const response = unwrapResponse( await renderRouterToStream({ - request: new Request('http://localhost/'), + request: new Request('http://localhost/', { + signal: controller.signal, + }), router, responseHeaders: new Headers(), children: null, }), ) - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'string-react-error') + controller.abort(new Error('request-gone')) + await expectBodyRejects(response, 'request-gone') + expect(errorSpy).not.toHaveBeenCalled() } finally { errorSpy.mockRestore() router.serverSsr?.cleanup() } }) - test('sync undefined onError before pipeable assignment still errors body', async () => { + test('request abort unblocks the readable renderer bot wait', async () => { + let resolveCancel!: () => void + let markCancelStarted!: () => void + const cancelStarted = new Promise((resolve) => { + markCancelStarted = resolve + }) + const cancelReadable = vi.fn( + () => + new Promise((resolve) => { + resolveCancel = resolve + markCancelStarted() + }), + ) + const readableStream = Object.assign( + new ReadableStream({ + cancel: cancelReadable, + }), + { allReady: new Promise(() => {}) }, + ) + reactDomServerMocks.renderToReadableStream = vi.fn(() => + Promise.resolve(readableStream), + ) + + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const originalServerSsr = router.serverSsr! + const controller = new AbortController() + const reason = new Error('readable-bot-gone') + try { + const responsePromise = renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + signal: controller.signal, + }), + router, + responseHeaders: new Headers(), + children: null, + }) + + await Promise.resolve() + controller.abort(reason) + + await cancelStarted + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + resolveCancel() + await expect(responsePromise).rejects.toBe(reason) + expect(cancelReadable).toHaveBeenCalledOnce() + } finally { + resolveCancel?.() + originalServerSsr.cleanup() + } + }) + + test('stream lifetime rejects the readable bot wait while readiness and cancellation stay pending', async () => { + let resolveReady!: () => void + const allReady = new Promise((resolve) => { + resolveReady = resolve + }) + const cancelReadable = vi.fn(() => new Promise(() => {})) + const readableStream = Object.assign( + new ReadableStream({ cancel: cancelReadable }), + { allReady }, + ) + reactDomServerMocks.renderToReadableStream = vi.fn(() => + Promise.resolve(readableStream), + ) + + const router = await buildRouter() + const owner = router.serverSsr! + const cleanup = vi.fn() + owner.onCleanup(cleanup) + const request = new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + }) + const resolved = vi.fn() + const rejected = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + const pending = renderRouterToStream({ + request, + router, + responseHeaders: new Headers(), + children: null, + }).then(resolved, rejected) + + try { + await Promise.resolve() + expect(routerTransformMocks.readableOptions).toHaveBeenCalledOnce() + expect(rejected).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(120_000) + + const expectedError = expect.objectContaining({ + message: 'Stream lifetime exceeded', + }) + expect(rejected).toHaveBeenCalledExactlyOnceWith(expectedError) + expect(cancelReadable).toHaveBeenCalledExactlyOnceWith(expectedError) + expect(readableStream.locked).toBe(false) + expect(request.signal.aborted).toBe(false) + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + + resolveReady() + await vi.advanceTimersByTimeAsync(0) + expect(resolved).not.toHaveBeenCalled() + } finally { + resolveReady() + await pending + owner.cleanup() + vi.useRealTimers() + warn.mockRestore() + } + }) + + test('fatal shell errors reject before a response is created', async () => { + const shellError = new Error('fatal-react-shell-error') + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + actualReactDomServer.renderToPipeableStream, + ) + const Fatal = () => { + throw shellError + } + + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const originalServerSsr = router.serverSsr! + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect( + renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: , + }), + ).rejects.toBe(shellError) + + expect(routerTransformMocks.readableOptions).toHaveBeenCalledOnce() + expect(cleanup).toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() + } finally { + errorSpy.mockRestore() + originalServerSsr.cleanup() + } + }) + + test('transform abort wins after shell readiness resolves before continuation', async () => { + const reason = new Error('transform-aborted-after-ready') const abort = vi.fn() + const pipe = vi.fn() reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( - (_children, opts) => { - opts.onError(undefined, { componentStack: '' }) - return { abort, pipe: vi.fn() } + (_children, options) => { + options.onShellReady() + return { abort, pipe } }, ) + routerTransformMocks.readableOptions.mockImplementationOnce((options) => { + options?.onAbort?.(reason) + }) const router = await buildRouter() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const originalServerSsr = router.serverSsr! try { - const response = unwrapResponse( - await renderRouterToStream({ + await expect( + renderRouterToStream({ request: new Request('http://localhost/'), router, responseHeaders: new Headers(), children: null, }), - ) + ).rejects.toBe(reason) - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'SSR aborted') + expect(abort).toHaveBeenCalledWith(reason) + expect(pipe).not.toHaveBeenCalled() + expect(cleanup).toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() } finally { - errorSpy.mockRestore() - router.serverSsr?.cleanup() + originalServerSsr.cleanup() } }) - test('undefined onError after response attach errors body', async () => { + test('synchronous renderer and transform setup failures do not orphan shell errors', async () => { + const rendererShellError = new Error('renderer-sync-shell-error') + const rendererSetupError = new Error('renderer-sync-setup-error') + const transformShellError = new Error('transform-sync-shell-error') + const transformSetupError = new Error('transform-sync-setup-error') + const unhandled: Array = [] + const onUnhandled = (error: unknown) => { + unhandled.push(error) + } + reactDomServerMocks.renderToPipeableStream + .mockImplementationOnce((_children, options) => { + options.onShellError(rendererShellError) + throw rendererSetupError + }) + .mockImplementationOnce((_children, options) => { + options.onShellError(transformShellError) + return { abort: vi.fn(), pipe: vi.fn() } + }) + routerTransformMocks.readableOptions.mockImplementationOnce(() => { + throw transformSetupError + }) + + const rendererRouter = await buildRouter() + const transformRouter = await buildRouter() + const rendererServerSsr = rendererRouter.serverSsr! + const transformServerSsr = transformRouter.serverSsr! + process.on('unhandledRejection', onUnhandled) + try { + await expect( + renderRouterToStream({ + request: new Request('http://localhost/'), + router: rendererRouter, + responseHeaders: new Headers(), + children: null, + }), + ).rejects.toBe(rendererSetupError) + await expect( + renderRouterToStream({ + request: new Request('http://localhost/'), + router: transformRouter, + responseHeaders: new Headers(), + children: null, + }), + ).rejects.toBe(transformSetupError) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(unhandled).toEqual([]) + expect(rendererRouter.serverSsr).toBeUndefined() + expect(transformRouter.serverSsr).toBeUndefined() + } finally { + process.off('unhandledRejection', onUnhandled) + rendererServerSsr.cleanup() + transformServerSsr.cleanup() + } + }) + + test('a live AbortError is logged without aborting the stream', async () => { const abort = vi.fn() - let onError!: (error: unknown, info: unknown) => void + const renderError = new DOMException('component failed', 'AbortError') reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( (_children, opts) => { - onError = opts.onError - return { abort, pipe: vi.fn() } + queueMicrotask(() => opts.onShellReady()) + return { + abort, + pipe(destination: NodeJS.WritableStream) { + destination.write('shell') + opts.onError(renderError, { + componentStack: '', + }) + destination.end('') + }, + } }, ) const router = await buildRouter() + expect(router.serverSsr!.takeInitialHydrationScriptTags()).toBeDefined() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const response = unwrapResponse( @@ -190,39 +722,56 @@ describe('renderRouterToStream - pipeable sync errors', () => { }), ) - onError(undefined, { componentStack: '' }) - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'SSR aborted') + await expect(response.text()).resolves.toContain('shell') + expect(routerTransformMocks.readableOptions).toHaveBeenCalledWith( + expect.objectContaining({ rendererSafePoint: 'script-close' }), + ) + expect(abort).not.toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith( + 'Error in renderToPipeableStream:', + renderError, + { componentStack: '' }, + ) } finally { errorSpy.mockRestore() router.serverSsr?.cleanup() } }) - test('setup throw rejects instead of returning streamed 200', async () => { - const setupError = new Error('setup-boom') - reactDomServerMocks.renderToPipeableStream.mockImplementationOnce(() => { - throw setupError + test('pipeable renderer cancellation does not log a render error', async () => { + let options: + | Parameters[1] + | undefined + const abort = vi.fn((reason: unknown) => { + options?.onError?.(reason, { componentStack: '' }) }) + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + (_children, nextOptions) => { + options = nextOptions + queueMicrotask(() => nextOptions.onShellReady()) + return { abort, pipe: vi.fn() } + }, + ) const router = await buildRouter() - const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') - const originalServerSsr = router.serverSsr! const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const reason = new Error('pipeable-consumer-gone') try { - await expect( - renderRouterToStream({ + const response = unwrapResponse( + await renderRouterToStream({ request: new Request('http://localhost/'), router, responseHeaders: new Headers(), children: null, }), - ).rejects.toThrow('setup-boom') + ) - expect(cleanup).toHaveBeenCalledOnce() + await response.body!.cancel(reason) + expect(abort).toHaveBeenCalledWith(reason) + expect(errorSpy).not.toHaveBeenCalled() } finally { errorSpy.mockRestore() - originalServerSsr.cleanup() + router.serverSsr?.cleanup() } }) @@ -237,12 +786,13 @@ describe('renderRouterToStream - pipeable sync errors', () => { const router = await buildRouter() const controller = new AbortController() + const request = new Request('http://localhost/', { + signal: controller.signal, + }) try { const response = unwrapResponse( await renderRouterToStream({ - request: new Request('http://localhost/', { - signal: controller.signal, - }), + request, router, responseHeaders: new Headers(), children: null, @@ -252,13 +802,94 @@ describe('renderRouterToStream - pipeable sync errors', () => { expect(response.body).not.toBeNull() controller.abort(new Error('request-gone')) await vi.waitFor(() => expect(abort).toHaveBeenCalledOnce()) - const terminated = await Promise.race([ - expectBodyRejects(response, 'request-gone').then(() => true), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - expect(terminated).toBe(true) + await expectBodyRejects(response, 'request-gone') } finally { router.serverSsr?.cleanup() } }) + + test('stream lifetime abort rejects before bot readiness even when React resolves onAllReady', async () => { + let options: + | Parameters[1] + | undefined + const pipe = vi.fn() + const abort = vi.fn((_reason?: unknown) => { + options?.onAllReady?.() + }) + reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( + (_children, nextOptions) => { + options = nextOptions + return { abort, pipe } + }, + ) + + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const originalServerSsr = router.serverSsr! + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + try { + const responsePromise = renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + }), + router, + responseHeaders: new Headers(), + children: null, + }) + const rejection = expect(responsePromise).rejects.toThrow( + 'Stream lifetime exceeded', + ) + + expect(routerTransformMocks.readableOptions).toHaveBeenCalledOnce() + expect(options?.onAllReady).toBeTypeOf('function') + await vi.runAllTimersAsync() + + await rejection + expect(abort).toHaveBeenCalledOnce() + expect(abort.mock.calls[0]![0]).toMatchObject({ + message: 'Stream lifetime exceeded', + }) + expect(pipe).not.toHaveBeenCalled() + expect(cleanup).toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() + } finally { + vi.useRealTimers() + warnSpy.mockRestore() + originalServerSsr.cleanup() + } + }) + + test('request abort before shell readiness rejects and cleans up', async () => { + const abort = vi.fn() + const controller = new AbortController() + reactDomServerMocks.renderToPipeableStream.mockReturnValueOnce({ + abort, + pipe: vi.fn(), + }) + + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const originalServerSsr = router.serverSsr! + const reason = new Error('request-gone-before-shell') + try { + const responsePromise = renderRouterToStream({ + request: new Request('http://localhost/', { + signal: controller.signal, + }), + router, + responseHeaders: new Headers(), + children: null, + }) + await Promise.resolve() + controller.abort(reason) + + await expect(responsePromise).rejects.toBe(reason) + expect(abort).toHaveBeenCalledOnce() + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + } finally { + originalServerSsr.cleanup() + } + }) }) diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md index d39c229768f..43063cddbf5 100644 --- a/packages/router-core/INTERNALS.md +++ b/packages/router-core/INTERNALS.md @@ -104,6 +104,9 @@ resource ownership, and a promise settling is not permission to publish. - `src/ssr/handlerCallback.ts`, `src/ssr/ssr-server.ts`, and `src/ssr/transformStreamWithRouter.ts` transfer stream ownership and coordinate serialization, injection, abort, and cleanup. + `src/ssr/hydrationScripts.ts` owns the request-local hydration script queue + and framing; `src/ssr/htmlBoundaryScanner.ts` owns the byte matchers. + See `src/ssr/STREAMING.md` for the transport contract. - Framework `Transitioner` and `Matches` implementations acknowledge exact publications and render only through the selected boundary. Framework `RouterClient` and render-to-stream implementations complete hydration and diff --git a/packages/router-core/benchmarks/ssr-streaming/.gitignore b/packages/router-core/benchmarks/ssr-streaming/.gitignore new file mode 100644 index 00000000000..6628455c0ad --- /dev/null +++ b/packages/router-core/benchmarks/ssr-streaming/.gitignore @@ -0,0 +1 @@ +/results/ diff --git a/packages/router-core/benchmarks/ssr-streaming/README.md b/packages/router-core/benchmarks/ssr-streaming/README.md new file mode 100644 index 00000000000..bda5d4ee121 --- /dev/null +++ b/packages/router-core/benchmarks/ssr-streaming/README.md @@ -0,0 +1,162 @@ +# SSR streaming benchmark + +This harness compares two Git revisions of router-core SSR streaming. Transform +runs validate byte count, SHA-256 digest, markers, selected transport API, and +lifecycle counts. Isolated transform timing includes hashing and marker scans; +warm transform timing moves those two checks into preflight requests. +Production-owner suites validate byte count, final owner state, and the 64 KiB +output chunk cap. Isolated owner timing includes hashing; warm and soak suites +use one separate digest-validating preflight per implementation. + +Generated JSON and Markdown summaries stay under `results/` and are ignored by +Git. CPU profiles default to a `*-profiles/` directory beside the configured +JSON output. `--profile-dir` overrides that location. + +## Quick start + +Compare the latest `origin/main` with `HEAD`: + +```sh +node packages/router-core/benchmarks/ssr-streaming/run.mjs \ + --origin=origin/main \ + --candidate=HEAD \ + --suite=common \ + --warmups=20 \ + --iterations=30 \ + --batch-requests=1000 \ + --output=packages/router-core/benchmarks/ssr-streaming/results/common.json +``` + +Use `--candidate=worktree` while testing an uncommitted optimization. Final +comparisons should use Git revisions so the result can be reproduced. + +Run the focused queue-mechanism benchmark with: + +```sh +pnpm --dir packages/router-core exec vitest bench \ + tests/hydrationQueue.bench.ts --run +``` + +## Suites + +| Suite | Purpose | Default measurement | +| ----------------- | ------------------------------------------------------------------------ | ------------------------------------------------- | +| `smoke` | Short raw, fast, and merge check | One isolated process per cell | +| `common` | Normal 1–64 KiB hydration payloads across React, Solid, and Vue profiles | Warm paired batches | +| `safe-points` | Many small React and Solid renderer records | Warm paired batches | +| `coalesced-close` | React script safe points inside large renderer chunks | Warm paired batches | +| `small-memory` | Small React raw, fast, and merge responses | Isolated processes with forced GC | +| `primary` | Large hydration, closing-tag, renderer-record, and backlog stress | Isolated processes with forced GC | +| `source` | Large flat and rope router sources | Isolated processes and direct `encodeInto` probes | +| `strings` | Large flat and rope renderer string records | Warm paired batches | +| `owner` | Production hydration owner and maximum legal backlogs | Isolated processes with forced GC | +| `owner-warm` | Production hydration owner latency | Warm paired batches | +| `owner-soak` | Production hydration owner retention after repeated requests | Post-return forced-GC checkpoints | + +Filter cells with comma-separated `--scenario` and `--mode` values. Isolated +suites also accept `--implementation` and `--runs`. Warm suites accept +`--warmups`, `--iterations`, and `--batch-requests`. + +The `coalesced-close` suite keeps hydration waiting until renderer EOF. Its +1 KiB and 64 KiB filler follows a script in one chunk, so returning at that +script can repeat the document-close search over the remaining bytes. It also +covers a close in the next chunk and a late-script control with little content +left to rescan. The filler contains complete HTML elements. An empty final +input record prevents source prefetch from finishing hydration before the +coalesced content drains. These cases measure repeated scanning within one +chunk; `safe-points` continues to measure separate small renderer records. + +The `owner-warm` suite requires both revisions to contain +`packages/router-core/src/ssr/hydrationScripts.ts`. Older `origin/main` +revisions do not contain this file. After the owner exists in a committed +baseline, use `--origin=HEAD --candidate=worktree` for later changes. + +During the initial owner introduction, run an isolated worktree-only cell: + +```sh +node packages/router-core/benchmarks/ssr-streaming/run.mjs \ + --candidate=worktree \ + --suite=owner \ + --implementation=worktree \ + --scenario=hydration-owner-16x1k \ + --runs=1 +``` + +For example: + +```sh +node packages/router-core/benchmarks/ssr-streaming/run.mjs \ + --origin=HEAD \ + --candidate=worktree \ + --suite=common \ + --scenario=hydration-32k \ + --warmups=30 \ + --iterations=50 \ + --batch-requests=2000 \ + --output=packages/router-core/benchmarks/ssr-streaming/results/candidate.json +``` + +## What the numbers mean + +Warm suites load both implementations into one Node process. They alternate which +implementation runs first for every request pair and at every batch boundary. +The warm-up count is per implementation: each warm-up iteration runs one +request against both revisions. Timed samples are complete request batches. + +For isolated transform cells, `elapsedMs` includes incremental hashing and +marker validation. For warm transform cells, it measures a byte-counted drain +after validated warm-up requests. `wallElapsedMs` also includes synthetic +request setup and lifecycle checks. Merge cells include source creation by the +fake producer. + +Isolated suites start a new Node process for each cell. Memory values are +sampled high-water deltas above a forced-GC baseline, not exact allocation +peaks and not proof that a leak is absent. + +Treat changes below 1% as inconclusive unless several independent processes +repeat the result. In soak output, heap, external, and ArrayBuffer checkpoints +describe retained JavaScript-visible memory after forced GC. RSS is allocator +high-water behavior and is not, by itself, evidence of a leak. Use unit and +integration tests for backpressure, cancellation, abort, timeout, Unicode, and +split-boundary correctness. The throughput harness does not replace those +tests. + +## Scope and limits + +- Normal transform suites use `Uint8Array` renderer records. The `strings` + suite isolates Node renderer string records. +- React uses the `script-close` safe point, Solid uses `record-end`, and Vue + uses only the universal router boundary, document close, and EOF points. +- The fixed boundary suffix lets old and new implementations consume identical + bytes and matches the production transport. +- The worker supplies the legacy `origin/main` transport and the current + hydration-output transport so the intended baseline and candidate can share + one fixture. It does not preserve intermediate private transport fields or + benchmark the complete production hydration producer. +- The `owner` suites call the production hydration owner directly. They do not + include the transform or a framework renderer. +- CPU results describe this Node and machine. They do not establish browser or + Cloudflare Worker performance. + +## Reproducibility + +The runner materializes the complete tracked `packages/router-core/src` tree +for both inputs before it builds. With `--candidate=worktree`, it first copies +one immutable worktree snapshot so later edits cannot change the measured +artifact. Results record available commit IDs, both source-tree hashes, bundle +hashes, platform details, and a hash of the measured source pair. Child +processes and bundles use `NODE_ENV=production`. + +Before publishing a conclusion: + +1. Use clean Git revision names for both sides. +2. Run the same harness revision for both implementations. +3. Verify matching output bytes and digests. +4. Repeat latency comparisons in fresh processes when the expected change is + small. +5. Run memory and correctness tests before retaining an optimization. + +For attribution only, `--cpu-prof=true` writes profiles into a sibling +`*-profiles/` directory by default. Use `--profile-dir=...` to choose another +new or empty directory. Profiles also contain fixture and validation work, so +use them as hints rather than standalone proof. diff --git a/packages/router-core/benchmarks/ssr-streaming/run.mjs b/packages/router-core/benchmarks/ssr-streaming/run.mjs new file mode 100644 index 00000000000..2d1f2245c26 --- /dev/null +++ b/packages/router-core/benchmarks/ssr-streaming/run.mjs @@ -0,0 +1,860 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { arch, cpus, platform, tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' +import { build } from 'esbuild' + +const here = dirname(fileURLToPath(import.meta.url)) +const repo = resolve(here, '../../../..') +const dependencyNodePaths = [resolve(repo, 'packages/router-core/node_modules')] +const args = Object.fromEntries( + process.argv.slice(2).map((arg) => { + const [name, value = 'true'] = arg.replace(/^--/, '').split('=', 2) + return [name, value] + }), +) +const knownArgs = new Set([ + 'suite', + 'origin', + 'candidate', + 'runs', + 'output', + 'keep-artifacts', + 'cpu-prof', + 'profile-dir', + 'scenario', + 'mode', + 'implementation', + 'warmups', + 'iterations', + 'batch-requests', +]) +for (const name of Object.keys(args)) { + if (!knownArgs.has(name)) { + throw new Error(`Unknown benchmark option: --${name}`) + } +} +const suite = args.suite ?? 'primary' +const suites = new Set([ + 'common', + 'safe-points', + 'coalesced-close', + 'small-memory', + 'smoke', + 'source', + 'strings', + 'owner', + 'owner-warm', + 'owner-soak', + 'primary', +]) +if (!suites.has(suite)) { + throw new Error(`Unknown benchmark suite: ${suite}`) +} +const isWarmSuite = + suite === 'common' || + suite === 'safe-points' || + suite === 'coalesced-close' || + suite === 'strings' || + suite === 'owner-warm' +const selectedImplementations = isWarmSuite + ? [] + : ['origin', 'worktree'].filter((implementation) => { + if (!args.implementation || args.implementation === 'all') { + return true + } + return args.implementation.split(',').includes(implementation) + }) +const unsupportedSuiteOption = ( + isWarmSuite + ? ['runs', 'implementation'] + : ['warmups', 'iterations', 'batch-requests'] +).find((name) => args[name] !== undefined) +if (unsupportedSuiteOption) { + throw new Error( + `--${unsupportedSuiteOption} is not supported by the ${suite} suite`, + ) +} +if ( + args['cpu-prof'] !== undefined && + !['true', 'false'].includes(args['cpu-prof']) +) { + throw new Error('--cpu-prof must be true or false') +} +const cpuProfiled = args['cpu-prof'] === 'true' +if (args['profile-dir'] !== undefined && !cpuProfiled) { + throw new Error('--profile-dir requires --cpu-prof=true') +} +const originRef = args.origin ?? 'origin/main' +const candidateRef = args.candidate ?? 'HEAD' +const originSha = execFileSync('git', ['rev-parse', `${originRef}^{commit}`], { + cwd: repo, + encoding: 'utf8', +}).trim() +const candidateSha = + candidateRef === 'worktree' + ? undefined + : execFileSync('git', ['rev-parse', `${candidateRef}^{commit}`], { + cwd: repo, + encoding: 'utf8', + }).trim() +const headSha = execFileSync('git', ['rev-parse', 'HEAD^{commit}'], { + cwd: repo, + encoding: 'utf8', +}).trim() +const runs = Number(args.runs ?? (suite === 'smoke' ? 1 : 3)) +const outputPath = resolve( + repo, + args.output ?? + `packages/router-core/benchmarks/ssr-streaming/results/${suite}.json`, +) +if (!outputPath.endsWith('.json')) { + throw new Error('Benchmark output must end in .json') +} +if (!Number.isInteger(runs) || runs < 1) { + throw new Error('--runs must be an integer greater than zero') +} +const keepArtifacts = args['keep-artifacts'] + ? resolve(repo, args['keep-artifacts']) + : undefined +if ( + keepArtifacts && + existsSync(keepArtifacts) && + readdirSync(keepArtifacts).length +) { + throw new Error('--keep-artifacts must name a new or empty directory') +} +const temp = keepArtifacts ?? mkdtempSync(join(tmpdir(), 'tsr-ssr-streaming-')) +mkdirSync(temp, { recursive: true }) +function cleanupTemporaryArtifacts() { + if (!keepArtifacts) { + rmSync(temp, { recursive: true, force: true }) + } +} +// Some setup and child-failure paths exit before the main try/finally can +// unwind. Register cleanup as soon as the temporary directory exists. +process.once('exit', cleanupTemporaryArtifacts) +const profileDir = cpuProfiled + ? resolve( + repo, + args['profile-dir'] ?? outputPath.replace(/\.json$/, '-profiles'), + ) + : undefined +if (profileDir) { + if (existsSync(profileDir) && readdirSync(profileDir).length) { + throw new Error( + `${args['profile-dir'] === undefined ? 'Default profile directory' : '--profile-dir'} must name a new or empty directory`, + ) + } + mkdirSync(profileDir, { recursive: true }) +} +const childEnvironment = { ...process.env, NODE_ENV: 'production' } + +function sourcePathsAt(ref) { + const output = execFileSync( + 'git', + ['ls-tree', '-r', '--name-only', ref, '--', 'packages/router-core/src'], + { cwd: repo, encoding: 'utf8' }, + ).trim() + return output ? output.split('\n') : [] +} + +function worktreeSourcePaths() { + const output = execFileSync( + 'git', + [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '--', + 'packages/router-core/src', + ], + { cwd: repo, encoding: 'utf8' }, + ).trim() + return output + ? output + .split('\n') + .filter((repositoryPath) => existsSync(resolve(repo, repositoryPath))) + .sort() + : [] +} + +function materializeSourceSnapshot(ref, sourceRoot, repositoryPaths) { + for (const repositoryPath of repositoryPaths) { + const target = join( + sourceRoot, + repositoryPath.slice('packages/router-core/src/'.length), + ) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync( + target, + execFileSync('git', ['show', `${ref}:${repositoryPath}`], { cwd: repo }), + ) + } +} + +function materializeWorktreeSnapshot(sourceRoot, repositoryPaths) { + for (const repositoryPath of repositoryPaths) { + const target = join( + sourceRoot, + repositoryPath.slice('packages/router-core/src/'.length), + ) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, readFileSync(resolve(repo, repositoryPath))) + } +} + +function sourceSnapshotSha256(sourceRoot, repositoryPaths) { + const hash = createHash('sha256') + for (const repositoryPath of repositoryPaths) { + hash.update(repositoryPath) + hash.update('\0') + hash.update( + readFileSync( + join( + sourceRoot, + repositoryPath.slice('packages/router-core/src/'.length), + ), + ), + ) + hash.update('\0') + } + return hash.digest('hex') +} + +const originSourceRoot = join(temp, 'origin/packages/router-core/src') +const candidateSourceRoot = join(temp, 'worktree/packages/router-core/src') +const originSourcePaths = sourcePathsAt(originSha) +const candidateSourcePaths = + candidateRef === 'worktree' + ? worktreeSourcePaths() + : sourcePathsAt(candidateSha) +if (suite.startsWith('owner')) { + const ownerSourcePath = 'packages/router-core/src/ssr/hydrationScripts.ts' + const missingImplementations = [] + const requiredImplementations = isWarmSuite + ? ['origin', 'worktree'] + : selectedImplementations + if ( + requiredImplementations.includes('origin') && + !originSourcePaths.includes(ownerSourcePath) + ) { + missingImplementations.push(`origin (${originRef})`) + } + if ( + requiredImplementations.includes('worktree') && + !candidateSourcePaths.includes(ownerSourcePath) + ) { + missingImplementations.push(`candidate (${candidateRef})`) + } + if (missingImplementations.length > 0) { + throw new Error( + `The ${suite} suite requires hydrationScripts.ts in the selected revision(s). ` + + `Missing from ${missingImplementations.join(' and ')}.`, + ) + } +} +materializeSourceSnapshot(originSha, originSourceRoot, originSourcePaths) +if (candidateRef === 'worktree') { + materializeWorktreeSnapshot(candidateSourceRoot, candidateSourcePaths) +} else { + materializeSourceSnapshot( + candidateSha, + candidateSourceRoot, + candidateSourcePaths, + ) +} +const originSourceSha256 = sourceSnapshotSha256( + originSourceRoot, + originSourcePaths, +) +const candidateSourceSha256 = sourceSnapshotSha256( + candidateSourceRoot, + candidateSourcePaths, +) +const measuredSourcePairSha256 = createHash('sha256') + .update(originSourceSha256) + .update('\0') + .update(candidateSourceSha256) + .digest('hex') +const transformSources = { + origin: readFileSync( + join(originSourceRoot, 'ssr/transformStreamWithRouter.ts'), + 'utf8', + ), + worktree: readFileSync( + join(candidateSourceRoot, 'ssr/transformStreamWithRouter.ts'), + 'utf8', + ), +} +const hasHistoricalBufferGuards = { + origin: transformSources.origin.includes( + 'SSR router HTML exceeded maximum buffer', + ), + worktree: transformSources.worktree.includes( + 'SSR router HTML exceeded maximum buffer', + ), +} +const transformExportNames = { + origin: transformSources.origin.includes( + 'export function transformStreamWithRouter(', + ) + ? 'transformStreamWithRouter' + : 'transformReadableStreamWithRouter', + worktree: transformSources.worktree.includes( + 'export function transformStreamWithRouter(', + ) + ? 'transformStreamWithRouter' + : 'transformReadableStreamWithRouter', +} + +// The worktree transform imports protocol constants from hydrationScripts. +// Its producer is not used by this benchmark worker, so the Vite-only script +// string can stay empty in the benchmark bundle. +const scriptStringPlugin = { + name: 'script-string', + setup(build) { + build.onResolve({ filter: /\?script-string$/ }, (args) => ({ + path: args.path, + namespace: 'script-string', + })) + build.onLoad({ filter: /.*/, namespace: 'script-string' }, () => ({ + contents: 'export default ""', + loader: 'js', + })) + }, +} + +async function bundle(implementation) { + const sourceRoot = + implementation === 'origin' ? originSourceRoot : candidateSourceRoot + const entry = join(temp, `${implementation}-entry.ts`) + writeFileSync( + entry, + suite === 'owner' || suite === 'owner-soak' + ? `import { runHydrationOwner, runHydrationOwnerSoak } from ${JSON.stringify(resolve(here, 'worker.ts'))}\n` + + `import { createHydrationScripts } from ${JSON.stringify(join(sourceRoot, 'ssr/hydrationScripts.ts'))}\n` + + `const values = Object.fromEntries(process.argv.slice(2).map((value) => value.replace(/^--/, '').split('=', 2)))\n` + + `${suite === 'owner-soak' ? 'runHydrationOwnerSoak' : 'runHydrationOwner'}(createHydrationScripts, ${JSON.stringify(implementation)}, values.scenario)\n` + : `import { run, runProbe } from ${JSON.stringify(resolve(here, 'worker.ts'))}\n` + + `import { ${transformExportNames[implementation]} as transform } from ${JSON.stringify(join(sourceRoot, 'ssr/transformStreamWithRouter.ts'))}\n` + + `const values = Object.fromEntries(process.argv.slice(2).map((value) => value.replace(/^--/, '').split('=', 2)))\n` + + `if (values.probe) { runProbe(values.shape, Number(values.size), values.strategy) } else { await run(transform, ${JSON.stringify(implementation)}, values.scenario, values.mode) }\n`, + ) + const outfile = join(temp, `${implementation}.mjs`) + await build({ + entryPoints: [entry], + outfile, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + logLevel: 'silent', + nodePaths: dependencyNodePaths, + plugins: [scriptStringPlugin], + define: { 'process.env.NODE_ENV': '"production"' }, + }) + return outfile +} + +async function bundleWarmComparison() { + const entry = join(temp, 'warm-comparison-entry.ts') + writeFileSync( + entry, + suite === 'owner-warm' + ? `import { runWarmHydrationOwnerComparison } from ${JSON.stringify(resolve(here, 'worker.ts'))}\n` + + `import { createHydrationScripts as origin } from ${JSON.stringify(join(originSourceRoot, 'ssr/hydrationScripts.ts'))}\n` + + `import { createHydrationScripts as worktree } from ${JSON.stringify(join(candidateSourceRoot, 'ssr/hydrationScripts.ts'))}\n` + + `const values = Object.fromEntries(process.argv.slice(2).map((value) => value.replace(/^--/, '').split('=', 2)))\n` + + `const result = runWarmHydrationOwnerComparison({ origin, worktree }, values.scenarios.split(','), Number(values.warmups), Number(values.iterations), Number(values.batchRequests))\n` + + `process.stdout.write(JSON.stringify(result) + '\\n')\n` + : `import { runWarmComparison } from ${JSON.stringify(resolve(here, 'worker.ts'))}\n` + + `import { ${transformExportNames.origin} as origin } from ${JSON.stringify(join(originSourceRoot, 'ssr/transformStreamWithRouter.ts'))}\n` + + `import { ${transformExportNames.worktree} as worktree } from ${JSON.stringify(join(candidateSourceRoot, 'ssr/transformStreamWithRouter.ts'))}\n` + + `const values = Object.fromEntries(process.argv.slice(2).map((value) => value.replace(/^--/, '').split('=', 2)))\n` + + `const result = await runWarmComparison({ origin, worktree }, values.scenarios.split(','), values.modes.split(','), Number(values.warmups), Number(values.iterations), Number(values.batchRequests))\n` + + `process.stdout.write(JSON.stringify(result) + '\\n')\n`, + ) + const outfile = join(temp, 'warm-comparison.mjs') + await build({ + entryPoints: [entry], + outfile, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + logLevel: 'silent', + nodePaths: dependencyNodePaths, + plugins: [scriptStringPlugin], + define: { 'process.env.NODE_ENV': '"production"' }, + }) + return outfile +} + +function tasksForSuite() { + if (suite === 'common') { + return [ + 'hydration-1k', + 'hydration-4k', + 'hydration-16k', + 'hydration-32k', + 'hydration-64k', + 'hydration-solid-32k', + 'hydration-vue-32k', + ] + } + if (suite === 'safe-points') { + return ['react-patches-64-records', 'solid-patches-64-records'] + } + if (suite === 'coalesced-close') { + return [ + 'react-coalesced-early-script-1k', + 'react-coalesced-early-script-64k', + 'react-coalesced-next-close-64k', + 'react-coalesced-late-script-64k', + ] + } + if (suite === 'small-memory') { + return ['hydration-1k', 'hydration-4k'] + } + if (suite === 'smoke') { + return ['closing-heavy-17m'] + } + if (suite === 'source') { + return [ + 'router-string-flat-1m', + 'router-string-flat-4m', + 'router-string-flat-17m', + 'router-string-flat-32m', + 'router-string-rope-1m', + 'router-string-rope-4m', + 'router-string-rope-17m', + 'router-string-rope-32m', + ] + } + if (suite === 'strings') { + return [ + 'application-string-flat-1m', + 'application-string-flat-4m', + 'application-string-flat-17m', + 'application-string-rope-1m', + 'application-string-rope-4m', + 'application-string-rope-17m', + ] + } + if (suite === 'owner' || suite === 'owner-warm') { + return [ + 'hydration-owner-1x1k', + 'hydration-owner-16x1k', + 'hydration-owner-400x40k', + 'hydration-owner-4095x4k', + ] + } + if (suite === 'owner-soak') { + return ['hydration-owner-16x1k', 'hydration-owner-4095x4k'] + } + return [ + 'hydration-32k', + 'hydration-17m', + 'router-records-15m', + 'closing-heavy-17m', + 'react18-patches-17m', + 'react19-patches-17m', + ] +} + +function writeSummary(results, path, warmComparison) { + const provenance = [ + `Origin: ${originRef} (${originSha})`, + `Candidate: ${candidateRef} (${candidateSha ?? `worktree snapshot ${candidateSourceSha256}`})`, + `Node: ${process.version}; NODE_ENV=production`, + ] + if (warmComparison) { + const lines = [ + '# Warm SSR streaming benchmark results', + '', + ...provenance, + `Requests per timed batch: ${warmComparison.batchRequests}`, + '', + '| baseline | scenario | framework | safe point | mode | baseline mean us/request | candidate mean us/request | aggregate change | paired geometric mean | paired median | paired p10 | paired p90 | wall aggregate |', + '| --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + ] + for (const comparison of warmComparison.comparisons) { + const baseline = results.find( + (value) => + value.scenario === comparison.scenario && + value.framework === comparison.framework && + value.rendererSafePoint === comparison.rendererSafePoint && + value.mode === comparison.mode && + value.implementation === comparison.baseline && + value.comparisonBaseline === comparison.baseline, + ) + const worktree = results.find( + (value) => + value.scenario === comparison.scenario && + value.framework === comparison.framework && + value.rendererSafePoint === comparison.rendererSafePoint && + value.mode === comparison.mode && + value.implementation === 'worktree' && + value.comparisonBaseline === comparison.baseline, + ) + if (!baseline || !worktree) { + throw new Error( + `Missing warm result row for ${comparison.scenario}/${comparison.framework}/${comparison.rendererSafePoint ?? '-'}/${comparison.mode}/${comparison.baseline}`, + ) + } + lines.push( + `| ${comparison.baseline} | ${comparison.scenario} | ${comparison.framework} | ${comparison.rendererSafePoint ?? '-'} | ${comparison.mode} | ${(baseline.statistics.elapsedMs.mean * 1000).toFixed(2)} | ${(worktree.statistics.elapsedMs.mean * 1000).toFixed(2)} | ${comparison.aggregateElapsedPercent.toFixed(1)}% | ${comparison.pairedElapsedPercent.geometricMean.toFixed(1)}% | ${comparison.pairedElapsedPercent.median.toFixed(1)}% | ${comparison.pairedElapsedPercent.p10.toFixed(1)}% | ${comparison.pairedElapsedPercent.p90.toFixed(1)}% | ${comparison.aggregateWallElapsedPercent.toFixed(1)}% |`, + ) + } + writeFileSync(path, `${lines.join('\n')}\n`) + return + } + + const lines = [ + '# SSR streaming benchmark results', + '', + ...provenance, + '', + '| implementation | run | scenario | framework | safe point | mode | TTFB ms | first router ms | MiB/s | heap peak MiB | external peak MiB | max chunk | records HWM |', + '| --- | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + ] + const soakRows = [] + for (const value of results) { + if (value.outcome === 'expected-error') { + lines.push( + `| ${value.implementation} | ${value.run ?? '-'} | ${value.scenario} | ${value.framework} | ${value.rendererSafePoint ?? '-'} | ${value.mode} | - | - | expected guard | - | - | - | - |`, + ) + continue + } + if (value.implementation === 'v8-probe') { + for (const pass of value.passes) { + lines.push( + `| v8-probe | ${pass.pass} | ${value.scenario} | - | - | ${value.mode} | - | - | ${pass.throughputMiBPerSecond.toFixed(1)} | ${(pass.memoryPeakBytes.heapUsed / 1048576).toFixed(1)} | ${(pass.memoryPeakBytes.external / 1048576).toFixed(1)} | ${value.outputChunkBytes} | 0 |`, + ) + } + continue + } + if (value.mode === 'owner-soak') { + soakRows.push(value) + continue + } + lines.push( + `| ${value.implementation} | ${value.run ?? '-'} | ${value.scenario} | ${value.framework} | ${value.rendererSafePoint ?? '-'} | ${value.mode} | ${value.ttfbMs?.toFixed(2) ?? '-'} | ${value.firstRouterScriptMs?.toFixed(2) ?? '-'} | ${value.throughputMiBPerSecond.toFixed(1)} | ${(value.memoryPeakBytes.heapUsed / 1048576).toFixed(1)} | ${(value.memoryPeakBytes.external / 1048576).toFixed(1)} | ${value.maxOutputChunkBytes} | ${value.sourceRecordHighWater} |`, + ) + } + if (soakRows.length > 0) { + lines.push( + '', + '## Post-GC retention soak', + '', + '| implementation | run | scenario | requests | MiB/s including checkpoint GC | retained heap HWM MiB | retained external HWM MiB | retained ArrayBuffer HWM MiB |', + '| --- | ---: | --- | ---: | ---: | ---: | ---: | ---: |', + ) + for (const value of soakRows) { + lines.push( + `| ${value.implementation} | ${value.run ?? '-'} | ${value.scenario} | ${value.requests} | ${value.throughputIncludingGcMiBPerSecond.toFixed(1)} | ${(value.retainedHighWaterBytes.heapUsed / 1048576).toFixed(1)} | ${(value.retainedHighWaterBytes.external / 1048576).toFixed(1)} | ${(value.retainedHighWaterBytes.arrayBuffers / 1048576).toFixed(1)} |`, + ) + } + } + writeFileSync(path, `${lines.join('\n')}\n`) +} + +try { + const bundles = {} + for (const implementation of selectedImplementations) { + bundles[implementation] = await bundle(implementation) + } + const scenarios = tasksForSuite().filter( + (scenario) => !args.scenario || args.scenario.split(',').includes(scenario), + ) + const supportedModes = + suite === 'owner' || suite === 'owner-warm' || suite === 'owner-soak' + ? ['owner'] + : suite === 'source' || + suite === 'strings' || + suite === 'safe-points' || + suite === 'coalesced-close' + ? ['merge'] + : isWarmSuite + ? ['fast', 'merge'] + : ['raw', 'fast', 'merge'] + const modes = supportedModes.filter( + (mode) => !args.mode || args.mode.split(',').includes(mode), + ) + const implementations = selectedImplementations + const results = [] + const artifactPaths = { ...bundles } + let warmComparison + if ( + scenarios.length === 0 || + modes.length === 0 || + (!isWarmSuite && implementations.length === 0) + ) { + throw new Error('Benchmark filters selected no cells') + } + if (isWarmSuite) { + const warmBundle = await bundleWarmComparison() + artifactPaths['warm-comparison'] = warmBundle + const focusedSafePoints = + suite === 'safe-points' || suite === 'coalesced-close' + const warmups = Number(args.warmups ?? (focusedSafePoints ? 20 : 50)) + const iterations = Number(args.iterations ?? (focusedSafePoints ? 20 : 50)) + const batchRequests = Number( + args['batch-requests'] ?? (focusedSafePoints ? 250 : 1000), + ) + if ( + scenarios.length === 0 || + !Number.isInteger(warmups) || + warmups < 0 || + !Number.isInteger(iterations) || + iterations < 1 || + !Number.isInteger(batchRequests) || + batchRequests < 1 + ) { + throw new Error('Invalid warm-suite scenario or iteration count') + } + const child = spawnSync( + process.execPath, + [ + '--expose-gc', + ...(profileDir + ? [ + '--cpu-prof', + `--cpu-prof-dir=${profileDir}`, + `--cpu-prof-name=warm-${suite}.cpuprofile`, + ] + : []), + warmBundle, + `--scenarios=${scenarios.join(',')}`, + `--modes=${modes.join(',')}`, + `--warmups=${warmups}`, + `--iterations=${iterations}`, + `--batchRequests=${batchRequests}`, + ], + { + cwd: repo, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + env: childEnvironment, + }, + ) + if (child.status !== 0) { + process.stderr.write(child.stderr) + process.stderr.write(child.stdout) + process.exit(child.status ?? 1) + } + warmComparison = JSON.parse(child.stdout.trim()) + if (warmComparison.comparisons.length === 0) { + throw new Error('Benchmark filters selected no warm comparison cells') + } + results.push(...warmComparison.results) + for (const comparison of warmComparison.comparisons) { + process.stdout.write( + `${comparison.baseline}->candidate ${comparison.scenario} ${comparison.framework} ${comparison.rendererSafePoint ?? 'no-extra-safe-point'} ${comparison.mode}: aggregate ${comparison.aggregateElapsedPercent.toFixed(1)}%, paired geometric mean ${comparison.pairedElapsedPercent.geometricMean.toFixed(1)}%\n`, + ) + } + } + for (const scenario of scenarios) { + const framework = + scenario.startsWith('hydration-solid-') || scenario.startsWith('solid-') + ? 'solid' + : scenario.startsWith('hydration-vue-') + ? 'vue' + : 'react' + const rendererSafePoint = + framework === 'react' + ? 'script-close' + : framework === 'solid' + ? 'record-end' + : undefined + for (const mode of modes) { + for (let run = 1; run <= runs; run++) { + const orders = [ + ['origin', 'worktree'], + ['worktree', 'origin'], + ] + const implementationOrder = orders[(run - 1) % orders.length].filter( + (implementation) => implementations.includes(implementation), + ) + for (const implementation of implementationOrder) { + const profileArgs = profileDir + ? [ + '--cpu-prof', + `--cpu-prof-dir=${profileDir}`, + `--cpu-prof-name=${implementation}-${scenario}-${mode}-${run}.cpuprofile`, + ] + : [] + const child = spawnSync( + process.execPath, + [ + '--expose-gc', + ...profileArgs, + bundles[implementation], + `--scenario=${scenario}`, + `--mode=${mode}`, + ], + { + cwd: repo, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + env: childEnvironment, + }, + ) + if (child.status !== 0) { + const expectedHistoricalLimit = + mode !== 'merge' || !hasHistoricalBufferGuards[implementation] + ? undefined + : scenario === 'react18-patches-17m' + ? 'SSR stream tail exceeded maximum buffer' + : scenario === 'hydration-17m' || + /^router-string-(flat|rope)-(17|32)m$/.test(scenario) + ? 'SSR router HTML exceeded maximum buffer' + : undefined + const diagnostic = `${child.stderr}\n${child.stdout}` + if ( + expectedHistoricalLimit && + diagnostic.includes(expectedHistoricalLimit) + ) { + results.push({ + implementation, + scenario, + framework, + rendererSafePoint, + mode, + outcome: 'expected-error', + error: expectedHistoricalLimit, + run, + }) + process.stdout.write( + `${implementation} ${scenario} ${mode} ${run}/${runs}: expected historical buffer guard\n`, + ) + continue + } + process.stderr.write(child.stderr) + process.stderr.write(child.stdout) + process.exit(child.status ?? 1) + } + const value = JSON.parse(child.stdout.trim()) + value.run = run + results.push(value) + const throughput = + value.throughputMiBPerSecond ?? + value.throughputIncludingGcMiBPerSecond + process.stdout.write( + `${implementation} ${scenario} ${mode} ${run}/${runs}: ${throughput.toFixed(1)} MiB/s\n`, + ) + } + } + } + } + if (suite === 'source' && !args.scenario && !args.mode) { + for (const shape of ['flat', 'rope']) { + for (const size of [1, 4, 17, 32]) { + for (const strategy of ['suffix', 'window']) { + const profileArgs = profileDir + ? [ + '--cpu-prof', + `--cpu-prof-dir=${profileDir}`, + `--cpu-prof-name=probe-${shape}-${size}m-${strategy}.cpuprofile`, + ] + : [] + const child = spawnSync( + process.execPath, + [ + '--expose-gc', + ...profileArgs, + bundles.worktree ?? bundles.origin, + '--probe=true', + `--shape=${shape}`, + `--size=${size}`, + `--strategy=${strategy}`, + ], + { + cwd: repo, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + env: childEnvironment, + }, + ) + if (child.status !== 0) { + process.stderr.write(child.stderr) + process.stderr.write(child.stdout) + process.exit(child.status ?? 1) + } + results.push(JSON.parse(child.stdout.trim())) + } + } + } + } + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync( + outputPath, + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + node: process.version, + nodeEnv: 'production', + cpuProfiled, + platform: { os: platform(), arch: arch(), cpu: cpus()[0]?.model }, + originRef, + originSha, + originSourceSha256, + candidateRef, + candidateSha, + headSha, + candidateSourceSha256, + benchmarkInput: + suite === 'strings' + ? 'renderer string records' + : suite.startsWith('owner') + ? 'production hydration owner' + : 'Uint8Array renderer records', + measuredSourcePairSha256, + artifactSha256: Object.fromEntries( + Object.entries(artifactPaths) + .map(([name, path]) => [ + name, + createHash('sha256').update(readFileSync(path)).digest('hex'), + ]) + .concat( + profileDir + ? readdirSync(profileDir) + .filter((name) => name.endsWith('.cpuprofile')) + .sort() + .map((name) => [ + `profile:${name}`, + createHash('sha256') + .update(readFileSync(join(profileDir, name))) + .digest('hex'), + ]) + : [], + ), + ), + suite, + runs: isWarmSuite ? undefined : runs, + warmComparison, + results: isWarmSuite ? undefined : results, + }, + null, + 2, + )}\n`, + ) + const summaryPath = outputPath.replace(/\.json$/, '.md') + writeSummary(results, summaryPath, warmComparison) + process.stdout.write(`Raw results: ${outputPath}\nSummary: ${summaryPath}\n`) +} finally { + cleanupTemporaryArtifacts() + process.removeListener('exit', cleanupTemporaryArtifacts) +} diff --git a/packages/router-core/benchmarks/ssr-streaming/worker.ts b/packages/router-core/benchmarks/ssr-streaming/worker.ts new file mode 100644 index 00000000000..5a12fe29201 --- /dev/null +++ b/packages/router-core/benchmarks/ssr-streaming/worker.ts @@ -0,0 +1,2254 @@ +import { createHash } from 'node:crypto' +import { ReadableStream } from 'node:stream/web' +import { performance } from 'node:perf_hooks' + +const MiB = 1024 * 1024 +// Shared legacy-compatible input: origin finds the ID, while the current +// transform finds the exact boundary suffix. Both benchmark the same bytes. +const BARRIER = + '' +const SHELL_PREFIX = '
done
' +const PREFIX = `${SHELL_PREFIX}${BARRIER}` +const SUFFIX = '' +const SCRIPT_OPEN = '' +const REACT_PATCH_PREFIX = '' +const REACT_PATCH_BYTES = 32 * 1024 +const REACT_PATCH = + REACT_PATCH_PREFIX + + 'x'.repeat( + REACT_PATCH_BYTES - REACT_PATCH_PREFIX.length - REACT_PATCH_SUFFIX.length, + ) + + REACT_PATCH_SUFFIX +const SMALL_REACT_PATCH = + '' +const SMALL_SOLID_RECORD = '' +const COALESCED_PATCH = '' +const COALESCED_LAST_PATCH = '' +const COALESCED_FILLER = '
ordinary text
' +// Mirrors of `src/ssr/hydrationScripts.ts` limits and states. The worker +// also runs against a baseline worktree, so it cannot import them; keep these +// in step with MAX_HYDRATION_OUTPUT_CHUNK_BYTES, MAX_DIRECT_CODE_UNITS, +// MIN_OUTPUT_BYTES, and HydrationScriptOutputState. +const OUTPUT_BYTES = 64 * 1024 +const FILL_BYTES = 64 * 1024 +const DIRECT_HYDRATION_CODE_UNITS = 16 * 1024 +const MIN_HYDRATION_OUTPUT_BYTES = 256 + +const HydrationOutputState = { + Waiting: 0, + Ready: 1, + Active: 2, + Done: 3, + Failed: 4, +} as const +type HydrationOutputState = + (typeof HydrationOutputState)[keyof typeof HydrationOutputState] +const HydrationRecordPhase = { + Opening: 0, + Source: 1, + Closing: 2, +} as const +type HydrationRecordPhase = + (typeof HydrationRecordPhase)[keyof typeof HydrationRecordPhase] +const hydrationEncoder = new TextEncoder() + +type Mode = 'raw' | 'fast' | 'merge' +type Shape = 'flat' | 'rope' +type Framework = 'react' | 'solid' | 'vue' +type RendererSafePoint = 'script-close' | 'record-end' +type BaselineImplementation = 'origin' +type AppStreamValue = Uint8Array | string +type Transform = ( + router: any, + stream: ReadableStream, + options?: { rendererSafePoint?: RendererSafePoint }, +) => ReadableStream + +type Memory = { + heapUsed: number + external: number + arrayBuffers: number + rss: number +} + +type ScenarioMetadata = { + name: string + framework: Framework + rendererSafePoint?: RendererSafePoint +} + +type Scenario = + | (ScenarioMetadata & { + kind: 'hydration' + records: number + recordBytes: number + shape: Shape + }) + | (ScenarioMetadata & { + kind: 'closing' + bytes: number + framework: 'react' + rendererSafePoint: 'script-close' + }) + | (ScenarioMetadata & { + kind: 'renderer-records' + record: string + records: number + closeBeforeRecords: boolean + routerRecordBytes: number + framework: 'react' | 'solid' + rendererSafePoint: RendererSafePoint + }) + | (ScenarioMetadata & { + kind: 'coalesced-close' + bytes: number + scriptAtEnd: boolean + closeInNextChunk: boolean + framework: 'react' + rendererSafePoint: 'script-close' + }) + | (ScenarioMetadata & { + kind: 'application-string' + bytes: number + shape: Shape + routerRecordBytes: number + framework: 'react' + rendererSafePoint: 'script-close' + }) + +function parseScenario(name: string): Scenario { + const coalescedCloseMatch = + /^react-coalesced-(early-script|next-close|late-script)-(1|64)k$/.exec(name) + if (coalescedCloseMatch) { + return { + kind: 'coalesced-close', + name, + bytes: Number(coalescedCloseMatch[2]) * 1024, + scriptAtEnd: coalescedCloseMatch[1] === 'late-script', + closeInNextChunk: coalescedCloseMatch[1] === 'next-close', + framework: 'react', + rendererSafePoint: 'script-close', + } + } + const commonHydrationMatch = + /^hydration(?:(?:-(solid|vue))?)-(1|4|16|32|64)k$/.exec(name) + if (commonHydrationMatch) { + const framework = + (commonHydrationMatch[1] as Framework | undefined) ?? 'react' + return { + kind: 'hydration', + name, + records: 1, + recordBytes: Number(commonHydrationMatch[2]) * 1024, + shape: 'flat', + framework, + rendererSafePoint: + framework === 'react' + ? 'script-close' + : framework === 'solid' + ? 'record-end' + : undefined, + } + } + if (name === 'hydration-17m') { + return { + kind: 'hydration', + name, + records: 1, + recordBytes: 17 * MiB, + shape: 'flat', + framework: 'react', + rendererSafePoint: 'script-close', + } + } + if (name === 'router-records-15m') { + return { + kind: 'hydration', + name, + records: 480, + recordBytes: 32 * 1024, + shape: 'flat', + framework: 'react', + rendererSafePoint: 'script-close', + } + } + if (name === 'closing-heavy-17m') { + return { + kind: 'closing', + name, + bytes: 17 * MiB, + framework: 'react', + rendererSafePoint: 'script-close', + } + } + if (name === 'react18-patches-17m') { + return { + kind: 'renderer-records', + name, + record: REACT_PATCH, + records: (17 * MiB) / REACT_PATCH_BYTES, + closeBeforeRecords: true, + routerRecordBytes: 32 * 1024, + framework: 'react', + rendererSafePoint: 'script-close', + } + } + if (name === 'react19-patches-17m') { + return { + kind: 'renderer-records', + name, + record: REACT_PATCH, + records: (17 * MiB) / REACT_PATCH_BYTES, + closeBeforeRecords: false, + routerRecordBytes: 32 * 1024, + framework: 'react', + rendererSafePoint: 'script-close', + } + } + if (name === 'react-patches-64-records') { + return { + kind: 'renderer-records', + name, + record: SMALL_REACT_PATCH, + records: 64, + closeBeforeRecords: false, + routerRecordBytes: 1024, + framework: 'react', + rendererSafePoint: 'script-close', + } + } + if (name === 'solid-patches-64-records') { + return { + kind: 'renderer-records', + name, + record: SMALL_SOLID_RECORD, + records: 64, + closeBeforeRecords: false, + routerRecordBytes: 1024, + framework: 'solid', + rendererSafePoint: 'record-end', + } + } + const applicationStringMatch = + /^application-string-(flat|rope)-(1|4|17)m$/.exec(name) + if (applicationStringMatch) { + return { + kind: 'application-string', + name, + bytes: Number(applicationStringMatch[2]) * MiB, + shape: applicationStringMatch[1] as Shape, + routerRecordBytes: 1024, + framework: 'react', + rendererSafePoint: 'script-close', + } + } + const routerMatch = /^(router-string)-(flat|rope)-(1|4|17|32)m$/.exec(name) + if (routerMatch) { + return { + kind: 'hydration', + name, + records: 1, + recordBytes: Number(routerMatch[3]) * MiB, + shape: routerMatch[2] as Shape, + framework: 'react', + rendererSafePoint: 'script-close', + } + } + throw new Error(`Unknown benchmark scenario: ${name}`) +} + +function forceGc() { + if (typeof globalThis.gc !== 'function') { + throw new Error('The benchmark worker requires node --expose-gc') + } + globalThis.gc() + globalThis.gc() +} + +function memory(): Memory { + const value = process.memoryUsage() + return { + heapUsed: value.heapUsed, + external: value.external, + arrayBuffers: value.arrayBuffers, + rss: value.rss, + } +} + +function delta(value: Memory, baseline: Memory): Memory { + return { + heapUsed: value.heapUsed - baseline.heapUsed, + external: value.external - baseline.external, + arrayBuffers: value.arrayBuffers - baseline.arrayBuffers, + rss: value.rss - baseline.rss, + } +} + +function maxMemory(target: Memory, value: Memory) { + target.heapUsed = Math.max(target.heapUsed, value.heapUsed) + target.external = Math.max(target.external, value.external) + target.arrayBuffers = Math.max(target.arrayBuffers, value.arrayBuffers) + target.rss = Math.max(target.rss, value.rss) +} + +function marker(index: number) { + return `r${index.toString(36).padStart(6, '0')}:` +} + +function fillCode(index: number) { + return 97 + (index % 20) +} + +function createSource(bytes: number, index: number, shape: Shape) { + const recordMarker = marker(index) + const remaining = bytes - recordMarker.length + if (remaining < 0) { + throw new Error('Source record is smaller than its marker') + } + if (shape === 'flat') { + const source = Buffer.allocUnsafe(bytes) + source.fill(fillCode(index)) + source.write(recordMarker, 0, 'ascii') + return source.toString('latin1') + } + const piece = String.fromCharCode(fillCode(index)).repeat(4096) + let source = recordMarker + let left = remaining + while (left > 0) { + source += left >= piece.length ? piece : piece.slice(0, left) + left -= Math.min(left, piece.length) + } + return source +} + +function updateRepeated( + hash: ReturnType, + code: number, + bytes: number, +) { + const buffer = Buffer.allocUnsafe(Math.min(FILL_BYTES, bytes || 1)).fill(code) + let left = bytes + while (left > 0) { + const length = Math.min(left, buffer.length) + hash.update(buffer.subarray(0, length)) + left -= length + } +} + +function updateSource( + hash: ReturnType, + bytes: number, + index: number, +) { + const recordMarker = marker(index) + hash.update(recordMarker) + updateRepeated(hash, fillCode(index), bytes - recordMarker.length) +} + +type RouterSource = { + records: number + recordBytes: number + shape: Shape +} + +function getRouterSource(scenario: Scenario): RouterSource | undefined { + if (scenario.kind === 'hydration') { + return { + records: scenario.records, + recordBytes: scenario.recordBytes, + shape: scenario.shape, + } + } + if (scenario.kind === 'renderer-records') { + return { + records: 1, + recordBytes: scenario.routerRecordBytes, + shape: 'flat', + } + } + if (scenario.kind === 'application-string') { + return { + records: 1, + recordBytes: scenario.routerRecordBytes, + shape: 'flat', + } + } + return undefined +} + +function expected(scenario: Scenario, mode: Mode) { + const hash = createHash('sha256') + let bytes = 0 + const add = (value: string) => { + hash.update(value) + bytes += Buffer.byteLength(value) + } + const addRouterRecord = (recordBytes: number, index: number) => { + add(SCRIPT_OPEN) + updateSource(hash, recordBytes, index) + bytes += recordBytes + add(SCRIPT_CLOSE) + } + const addRouterSources = () => { + const source = getRouterSource(scenario) + if (!source) { + return + } + for (let index = 0; index < source.records; index++) { + addRouterRecord(source.recordBytes, index) + } + } + const addRendererRecords = (record: string, records: number) => { + for (let index = 0; index < records; index++) { + add(record) + } + } + + if (scenario.kind === 'coalesced-close') { + add(PREFIX) + if (!scenario.scriptAtEnd) { + add(COALESCED_PATCH) + } + add(createCoalescedFiller(scenario.bytes)) + if (scenario.scriptAtEnd) { + add(COALESCED_PATCH) + } + if (mode !== 'merge') { + add(SUFFIX) + } + add(COALESCED_LAST_PATCH) + if (mode === 'merge') { + add(SUFFIX) + } + } else if (scenario.kind === 'closing') { + add(SHELL_PREFIX) + const pattern = '
x
' + const repetitions = Math.floor(scenario.bytes / pattern.length) + const remainder = scenario.bytes % pattern.length + for (let index = 0; index < repetitions; index++) { + add(pattern) + } + add(pattern.slice(0, remainder)) + add(BARRIER) + add(SUFFIX) + } else if (scenario.kind === 'hydration') { + if (mode === 'merge') { + add(PREFIX) + addRouterSources() + } else { + add(SHELL_PREFIX) + addRouterSources() + add(BARRIER) + } + add(SUFFIX) + } else if (scenario.kind === 'renderer-records') { + if (mode === 'merge') { + add(PREFIX) + addRendererRecords(scenario.record, scenario.records) + addRouterSources() + add(SUFFIX) + } else { + add(SHELL_PREFIX) + addRouterSources() + add(BARRIER) + if (scenario.closeBeforeRecords) { + add(SUFFIX) + } + addRendererRecords(scenario.record, scenario.records) + if (!scenario.closeBeforeRecords) { + add(SUFFIX) + } + } + } else { + if (mode === 'merge') { + add(PREFIX) + addRouterSources() + } else { + add(SHELL_PREFIX) + addRouterSources() + add(BARRIER) + } + updateRepeated(hash, 'x'.charCodeAt(0), scenario.bytes) + bytes += scenario.bytes + add(SUFFIX) + } + return { bytes, digest: hash.digest('hex') } +} + +const expectedOutputCache = new Map>() + +function getExpectedOutput(scenario: Scenario, mode: Mode) { + const key = `${scenario.name}:${mode}` + let value = expectedOutputCache.get(key) + if (!value) { + value = expected(scenario, mode) + expectedOutputCache.set(key, value) + } + return value +} + +function* repeatedPatternBytes(pattern: string, bytes: number) { + const chunk = pattern.repeat(Math.floor(FILL_BYTES / pattern.length)) + let left = bytes + while (left > 0) { + const value = chunk.slice(0, Math.min(left, chunk.length)) + yield Buffer.from(value) + left -= value.length + } +} + +function* repeatedCompletePatternBytes( + pattern: string, + bytes: number, + maxChunkBytes = FILL_BYTES, +) { + let repetitions = Math.floor(bytes / pattern.length) + const repetitionsPerChunk = Math.max( + 1, + Math.floor(maxChunkBytes / pattern.length), + ) + const fullChunk = pattern.repeat(repetitionsPerChunk) + while (repetitions > 0) { + const count = Math.min(repetitions, repetitionsPerChunk) + yield Buffer.from( + count === repetitionsPerChunk ? fullChunk : pattern.repeat(count), + ) + repetitions -= count + } +} + +function* routerScriptBytes(source: RouterSource): Generator { + for (let index = 0; index < source.records; index++) { + yield Buffer.from(SCRIPT_OPEN + marker(index)) + let left = source.recordBytes - marker(index).length + while (left > 0) { + const length = Math.min(left, FILL_BYTES) + yield Buffer.alloc(length, fillCode(index)) + left -= length + } + yield Buffer.from(SCRIPT_CLOSE) + } +} + +function* integratedBytes(scenario: Scenario): Generator { + if (scenario.kind === 'coalesced-close') { + yield* coalescedCloseApplicationBytes(scenario) + return + } + if (scenario.kind === 'closing') { + yield Buffer.from(SHELL_PREFIX) + yield* repeatedPatternBytes('
x
', scenario.bytes) + yield Buffer.from(BARRIER) + yield Buffer.from(SUFFIX) + return + } + + yield Buffer.from(SHELL_PREFIX) + const source = getRouterSource(scenario) + if (source) { + yield* routerScriptBytes(source) + } + yield Buffer.from(BARRIER) + + if (scenario.kind === 'hydration') { + yield Buffer.from(SUFFIX) + } else if (scenario.kind === 'renderer-records') { + if (scenario.closeBeforeRecords) { + yield Buffer.from(SUFFIX) + } + yield* repeatedCompletePatternBytes( + scenario.record, + scenario.record.length * scenario.records, + scenario.record.length, + ) + if (!scenario.closeBeforeRecords) { + yield Buffer.from(SUFFIX) + } + } else { + yield Buffer.alloc(scenario.bytes, 'x'.charCodeAt(0)) + yield Buffer.from(SUFFIX) + } +} + +function createCoalescedFiller(bytes: number) { + return ( + COALESCED_FILLER.repeat(Math.floor(bytes / COALESCED_FILLER.length)) + + 'x'.repeat(bytes % COALESCED_FILLER.length) + ) +} + +function* coalescedCloseApplicationBytes( + scenario: Extract, +) { + const filler = createCoalescedFiller(scenario.bytes) + const content = + PREFIX + + (scenario.scriptAtEnd ? filler + COALESCED_PATCH : COALESCED_PATCH + filler) + const close = SUFFIX + COALESCED_LAST_PATCH + yield Buffer.from(content + (scenario.closeInNextChunk ? '' : close)) + if (scenario.closeInNextChunk) { + yield Buffer.from(close) + } + // Keep hydration Waiting while the coalesced bytes drain. Without this + // record, source prefetch can report EOF before the transform scans them. + yield new Uint8Array(0) +} + +function* rendererRecordApplicationBytes( + scenario: Extract, +) { + yield Buffer.from(PREFIX) + if (scenario.closeBeforeRecords) { + yield Buffer.from(SUFFIX) + } + yield* repeatedCompletePatternBytes( + scenario.record, + scenario.record.length * scenario.records, + scenario.record.length, + ) + if (!scenario.closeBeforeRecords) { + yield Buffer.from(SUFFIX) + } + // This empty record lets the transform consume all renderer bytes before + // the source reports EOF and starts the independent router producer. + yield new Uint8Array(0) +} + +function streamFromIterator( + iterator: Iterator, + onEof?: () => void, + beforeEof?: Promise, + onBeforeEof?: () => void, +) { + return new ReadableStream({ + async pull(controller) { + const next = iterator.next() + if (next.done) { + onBeforeEof?.() + await beforeEof + onEof?.() + controller.close() + } else { + controller.enqueue(next.value) + } + }, + }) +} + +function createApplicationString( + scenario: Extract, +) { + if (scenario.shape === 'flat') { + const value = Buffer.allocUnsafe( + PREFIX.length + scenario.bytes + SUFFIX.length, + ) + value.write(PREFIX, 0, 'ascii') + value.fill('x'.charCodeAt(0), PREFIX.length, PREFIX.length + scenario.bytes) + value.write(SUFFIX, PREFIX.length + scenario.bytes, 'ascii') + return value.toString('latin1') + } + const piece = 'x'.repeat(4096) + let value = PREFIX + let left = scenario.bytes + while (left > 0) { + value += left >= piece.length ? piece : piece.slice(0, left) + left -= Math.min(left, piece.length) + } + return value + SUFFIX +} + +function makeRouter(serializationFinished: boolean) { + let finished = serializationFinished + let streamFastPathReserved = false + let cleanupCount = 0 + let renderFinishedCount = 0 + let reserveCalls = 0 + let scriptSubscriptions = 0 + let htmlSubscriptions = 0 + let bufferedHtmlReads = 0 + let fastPathGrants = 0 + let hydrationOutputClaims = 0 + let hydrationOutputSubscriptions = 0 + let hydrationOutputPulls = 0 + const scriptListeners = new Set<(parts: ReadonlyArray) => void>() + const htmlListeners = new Set<(html: string) => void>() + const finishedListeners = new Set<() => void>() + const pendingScripts: Array = [] + const bufferedHtml: Array = [] + const hydrationSources: Array = [] + let hydrationSourceHead = 0 + let hydrationSource: string | undefined + let hydrationSegment = '' + let hydrationSegmentOffset = 0 + let hydrationPhase: HydrationRecordPhase = HydrationRecordPhase.Opening + let hydrationOutputCapacity = MIN_HYDRATION_OUTPUT_BYTES + let hydrationState: HydrationOutputState = HydrationOutputState.Waiting + let hydrationError: unknown + let hydrationListener: (() => void) | undefined + let hydrationOutput: + | { + readonly state: number + readonly error: unknown + pullChunk(): Uint8Array + subscribe(onChange: () => void): () => void + } + | undefined + let liftBarrier!: () => void + const barrier = new Promise((resolve) => { + liftBarrier = resolve + }) + + function hydrationQueueIsEmpty() { + return hydrationSourceHead === hydrationSources.length + } + + function refreshHydrationState(notify = true) { + const next = hydrationSource + ? HydrationOutputState.Active + : !hydrationQueueIsEmpty() + ? HydrationOutputState.Ready + : finished + ? HydrationOutputState.Done + : HydrationOutputState.Waiting + if (hydrationState !== next) { + hydrationState = next + if (notify) { + hydrationListener?.() + } + } + } + + function releaseHydrationSource() { + hydrationSources[hydrationSourceHead++] = undefined + if (hydrationQueueIsEmpty()) { + hydrationSources.length = 0 + hydrationSourceHead = 0 + } + hydrationSource = undefined + hydrationSegment = '' + hydrationSegmentOffset = 0 + hydrationPhase = HydrationRecordPhase.Opening + refreshHydrationState(false) + } + + function startHydrationSource(codeUnits: number) { + hydrationSource = hydrationSources[hydrationSourceHead]! + hydrationPhase = HydrationRecordPhase.Opening + hydrationSegment = SCRIPT_OPEN + hydrationSegmentOffset = 0 + hydrationOutputCapacity = Math.max( + MIN_HYDRATION_OUTPUT_BYTES, + Math.min(OUTPUT_BYTES, codeUnits), + ) + hydrationState = HydrationOutputState.Active + } + + function advanceHydrationSegment() { + if (hydrationPhase === HydrationRecordPhase.Opening) { + hydrationPhase = HydrationRecordPhase.Source + hydrationSegment = hydrationSource! + hydrationSegmentOffset = 0 + } else if (hydrationPhase === HydrationRecordPhase.Source) { + hydrationPhase = HydrationRecordPhase.Closing + hydrationSegment = SCRIPT_CLOSE + hydrationSegmentOffset = 0 + } else { + releaseHydrationSource() + } + } + + function pullHydrationChunk() { + hydrationOutputPulls++ + if ( + hydrationState !== HydrationOutputState.Ready && + hydrationState !== HydrationOutputState.Active + ) { + throw new Error('Benchmark hydration output is not ready') + } + + try { + if (hydrationState === HydrationOutputState.Ready) { + const source = hydrationSources[hydrationSourceHead]! + const codeUnits = + SCRIPT_OPEN.length + source.length + SCRIPT_CLOSE.length + if (codeUnits <= DIRECT_HYDRATION_CODE_UNITS) { + const bytes = hydrationEncoder.encode( + SCRIPT_OPEN + source + SCRIPT_CLOSE, + ) + hydrationSource = source + releaseHydrationSource() + return bytes + } + startHydrationSource(codeUnits) + } + + const bytes = new Uint8Array(hydrationOutputCapacity) + let outputOffset = 0 + while (hydrationSource) { + if (hydrationSegmentOffset === hydrationSegment.length) { + advanceHydrationSegment() + } else if (outputOffset === bytes.length) { + break + } else { + const target = + outputOffset === 0 ? bytes : bytes.subarray(outputOffset) + const result = hydrationEncoder.encodeInto( + hydrationSegment.slice(hydrationSegmentOffset), + target, + ) + if (result.read === 0) { + if (outputOffset === 0) { + throw new Error('Benchmark hydration encoder made no progress') + } + break + } + hydrationSegmentOffset += result.read + outputOffset += result.written + } + } + if (outputOffset === 0) { + throw new Error('Benchmark hydration record produced no output') + } + return outputOffset === bytes.length + ? bytes + : bytes.subarray(0, outputOffset) + } catch (error) { + hydrationError = error + hydrationState = HydrationOutputState.Failed + hydrationListener?.() + throw error + } + } + + function reserveFastPath(output?: typeof hydrationOutput) { + reserveCalls++ + if ( + !finished || + streamFastPathReserved || + pendingScripts.length > 0 || + hydrationSource !== undefined || + !hydrationQueueIsEmpty() || + (hydrationOutput ? output !== hydrationOutput : output !== undefined) + ) { + return false + } + streamFastPathReserved = true + fastPathGrants++ + return true + } + + function claimOutput() { + hydrationOutputClaims++ + if (hydrationOutput) { + throw new Error('Benchmark hydration output already has a consumer') + } + hydrationOutput = { + get state() { + return hydrationState + }, + get error() { + return hydrationError + }, + pullChunk: pullHydrationChunk, + subscribe(onChange: () => void) { + hydrationOutputSubscriptions++ + if (hydrationListener) { + throw new Error('Benchmark hydration output already has a subscriber') + } + hydrationListener = onChange + return () => { + if (hydrationListener === onChange) { + hydrationListener = undefined + } + } + }, + } + refreshHydrationState(false) + return hydrationOutput + } + + const hydrationScripts = { + reserveFastPath, + claimOutput, + liftBarrier, + // The worktree transform skips barrier scanning until the initial take. + // The synthetic fixture renders the boundary in its input, so report the + // take as already done. + isInitialTaken: () => true, + // The worktree transform arms the serialization deadline through the + // transport; the fixture manages its own timing. + startSerializationTimeout: (_timeoutMs: number) => {}, + } + + const serverSsr = { + hydrationScripts, + isSerializationFinished: () => finished, + // Keep the old transport shape so this one fixture can run origin and the + // candidate baseline against the same synthetic request. + reserveStreamFastPath: reserveFastPath, + claimHydrationScriptOutput: claimOutput, + onScriptBatch(listener: (parts: ReadonlyArray) => void) { + scriptSubscriptions++ + scriptListeners.add(listener) + if (pendingScripts.length > 0) { + const parts = pendingScripts.splice(0) + listener(parts) + } + return () => scriptListeners.delete(listener) + }, + onInjectedHtml(listener: (html: string) => void) { + htmlSubscriptions++ + htmlListeners.add(listener) + return () => htmlListeners.delete(listener) + }, + takeBufferedHtml() { + bufferedHtmlReads++ + return bufferedHtml.shift() + }, + onSerializationFinished(listener: () => void) { + finishedListeners.add(listener) + return () => finishedListeners.delete(listener) + }, + setRenderFinished() { + renderFinishedCount++ + liftBarrier() + }, + liftScriptBarrier: liftBarrier, + // The worktree transform registers a cleanup listener to tear down + // promptly on external cleanup. The fixture drives termination itself, + // so registration is accepted and ignored. + onCleanup(_listener: () => void) {}, + cleanup() { + cleanupCount++ + scriptListeners.clear() + htmlListeners.clear() + finishedListeners.clear() + pendingScripts.length = 0 + bufferedHtml.length = 0 + hydrationSources.length = 0 + hydrationSourceHead = 0 + hydrationSource = undefined + hydrationSegment = '' + hydrationSegmentOffset = 0 + hydrationPhase = HydrationRecordPhase.Opening + hydrationOutputCapacity = MIN_HYDRATION_OUTPUT_BYTES + hydrationListener = undefined + hydrationOutput = undefined + hydrationError = undefined + hydrationState = HydrationOutputState.Done + }, + } + return { + router: { options: {}, serverSsr }, + emit(source: string) { + if (hydrationOutput) { + hydrationSources.push(source) + refreshHydrationState() + } else if (scriptListeners.size > 0) { + for (const listener of scriptListeners) { + listener([source]) + } + } else { + pendingScripts.push(source) + } + if (htmlListeners.size > 0) { + const html = SCRIPT_OPEN + source + SCRIPT_CLOSE + bufferedHtml.push(html) + for (const listener of htmlListeners) { + listener(html) + } + if (bufferedHtml[0] === html) { + bufferedHtml.shift() + } + } + }, + finish() { + finished = true + refreshHydrationState() + for (const listener of finishedListeners) { + listener() + } + }, + barrier, + counts: () => ({ + cleanupCount, + renderFinishedCount, + reserveCalls, + fastPathGrants, + scriptSubscriptions, + htmlSubscriptions, + bufferedHtmlReads, + hydrationOutputClaims, + hydrationOutputSubscriptions, + hydrationOutputPulls, + }), + } +} + +function createInput( + scenario: Scenario, + mode: Mode, + onEof: () => void, + beforeEof?: Promise, + onBeforeEof?: () => void, +) { + if (mode === 'merge' && scenario.kind === 'application-string') { + return streamFromIterator( + [createApplicationString(scenario)][Symbol.iterator](), + onEof, + beforeEof, + onBeforeEof, + ) + } + if (mode === 'merge' && scenario.kind === 'hydration') { + return streamFromIterator( + [Buffer.from(PREFIX), Buffer.from(SUFFIX)][Symbol.iterator](), + onEof, + beforeEof, + onBeforeEof, + ) + } + if (mode === 'merge' && scenario.kind === 'renderer-records') { + return streamFromIterator( + rendererRecordApplicationBytes(scenario), + onEof, + beforeEof, + onBeforeEof, + ) + } + return streamFromIterator( + integratedBytes(scenario), + onEof, + beforeEof, + onBeforeEof, + ) +} + +function findNeedle(chunk: Uint8Array, needle: Buffer, tail: Buffer) { + const value = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + let count = 0 + + if (tail.length > 0 && value.length > 0) { + const headLength = Math.min(value.length, needle.length - 1) + const boundary = Buffer.allocUnsafe(tail.length + headLength) + boundary.set(tail) + boundary.set(value.subarray(0, headLength), tail.length) + let offset = 0 + for (;;) { + const found = boundary.indexOf(needle, offset) + if (found === -1) { + break + } + if (found < tail.length && found + needle.length > tail.length) { + count++ + } + offset = found + 1 + } + } + + let offset = 0 + for (;;) { + const found = value.indexOf(needle, offset) + if (found === -1) { + break + } + count++ + offset = found + 1 + } + + const keep = Math.min(needle.length - 1, tail.length + value.length) + let nextTail: Buffer + if (keep === 0) { + nextTail = Buffer.alloc(0) + } else if (value.length >= keep) { + nextTail = Buffer.from(value.subarray(value.length - keep)) + } else { + nextTail = Buffer.allocUnsafe(keep) + const tailBytes = keep - value.length + nextTail.set(tail.subarray(tail.length - tailBytes)) + nextTail.set(value, tailBytes) + } + return { + count, + tail: nextTail, + } +} + +export async function run( + transform: Transform, + implementation: string, + scenarioName: string, + mode: Mode, + writeResult = true, + measureMemory = true, + validateOutput = true, +) { + const scenario = parseScenario(scenarioName) + const expectedOutput = getExpectedOutput(scenario, mode) + const routerSource = getRouterSource(scenario) + const containsRouterScripts = routerSource !== undefined + const hasLateScripts = mode === 'merge' && containsRouterScripts + const fake = makeRouter(mode !== 'merge') + let resolveProducerComplete!: () => void + const producerComplete = new Promise((resolve) => { + resolveProducerComplete = resolve + }) + if (!hasLateScripts) { + resolveProducerComplete() + } + let resolveAppContentConsumed!: () => void + const appContentConsumed = new Promise((resolve) => { + resolveAppContentConsumed = resolve + }) + if (scenario.kind !== 'renderer-records' || mode !== 'merge') { + resolveAppContentConsumed() + } + let startedAt = 0 + let rendererEofMs: number | null = null + const input = createInput( + scenario, + mode, + () => { + rendererEofMs = performance.now() - startedAt + if (mode === 'merge' && !hasLateScripts) { + fake.finish() + } + }, + hasLateScripts ? producerComplete : undefined, + resolveAppContentConsumed, + ) + if (measureMemory) { + forceGc() + } + const baseline = measureMemory ? memory() : undefined + const peak: Memory = { heapUsed: 0, external: 0, arrayBuffers: 0, rss: 0 } + const sample = () => { + if (!measureMemory) { + return + } + const value = delta(memory(), baseline!) + maxMemory(peak, value) + } + + startedAt = performance.now() + const output = + mode === 'raw' + ? (input as ReadableStream) + : transform( + fake.router, + input, + scenario.rendererSafePoint + ? { rendererSafePoint: scenario.rendererSafePoint } + : {}, + ) + + let sourceProducerMs = 0 + let producerFinishedMs: number | null = null + let outstandingSourceRecords = 0 + let outstandingSourceCodeUnits = 0 + let sourceRecordHighWater = 0 + let sourceCodeUnitHighWater = 0 + const producer = hasLateScripts + ? (async () => { + await fake.barrier + await appContentConsumed + const sourceStartedAt = performance.now() + for (let index = 0; index < routerSource!.records; index++) { + const source = createSource( + routerSource!.recordBytes, + index, + routerSource!.shape, + ) + outstandingSourceRecords++ + outstandingSourceCodeUnits += source.length + sourceRecordHighWater = Math.max( + sourceRecordHighWater, + outstandingSourceRecords, + ) + sourceCodeUnitHighWater = Math.max( + sourceCodeUnitHighWater, + outstandingSourceCodeUnits, + ) + fake.emit(source) + if (index % 16 === 15) { + await new Promise((resolve) => setImmediate(resolve)) + } + } + fake.finish() + sourceProducerMs = performance.now() - sourceStartedAt + producerFinishedMs = performance.now() - startedAt + sample() + resolveProducerComplete() + })() + : Promise.resolve() + + const reader = output.getReader() + const hash = validateOutput ? createHash('sha256') : undefined + const firstScriptNeedle = Buffer.from(marker(0)) + const completedScriptNeedle = Buffer.from(SCRIPT_CLOSE) + let firstScriptTail: Buffer = Buffer.alloc(0) + let completedScriptTail: Buffer = Buffer.alloc(0) + let firstRouterScriptMs: number | null = null + let bytes = 0 + let chunks = 0 + let maxOutputChunkBytes = 0 + let ttfbMs: number | null = null + for (;;) { + const result = await reader.read() + if (result.done) { + break + } + const now = performance.now() + if (ttfbMs === null) { + ttfbMs = now - startedAt + } + const value = result.value + bytes += value.byteLength + chunks++ + maxOutputChunkBytes = Math.max(maxOutputChunkBytes, value.byteLength) + hash?.update(value) + if ( + validateOutput && + containsRouterScripts && + firstRouterScriptMs === null + ) { + const match = findNeedle(value, firstScriptNeedle, firstScriptTail) + firstScriptTail = match.tail + if (match.count > 0) { + firstRouterScriptMs = now - startedAt + } + } + if (validateOutput && hasLateScripts) { + const completed = findNeedle( + value, + completedScriptNeedle, + completedScriptTail, + ) + completedScriptTail = completed.tail + if (completed.count > 0) { + outstandingSourceRecords -= completed.count + outstandingSourceCodeUnits -= + completed.count * routerSource!.recordBytes + } + } + if (chunks % 16 === 0) { + sample() + } + } + await producer + sample() + const elapsedMs = performance.now() - startedAt + const digest = hash?.digest('hex') ?? expectedOutput.digest + const memoryBeforeFinalGc = measureMemory ? memory() : undefined + if (measureMemory) { + maxMemory(peak, delta(memoryBeforeFinalGc!, baseline!)) + forceGc() + } + const finalMemory = measureMemory ? memory() : undefined + const counts = fake.counts() + let selectedApi: + | 'none' + | 'hydration-output' + | 'script-batch' + | 'injected-html' + | 'buffered-html' = 'none' + if ( + bytes !== expectedOutput.bytes || + (validateOutput && digest !== expectedOutput.digest) + ) { + throw new Error( + `Output validation failed: ${bytes}/${digest} !== ${expectedOutput.bytes}/${expectedOutput.digest}`, + ) + } + if (validateOutput && hasLateScripts && outstandingSourceRecords !== 0) { + throw new Error( + `Only ${routerSource!.records - outstandingSourceRecords} router records completed`, + ) + } + if (validateOutput && containsRouterScripts && firstRouterScriptMs === null) { + throw new Error('The output did not contain the first router script') + } + if ( + validateOutput && + hasLateScripts && + scenario.kind === 'hydration' && + (firstRouterScriptMs === null || + rendererEofMs === null || + firstRouterScriptMs >= rendererEofMs) + ) { + throw new Error( + `Router source did not start before renderer EOF: ${firstRouterScriptMs}/${rendererEofMs}`, + ) + } + if ( + validateOutput && + hasLateScripts && + (producerFinishedMs === null || + rendererEofMs === null || + producerFinishedMs > rendererEofMs) + ) { + throw new Error( + `Router producer finished after renderer EOF: ${producerFinishedMs}/${rendererEofMs}`, + ) + } + if ( + mode !== 'raw' && + (counts.cleanupCount < 1 || counts.renderFinishedCount !== 1) + ) { + throw new Error(`Invalid lifecycle counts: ${JSON.stringify(counts)}`) + } + if ( + mode === 'fast' && + (counts.reserveCalls !== 1 || + counts.fastPathGrants !== 1 || + counts.hydrationOutputClaims !== 0 || + counts.hydrationOutputSubscriptions !== 0 || + counts.hydrationOutputPulls !== 0) + ) { + throw new Error( + `Fast path was not selected exactly once: ${JSON.stringify(counts)}`, + ) + } + if (mode === 'merge') { + const mergeApi = + counts.hydrationOutputClaims === 1 && + counts.hydrationOutputSubscriptions === 1 && + counts.scriptSubscriptions === 0 && + counts.htmlSubscriptions === 0 + ? 'hydration-output' + : counts.hydrationOutputClaims === 0 && + counts.scriptSubscriptions === 1 && + counts.htmlSubscriptions === 0 + ? 'script-batch' + : counts.hydrationOutputClaims === 0 && + counts.scriptSubscriptions === 0 && + counts.htmlSubscriptions === 1 + ? counts.bufferedHtmlReads > 0 + ? 'buffered-html' + : 'injected-html' + : undefined + if (!mergeApi || counts.reserveCalls < 1) { + throw new Error( + `Merge path/API assertion failed: ${JSON.stringify(counts)}`, + ) + } + selectedApi = mergeApi + } + const dynamicPassThroughSelected = + mode === 'merge' && counts.fastPathGrants === 1 + const result = { + implementation, + scenario: scenario.name, + framework: scenario.framework, + rendererSafePoint: scenario.rendererSafePoint, + mode, + memoryMeasured: measureMemory, + selectedApi, + dynamicPassThroughSelected, + outputBytes: bytes, + outputSha256: digest, + chunks, + maxOutputChunkBytes, + ttfbMs, + firstRouterScriptMs, + producerFinishedMs, + rendererEofMs, + elapsedMs, + throughputMiBPerSecond: bytes / MiB / (elapsedMs / 1000), + sourceProducerMs, + sourceRecordHighWater, + sourceCodeUnitHighWater, + memoryBaselineBytes: baseline, + memoryPeakBytes: measureMemory ? peak : undefined, + memoryBeforeFinalGcBytes: memoryBeforeFinalGc, + memoryBeforeFinalGcDeltaBytes: measureMemory + ? delta(memoryBeforeFinalGc!, baseline!) + : undefined, + memoryAfterFinalGcBytes: finalMemory, + memoryAfterFinalGcDeltaBytes: measureMemory + ? delta(finalMemory!, baseline!) + : undefined, + resourceUsage: measureMemory ? process.resourceUsage() : undefined, + lifecycle: counts, + } + if (writeResult) { + process.stdout.write(`${JSON.stringify(result)}\n`) + } + return result +} + +type NumericSummary = { + mean: number + median: number + p10: number + p90: number + min: number + max: number +} + +function summarizeNumbers(values: Array): NumericSummary { + const sorted = values.slice().sort((a, b) => a - b) + const at = (fraction: number) => + sorted[Math.floor((sorted.length - 1) * fraction)]! + const middle = Math.floor(sorted.length / 2) + const median = + sorted.length % 2 === 0 + ? (sorted[middle - 1]! + sorted[middle]!) / 2 + : sorted[middle]! + return { + mean: values.reduce((sum, value) => sum + value, 0) / values.length, + median, + p10: at(0.1), + p90: at(0.9), + min: sorted[0]!, + max: sorted[sorted.length - 1]!, + } +} + +type MeasuredRun = Awaited> + +type WarmBatch = { + requests: number + elapsedMs: number + wallElapsedMs: number + ttfbMs: number + outputBytes: number + outputSha256: string + chunks: number + maxOutputChunkBytes: number + sourceRecordHighWater: number + dynamicPassThroughRequests: number +} + +type WarmBatchAccumulator = Omit< + WarmBatch, + 'outputBytes' | 'outputSha256' | 'chunks' +> & { + first?: MeasuredRun +} + +function createWarmBatchAccumulator(requests: number): WarmBatchAccumulator { + return { + requests, + elapsedMs: 0, + wallElapsedMs: 0, + ttfbMs: 0, + maxOutputChunkBytes: 0, + sourceRecordHighWater: 0, + dynamicPassThroughRequests: 0, + } +} + +function recordWarmRun( + batch: WarmBatchAccumulator, + row: MeasuredRun, + wallElapsedMs: number, +) { + batch.first ??= row + batch.elapsedMs += row.elapsedMs + batch.wallElapsedMs += wallElapsedMs + batch.ttfbMs += row.ttfbMs! + batch.maxOutputChunkBytes = Math.max( + batch.maxOutputChunkBytes, + row.maxOutputChunkBytes, + ) + batch.sourceRecordHighWater = Math.max( + batch.sourceRecordHighWater, + row.sourceRecordHighWater, + ) + if (row.dynamicPassThroughSelected) { + batch.dynamicPassThroughRequests++ + } +} + +function finishWarmBatch(batch: WarmBatchAccumulator): WarmBatch { + const { first, ...totals } = batch + if (!first) { + throw new Error('A warm benchmark batch did not run any requests') + } + return { + ...totals, + outputBytes: first.outputBytes, + outputSha256: first.outputSha256, + chunks: first.chunks, + } +} + +async function runWarmPair( + baselineName: BaselineImplementation, + baselineTransform: Transform, + worktreeTransform: Transform, + scenario: Scenario, + mode: Mode, + requests: number, + startWith: 'baseline' | 'worktree', +) { + const batches = { + baseline: createWarmBatchAccumulator(requests), + worktree: createWarmBatchAccumulator(requests), + } + + for (let index = 0; index < requests; index++) { + const baselineFirst = (index & 1) === (startWith === 'baseline' ? 0 : 1) + let implementation: 'baseline' | 'worktree' = baselineFirst + ? 'baseline' + : 'worktree' + for (let position = 0; position < 2; position++) { + const wallStartedAt = performance.now() + const row = await run( + implementation === 'baseline' ? baselineTransform : worktreeTransform, + implementation === 'baseline' ? baselineName : 'worktree', + scenario.name, + mode, + false, + false, + false, + ) + recordWarmRun( + batches[implementation], + row, + performance.now() - wallStartedAt, + ) + implementation = implementation === 'baseline' ? 'worktree' : 'baseline' + } + } + + return { + baseline: finishWarmBatch(batches.baseline), + worktree: finishWarmBatch(batches.worktree), + } +} + +function summarizeWarmBatches( + implementation: string, + scenario: Scenario, + mode: Mode, + rows: Array, +) { + const batchRequests = rows[0]!.requests + const elapsedMs = summarizeNumbers( + rows.map((row) => row.elapsedMs / row.requests), + ) + const batchElapsedMs = summarizeNumbers(rows.map((row) => row.elapsedMs)) + const batchWallElapsedMs = summarizeNumbers( + rows.map((row) => row.wallElapsedMs), + ) + const ttfbMs = summarizeNumbers(rows.map((row) => row.ttfbMs / row.requests)) + const throughputMiBPerSecond = summarizeNumbers( + rows.map( + (row) => (row.outputBytes * row.requests) / MiB / (row.elapsedMs / 1000), + ), + ) + const dynamicPassThroughRequests = rows.reduce( + (total, row) => total + row.dynamicPassThroughRequests, + 0, + ) + return { + implementation, + scenario: scenario.name, + framework: scenario.framework, + rendererSafePoint: scenario.rendererSafePoint, + mode, + measurement: 'warm', + memoryMeasured: false, + iterations: rows.length, + batchRequests, + measuredRequests: rows.length * batchRequests, + dynamicPassThroughRequests, + dynamicPassThroughRate: + dynamicPassThroughRequests / (rows.length * batchRequests), + outputBytes: rows[0]!.outputBytes, + outputSha256: rows[0]!.outputSha256, + chunks: rows[0]!.chunks, + maxOutputChunkBytes: Math.max( + ...rows.map((row) => row.maxOutputChunkBytes), + ), + ttfbMs: ttfbMs.median, + elapsedMs: elapsedMs.median, + throughputMiBPerSecond: throughputMiBPerSecond.median, + sourceRecordHighWater: Math.max( + ...rows.map((row) => row.sourceRecordHighWater), + ), + statistics: { + elapsedMs, + batchElapsedMs, + batchWallElapsedMs, + ttfbMs, + throughputMiBPerSecond, + }, + } +} + +function summarizePercentRatios(values: Array) { + const logs = summarizeNumbers(values.map((value) => Math.log(value))) + const toPercent = (value: number) => (Math.exp(value) - 1) * 100 + return { + geometricMean: toPercent(logs.mean), + median: toPercent(logs.median), + p10: toPercent(logs.p10), + p90: toPercent(logs.p90), + min: toPercent(logs.min), + max: toPercent(logs.max), + } +} + +async function measureWarmComparison( + baselineName: BaselineImplementation, + baselineTransform: Transform, + worktreeTransform: Transform, + scenarioNames: Array, + requestedModes: Array, + warmups: number, + iterations: number, + batchRequests: number, +) { + const results = [] + const comparisons = [] + + for (const scenarioName of scenarioNames) { + const scenario = parseScenario(scenarioName) + const availableModes: Array = + scenario.kind === 'application-string' || + scenario.kind === 'coalesced-close' + ? ['merge'] + : scenario.kind !== 'renderer-records' && scenario.framework === 'react' + ? ['fast', 'merge'] + : ['merge'] + const modes = availableModes.filter((mode) => requestedModes.includes(mode)) + for (const mode of modes) { + await run( + baselineTransform, + baselineName, + scenario.name, + mode, + false, + false, + true, + ) + await run( + worktreeTransform, + 'worktree', + scenario.name, + mode, + false, + false, + true, + ) + forceGc() + for (let index = 0; index < warmups; index++) { + const order: Array<'baseline' | 'worktree'> = + index % 2 === 0 ? ['baseline', 'worktree'] : ['worktree', 'baseline'] + for (const implementation of order) { + await run( + implementation === 'baseline' + ? baselineTransform + : worktreeTransform, + implementation === 'baseline' ? baselineName : 'worktree', + scenario.name, + mode, + false, + false, + ) + } + } + + const rows: Record<'baseline' | 'worktree', Array> = { + baseline: [], + worktree: [], + } + const elapsedRatios: Array = [] + const wallElapsedRatios: Array = [] + for (let index = 0; index < iterations; index++) { + const pair = await runWarmPair( + baselineName, + baselineTransform, + worktreeTransform, + scenario, + mode, + batchRequests, + index % 2 === 0 ? 'baseline' : 'worktree', + ) + rows.baseline.push(pair.baseline) + rows.worktree.push(pair.worktree) + elapsedRatios.push(pair.worktree.elapsedMs / pair.baseline.elapsedMs) + wallElapsedRatios.push( + pair.worktree.wallElapsedMs / pair.baseline.wallElapsedMs, + ) + } + + const baseline = summarizeWarmBatches( + baselineName, + scenario, + mode, + rows.baseline, + ) + const worktree = summarizeWarmBatches( + 'worktree', + scenario, + mode, + rows.worktree, + ) + const baselineElapsedTotal = rows.baseline.reduce( + (total, row) => total + row.elapsedMs, + 0, + ) + const worktreeElapsedTotal = rows.worktree.reduce( + (total, row) => total + row.elapsedMs, + 0, + ) + const baselineWallTotal = rows.baseline.reduce( + (total, row) => total + row.wallElapsedMs, + 0, + ) + const worktreeWallTotal = rows.worktree.reduce( + (total, row) => total + row.wallElapsedMs, + 0, + ) + results.push( + { ...baseline, comparisonBaseline: baselineName }, + { ...worktree, comparisonBaseline: baselineName }, + ) + comparisons.push({ + baseline: baselineName, + scenario: scenario.name, + framework: scenario.framework, + rendererSafePoint: scenario.rendererSafePoint, + mode, + pairedElapsedPercent: summarizePercentRatios(elapsedRatios), + pairedWallElapsedPercent: summarizePercentRatios(wallElapsedRatios), + aggregateElapsedPercent: + (worktreeElapsedTotal / baselineElapsedTotal - 1) * 100, + aggregateWallElapsedPercent: + (worktreeWallTotal / baselineWallTotal - 1) * 100, + elapsedMedianPercent: + (worktree.statistics.elapsedMs.median / + baseline.statistics.elapsedMs.median - + 1) * + 100, + samples: rows.baseline.map((baselineRow, index) => ({ + baselineElapsedMs: baselineRow.elapsedMs, + candidateElapsedMs: rows.worktree[index]!.elapsedMs, + baselineWallElapsedMs: baselineRow.wallElapsedMs, + candidateWallElapsedMs: rows.worktree[index]!.wallElapsedMs, + elapsedRatio: elapsedRatios[index]!, + wallElapsedRatio: wallElapsedRatios[index]!, + })), + }) + } + } + + return { + warmups, + iterations, + batchRequests, + measuredRequestsPerImplementation: iterations * batchRequests, + scenarios: scenarioNames, + order: `${baselineName}/worktree order alternates for every request and reverses at each batch start`, + validation: + 'SHA-256 and marker checks in untimed preflight/warm-up requests; timed requests retain byte, lifecycle, and API checks', + source: + 'fresh renderer bytes for fast mode and one fresh router source string for merge mode', + memory: 'not measured in the warm latency loop', + garbageCollection: + 'forced once before warm-up; natural GC remains enabled for all measured batches', + results, + comparisons, + } +} + +export function runWarmComparison( + transforms: Record<'origin' | 'worktree', Transform>, + scenarioNames: Array, + requestedModes: Array, + warmups: number, + iterations: number, + batchRequests: number, +) { + return measureWarmComparison( + 'origin', + transforms.origin, + transforms.worktree, + scenarioNames, + requestedModes, + warmups, + iterations, + batchRequests, + ) +} + +function safeWindowEnd(value: string, start: number, wantedEnd: number) { + const end = Math.min(wantedEnd, value.length) + if (end <= start || end >= value.length) { + return end + } + const previous = value.charCodeAt(end - 1) + const next = value.charCodeAt(end) + return previous >= 0xd800 && + previous <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff + ? end - 1 + : end +} + +export function runProbe( + shape: Shape, + sizeMiB: number, + strategy: 'suffix' | 'window', +) { + const bytes = sizeMiB * MiB + const source = createSource(bytes, 0, shape) + const expectedHash = createHash('sha256') + updateSource(expectedHash, bytes, 0) + const expectedDigest = expectedHash.digest('hex') + forceGc() + const baseline = memory() + const encoder = new TextEncoder() + const passes = [] + for (let pass = 1; pass <= 2; pass++) { + const hash = createHash('sha256') + const peak: Memory = { heapUsed: 0, external: 0, arrayBuffers: 0, rss: 0 } + let offset = 0 + let chunks = 0 + const startedAt = performance.now() + while (offset < source.length) { + const output = new Uint8Array(OUTPUT_BYTES) + const input = + strategy === 'suffix' + ? source.slice(offset) + : source.slice( + offset, + safeWindowEnd(source, offset, offset + OUTPUT_BYTES), + ) + const encoded = encoder.encodeInto(input, output) + if (encoded.read === 0) { + throw new Error('Probe encoder made no progress') + } + offset += encoded.read + hash.update(output.subarray(0, encoded.written)) + chunks++ + if (chunks % 16 === 0) { + maxMemory(peak, delta(memory(), baseline)) + } + } + const elapsedMs = performance.now() - startedAt + const digest = hash.digest('hex') + if (digest !== expectedDigest || offset !== source.length) { + throw new Error('Source-window probe validation failed') + } + maxMemory(peak, delta(memory(), baseline)) + forceGc() + const afterGc = delta(memory(), baseline) + passes.push({ + pass, + elapsedMs, + throughputMiBPerSecond: bytes / MiB / (elapsedMs / 1000), + chunks, + memoryPeakBytes: peak, + memoryAfterGcBytes: afterGc, + }) + } + process.stdout.write( + `${JSON.stringify({ + implementation: 'v8-probe', + scenario: `encode-${shape}-${sizeMiB}m`, + mode: strategy, + sourceReadyBaselineBytes: baseline, + outputBytes: bytes, + outputChunkBytes: OUTPUT_BYTES, + outputSha256: expectedDigest, + passes, + })}\n`, + ) +} + +type HydrationOwner = { + takeInitialHydrationScriptTags(): unknown + claimOutput(): { + state: number + pullChunk(): Uint8Array + } + liftBarrier(): void + pushSource(source: string): void + finish(): void + cleanup(): void +} + +type CreateHydrationOwner = ( + nonce: string | undefined, + initialSources: ReadonlyArray, +) => HydrationOwner + +type HydrationOwnerExpected = { + records: number + recordBytes: number + bytes: number + digest: string +} + +const hydrationOwnerExpectedCache = new Map() + +function getHydrationOwnerExpected(scenarioName: string) { + const cached = hydrationOwnerExpectedCache.get(scenarioName) + if (cached) { + return cached + } + const match = /^hydration-owner-(\d+)x(\d+)k$/.exec(scenarioName) + if (!match) { + throw new Error(`Unknown hydration-owner scenario: ${scenarioName}`) + } + const records = Number(match[1]) + const recordBytes = Number(match[2]) * 1024 + const hash = createHash('sha256') + let bytes = 0 + const addExpected = (value: string) => { + hash.update(value) + bytes += Buffer.byteLength(value) + } + let partIndex = 0 + while (partIndex <= records) { + const batchStart = partIndex + let codeUnits = SCRIPT_OPEN.length + SCRIPT_CLOSE.length - 1 + while (partIndex <= records) { + const partLength = + partIndex === records ? '$_TSR.e()'.length : recordBytes + const nextCodeUnits = codeUnits + 1 + partLength + if (partIndex > batchStart && nextCodeUnits > OUTPUT_BYTES) { + break + } + codeUnits = nextCodeUnits + partIndex++ + if (codeUnits > OUTPUT_BYTES) { + break + } + } + addExpected(SCRIPT_OPEN) + for (let index = batchStart; index < partIndex; index++) { + if (index > batchStart) { + addExpected(';') + } + if (index === records) { + addExpected('$_TSR.e()') + } else { + updateSource(hash, recordBytes, index) + bytes += recordBytes + } + } + addExpected(SCRIPT_CLOSE) + } + const expected = { + records, + recordBytes, + bytes, + digest: hash.digest('hex'), + } + hydrationOwnerExpectedCache.set(scenarioName, expected) + return expected +} + +function measureHydrationOwner( + createHydrationOwner: CreateHydrationOwner, + implementation: string, + scenarioName: string, + measureMemory: boolean, + validateOutput = true, +) { + const expected = getHydrationOwnerExpected(scenarioName) + if (measureMemory) { + forceGc() + } + const baseline = measureMemory ? memory() : undefined + const peak: Memory = { heapUsed: 0, external: 0, arrayBuffers: 0, rss: 0 } + const sample = () => { + if (measureMemory) { + maxMemory(peak, delta(memory(), baseline!)) + } + } + const owner = createHydrationOwner(undefined, []) + owner.takeInitialHydrationScriptTags() + const output = owner.claimOutput() + owner.liftBarrier() + for (let index = 0; index < expected.records; index++) { + owner.pushSource(createSource(expected.recordBytes, index, 'flat')) + } + owner.finish() + sample() + const hash = validateOutput ? createHash('sha256') : undefined + let bytes = 0 + let chunks = 0 + let maxOutputChunkBytes = 0 + const startedAt = performance.now() + while ( + output.state === HydrationOutputState.Ready || + output.state === HydrationOutputState.Active + ) { + const chunk = output.pullChunk() + hash?.update(chunk) + bytes += chunk.byteLength + chunks++ + maxOutputChunkBytes = Math.max(maxOutputChunkBytes, chunk.byteLength) + if (measureMemory && (chunks & 15) === 0) { + sample() + } + } + const elapsedMs = performance.now() - startedAt + if (measureMemory) { + sample() + } + const digest = hash?.digest('hex') ?? expected.digest + if ( + output.state !== HydrationOutputState.Done || + bytes !== expected.bytes || + (validateOutput && digest !== expected.digest) || + maxOutputChunkBytes > OUTPUT_BYTES + ) { + throw new Error( + `Hydration-owner validation failed: ${output.state}/${bytes}/${digest}/${maxOutputChunkBytes}`, + ) + } + owner.cleanup() + return { + implementation, + scenario: scenarioName, + framework: 'router-core', + mode: 'owner', + outputBytes: bytes, + outputSha256: digest, + chunks, + maxOutputChunkBytes, + elapsedMs, + throughputMiBPerSecond: bytes / MiB / (elapsedMs / 1000), + memoryPeakBytes: measureMemory ? peak : undefined, + sourceRecordHighWater: expected.records + 1, + } +} + +export function runHydrationOwner( + createHydrationOwner: CreateHydrationOwner, + implementation: string, + scenarioName: string, +) { + process.stdout.write( + `${JSON.stringify( + measureHydrationOwner( + createHydrationOwner, + implementation, + scenarioName, + true, + ), + )}\n`, + ) +} + +export function runHydrationOwnerSoak( + createHydrationOwner: CreateHydrationOwner, + implementation: string, + scenarioName: string, +) { + const expected = getHydrationOwnerExpected(scenarioName) + const requestsPerBlock = expected.records > 1000 ? 5 : 500 + const blocks = 10 + measureHydrationOwner( + createHydrationOwner, + implementation, + scenarioName, + false, + true, + ) + for (let index = 0; index < Math.min(100, requestsPerBlock); index++) { + measureHydrationOwner( + createHydrationOwner, + implementation, + scenarioName, + false, + false, + ) + } + forceGc() + const baseline = memory() + const highWater: Memory = { + heapUsed: 0, + external: 0, + arrayBuffers: 0, + rss: 0, + } + const checkpoints = [] + const startedAt = performance.now() + for (let block = 1; block <= blocks; block++) { + for (let request = 0; request < requestsPerBlock; request++) { + measureHydrationOwner( + createHydrationOwner, + implementation, + scenarioName, + false, + false, + ) + } + forceGc() + const retained = delta(memory(), baseline) + maxMemory(highWater, retained) + checkpoints.push({ block, requests: block * requestsPerBlock, retained }) + } + const elapsedMs = performance.now() - startedAt + const finalRetained = checkpoints.at(-1)!.retained + process.stdout.write( + `${JSON.stringify({ + implementation, + scenario: scenarioName, + framework: 'router-core', + mode: 'owner-soak', + requests: blocks * requestsPerBlock, + aggregateOutputBytes: expected.bytes * blocks * requestsPerBlock, + validatedPerRequestOutputSha256: expected.digest, + elapsedIncludingGcMs: elapsedMs, + throughputIncludingGcMiBPerSecond: + (expected.bytes * blocks * requestsPerBlock) / MiB / (elapsedMs / 1000), + retainedHighWaterBytes: highWater, + retainedAfterFinalGcBytes: finalRetained, + retentionCheckpoints: checkpoints, + sourceRecordHighWater: expected.records + 1, + })}\n`, + ) +} + +export function runWarmHydrationOwnerComparison( + factories: Record<'origin' | 'worktree', CreateHydrationOwner>, + scenarioNames: Array, + warmups: number, + iterations: number, + batchRequests: number, +) { + const results = [] + const comparisons = [] + for (const scenarioName of scenarioNames) { + measureHydrationOwner(factories.origin, 'origin', scenarioName, false, true) + measureHydrationOwner( + factories.worktree, + 'worktree', + scenarioName, + false, + true, + ) + forceGc() + for (let index = 0; index < warmups; index++) { + const first = index % 2 === 0 ? 'origin' : 'worktree' + const second = first === 'origin' ? 'worktree' : 'origin' + measureHydrationOwner(factories[first], first, scenarioName, false, false) + measureHydrationOwner( + factories[second], + second, + scenarioName, + false, + false, + ) + } + const elapsed: Record<'origin' | 'worktree', Array> = { + origin: [], + worktree: [], + } + const wall: Record<'origin' | 'worktree', Array> = { + origin: [], + worktree: [], + } + const samples = [] + const firstResult: Partial< + Record<'origin' | 'worktree', ReturnType> + > = {} + for (let iteration = 0; iteration < iterations; iteration++) { + const totals = { + origin: { elapsed: 0, wall: 0 }, + worktree: { elapsed: 0, wall: 0 }, + } + for (let request = 0; request < batchRequests; request++) { + const originFirst = (request & 1) === (iteration & 1) + const order: Array<'origin' | 'worktree'> = originFirst + ? ['origin', 'worktree'] + : ['worktree', 'origin'] + for (const implementation of order) { + const startedAt = performance.now() + const row = measureHydrationOwner( + factories[implementation], + implementation, + scenarioName, + false, + false, + ) + firstResult[implementation] ??= row + totals[implementation].wall += performance.now() - startedAt + totals[implementation].elapsed += row.elapsedMs + } + } + elapsed.origin.push(totals.origin.elapsed / batchRequests) + elapsed.worktree.push(totals.worktree.elapsed / batchRequests) + wall.origin.push(totals.origin.wall / batchRequests) + wall.worktree.push(totals.worktree.wall / batchRequests) + samples.push({ + baselineElapsedMs: totals.origin.elapsed, + candidateElapsedMs: totals.worktree.elapsed, + baselineWallElapsedMs: totals.origin.wall, + candidateWallElapsedMs: totals.worktree.wall, + elapsedRatio: totals.worktree.elapsed / totals.origin.elapsed, + wallElapsedRatio: totals.worktree.wall / totals.origin.wall, + }) + } + const originElapsed = summarizeNumbers(elapsed.origin) + const worktreeElapsed = summarizeNumbers(elapsed.worktree) + for (const implementation of ['origin', 'worktree'] as const) { + const statistics = summarizeNumbers(elapsed[implementation]) + results.push({ + ...firstResult[implementation], + implementation, + measurement: 'warm', + memoryMeasured: false, + iterations, + batchRequests, + measuredRequests: iterations * batchRequests, + elapsedMs: statistics.median, + throughputMiBPerSecond: + firstResult[implementation]!.outputBytes / + MiB / + (statistics.median / 1000), + statistics: { elapsedMs: statistics }, + comparisonBaseline: 'origin', + }) + } + comparisons.push({ + baseline: 'origin', + scenario: scenarioName, + framework: 'router-core', + mode: 'owner', + pairedElapsedPercent: summarizePercentRatios( + samples.map((sample) => sample.elapsedRatio), + ), + pairedWallElapsedPercent: summarizePercentRatios( + samples.map((sample) => sample.wallElapsedRatio), + ), + aggregateElapsedPercent: + (worktreeElapsed.mean / originElapsed.mean - 1) * 100, + aggregateWallElapsedPercent: + (summarizeNumbers(wall.worktree).mean / + summarizeNumbers(wall.origin).mean - + 1) * + 100, + elapsedMedianPercent: + (worktreeElapsed.median / originElapsed.median - 1) * 100, + samples, + }) + } + return { + warmups, + iterations, + batchRequests, + measuredRequestsPerImplementation: iterations * batchRequests, + scenarios: scenarioNames, + order: 'origin/worktree order alternates for every request and batch', + validation: + 'SHA-256 in one untimed preflight per implementation; timed requests retain byte, state, and output-limit checks', + memory: 'not measured in the warm latency loop', + garbageCollection: 'forced once before each scenario warm-up', + results, + comparisons, + } +} diff --git a/packages/router-core/src/await-signal.ts b/packages/router-core/src/await-signal.ts index c3506e2abd7..61d59bde2df 100644 --- a/packages/router-core/src/await-signal.ts +++ b/packages/router-core/src/await-signal.ts @@ -1,27 +1,61 @@ +function observeLate( + callback: ((value: T) => unknown) | undefined, + value: T, +) { + if (!callback) { + return + } + + try { + const result = callback(value) + if (result !== undefined) { + void Promise.resolve(result).catch(() => {}) + } + } catch {} +} + +/** + * Await `value` unless `signal` aborts first. A result that settles after the + * abort is passed to `onLate` / `onLateError` instead. + * + * One abort listener per wait: SSR requests nest at most a few waits on one + * signal, so pooling them was measurably slower than this. + */ export function waitForReason( value: T | PromiseLike, signal: AbortSignal, - onLate?: (value: T) => void, + onLate?: (value: T) => unknown, + onLateError?: (reason: unknown) => unknown, ): Promise { const promise = Promise.resolve(value) if (signal.aborted) { - if (!onLate) { - return Promise.race([Promise.reject(signal.reason), promise]) - } - void promise.then(onLate, () => {}) + void promise.then( + (result) => observeLate(onLate, result), + (error) => observeLate(onLateError, error), + ) return Promise.reject(signal.reason) } + return new Promise((resolve, reject) => { const abort = () => reject(signal.reason) signal.addEventListener('abort', abort, { once: true }) - promise - .then((result) => { + void promise.then( + (result) => { + signal.removeEventListener('abort', abort) if (signal.aborted) { - onLate?.(result) + observeLate(onLate, result) } else { resolve(result) } - }, reject) - .finally(() => signal.removeEventListener('abort', abort)) + }, + (error) => { + signal.removeEventListener('abort', abort) + if (signal.aborted) { + observeLate(onLateError, error) + } else { + reject(error) + } + }, + ) }) } diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 19b2cc7b602..24fd52434bc 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -273,7 +273,6 @@ export type { ParseLocationFn, InvalidateFn, ControllablePromise, - InjectedHtmlEntry, EmitFn, LoadFn, SubscribeFn, @@ -316,6 +315,7 @@ export { isPlainArray, deepEqual, createControlledPromise, + isPromise, isModuleNotFoundError, DEFAULT_PROTOCOL_ALLOWLIST, escapeHtml, @@ -472,19 +472,10 @@ export type { SerializationError, } from './ssr/serializer/transformer' -export { - createSerializationAdapter, - makeSerovalPlugin, - makeSsrSerovalPlugin, -} from './ssr/serializer/transformer' - -export { defaultSerovalPlugins } from './ssr/serializer/seroval-plugins' +export { createSerializationAdapter } from './ssr/serializer/transformer' +export { makeSerovalPlugin } from './ssr/serializer/makeSerovalPlugin' -export { - RawStream, - createRawStreamRPCPlugin, - createRawStreamDeserializePlugin, -} from './ssr/serializer/RawStream' +export { RawStream } from './ssr/serializer/RawStream' export type { OnRawStreamCallback, RawStreamHint, diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index fc966253e77..488d25d3b74 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -9,7 +9,6 @@ import { } from './router' import { hydrateSsrMatchId } from './ssr/ssr-match-id' import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './ssr/constants' -import type { AnySerializationAdapter } from './ssr/serializer/transformer' import type { TsrSsrGlobal } from './ssr/types' import type { ParsedLocation } from './location' import type { NavigateOptions } from './link' @@ -2170,9 +2169,7 @@ export async function hydrate(router: AnyRouter): Promise { } const tsr = window.$_TSR! - const adapters = router.options.serializationAdapters as - | Array - | undefined + const adapters = router.options.serializationAdapters if (adapters?.length) { tsr.t = new Map( adapters.map((adapter) => [adapter.key, adapter.fromSerializable]), diff --git a/packages/router-core/src/load-server.ts b/packages/router-core/src/load-server.ts index f04011d1a4a..6f476896380 100644 --- a/packages/router-core/src/load-server.ts +++ b/packages/router-core/src/load-server.ts @@ -845,7 +845,24 @@ async function executeServerLane( signal, ) signal?.throwIfAborted() - router.serverSsr?.onCleanup(abortLane) + // Deferred loader work can outlive this lane, and the request signal above + // only covers one way a response ends. SSR cleanup covers them all: + // + // - client disconnected -> abort (work may be pending) + // - serialization or lifetime timeout -> abort (a deferred value hung) + // - body disposed server-side (HEAD strip, + // middleware replaced the response) -> abort (never delivered) + // - plain response / `hydrate: false`: the + // loader data was never dehydrated -> abort (never consumed) + // - stream completed: every dehydrated value + // already settled -> nothing left to abort + // + // `settled` is false in exactly the first four cases. + router.serverSsr?.onCleanup((settled) => { + if (!settled) { + abortLane() + } + }) return { type: 'render', status: terminal.status, matches: lane.matches } } finally { signal?.removeEventListener('abort', abortLane) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e5b6255c63b..5d8229b3c39 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -107,17 +107,17 @@ import type { CommitLocationOptions, NavigateFn, } from './RouterProvider' -import type { - Manifest, - ManifestRouteAssets, - RouterManagedTag, -} from './manifest' +import type { Manifest, ManifestRouteAssets } from './manifest' import type { AnySchema, AnyValidator } from './validators' import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link' import type { AnySerializationAdapter, ValidateSerializableInput, } from './ssr/serializer/transformer' +import type { + HydrationScriptOutput, + InitialHydrationScriptTags, +} from './ssr/hydrationScripts' import type { GetStoreConfig, RouterStores } from './stores' export type ControllablePromise = Promise & { @@ -125,8 +125,6 @@ export type ControllablePromise = Promise & { reject: (value?: any) => void } -export type InjectedHtmlEntry = Promise - export interface Register { // Lots of things on here like... // router @@ -798,17 +796,25 @@ export type ClearCacheFn = (opts?: { filter?: (d: MakeRouteMatchUnion) => boolean }) => void +/** + * Server-side SSR request contract. + * + * Tiering rule: the flat methods are the adapter/framework contract; + * `hydrationScripts` is transport for the core SSR stream merger only. + * New members must land on the matching tier. + */ export interface ServerSsr { - /** Framework-only: injects router-owned HTML into the SSR stream. */ - injectHtml: (html: string) => void - /** Framework-only: injects a router-owned script tag into the SSR stream. */ - injectScript: (script: string) => void - isDehydrated: () => boolean - isSerializationFinished: () => boolean - /** Framework-only: atomically reserves the pass-through stream path if safe. */ - reserveStreamFastPath: () => boolean - /** Framework-only. */ - onInjectedHtml: (listener: () => void) => () => void + /** @internal Transport access for the core SSR stream merger. */ + readonly hydrationScripts: { + /** Request signal already observed by the live response transform. */ + requestSignal?: AbortSignal + reserveFastPath: (output?: HydrationScriptOutput) => boolean + claimOutput: () => HydrationScriptOutput + liftBarrier: () => void + isInitialTaken: () => boolean + skipInitialTake: () => void + startSerializationTimeout: (timeoutMs: number) => void + } /** Framework-only. */ onRenderFinished: (listener: () => void) => void /** Framework-only. */ @@ -821,19 +827,28 @@ export interface ServerSsr { * resources whose references would otherwise pin the router (e.g. query * cache subscriptions, gcTime timers, abort controllers). * + * `settled` is true when every value the router dehydrated has settled, so + * no loader work can still be pending. It is false when the response ended + * early (abort, cancellation, timeout) or never consumed the loader data. + * * Listeners run synchronously and exactly once. Errors are caught and logged. + * A listener registered after cleanup already ran is invoked immediately. */ - onCleanup: (listener: () => void) => void - /** Framework-only. */ - onSerializationFinished: (listener: () => void) => () => void + onCleanup: (listener: (settled: boolean) => void) => void /** Framework-only. */ - dehydrate: (opts?: { requestAssets?: ManifestRouteAssets }) => Promise - /** Framework-only. */ - takeBufferedScripts: () => RouterManagedTag | undefined - /** Framework-only: takes buffered router-owned HTML. */ - takeBufferedHtml: () => string | undefined + dehydrate: (opts?: { + requestAssets?: ManifestRouteAssets + signal?: AbortSignal + }) => Promise + /** + * Framework-only: opt this request out of hydration output entirely (for + * example a `hydrate: false` page). Call instead of `dehydrate()`, before + * rendering starts. No hydration scripts are emitted, `` renders + * no boundary, and the response takes the pass-through stream path. + */ + disableHydration: () => void /** Framework-only. */ - liftScriptBarrier: () => void + takeInitialHydrationScriptTags: () => InitialHydrationScriptTags | undefined } export interface RouterSsrLifecycle { diff --git a/packages/router-core/src/ssr/STREAMING.md b/packages/router-core/src/ssr/STREAMING.md new file mode 100644 index 00000000000..a80d4e19e71 --- /dev/null +++ b/packages/router-core/src/ssr/STREAMING.md @@ -0,0 +1,614 @@ +# SSR streaming + +This document explains how TanStack Router adds hydration scripts to server-rendered HTML. +It describes the transport in `router-core` and the React, Solid, and Vue adapters. + +## Main terms + +The renderer and Seroval produce output independently. + +- The **application output** is the HTML from the framework renderer. +- A **hydration source** is one complete JavaScript source string from the router or Seroval. +- An **initial tag** is a hydration `` as a safe point. +After the boundary, each such sequence must be a position where script insertion is valid. + +These rules are application and adapter contracts. +The byte scanner cannot tell structural markup from matching text. + +## The boundary + +The boundary is a private transport marker emitted by ``. +The boundary script removes itself in the browser. + +The transform scans for the boundary only after the initial take. +Before that take, it forwards consumed application bytes without a boundary scan. + +When the transform finds the boundary, it enters the `Merge` phase and lifts the hydration barrier. +Queued late sources then become eligible for output. + +If application EOF arrives before the boundary, hydration-enabled merge mode releases the initial sources as late records. +The transform then drains them after the delivered document, as it does for any EOF safe point. +The page cannot hydrate without the route scripts that `` renders, but the response completes. +Disabled hydration and initial pass-through do not require a boundary. + +## Transform paths + +The transform uses initial pass-through or merge mode. +Merge mode can later change to dynamic pass-through. + +### Initial pass-through + +The hydration state reserves initial pass-through only when all these conditions are true: + +- The state is live and has no failure. +- Serialization is complete. +- The initial take or `disableHydration()` committed the initial output. +- No queued or active hydration output remains. +- No consumer or earlier fast-path reservation conflicts with this reservation. + +This path forwards each `Uint8Array` from the renderer unchanged. +It does not scan, copy, or move those bytes. + +A Node renderer can also send string records. +The path encodes one string record at a time in chunks of at most 64 KiB. +It does not join separate string records. + +At application EOF, the path marks rendering as complete and closes normally. + +### Disabled hydration + +`serverSsr.disableHydration()` disables router hydration output for one request. +Call it instead of `dehydrate()`, before rendering starts. + +The runtime also rejects calls after any of these events: + +- `dehydrate()` starts. +- The first `` take commits initial output. +- The transform claims the hydration output. +- The hydration producer completes or starts an active record. + +The call removes the queued bootstrap sources. +It commits the initial state, lifts the barrier, and marks the producer as complete. + +The request emits no hydration tags or router boundary. +`` can still render normal route and manifest scripts. +The transform can use initial pass-through. + +### Merge mode + +The transform uses merge mode when it cannot reserve initial pass-through. +Merge mode claims the request hydration output for one consumer. +A second consumer causes an error. + +Merge mode recognizes these universal byte patterns: + +1. The fixed router boundary suffix. +2. The exact canonical close. + +An adapter can add one safe-point profile: + +- `script-close` matches exact lowercase ``. +- `record-end` uses the end of a complete renderer record. + +The core does not select a profile from a framework name. +The adapter selects it. + +The boundary and script-close matchers keep partial matches across input chunks. +Their patterns have a unique first byte. + +The canonical close repeats its first byte. +The transform uses an exact search and a carry of at most 13 bytes for that pattern. + +### Merge phases + +| Phase | Meaning | +| ---------------- | -------------------------------------------------------------- | +| `BeforeBoundary` | Forward application output and wait for the one-time boundary. | +| `Merge` | Find safe points and the canonical close. | +| `HeldClose` | Keep the canonical close while later output drains. | +| `PassThrough` | Forward all later application output without scanning. | + +Only `Merge` can enter `PassThrough`. +`HeldClose` must preserve final document order, so it cannot enter that phase. + +A ready late record starts only after the boundary and at a safe point. +An active late record drains completely before application output resumes. + +### Dynamic pass-through + +Merge mode can stop scanning after the boundary. +It does this only when all these conditions are true: + +- The hydration output is `Done`. +- No hydration source or active record remains. +- The transform holds no partial canonical close. +- The hydration state can reserve the fast path for the current consumer. + +After the switch, all later application output passes through unchanged. +The transform does not scan or move later document closes. + +## Adapter safe points + +| Adapter or path | Additional safe point | +| --------------- | --------------------------------- | +| React | Exact lowercase `` | +| Solid | End of a complete renderer record | +| Vue | None | +| Eager string | None | + +The boundary and canonical close are safe points for every adapter. +Ordinary EOF is also a safe point after the boundary. +A partial canonical close at EOF is forwarded as ordinary content, as described below. + +### React + +The transform treats React chunk boundaries as arbitrary. +It does not use them as safe points. + +The script-close profile lets a late record follow a complete React patch script. +It can also emit router data while React waits for later Suspense work. + +The adapter uses `renderToReadableStream` when that function exists. +Otherwise, it uses `renderToPipeableStream`. +Both paths use the same script-close profile. +Both paths set `progressiveChunkSize` to positive infinity so React does not split completed Suspense boundaries into separate patches based only on output size. +The adapter does not inspect the React version. + +### Solid + +The Solid adapter preserves each complete shell or patch string as one renderer record. +It uses the end of that record as a safe point. + +An internal 64 KiB encoding chunk is not a record end. +The safe point occurs only after the complete source record drains. + +### Vue + +The Vue adapter does not treat renderer record ends as safe points. +It uses only the boundary, the canonical close, and eligible EOF. + +The Vue application output must have one `` element as its outer root. +An outer Vue Fragment or root array is not supported. +Its closing marker follows ``, and the transform does not preserve that position. +Fragments inside `` are supported. + +## Canonical document close + +Merge mode recognizes only this exact adjacent lowercase sequence: + +```html + +``` + +While the transform remains in the `Merge` phase, the first matching sequence after the boundary becomes the held canonical close. +The transform emits preceding application bytes and holds the 14-byte close. + +A match inside one byte chunk advances past the close without copying it. +The transform can emit the preceding prefix as a view of that chunk. +A match across chunks uses the carry and one small combined buffer. +The transform caches the search result for the current byte chunk, including a missing match. +Returning from a renderer safe point reuses that result; advancing to another byte chunk clears it. + +The transform continues to process renderer patches and late records after it holds the close. +All other application bytes keep their order. +Late records keep FIFO order. +The two streams can interleave only at safe points. + +After successful completion, the transform emits a fresh 14-byte copy of the held close last. +On failure or cancellation, the transform does not emit the held close. +Bytes already delivered to the consumer remain delivered. + +The match is case-sensitive and byte-exact. +Whitespace or a text node between `` and `` prevents a match. + +If the transform completes successfully without a canonical match, it preserves all application bytes. +A later record can then appear after ``. +This can happen at a React script close, a Solid record end, or EOF. + +Supporting every equivalent HTML form requires an HTML parser. +This transport uses exact byte matching instead. + +Initial and dynamic pass-through never move a document close. +They also preserve bytes that follow ``. + +### EOF rules + +After the boundary, ordinary EOF can provide the final safe point. +If a late record is `Active`, it drains first. +These rules apply after no record is active: + +- If output is `Ready`, the transform drains ready late records at EOF. +- If output is `Waiting`, the transform waits for a state change. +- If normal serialization reaches `Done`, the transform completes. +- If output is `Failed`, the transform fails immediately. + +A partial canonical-close prefix at EOF is ordinary application content. +The transform forwards those bytes first and then applies the rules above. +A late record can follow them, exactly as after a document without a canonical close. + +Server cleanup terminates the transform instead of using these `Done` rules. + +## Hydration output states + +`claimOutput()` creates one pull view for merge mode. +The view has five states: + +| State | Meaning | +| --------- | ---------------------------------------------------------------------------------- | +| `Waiting` | No late record is eligible. The queue can still contain blocked sources. | +| `Ready` | Queued sources can start at the next safe point. | +| `Active` | One late record is in progress and must finish. | +| `Done` | Normal output drained, hydration is disabled, or cleanup made the output inactive. | +| `Failed` | The response must fail immediately. | + +The hydration output permits one consumer claim. +While it is live, it permits one active subscriber at a time. +Another subscriber can register after the first subscriber unregisters. +After cleanup, subscription returns a function that does nothing. + +The transform calls `pullChunk()` only from a response-stream pull. +Each call returns at most 64 KiB of bytes. + +## Late record framing + +`hydrationScripts.ts` owns the complete framing of every late record. +The framing includes the opening tag, escaped nonce, separators, self-removal source, and closing tag. + +A late record has this logical form: + +```text + +``` + +The nonce attribute is absent when the request has no nonce. +The code escapes `&`, double quotes, single quotes, `<`, and `>` in a dynamic nonce. + +The transform does not inspect this framing. + +### Late record size + +The hydration output selects a FIFO source prefix for each late record. +Its target is at most 64 Ki UTF-16 code units for the complete framed record. + +The hydration output never splits one hydration source across script tags. +Therefore, one large source can create a record above the target. + +If a complete record is at most 16 Ki code units, the hydration output joins and encodes it once. +The hydration output encodes larger records in bounded parts with `encodeInto()`. + +Every returned byte chunk is at most 64 KiB. +Application output cannot appear inside an active record. + +### Queue ownership + +The queue uses one source array and a head index. +When it selects the complete unconsumed array, it can reuse that array internally. + +For a partial selection, it copies the selected prefix. +It replaces consumed queue slots with empty slots instead of shifting the live suffix. + +The queue removes its backing array when it becomes empty. +For a busy queue, it can compact after at least 1,024 consumed entries. +The consumed prefix must also be at least as large as the live suffix. + +Compaction keeps FIFO order. + +The hydration output releases a source from backlog accounting after its text finishes encoding. +The record stays `Active` until its separators and closing code drain. + +The boundary does not move or copy queued source strings. +It only makes them eligible for a pull. + +Normal serialization adds `$_TSR.e()` as the final hydration source. +The hydration output becomes `Done` after that source and all earlier sources drain. + +A backlog error changes the state to `Failed`, drops queued and active sources, +and causes Router to dispose of the SSR serializer. +Cleanup clears the queue and changes a claimed output to `Done`. + +## Backpressure and memory limits + +One downstream pull emits at most one output chunk. +The transform starts at most one application read ahead. + +The merge path can retain these main values: + +- One current application record and its encoder cursor, or one in-flight or prefetched nonterminal application read. +- One encoded application-string chunk of at most 64 KiB. The current input string record can be larger. +- At most 13 bytes from a partial canonical close. +- One active late record with its selected sources. +- One queue of later hydration sources. +- One hydration output buffer of at most 64 KiB. + +The hydration state permits at most 4,096 retained sources across initial, queued, and active output. +It also permits at most 16,777,216 UTF-16 code units of regular retained source text. + +These limits include constructor seeds and later sources. +They apply before and after the initial take. + +The limits apply to the pending backlog, not to total response output. +Initial-source accounting ends at the initial take. +Late-source accounting ends when each source finishes encoding. + +One source can exceed the 16 Mi-code-unit limit. +This exception supports one large loader or query value. +A second oversized source cannot be retained while the first is queued or being encoded. + +Cleanup clears source arrays, active encoder state, callbacks, timers, and stored errors. + +New eligible output and failures can wake the transform through one subscription. +`pullChunk()` does not notify the subscriber about successful state transitions. +When a producer update or barrier transition changes the output state, the hydration state still notifies the transform. +This notification can occur while a response-stream pull is pending. +Server cleanup terminates through a separate cleanup listener. +Application reads wake the transform separately. +The transform uses one waiting resolver at a time. +It does not create `Promise.race()` handlers in its pump. + +## Unicode and string input + +Built-in streaming adapters send UTF-8 `Uint8Array` records. +The transform forwards application bytes without decoding them. +A UTF-8 character can span byte records. + +A Node pipeable stream can also send string records. +The transform encodes each string record independently in chunks of at most 64 KiB. + +It does not repair a surrogate pair that spans two separate string records. +A record-end safe point occurs after the complete string, not after an internal encoding chunk. + +Each hydration source is one complete JavaScript string. +A semicolon separates adjacent sources in one tag. +HTML markup separates sources in different tags. + +`TextEncoder` and `encodeInto()` do not split a valid surrogate pair inside one source. +The implementation does not cut sources at estimated UTF-16 offsets. + +### Eager string output + +The eager helper uses the same merge path when initial pass-through is unavailable. +It encodes `` and the HTML string in bounded parts. + +It decodes merged output incrementally with `TextDecoder`. +It does not first collect one complete byte buffer or call `Response.text()`. + +When initial pass-through is available, the helper returns `` plus the original HTML string. +It then cleans the server state in a `finally` block. + +## Completion and cleanup + +The streaming paths use one finalizer. +The eager fast path uses its own `try` and `finally` because it has no response stream. + +| Result | Response output | Application reader | `onAbort` | +| ------------------------ | --------------- | ------------------ | --------- | +| Natural completion | Close | Release | No | +| Request abort | Error | Cancel | Yes | +| Consumer cancellation | No extra signal | Cancel | Yes | +| Stream or output failure | Error | Cancel | Yes | +| Serialization timeout | Error | Cancel | Yes | +| Lifetime timeout | Error | Cancel | Yes | +| External server cleanup | Error | Cancel | Yes | + +The terminal guard makes cleanup run once. +A non-success result calls the adapter `onAbort` function at most once. + +After natural completion, the transform releases the application reader. +After another result, it cancels the reader. +Reader cancellation can finish after router cleanup. + +Finally, server cleanup disposes of the SSR serializer and clears Router-owned hydration state. +It also stops the reader pump that Router's SSR RawStream bridge creates. +It clears the request router's `router.ssr` and `router.serverSsr` slots. + +Cleanup listeners receive one boolean, `settled`. +It is true when every value the router dehydrated has settled. +Rejected hydration output, including a backlog failure, stops serialization without marking deferred values as settled. +Router aborts the request's route-match controllers only when `settled` is false, because only then can loader work still be pending. + +Canceling a RawStream that the SSR or JSON bridge reconstructs removes Router's Seroval-stream listener. +Seroval and its official plugins remain responsible for work that they create. + +### Timeouts + +In merge mode, application EOF starts the serialization timeout and calls `setRenderFinished()`. +This happens as soon as a prefetched read reports EOF, even if the consumer pauses. +The default serialization timeout is 60 seconds. + +The timeout runs only while Seroval remains active. +It changes hydration output to `Failed` when it expires. + +Every live streaming transform also has a lifetime timeout. +Its default is twice the serialization timeout. + +Some Web runtimes return numeric timer IDs with no `unref()` method. +The code calls `unref()` only when the returned timer handle provides it. + +The serialization timeout stays referenced because it protects active work after application rendering ends. + +### External cleanup and response disposal + +`ServerSsr.cleanup()` notifies each live transform. +The transform then fails with an `AbortError`, cancels its reader, and calls the adapter abort function. + +A stream response also provides an idempotent `dispose()` function. +Disposal removes router ownership before it cancels the response body. +A renderer can ignore that body cancellation. + +After response handoff, request-abort binding has four cases: + +- If the request is already aborted, handoff disposes the stream immediately. +- If the live transform already observes the same request signal, handoff reuses its listener. On request abort, SSR cleanup also disposes the final response, including a body wrapped by middleware. +- Otherwise, with live router SSR state, handoff adds a listener to dispose the stream, and SSR cleanup removes that listener. +- Without live router SSR state, abort still disposes the stream. The one-time listener ends with the request signal. + +### Adapter cancellation limits + +Every streaming adapter rejects a request that is already aborted before renderer setup. +The adapters and core transform also detect an abort during synchronous setup before they return a response. + +The React readable path passes the request signal to `renderToReadableStream`. +The React pipeable path passes `abort()` to the transform. + +For Solid, cancelling the transform's reader errors the writable used by its renderer. +Vue pipes its public readable renderer stream into the transform, so reader cancellation propagates to Vue. +Neither renderer provides a disposal handle for unresolved component work. + +The adapters stop outgoing bytes and release router-owned state. +An unresolved Solid or Vue resource can retain its renderer closure until that resource settles. + +React and Solid bot-readiness waits observe an adapter signal that the transform aborts on failure or cancellation, including request abort, lifetime expiry, and external SSR cleanup. +These waits cannot keep the response promise pending after the transform terminates. +The extra controller is allocated only for bot waits on the readable-stream paths. +Each wait removes its listener when the wait ends. + +## Test coverage + +Unit tests cover every byte split of the boundary and script-close patterns. +They also cover every internal split of the canonical close. + +Hydration-state tests cover initial ownership, exact framing, nonce escaping, FIFO order, size limits, completion, failure, and cleanup. + +Transform tests cover safe points, content after ``, EOF, close carry, ordering, cancellation, timeouts, Unicode, and stream errors. + +Adapter tests make sure that React, Solid, and Vue select the documented safe-point profiles. +Real-renderer tests cover React 19 script closes and Solid record ends. + +React and Solid browser suites cover CSP for ordinary SSR output. +No browser CSP test forces a late dynamic hydration record. +Vue has no equivalent browser CSP suite. + +Normal unit tests cover backpressure correctness and lost-record prevention. +Slow-consumer memory tests run only when `RUN_BACKPRESSURE_PERF=1`. + +Browser tests cover hydration data, interaction, data order, and removal of initial hydration tags and the boundary. + +The [SSR streaming benchmark](../../benchmarks/ssr-streaming/README.md) measures throughput and retained memory. +Use interleaved warm runs for latency comparisons. +Use isolated processes with forced garbage collection for memory comparisons. + +Treat machine-specific measurements as supporting evidence. +They are not part of the transport contract. diff --git a/packages/router-core/src/ssr/client.ts b/packages/router-core/src/ssr/client.ts index 79ed2f1e8a2..dc769e7495a 100644 --- a/packages/router-core/src/ssr/client.ts +++ b/packages/router-core/src/ssr/client.ts @@ -4,3 +4,11 @@ export type { JsonResponse } from './json' export { hydrate } from './ssr-client' export * from './ssr-client' export type { TsrSsrGlobal, DehydratedMatch, DehydratedRouter } from './types' +export { + createDefaultSerovalPlugins, + defaultSerovalPlugins, + defaultSerovalDeserializerPlugins, +} from './serializer/seroval-plugins' +export { makeSerovalPlugin } from './serializer/makeSerovalPlugin' +export type { AnySerializationAdapter } from './serializer/transformer' +export { createRawStreamDeserializePlugin } from './serializer/RawStreamRPCPlugin' diff --git a/packages/router-core/src/ssr/constants.ts b/packages/router-core/src/ssr/constants.ts index 59d6c161001..8ce3dcfa0d6 100644 --- a/packages/router-core/src/ssr/constants.ts +++ b/packages/router-core/src/ssr/constants.ts @@ -1,3 +1,2 @@ export const GLOBAL_TSR = '$_TSR' export declare const GLOBAL_SEROVAL: '$R' -export const TSR_SCRIPT_BARRIER_ID = '$tsr-stream-barrier' diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index a945effd010..a2e195db8b0 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -1,5 +1,6 @@ import { createMemoryHistory } from '@tanstack/history' import { _getRenderedMatches } from '../load-client' +import { waitForReason as waitForRequest } from '../await-signal' import { mergeHeaders } from './headers' import { attachRouterServerSsrUtils, @@ -8,7 +9,8 @@ import { } from './ssr-server' import { bindSsrResponseToRequest, - disposeSsrResponseDetached, + disposeSsrResponse, + isSsrResponse, } from './handlerCallback' import type { HandlerCallback } from './handlerCallback' import type { AnyHeaders } from './headers' @@ -19,77 +21,14 @@ export type RequestHandler = ( cb: HandlerCallback, ) => Promise -type RequestWaiter = ((reason: unknown) => void) | undefined +export { waitForRequest } -const requestWaiters = new WeakMap>() - -function removeRequestWaiter( - waiters: Array, - index: number, - reject: (reason: unknown) => void, -) { - if (waiters[index] !== reject) { - return - } - if (index !== waiters.length - 1) { - waiters[index] = undefined - return - } - - waiters.pop() - while (waiters.length && waiters[waiters.length - 1] === undefined) { - waiters.pop() - } -} - -export function waitForRequest( - value: T | PromiseLike, - signal: AbortSignal, - onLate?: (value: T) => void, -): Promise { - const promise = Promise.resolve(value) - if (signal.aborted) { - void promise.then(onLate, () => {}) - return Promise.reject(signal.reason) - } - - return new Promise((resolve, reject) => { - let waiters = requestWaiters.get(signal) - let index: number - if (waiters) { - index = waiters.push(reject) - 1 - } else { - const newWaiters: Array = [reject] - waiters = newWaiters - index = 0 - requestWaiters.set(signal, newWaiters) - signal.addEventListener( - 'abort', - () => { - requestWaiters.delete(signal) - for (const rejectWaiter of newWaiters) { - rejectWaiter?.(signal.reason) - } - newWaiters.length = 0 - }, - { once: true }, - ) +function createLateResponseDisposer(signal: AbortSignal) { + return (result: unknown) => { + if (result instanceof Response || isSsrResponse(result)) { + disposeSsrResponse(result, signal.reason) } - void promise.then( - (result) => { - removeRequestWaiter(waiters, index, reject) - if (signal.aborted) { - onLate?.(result) - } else { - resolve(result) - } - }, - (error) => { - removeRequestWaiter(waiters, index, reject) - reject(error) - }, - ) - }) + } } export function createRequestHandler({ @@ -102,14 +41,19 @@ export function createRequestHandler({ getRouterManifest?: () => ServerManifest | Promise }): RequestHandler { return async (cb) => { - request.signal.throwIfAborted() + const signal = request.signal + signal.throwIfAborted() + const manifest = getRouterManifest + ? await waitForRequest(getRouterManifest(), signal) + : undefined + signal.throwIfAborted() const router = createRouter() let responseOwnsCleanup = false try { attachRouterServerSsrUtils({ router, - manifest: await waitForRequest(getRouterManifest?.(), request.signal), + manifest, }) // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. @@ -129,40 +73,36 @@ export function createRequestHandler({ }) await router.load({ - _signal: request.signal, + _signal: signal, }) - request.signal.throwIfAborted() + signal.throwIfAborted() const result = router._serverResult if (result?.type === 'redirect') { return result.redirect } - await waitForRequest(router.serverSsr?.dehydrate(), request.signal) - request.signal.throwIfAborted() + await router.serverSsr?.dehydrate({ signal }) + signal.throwIfAborted() const responseHeaders = getRequestHeaders({ router, }) - request.signal.throwIfAborted() + signal.throwIfAborted() + const disposeLate = createLateResponseDisposer(signal) const response = await waitForRequest( cb({ request, router, responseHeaders, }), - request.signal, - (late) => { - disposeSsrResponseDetached(late, request.signal.reason) - }, - ) - const ssrResponse = bindSsrResponseToRequest( - router, - response, - request.signal, + signal, + disposeLate, + disposeLate, ) - request.signal.throwIfAborted() + const ssrResponse = bindSsrResponseToRequest(router, response, signal) + signal.throwIfAborted() responseOwnsCleanup = ssrResponse.serverSsrCleanup === 'stream' return ssrResponse.response } finally { diff --git a/packages/router-core/src/ssr/handlerCallback.ts b/packages/router-core/src/ssr/handlerCallback.ts index 368c8a2a61b..c64f5c0d0ca 100644 --- a/packages/router-core/src/ssr/handlerCallback.ts +++ b/packages/router-core/src/ssr/handlerCallback.ts @@ -8,7 +8,7 @@ export type SsrResponse = | { response: Response serverSsrCleanup: 'stream' - dispose: (reason?: unknown) => Promise + dispose: (reason?: unknown) => undefined } export type HandlerCallbackResult = Response | SsrResponse @@ -30,67 +30,51 @@ export function normalizeSsrResponse( : { response: result, serverSsrCleanup: 'none' } } -export function disposeSsrResponse( - response: SsrResponse, - reason?: unknown, -): Promise { - if (response.serverSsrCleanup !== 'stream') { - return Promise.resolve() - } - try { - return Promise.resolve(response.dispose(reason)) - } catch (error) { - return Promise.reject(error) +function cancelResponseBody(response: Response, reason?: unknown): void { + const body = response.body + if (!body) { + return } + void body.cancel(reason).catch(console.error) } -export function disposeSsrResponseDetached( +export function disposeSsrResponse( result: HandlerCallbackResult, reason?: unknown, - onError: (error: unknown) => void = console.error, -): void { - const ssrResponse = normalizeSsrResponse(result) - if (ssrResponse.serverSsrCleanup === 'stream') { - void disposeSsrResponse(ssrResponse, reason).catch(onError) - return +): undefined { + const response = normalizeSsrResponse(result) + if (response.serverSsrCleanup === 'stream') { + response.dispose(reason) + } else { + cancelResponseBody(response.response, reason) } +} - if (ssrResponse.response.body) { - try { - void ssrResponse.response.body.cancel(reason).catch(onError) - } catch (error) { - onError(error) - } - } +/** The HTTP status that Router's server load selected for this render. */ +export function getSsrStatus(router: AnyRouter) { + return router._serverResult?.type === 'render' + ? router._serverResult.status + : 200 } -export function createSsrStreamResponse( - router: TRouter, +export function createSsrStreamResponse( + router: AnyRouter, response: Response, -): SsrResponse { - if (!response.body) { +): Extract { + const body = response.body + if (!body) { throw new Error('Invariant failed: SSR stream response requires a body') } - let disposed = false return { response, serverSsrCleanup: 'stream', - async dispose(reason?: unknown) { - if (disposed) { - return - } - disposed = true - + dispose(reason?: unknown): undefined { // Sever router ownership before asking user/renderer stream machinery to // cancel. A custom stream is allowed to ignore cancellation forever. router.serverSsr?.cleanup() - try { - await response.body!.cancel(reason) - } catch { - // Cleanup above already released router SSR state. - } + void body.cancel(reason).catch(() => {}) }, } } @@ -103,46 +87,57 @@ export function bindSsrResponseToRequest( const ssrResponse = normalizeSsrResponse(result) if (ssrResponse.serverSsrCleanup !== 'stream') { if (signal.aborted) { - disposeSsrResponseDetached(result, signal.reason) + disposeSsrResponse(result, signal.reason) } return ssrResponse } - const failed = (error: unknown) => { - router?.serverSsr?.cleanup() - console.error(error) - } const abort = () => { - disposeSsrResponseDetached(ssrResponse, signal.reason, failed) + disposeSsrResponse(ssrResponse, signal.reason) } if (signal.aborted) { abort() return ssrResponse } + const serverSsr = router?.serverSsr + if (serverSsr?.hydrationScripts.requestSignal === signal) { + // The transform already observes this request. Its cleanup must also + // dispose the final response, including any middleware-owned body. + serverSsr.onCleanup(() => { + if (signal.aborted) { + abort() + } + }) + return ssrResponse + } + signal.addEventListener('abort', abort, { once: true }) - router?.serverSsr?.onCleanup(() => { + if (!serverSsr) { + return ssrResponse + } + + serverSsr.onCleanup(() => { signal.removeEventListener('abort', abort) }) return ssrResponse } -export async function replaceSsrResponse( +export function replaceSsrResponse( result: HandlerCallbackResult, response: Response, reason?: unknown, -): Promise { - const ssrResponse = normalizeSsrResponse(result) - await disposeSsrResponse(ssrResponse, reason) +): Extract { + disposeSsrResponse(result, reason) return { response, serverSsrCleanup: 'none' } } -export async function stripSsrResponseBody( +export function stripSsrResponseBody( result: HandlerCallbackResult, reason?: unknown, -): Promise { +): Extract { const ssrResponse = normalizeSsrResponse(result) - await disposeSsrResponse(ssrResponse, reason) + disposeSsrResponse(ssrResponse, reason) return { response: new Response(null, ssrResponse.response), serverSsrCleanup: 'none', diff --git a/packages/router-core/src/ssr/htmlBoundaryScanner.ts b/packages/router-core/src/ssr/htmlBoundaryScanner.ts new file mode 100644 index 00000000000..cbe27e1ba29 --- /dev/null +++ b/packages/router-core/src/ssr/htmlBoundaryScanner.ts @@ -0,0 +1,124 @@ +const textEncoder = new TextEncoder() + +export const DOCUMENT_CLOSE = '' +export const SCRIPT_CLOSE = '' +export const DOCUMENT_CLOSE_ANCHOR_INDEX = DOCUMENT_CLOSE.indexOf('y') +export const SCRIPT_CLOSE_ANCHOR_INDEX = SCRIPT_CLOSE.indexOf('p') + +export const DOCUMENT_CLOSE_BYTES = textEncoder.encode(DOCUMENT_CLOSE) +export const SCRIPT_CLOSE_BYTES = textEncoder.encode(SCRIPT_CLOSE) + +/** + * State for matching a fixed ASCII sequence across input chunks. + * + * The pattern must be non-empty, its first byte must be unique, and the anchor + * index must point inside the pattern. + */ +export type ByteMatcherState = { + readonly pattern: Uint8Array + readonly anchorIndex: number + matched: number +} + +/** Advance matcher state and return the local offset after a complete match. */ +export function advanceByteMatcher( + matcher: ByteMatcherState, + value: Uint8Array, + startIndex = 0, + findLast = false, +) { + const { pattern, anchorIndex } = matcher + let matched = matcher.matched + let lastMatchEnd: number | undefined + let index = startIndex + while (index < value.length) { + if (matched === 0) { + if (anchorIndex > 0 && index < value.length - anchorIndex) { + const anchor = value.indexOf(pattern[anchorIndex]!, index + anchorIndex) + if (anchor < 0) { + index = value.length - anchorIndex + continue + } + index = anchor - anchorIndex + } else { + index = value.indexOf(pattern[0]!, index) + if (index < 0) { + matcher.matched = matched + return lastMatchEnd + } + } + } + + const byte = value[index]! + if (byte === pattern[matched]) { + matched++ + } else { + matched = byte === pattern[0] ? 1 : 0 + } + index++ + + if (matched === pattern.length) { + matched = 0 + if (!findLast) { + matcher.matched = matched + return index + } + lastMatchEnd = index + } + } + matcher.matched = matched + return lastMatchEnd +} + +/** Find a complete fixed sequence that is contained in one byte chunk. */ +export function findExactBytes( + value: Uint8Array, + pattern: Uint8Array, + startIndex = 0, + anchorIndex = 0, +) { + let anchor = value.indexOf(pattern[anchorIndex]!, startIndex + anchorIndex) + while (anchor >= 0) { + const candidate = anchor - anchorIndex + if (candidate + pattern.length > value.length) { + return -1 + } + let patternIndex = 0 + while ( + patternIndex < pattern.length && + value[candidate + patternIndex] === pattern[patternIndex] + ) { + patternIndex++ + } + if (patternIndex === pattern.length) { + return candidate + } + anchor = value.indexOf(pattern[anchorIndex]!, anchor + 1) + } + return -1 +} + +/** + * Find the longest suffix that can become the fixed sequence in the next + * chunk. The returned index starts that suffix. + */ +export function getExactBytesPrefixAtEnd( + value: Uint8Array, + pattern: Uint8Array, + startIndex = 0, +) { + candidate: for ( + let length = Math.min(pattern.length - 1, value.length - startIndex); + length > 0; + length-- + ) { + const candidateStart = value.length - length + for (let index = 0; index < length; index++) { + if (value[candidateStart + index] !== pattern[index]) { + continue candidate + } + } + return candidateStart + } + return undefined +} diff --git a/packages/router-core/src/ssr/hydrationScripts.ts b/packages/router-core/src/ssr/hydrationScripts.ts new file mode 100644 index 00000000000..9349e0e79b0 --- /dev/null +++ b/packages/router-core/src/ssr/hydrationScripts.ts @@ -0,0 +1,692 @@ +/* eslint-disable @typescript-eslint/triple-slash-reference */ +/// + +import { getCrossReferenceHeader } from 'seroval' +import { invariant } from '../invariant' +import minifiedTsrBootStrapScript from './tsrScript?script-string' +import { GLOBAL_TSR } from './constants' +import type { RouterManagedTag } from '../manifest' + +export const SSR_SERIALIZATION_SCOPE_ID = 'tsr' +const HYDRATION_SCRIPT_BOUNDARY_TAIL = ';/*$tsr-stream-boundary*/' + +const encoder = new TextEncoder() +const SOURCE_SEPARATOR = ';' +const MAX_INITIAL_SOURCE_CODE_UNITS = 16 * 1024 +const MAX_BACKLOG_CODE_UNITS = 16 * 1024 * 1024 +const MAX_BACKLOG_SOURCES = 4_096 +const MIN_OUTPUT_BYTES = 256 +const MAX_DIRECT_CODE_UNITS = 16 * 1024 +export const MAX_HYDRATION_OUTPUT_CHUNK_BYTES = 64 * 1024 +const MAX_DYNAMIC_RECORD_CODE_UNITS = MAX_HYDRATION_OUTPUT_CHUNK_BYTES + +const STREAM_PART_ATTRIBUTE = 'data-tsr-stream-part' +const INITIAL_CLEANUP_SOURCE = `{let s=document.currentScript,p;while((p=s.previousElementSibling)&&p.hasAttribute('${STREAM_PART_ATTRIBUTE}'))p.remove();s.remove()}` +const INITIAL_CLEANUP_SUFFIX = SOURCE_SEPARATOR + INITIAL_CLEANUP_SOURCE +const DYNAMIC_CLOSE_SOURCE = 'document.currentScript.remove()' +export const HYDRATION_SCRIPT_BOUNDARY_SOURCE = + `document.currentScript.remove()` + HYDRATION_SCRIPT_BOUNDARY_TAIL +export const HYDRATION_SCRIPT_BOUNDARY_SUFFIX = + HYDRATION_SCRIPT_BOUNDARY_TAIL + '' +export const HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX = + HYDRATION_SCRIPT_BOUNDARY_SUFFIX.lastIndexOf('*') +export const HYDRATION_SCRIPT_BOUNDARY_BYTES = encoder.encode( + HYDRATION_SCRIPT_BOUNDARY_SUFFIX, +) + +const ROUTER_PREFIX = GLOBAL_TSR + '.router=' +const PROMISE_PREFIX = GLOBAL_TSR + '.p(()=>' +const DEFAULT_INITIAL_SOURCES = [ + getCrossReferenceHeader(SSR_SERIALIZATION_SCOPE_ID), + minifiedTsrBootStrapScript, +] + +export const HydrationScriptOutputState = { + Waiting: 0, + Ready: 1, + Active: 2, + Done: 3, + Failed: 4, +} as const + +export type HydrationScriptOutputState = + (typeof HydrationScriptOutputState)[keyof typeof HydrationScriptOutputState] + +/** A request-local, single-consumer stream of complete records. */ +export type HydrationScriptOutput = { + readonly state: HydrationScriptOutputState + readonly error: unknown + pullChunk: () => Uint8Array + subscribe: (onChange: () => void) => () => void +} + +type ConsumerOwnership = + | HydrationScriptOutput + | 'fast-path' + | 'cleaned' + | undefined + +/** The one-time initial `` take: hydration tags plus the boundary. */ +export type InitialHydrationScriptTags = { + before: Array + boundary: RouterManagedTag +} + +function escapeAttribute(value: string) { + return value.replace(/[&"'<>]/g, (char) => `&#${char.charCodeAt(0)};`) +} + +function createInitialTags( + sources: ReadonlyArray, + nonce: string | undefined, +): InitialHydrationScriptTags { + const before: Array = [] + for (const source of sources) { + if (!source) { + continue + } + const previous = before[before.length - 1] + if ( + previous?.children && + previous.children.length + SOURCE_SEPARATOR.length + source.length <= + MAX_INITIAL_SOURCE_CODE_UNITS + ) { + previous.children += SOURCE_SEPARATOR + source + } else { + before.push({ + tag: 'script', + attrs: { nonce, [STREAM_PART_ATTRIBUTE]: '' }, + children: source, + }) + } + } + // Cleanup runs before route and asset scripts. If hydration code fails, its + // transport tags can remain because that page cannot hydrate successfully. + const lastHydrationTag = before[before.length - 1] + if (lastHydrationTag) { + const lastSource = lastHydrationTag.children! + if ( + lastSource.length + INITIAL_CLEANUP_SUFFIX.length <= + MAX_INITIAL_SOURCE_CODE_UNITS + ) { + lastHydrationTag.children = lastSource + INITIAL_CLEANUP_SUFFIX + } else { + // Do not concatenate onto a large source. A renderer could flatten the + // result and temporarily retain a second copy of the hydration payload. + before.push({ + tag: 'script', + attrs: { nonce, [STREAM_PART_ATTRIBUTE]: '' }, + children: INITIAL_CLEANUP_SOURCE, + }) + } + } + return { + before, + boundary: { + tag: 'script', + attrs: { nonce }, + children: HYDRATION_SCRIPT_BOUNDARY_SOURCE, + } satisfies RouterManagedTag, + } +} + +type HydrationScripts = { + pushSerializedSource: ( + data: string, + initial: boolean, + wrap: boolean, + ) => boolean + pushSource: (nextSource: string) => boolean + fail: (reason: unknown) => void + finish: () => void + takeInitialHydrationScriptTags: () => InitialHydrationScriptTags | undefined + disableHydration: () => void + isInitialTaken: () => boolean + skipInitialTake: () => void + liftBarrier: () => void + claimOutput: () => HydrationScriptOutput + reserveFastPath: (output?: HydrationScriptOutput) => boolean + startSerializationTimeout: (timeoutMs: number) => void + cleanup: () => void +} + +class HydrationScriptsOwner implements HydrationScriptOutput { + private queuedSources: Array = [] + private queuedSourceHead = 0 + private initialTaken = false + private barrierLifted = false + private producerDone = false + private consumer: ConsumerOwnership + private listener: (() => void) | undefined + + private active: Array | undefined + private retainedSources = 0 + private regularCodeUnits = 0 + private hasOversizedSource = false + + private segmentIndex = 0 + private closingSegmentIndex = 0 + private source = '' + private sourceOffset = 0 + private outputCapacity = MIN_OUTPUT_BYTES + + private outputState: HydrationScriptOutputState = + HydrationScriptOutputState.Waiting + private outputError: unknown + private timeout: ReturnType | undefined + private opening: string | undefined + + constructor( + private readonly nonce: string | undefined, + initialSources?: ReadonlyArray, + ) { + // ServerSsr exposes this method as a bare callback, so keep this single + // method bound while all other owner operations live on the prototype. + this.takeInitialHydrationScriptTags = + this.takeInitialHydrationScriptTags.bind(this) + + const seedSources = initialSources ?? DEFAULT_INITIAL_SOURCES + for (const seedSource of seedSources) { + if (!this.account(seedSource)) { + break + } + this.queuedSources.push(seedSource) + } + } + + get state() { + return this.outputState + } + + get error() { + return this.outputError + } + + private notify() { + try { + this.listener?.() + } catch (listenerError) { + console.error('Hydration script output listener error:', listenerError) + } + } + + private refresh(notifyChange = true) { + const next = + this.outputState === HydrationScriptOutputState.Failed + ? HydrationScriptOutputState.Failed + : this.active + ? HydrationScriptOutputState.Active + : typeof this.consumer === 'object' && + this.initialTaken && + this.barrierLifted && + !this.queueIsEmpty() + ? HydrationScriptOutputState.Ready + : this.producerDone && this.initialTaken && this.queueIsEmpty() + ? HydrationScriptOutputState.Done + : HydrationScriptOutputState.Waiting + if (this.outputState !== next) { + this.outputState = next + if (notifyChange) { + this.notify() + } + } + } + + private clearTimeoutIfSet() { + if (this.timeout !== undefined) { + clearTimeout(this.timeout) + this.timeout = undefined + } + } + + private queueIsEmpty() { + return this.queuedSourceHead === this.queuedSources.length + } + + private clearQueue() { + this.queuedSources = [] + this.queuedSourceHead = 0 + } + + private dropBufferedOutput() { + this.clearQueue() + this.active = undefined + this.retainedSources = 0 + this.regularCodeUnits = 0 + this.hasOversizedSource = false + this.segmentIndex = 0 + this.closingSegmentIndex = 0 + this.source = '' + this.sourceOffset = 0 + this.outputCapacity = MIN_OUTPUT_BYTES + this.opening = undefined + } + + fail(reason: unknown) { + if ( + this.consumer === 'cleaned' || + this.outputState === HydrationScriptOutputState.Failed + ) { + return + } + this.outputError = reason + this.clearTimeoutIfSet() + this.dropBufferedOutput() + this.outputState = HydrationScriptOutputState.Failed + this.notify() + } + + private rejectBacklog(kind: 'source-part' | 'code-unit') { + this.fail(new Error(`SSR hydration backlog exceeded maximum ${kind} count`)) + return false + } + + private account(nextSource: string) { + if (this.retainedSources === MAX_BACKLOG_SOURCES) { + return this.rejectBacklog('source-part') + } + if (nextSource.length > MAX_BACKLOG_CODE_UNITS) { + if (this.hasOversizedSource) { + return this.rejectBacklog('code-unit') + } + this.hasOversizedSource = true + } else if ( + this.regularCodeUnits + nextSource.length > + MAX_BACKLOG_CODE_UNITS + ) { + return this.rejectBacklog('code-unit') + } else { + this.regularCodeUnits += nextSource.length + } + this.retainedSources++ + return true + } + + private releaseSource(part: string) { + this.retainedSources-- + if (part.length > MAX_BACKLOG_CODE_UNITS) { + this.hasOversizedSource = false + } else { + this.regularCodeUnits -= part.length + } + } + + private releaseAccounting(batch: ReadonlyArray) { + for (const part of batch) { + if (part !== undefined) { + this.releaseSource(part) + } + } + } + + liftBarrier() { + if (this.consumer !== 'cleaned' && !this.barrierLifted) { + this.barrierLifted = true + this.refresh() + } + } + + private producerCanWrite() { + return ( + this.consumer !== 'cleaned' && + this.outputState !== HydrationScriptOutputState.Failed && + !this.producerDone + ) + } + + pushSource(nextSource: string) { + if (!this.producerCanWrite()) { + return false + } + if (this.account(nextSource)) { + this.queuedSources.push(nextSource) + if (this.initialTaken) { + this.refresh() + } + } else { + return false + } + // A notification can synchronously fail or clean up this owner. + return this.producerCanWrite() + } + + private takeQueuedBatch(batchLength: number) { + if ( + this.queuedSourceHead === 0 && + batchLength === this.queuedSources.length + ) { + const batch = this.queuedSources + this.clearQueue() + return batch + } + const end = this.queuedSourceHead + batchLength + const batch = this.queuedSources.slice(this.queuedSourceHead, end) + for (let index = this.queuedSourceHead; index < end; index++) { + this.queuedSources[index] = undefined + } + this.queuedSourceHead = end + if (this.queueIsEmpty()) { + this.clearQueue() + } else if ( + this.queuedSourceHead >= 1024 && + this.queuedSourceHead >= this.queuedSources.length - this.queuedSourceHead + ) { + this.queuedSources = this.queuedSources.slice(this.queuedSourceHead) + this.queuedSourceHead = 0 + } + return batch + } + + private release(batch: Array) { + this.releaseAccounting(batch) + this.active = undefined + this.source = '' + this.sourceOffset = 0 + this.refresh(false) + } + + private advanceSource() { + const batch = this.active! + if (this.segmentIndex > 0 && this.segmentIndex < this.closingSegmentIndex) { + const partIndex = (this.segmentIndex - 1) >> 1 + if (this.segmentIndex % 2 === 1) { + const part = batch[partIndex] + if (part !== undefined) { + this.releaseSource(part) + batch[partIndex] = undefined + } + } + } + this.segmentIndex++ + // Segment 0 is the opening tag. Sources and separators alternate until + // the closing segment. The next advance releases the complete record. + if (this.segmentIndex < this.closingSegmentIndex) { + const partIndex = (this.segmentIndex - 1) >> 1 + this.source = + this.segmentIndex % 2 === 1 ? batch[partIndex]! : SOURCE_SEPARATOR + } else if (this.segmentIndex === this.closingSegmentIndex) { + this.source = DYNAMIC_CLOSE_SOURCE + } else { + this.release(batch) + } + this.sourceOffset = 0 + } + + private pullActive() { + const bytes = new Uint8Array(this.outputCapacity) + let offset = 0 + while (this.active) { + if (this.sourceOffset === this.source.length) { + this.advanceSource() + } else if (offset === bytes.length) { + break + } else { + const target = offset === 0 ? bytes : bytes.subarray(offset) + const result = encoder.encodeInto( + this.source.slice(this.sourceOffset), + target, + ) + if (result.read === 0) { + break + } + this.sourceOffset += result.read + offset += result.written + } + } + if (offset === 0) { + throw new Error('SSR router script record produced no output') + } + if (offset === bytes.length) { + return bytes + } + // A subarray view pins the whole output buffer. Copy mostly-empty tail + // chunks (typically the final close-tag remnant) so the large buffer can + // be collected immediately. + return offset * 2 < bytes.length + ? bytes.slice(0, offset) + : bytes.subarray(0, offset) + } + + private pullReady() { + const scriptOpening = (this.opening ??= this.nonce + ? `` - serverSsr.injectHtml(html) - }, - dehydrate: async (opts?: { requestAssets?: ManifestRouteAssets }) => { - if (_dehydrated) { + hydrationScripts, + dehydrate: async (opts?: { + requestAssets?: ManifestRouteAssets + signal?: AbortSignal + }) => { + // Guard synchronously before the first await: a concurrent second call + // would double-serialize and corrupt the hydration payload. + if (dehydrationPhase !== 'idle') { if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: router is already dehydrated!') + throw new Error( + dehydrationPhase === 'disabled' + ? 'Invariant failed: hydration is disabled for this request!' + : 'Invariant failed: router is already dehydrated!', + ) } invariant() } + opts?.signal?.throwIfAborted() + dehydrationPhase = 'started' let matchesToDehydrate = _getRenderedMatches(router.stores.matches.get()) const isShell = router.isShell() if (isShell) { @@ -515,210 +445,192 @@ export function attachRouterServerSsrUtils({ cacheKey, ) - manifestToDehydrate = { - ...(manifest.scriptFormat - ? { scriptFormat: manifest.scriptFormat } - : {}), - ...(preparedManifest.inlineCssHrefs - ? { inlineStyle: createInlineCssPlaceholderAsset() } - : {}), - routes: preparedManifest.routes, - } - - // Merge request-scoped assets into root route (without mutating cached manifest) - const requestAssets = opts?.requestAssets - if (hasRequestAssets(requestAssets)) { - const existingRoot = manifestToDehydrate.routes[rootRouteId] - manifestToDehydrate.routes = { - ...manifestToDehydrate.routes, - [rootRouteId]: mergeRequestAssetsIntoRootRoute( - existingRoot, - requestAssets, - ), - } - } + manifestToDehydrate = composeManifest( + manifest.scriptFormat, + preparedManifest.inlineCssHrefs + ? createInlineCssPlaceholderAsset() + : undefined, + preparedManifest.routes, + opts?.requestAssets, + ) } const dehydratedRouter: DehydratedRouter = { manifest: manifestToDehydrate, matches, } - const dehydratedData = await router.options.dehydrate?.() + const dehydrate = router.options.dehydrate + const dehydratedData = dehydrate + ? opts?.signal + ? await waitForReason(dehydrate.call(router.options), opts.signal) + : await dehydrate.call(router.options) + : undefined + opts?.signal?.throwIfAborted() if (cleanupStarted) { return } - if (dehydratedData) { + if (dehydratedData !== undefined) { dehydratedRouter.dehydratedData = dehydratedData } - _dehydrated = true - const trackPlugins = { didRun: false } - const serializationAdapters = router.options.serializationAdapters as - | Array - | undefined + const serializationAdapters = router.options.serializationAdapters const plugins = serializationAdapters - ? serializationAdapters - .map((t) => makeSsrSerovalPlugin(t, trackPlugins)) - .concat(defaultSerovalPlugins) - : defaultSerovalPlugins + ? [ + ...serializationAdapters.map((adapter) => + makeSsrSerovalPlugin(adapter, trackPlugins), + ), + ...ssrSerovalPlugins, + ] + : ssrSerovalPlugins let serializationCompleteSignaled = false - const signalSerializationComplete = () => { - if (serializationCompleteSignaled || cleanupStarted) return + let initialSerialized = false + const completeScriptSerialization = ( + result: boolean | { error: unknown }, + ) => { + if (serializationCompleteSignaled || cleanupStarted) { + return + } serializationCompleteSignaled = true - _serializationFinished = true - - const listeners = serializationFinishedListeners.slice() - serializationFinishedListeners.length = 0 - - for (const l of listeners) { - try { - l() - } catch (err) { - console.error('Serialization listener error:', err) - } + const dispose = disposeSerialization + disposeSerialization = undefined + if (result === true) { + settled = true + hydrationScripts.finish() + } else if (result) { + hydrationScripts.fail(result.error) + } + if (dispose) { + // Seroval invokes completion callbacks before it marks its stream as + // inactive. Clear ownership before notifying the hydration consumer, + // which can synchronously clean up this request, then dispose later. + queueMicrotask(() => disposeSerializationSafely(dispose)) } } - const finishScriptSerialization = () => { - if (serializationCompleteSignaled || cleanupStarted) return - scriptBuffer.enqueue(GLOBAL_TSR + '.e()') - // Must synchronously notify injected HTML listeners before signaling - // completion; otherwise the held tail could flush ahead of the - // end script. - scriptBuffer.flush() - signalSerializationComplete() - } - - crossSerializeStream(dehydratedRouter, { + let synchronousFailure: { error: unknown } | undefined + const dispose = crossSerializeStream(dehydratedRouter, { refs: new Map(), plugins, onSerialize: (data, initial) => { - let serialized = initial ? TSR_PREFIX + data : data - if (trackPlugins.didRun) { - serialized = P_PREFIX + serialized + P_SUFFIX + if (serializationCompleteSignaled || cleanupStarted) { + return + } + initialSerialized ||= initial + if ( + !hydrationScripts.pushSerializedSource( + data, + initial, + trackPlugins.didRun, + ) + ) { + // Rejected output stops the producer while deferred work may remain. + completeScriptSerialization(false) } - scriptBuffer.enqueue(serialized) }, onError: (err: unknown) => { - console.error('Serialization error:', err) - if (err && (err as any).stack) { - console.error((err as any).stack) + if (serializationCompleteSignaled || cleanupStarted) { + return } - finishScriptSerialization() + console.error('Serialization error:', err) + synchronousFailure = { error: err } + completeScriptSerialization({ error: err }) }, - scopeId: SCOPE_ID, + scopeId: SSR_SERIALIZATION_SCOPE_ID, onDone: () => { - finishScriptSerialization() + if (initialSerialized) { + completeScriptSerialization(true) + } }, }) - }, - isDehydrated() { - return _dehydrated - }, - isSerializationFinished() { - return _serializationFinished - }, - reserveStreamFastPath() { - if ( - !cleanupStarted && - _serializationFinished && - !streamFastPathReserved && - renderFinishedListeners.length === 0 && - !injectedHtmlBuffer && - !scriptBuffer.hasPending() - ) { - streamFastPathReserved = true - return true + // Seroval can call onDone synchronously before it returns dispose(). + if (cleanupStarted || serializationCompleteSignaled) { + disposeSerializationSafely(dispose) + } else { + disposeSerialization = dispose + } + if (synchronousFailure) { + throw synchronousFailure.error } - return false - }, - onInjectedHtml: (listener) => { - if (cleanupStarted) return () => {} - injectedHtmlListeners.push(listener) - return () => removeListener(injectedHtmlListeners, listener) }, onRenderFinished: (listener) => { - if (cleanupStarted || streamFastPathReserved) return - renderFinishedListeners.push(listener) - }, - onSerializationFinished: (listener) => { - if (cleanupStarted) return () => {} - if (_serializationFinished && !cleanupStarted) { + if (cleanupStarted) { + return + } + if (renderFinished) { try { listener() - } catch (err) { - console.error('Serialization listener error:', err) + } catch (error) { + console.error('Error in render finished listener:', error) } - return () => {} + return } - serializationFinishedListeners.push(listener) - return () => removeListener(serializationFinishedListeners, listener) + renderFinishedListeners.push(listener) }, onCleanup: (listener) => { - if (cleanupStarted) return - cleanupListeners.push(listener) - }, - setRenderFinished: () => { - if (cleanupStarted) return - scriptBuffer.liftBarrier() - const listeners = renderFinishedListeners.slice() - renderFinishedListeners.length = 0 - for (const l of listeners) { + if (cleanupStarted) { + // Cleanup already happened (or is running). Invoke immediately so + // late registrants can still release their resources instead of + // silently retaining them (standard disposer convention). try { - l() - } catch (err) { - console.error('Error in render finished listener:', err) + listener(settled) + } catch (error) { + console.error('Error in SSR cleanup listener:', error) } + return } - if (_serializationFinished) { - scriptBuffer.flush() - } + cleanupListeners.push(listener) }, - takeBufferedScripts() { - const scripts = scriptBuffer.takeAll() - if (!scripts) return undefined - const serverBufferedScript: RouterManagedTag = { - tag: 'script', - attrs: { - nonce: router.options.ssr?.nonce, - className: '$tsr', - id: TSR_SCRIPT_BARRIER_ID, - }, - children: scripts, + setRenderFinished: () => { + if (cleanupStarted || renderFinished) { + return } - return serverBufferedScript - }, - liftScriptBarrier() { - scriptBuffer.liftBarrier() + renderFinished = true + hydrationScripts.liftBarrier() + notifyAndClearListeners( + renderFinishedListeners, + 'Error in render finished listener:', + undefined, + ) }, - takeBufferedHtml() { - if (!injectedHtmlBuffer) { - return undefined + disableHydration: () => { + if (cleanupStarted || dehydrationPhase === 'disabled') { + return + } + if (dehydrationPhase !== 'idle') { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'Invariant failed: cannot disable hydration after dehydrate()!', + ) + } + + invariant() } - const buffered = injectedHtmlBuffer - injectedHtmlBuffer = '' - return buffered + // The owner rejects later takes/claims; guard order matters so a + // throwing owner does not leave the phase half-set. + hydrationScripts.disableHydration() + dehydrationPhase = 'disabled' }, + takeInitialHydrationScriptTags: + hydrationScripts.takeInitialHydrationScriptTags, cleanup() { // Guard against multiple/reentrant cleanup calls. A listener could call // cleanup() again indirectly; snapshot + clear before invoking so each // listener runs exactly once and reentry is a no-op. - if (cleanupStarted) return - cleanupStarted = true - const listeners = cleanupListeners.slice() - cleanupListeners.length = 0 - for (const l of listeners) { - try { - l() - } catch (err) { - console.error('Error in SSR cleanup listener:', err) - } + if (cleanupStarted) { + return } + cleanupStarted = true + hydrationScripts.cleanup() + const dispose = disposeSerialization + disposeSerialization = undefined + disposeSerializationSafely(dispose) + notifyAndClearListeners( + cleanupListeners, + 'Error in SSR cleanup listener:', + settled, + ) renderFinishedListeners.length = 0 - injectedHtmlListeners.length = 0 - serializationFinishedListeners.length = 0 - injectedHtmlBuffer = '' - scriptBuffer.cleanup() + router.ssr = undefined router.serverSsr = undefined }, } @@ -762,7 +674,9 @@ export function getOrigin(request: Request) { // chromium treats search params differently than paths, i.e. "|" is not encoded in search params. export function getNormalizedURL(url: string | URL, base?: string | URL) { // ensure backslashes are encoded correctly in the URL - if (typeof url === 'string') url = url.replace('\\', '%5C') + if (typeof url === 'string') { + url = url.replace('\\', '%5C') + } const rawUrl = new URL(url, base) const { path: decodedPathname, handledProtocolRelativeURL } = decodePath( diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 03c6ecf2e13..ae63c308acb 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -1,32 +1,52 @@ -import { ReadableStream } from 'node:stream/web' import { Readable } from 'node:stream' -import { TSR_SCRIPT_BARRIER_ID } from './constants' +import { + DOCUMENT_CLOSE_ANCHOR_INDEX, + DOCUMENT_CLOSE_BYTES, + SCRIPT_CLOSE_ANCHOR_INDEX, + SCRIPT_CLOSE_BYTES, + advanceByteMatcher, + findExactBytes, + getExactBytesPrefixAtEnd, +} from './htmlBoundaryScanner' +import { + HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX, + HYDRATION_SCRIPT_BOUNDARY_BYTES, + HydrationScriptOutputState, +} from './hydrationScripts' +import type { ReadableStream as NodeReadableStream } from 'node:stream/web' import type { AnyRouter } from '../router' +import type { ByteMatcherState } from './htmlBoundaryScanner' +import type { HydrationScriptOutput } from './hydrationScripts' export type TransformStreamWithRouterOptions = { - /** The request lifetime that owns this response stream. */ - signal?: AbortSignal /** Timeout for serialization to complete after app render finishes (default: 60000ms) */ timeoutMs?: number - /** Maximum lifetime of the stream transform (default: 120000ms). Safety net for cleanup. */ + /** Maximum lifetime of the stream transform. Defaults to twice timeoutMs. */ lifetimeMs?: number + /** Cancels the transform and releases SSR state when the request ends. */ + signal?: AbortSignal + /** + * Additional point after which the renderer guarantees that a router script + * can be inserted. This is an adapter contract, not a user streaming policy. + * The router boundary, canonical document close, and EOF are always safe. + */ + rendererSafePoint?: 'script-close' | 'record-end' /** * Called exactly once when the stream is torn down due to abort/error/ * cancel/timeout — NOT on natural successful completion. Use this to - * abort a hidden producer upstream of any PassThrough you passed in - * (e.g. React `renderToPipeableStream`'s `abort()`). + * abort a hidden producer upstream of any stream passed to this transform. * Errors thrown from this callback are swallowed. */ onAbort?: (reason?: unknown) => void } -export function transformReadableStreamWithRouter( - router: AnyRouter, - routerStream: ReadableStream, - opts?: TransformStreamWithRouterOptions, -) { - return transformStreamWithRouter(router, routerStream, opts) -} +type AppStreamValue = Uint8Array | string +/** Renderer output: UTF-8 bytes, or string records from a Node pipeable. */ +export type AppStream = + | ReadableStream + | ReadableStream + | ReadableStream + | NodeReadableStream export function transformPipeableStreamWithRouter( router: AnyRouter, @@ -34,880 +54,975 @@ export function transformPipeableStreamWithRouter( opts?: TransformStreamWithRouterOptions, ) { return Readable.fromWeb( - transformStreamWithRouter(router, Readable.toWeb(routerStream), opts), + transformReadableStreamWithRouter( + router, + Readable.toWeb(routerStream) as AppStream, + opts, + ) as NodeReadableStream, ) } -// Minimum length of a valid closing tag: = 4 characters -const MIN_CLOSING_TAG_LENGTH = 4 - -// Default timeout values (in milliseconds) -const DEFAULT_SERIALIZATION_TIMEOUT_MS = 60000 -const DEFAULT_LIFETIME_TIMEOUT_MS = DEFAULT_SERIALIZATION_TIMEOUT_MS * 2 -const MAX_LEFTOVER_CHARS = 2048 -const MAX_TAIL_CHARS = 64 * 1024 -const MAX_ROUTER_HTML_CHARS = 16 * 1024 * 1024 -const MAX_PENDING_WRITE_CHARS = 16 * 1024 * 1024 - -// Merge lifecycle: body bytes can stream, router HTML must precede tail, -// terminal states own close/error/cleanup exactly once. -const MergeState = { - ReadingBody: 0, - HoldingTail: 1, - AppDone: 2, - Draining: 3, - Done: 4, -} as const - -type MergeState = (typeof MergeState)[keyof typeof MergeState] - -// Module-level encoder (stateless, safe to reuse) -const textEncoder = new TextEncoder() - -const noop = () => {} -const resolvedPromise = Promise.resolve() - -// Returns -bodyEndIndex - 2 when is found; otherwise returns -// the position after the last valid closing tag, or -1 when none exists. -function findHtmlBoundary(str: string): number { - let lastClosingTagEnd = -1 - let searchFrom = str.length - MIN_CLOSING_TAG_LENGTH - - while (searchFrom >= 0) { - const openSlash = str.lastIndexOf('. Negative return encodes the - // body start index without allocating a result object. - if ( - (str.charCodeAt(openSlash + 2) | 32) === 98 && - (str.charCodeAt(openSlash + 3) | 32) === 111 && - (str.charCodeAt(openSlash + 4) | 32) === 100 && - (str.charCodeAt(openSlash + 5) | 32) === 121 && - str.charCodeAt(openSlash + 6) === 62 - ) { - return -openSlash - 2 +export async function transformHtmlStringWithRouter( + router: AnyRouter, + html: string, + opts?: TransformStreamWithRouterOptions, +) { + const serverSsr = router.serverSsr + if (!serverSsr) { + throw new Error('Invariant failed: router.serverSsr is required') + } + const hydrationScripts = serverSsr.hydrationScripts + if (hydrationScripts.reserveFastPath()) { + try { + opts?.signal?.throwIfAborted() + serverSsr.setRenderFinished() + return '' + html + } catch (error) { + try { + opts?.onAbort?.(error) + } catch { + // User cleanup must not block SSR cleanup. + } + throw error + } finally { + serverSsr.cleanup() } + } - if (lastClosingTagEnd === -1) { - let i = openSlash + 2 - const startCode = str.charCodeAt(i) - if ( - (startCode >= 97 && startCode <= 122) || - (startCode >= 65 && startCode <= 90) - ) { - i++ - while (i < str.length) { - const code = str.charCodeAt(i) - if ( - (code >= 97 && code <= 122) || // a-z - (code >= 65 && code <= 90) || // A-Z - (code >= 48 && code <= 57) || // 0-9 - code === 95 || // _ - code === 58 || // : - code === 46 || // . - code === 45 // - - ) { - i++ - } else { - break - } - } + // The merge path chunks string records itself. + const appStream = new ReadableStream({ + start(controller) { + controller.enqueue('') + controller.enqueue(html) + controller.close() + }, + }) + const output = transformReadableStreamWithRouter(router, appStream, opts) + return readUtf8Stream(output) +} - if (str.charCodeAt(i) === 62) { - lastClosingTagEnd = i + 1 - } +async function readUtf8Stream(stream: ReadableStream) { + const reader = stream.getReader() + const decoder = new TextDecoder() + let text = '' + try { + for (;;) { + const { done, value } = await reader.read() + if (done) { + break } + text += decoder.decode(value, { stream: true }) } - - searchFrom = openSlash - 1 + return text + decoder.decode() + } finally { + reader.releaseLock() } - - return lastClosingTagEnd } -/** - * Releasing the lock can throw if a pending read is still settling or if the - * lock was already released. - */ -type ReaderOps = { - cancel: (reason?: unknown) => Promise - releaseLock: () => void -} +const DEFAULT_SERIALIZATION_TIMEOUT_MS = 60_000 +const MIN_APPLICATION_STRING_CHUNK_BYTES = 256 +const MAX_APPLICATION_STRING_CHUNK_BYTES = 64 * 1024 -function safeReleaseReader(reader: ReaderOps) { +const ApplicationPhase = { + BeforeBoundary: 0, + Merge: 1, + HeldClose: 2, + PassThrough: 3, +} as const + +type ApplicationPhase = (typeof ApplicationPhase)[keyof typeof ApplicationPhase] +type Termination = 'complete' | 'cancel' | 'failure' +type AppStreamReader = ReadableStreamDefaultReader + +const textEncoder = new TextEncoder() + +function releaseReader(reader: AppStreamReader) { try { reader.releaseLock() - return true } catch { - return false + // A nonstandard reader may still reject lock release. } } -/** - * Cancel a reader without producing an unhandled rejection. `reader.cancel()` - * can reject (e.g. when the underlying source's cancel() throws), and - * downstream cancel() should still wait for upstream teardown when possible. - */ -function safeCancelReader(reader: ReaderOps, reason?: unknown): Promise { - let cancelPromise: Promise | undefined +function cancelReader(reader: AppStreamReader, reason?: unknown) { + const cancelled = reader.cancel(reason).catch(() => {}) + // Native readers close synchronously when cancel() starts. Release the lock + // before an arbitrary underlying cancel promise has a chance to park. + releaseReader(reader) + return cancelled +} + +function finalizeSsrStream( + kind: Termination, + reason: unknown, + controller: ReadableStreamDefaultController, + reader: AppStreamReader, + serverSsr: NonNullable, + onAbort?: (reason?: unknown) => void, +) { try { - cancelPromise = reader.cancel(reason) + if (kind === 'complete') { + controller.close() + } else if (kind === 'failure') { + controller.error(reason) + } } catch { - // ignore + // The stream already reached a terminal state. } - if (!safeReleaseReader(reader) && cancelPromise) { - return cancelPromise.then(noop, noop).then(() => { - safeReleaseReader(reader) - }) + const aborted = kind !== 'complete' + if (aborted) { + try { + onAbort?.(reason) + } catch { + // User cleanup must not block SSR cleanup. + } } - return cancelPromise ? cancelPromise.then(noop, noop) : resolvedPromise + const readerDone = aborted + ? cancelReader(reader, reason) + : releaseReader(reader) + serverSsr.cleanup() + return readerDone } -function createReaderState(appStream: ReadableStream) { - const reader = appStream.getReader() - let released = false - - return { - reader, - cancel: (reason?: unknown) => { - if (released) return resolvedPromise - released = true - return safeCancelReader(reader, reason) - }, - release: () => { - if (released) return - released = true - safeReleaseReader(reader) - }, - } +function getLifetimeMs(opts?: TransformStreamWithRouterOptions) { + return ( + opts?.lifetimeMs ?? + (opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS) * 2 + ) } -function createAbortNotifier(opts?: TransformStreamWithRouterOptions) { - let abortNotified = false - return (reason?: unknown) => { - if (abortNotified) return - abortNotified = true - try { - opts?.onAbort?.(reason) - } catch { - // swallow user errors - } - } +// External serverSsr.cleanup() severs router ownership. The transform must +// release the renderer, timers, listeners, and buffers immediately instead of +// retaining them until the lifetime backstop fires. AbortError identifies this +// intentional cancellation to downstream consumers. +function createCleanupAbortError() { + const error = new Error('SSR stream transform aborted by router SSR cleanup') + error.name = 'AbortError' + return error } -function listenToAbort( +function listenForAbort( signal: AbortSignal | undefined, - onAbort: (reason?: unknown) => void, + onAbort: (reason: unknown) => void, ) { if (!signal) { - return - } - if (signal.aborted) { - onAbort(signal.reason) - return + return undefined } const listener = () => onAbort(signal.reason) signal.addEventListener('abort', listener, { once: true }) return () => signal.removeEventListener('abort', listener) } -export function transformStreamWithRouter( - router: AnyRouter, - appStream: ReadableStream, - opts?: TransformStreamWithRouterOptions, -) { - const serverSsr = router.serverSsr - if (!serverSsr) { - throw new Error('Invariant failed: router.serverSsr is required') - } - if (serverSsr.reserveStreamFastPath()) { - return makeFastPathStream(appStream, opts, serverSsr) - } - - return makeMainStream(serverSsr, appStream, opts) +/** + * Create a timer that does not keep the Node.js process alive when this + * last-resort stream backstop is the only remaining work. + * + * Node's global `setTimeout()` returns a `Timeout` object with `unref()`. + * Web-standard runtimes return a numeric timer ID instead. Cloudflare Workers + * retain that Web behavior for global timers even when `nodejs_compat` is + * enabled. Accessing an optional property on a numeric ID is safe, so timer + * creation can normalize the Node-only capability without allocating a + * wrapper object. The native handle is returned unchanged for `clearTimeout`. + */ +function setUnrefTimeout(callback: () => void, timeoutMs: number) { + const handle = setTimeout(callback, timeoutMs) + const portableHandle = handle as typeof handle & { unref?: () => void } + portableHandle.unref?.() + return handle } -// ===================================================================== -// Fast path: passthrough with cleanup + backpressure on app reads. -// ===================================================================== -function makeFastPathStream( - appStream: ReadableStream, - opts?: TransformStreamWithRouterOptions, - serverSsr?: NonNullable, +/** + * Arm the shared teardown triggers of a transform stream: the lifetime + * backstop timer, the request-abort listener, and the external-cleanup + * listener. Returns a disarm function that `terminate()` calls exactly once; + * teardown ordering must stay identical between the fast and merge paths. + */ +function armStreamLifecycle( + serverSsr: NonNullable, + opts: TransformStreamWithRouterOptions | undefined, + isTerminal: () => boolean, + terminate: (kind: Termination, reason?: unknown) => unknown, ) { - let cleanedUp = false - let controller: ReadableStreamDefaultController | undefined - let state: MergeState = MergeState.ReadingBody + const signal = opts?.signal let lifetimeTimeoutHandle: ReturnType | undefined - let stopListeningToAbort: (() => void) | undefined - let stopListeningToInjectedHtml: (() => void) | undefined - const readerState = createReaderState(appStream) - const notifyAbort = createAbortNotifier(opts) - const isDone = () => state === MergeState.Done - let renderFinished = false - - const finishSsrRendering = () => { - if (!serverSsr || renderFinished) return true - renderFinished = true - try { - serverSsr.setRenderFinished() - return true - } catch (error) { - safeError(error) - cleanup(error) - return false + let stopAbortListener: (() => void) | undefined + const disarm = () => { + stopAbortListener?.() + stopAbortListener = undefined + if (signal && serverSsr.hydrationScripts.requestSignal === signal) { + serverSsr.hydrationScripts.requestSignal = undefined } - } - - const cleanup = (reason?: unknown, cancelReader = true) => { - if (cleanedUp) return resolvedPromise - cleanedUp = true - if (lifetimeTimeoutHandle !== undefined) { clearTimeout(lifetimeTimeoutHandle) lifetimeTimeoutHandle = undefined } - stopListeningToAbort?.() - stopListeningToAbort = undefined - try { - stopListeningToInjectedHtml?.() - } catch { - // ignore - } - stopListeningToInjectedHtml = undefined + } - if (cancelReader) { - // Notify the producer immediately. Reader cancellation may take time to - // settle, and upstream renderers must tolerate abort + cancel overlap. - notifyAbort(reason) + const lifetimeMs = getLifetimeMs(opts) + lifetimeTimeoutHandle = setUnrefTimeout(() => { + if (isTerminal()) { + return } - const readerDone = cancelReader - ? readerState.cancel(reason) - : (readerState.release(), resolvedPromise) - if (serverSsr) { - try { - serverSsr.cleanup() - } catch (error) { - console.error('Error in SSR cleanup:', error) - } + const error = new Error('Stream lifetime exceeded') + console.warn( + `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`, + ) + terminate('failure', error) + }, lifetimeMs) + stopAbortListener = listenForAbort(signal, (reason) => { + terminate('failure', reason) + }) + if (signal) { + serverSsr.hydrationScripts.requestSignal = signal + } + // External serverSsr.cleanup() must release the reader, renderer, timer, + // and buffers promptly — a parked pump only wakes through its own + // subscriptions, and a discarded response never pulls at all. + serverSsr.onCleanup(() => { + if (!isTerminal()) { + terminate('failure', createCleanupAbortError()) } - return readerDone + }) + return disarm +} + +function cleanupFailedStreamCreation( + serverSsr: NonNullable, + onAbort: TransformStreamWithRouterOptions['onAbort'], + error: unknown, +) { + try { + onAbort?.(error) + } catch { + // User cleanup must not block SSR cleanup. } + serverSsr.cleanup() +} - const safeClose = () => { - if (isDone()) return - state = MergeState.Done - try { - controller?.close() - } catch { - // ignore - } +function encodeStringSource(value: string, offset: number) { + const remaining = value.length - offset + const capacity = Math.min( + MAX_APPLICATION_STRING_CHUNK_BYTES, + Math.max( + MIN_APPLICATION_STRING_CHUNK_BYTES, + Math.min(value.length, remaining * 3), + ), + ) + const output = new Uint8Array(capacity) + // encodeInto() stops before a code point that does not fit, so it never + // splits a surrogate pair across chunks. + const { read, written } = textEncoder.encodeInto( + offset === 0 ? value : value.slice(offset), + output, + ) + return { + bytes: written === output.length ? output : output.subarray(0, written), + read, } +} - const safeError = (error: unknown) => { - if (isDone()) return - state = MergeState.Done - try { - controller?.error(error) - } catch { - // ignore - } +export function transformReadableStreamWithRouter( + router: AnyRouter, + appStream: AppStream, + opts?: TransformStreamWithRouterOptions, +) { + const serverSsr = router.serverSsr + if (!serverSsr) { + throw new Error('Invariant failed: router.serverSsr is required') } + const hydrationScripts = serverSsr.hydrationScripts - if (serverSsr) { - stopListeningToInjectedHtml = serverSsr.onInjectedHtml(() => { - const err = new Error('SSR router HTML injected during fast path') - safeError(err) - cleanup(err) - }) + let reader: AppStreamReader + try { + reader = appStream.getReader() as AppStreamReader + } catch (error) { + cleanupFailedStreamCreation(serverSsr, opts?.onAbort, error) + throw error } - const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS - lifetimeTimeoutHandle = setTimeout(() => { - if (!cleanedUp && !isDone()) { - const err = new Error('Stream lifetime exceeded') - console.warn( - `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`, - ) - safeError(err) - cleanup(err) + try { + opts?.signal?.throwIfAborted() + if (hydrationScripts.reserveFastPath()) { + return makeFastPathStream(serverSsr, reader, opts) } - }, lifetimeMs) + const hydrationOutput = hydrationScripts.claimOutput() + if (hydrationOutput.state === HydrationScriptOutputState.Failed) { + throw hydrationOutput.error + } + return makeMergeStream(serverSsr, reader, hydrationOutput, opts) + } catch (error) { + void cancelReader(reader, error) + cleanupFailedStreamCreation(serverSsr, opts?.onAbort, error) + throw error + } +} + +// The fast path forwards renderer bytes without scanning or copying them. +function makeFastPathStream( + serverSsr: NonNullable, + reader: AppStreamReader, + opts?: TransformStreamWithRouterOptions, +) { + let terminal = false + let controller!: ReadableStreamDefaultController + let appString: string | undefined + let appStringOffset = 0 + // Assigned after the stream exists; `terminate` can run before that when an + // already-cleaned owner fires its cleanup listener synchronously. + let disarmLifecycle = (): void => {} + + function terminate(kind: Termination, reason?: unknown) { + if (terminal) { + return + } + terminal = true + disarmLifecycle() + appString = undefined + return finalizeSsrStream( + kind, + reason, + controller, + reader, + serverSsr, + opts?.onAbort, + ) + } const stream = new ReadableStream({ start(c) { controller = c }, async pull(c) { - if (cleanedUp || isDone()) return + if (terminal) { + return + } try { - const { done, value } = await readerState.reader.read() - if (!done) { - if (!cleanedUp && !isDone()) { + for (;;) { + if (appString !== undefined) { + const encoded = encodeStringSource(appString, appStringOffset) + appStringOffset += encoded.read + if (appStringOffset === appString.length) { + appString = undefined + appStringOffset = 0 + } + if (encoded.bytes.byteLength > 0) { + c.enqueue(encoded.bytes) + return + } + continue + } + const { done, value } = await reader.read() + if (terminal) { + return + } + if (done) { + serverSsr.setRenderFinished() + return terminate('complete') + } + if (typeof value === 'string') { + if (value.length > 0) { + appString = value + } + } else if (value.byteLength > 0) { c.enqueue(value) + return } - return } - - if (cleanedUp || isDone()) return - - if (!finishSsrRendering()) return - safeClose() - return cleanup(undefined, false) } catch (error) { - if (cleanedUp) return - console.error('Error reading appStream:', error) - if (state < MergeState.AppDone) { - try { - serverSsr?.setRenderFinished() - } catch { - // ignore - } - } - safeError(error) - return cleanup(error) - } finally { - if (cleanedUp || isDone()) { - readerState.release() + if (terminal) { + return } + console.error('Error processing appStream:', error) + return terminate('failure', error) } }, cancel(reason) { - state = MergeState.Done - return cleanup(reason) + return terminate('cancel', reason) }, }) - stopListeningToAbort = listenToAbort(opts?.signal, (reason) => { - safeError(reason) - cleanup(reason) - }) + disarmLifecycle = armStreamLifecycle( + serverSsr, + opts, + () => terminal, + terminate, + ) return stream } -// ===================================================================== -// Main path: scan + inject router HTML/scripts with full backpressure. -// -// ALL output (app chunks AND router-injected HTML/scripts) flows through a -// single pendingWrites queue and is only enqueued onto the downstream -// controller when desiredSize > 0. This prevents native-memory growth of -// queued Uint8Arrays under slow HTTP consumers. -// ===================================================================== -function makeMainStream( +// The merge path searches only router- and renderer-owned ASCII delimiters. +// Application bytes otherwise leave through zero-copy subarray views. +function makeMergeStream( serverSsr: NonNullable, - appStream: ReadableStream, + reader: AppStreamReader, + hydrationOutput: HydrationScriptOutput, opts?: TransformStreamWithRouterOptions, ) { - let stopListeningToInjectedHtml: (() => void) | undefined - let stopListeningToSerializationFinished: (() => void) | undefined - let serializationTimeoutHandle: ReturnType | undefined - let lifetimeTimeoutHandle: ReturnType | undefined - let stopListeningToAbort: (() => void) | undefined - let cleanedUp = false - - let controller: ReadableStreamDefaultController | undefined - let closeWhenDrained = false - let state: MergeState = MergeState.ReadingBody - - const readerState = createReaderState(appStream) - const notifyAbort = createAbortNotifier(opts) - - // Single output queue: app chunks + router-injected HTML/scripts. - // Stored as STRINGS to avoid holding native-backed Uint8Arrays in our queue - // while waiting for downstream capacity. Encoding happens at enqueue time - // (drainPending) so the bytes live only inside the controller's internal - // queue, not in two places. - // - // Uses an index pointer instead of Array.prototype.shift() (which is O(n)) - // so many small router-injected script chunks stay O(1) per chunk. - const pendingWrites: Array = [] - let pendingWriteHead = 0 - let pendingWriteChars = 0 - - function clearPending() { - pendingWrites.length = 0 - pendingWriteHead = 0 - pendingWriteChars = 0 - } - - // Backpressure: pull() resolves drainResolve to let the read loop advance. - let drainResolve: (() => void) | null = null - const waitForDrain = () => - new Promise((r) => { - drainResolve = r + const hydrationScripts = serverSsr.hydrationScripts + let controller!: ReadableStreamDefaultController + let terminal = false + let appDone = false + let applicationPhase: ApplicationPhase = ApplicationPhase.BeforeBoundary + let insertionBoundary = false + + let stopHydrationOutputListener: (() => void) | undefined + let appReadPending = false + let settledAppRead: ReadableStreamReadValueResult | undefined + + let appBytes: Uint8Array | undefined + let appOffset = 0 + let documentCloseIndex: number | undefined + let appString: string | undefined + let appStringOffset = 0 + + const useScriptCloseSafePoints = opts?.rendererSafePoint === 'script-close' + const useRecordEndSafePoints = opts?.rendererSafePoint === 'record-end' + + const barrierMatcher: ByteMatcherState = { + pattern: HYDRATION_SCRIPT_BOUNDARY_BYTES, + anchorIndex: HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX, + matched: 0, + } + let safePointMatcher: ByteMatcherState | undefined + // Split document closes carry at most 13 bytes across chunks. This uses + // findExactBytes + getExactBytesPrefixAtEnd instead of advanceByteMatcher + // because `` repeats its first byte (`<` at 0 and 7), which + // violates the matcher's unique-first-byte resync precondition. + let closeCarry: Uint8Array | undefined + + let wakeResolve: (() => void) | undefined + // Assigned after the stream exists; `terminate` can run before that when an + // already-cleaned owner fires its cleanup listener synchronously. + let disarmLifecycle = (): void => {} + + function waitForWake() { + return new Promise((resolve) => { + wakeResolve = resolve }) - const signalDrain = () => { - if (drainResolve) { - const r = drainResolve - drainResolve = null - r() - } - } - - const isDone = () => state === MergeState.Done - - function drainPending() { - if (!controller || isDone()) return - while (pendingWriteHead < pendingWrites.length) { - const ds = controller.desiredSize - if (ds !== null && ds <= 0) return - const next = pendingWrites[pendingWriteHead]! - // Release reference for GC; compact when fully drained. - pendingWrites[pendingWriteHead] = '' - pendingWriteHead++ - pendingWriteChars -= next.length - try { - controller.enqueue(textEncoder.encode(next)) - } catch (error) { - safeError(error) - cleanup(error) - return - } - } - // Fully drained: reset array so it doesn't grow unbounded across SSR. - if (pendingWriteHead >= pendingWrites.length) { - pendingWrites.length = 0 - pendingWriteHead = 0 - } - // If we've flushed everything and tryFinish requested close, close now. - if (closeWhenDrained && pendingWriteHead >= pendingWrites.length) { - closeWhenDrained = false - safeClose() - cleanup(undefined, false) - } } - /** - * Enqueue a string chunk through the backpressure queue. Stored as a - * string and encoded only when the downstream actually accepts the chunk - * — keeps native-memory pressure inside the controller's queue (which - * honors desiredSize) rather than ours. - */ - function writeChunk(chunk: string) { - if (cleanedUp || isDone()) return - if (!chunk.length) return - if (pendingWriteChars + chunk.length > MAX_PENDING_WRITE_CHARS) { - const err = new Error('SSR stream pending output exceeded maximum buffer') - safeError(err) - cleanup(err) - return + function wakePump() { + const resolve = wakeResolve + wakeResolve = undefined + resolve?.() + } + + // Returns true when a chunk was enqueued. Every process* function + // propagates this boolean so pump() emits at most one chunk per downstream + // pull — the backpressure contract of this transform. + function enqueueAppBytes(value: Uint8Array) { + if (value.length === 0) { + return false } - pendingWrites.push(chunk) - pendingWriteChars += chunk.length - drainPending() + controller.enqueue(value) + return true } - function safeClose() { - if (isDone()) return - state = MergeState.Done - try { - controller?.close() - } catch { - // ignore + function finishAppChunk() { + appBytes = undefined + documentCloseIndex = undefined + if ( + appString === undefined && + useRecordEndSafePoints && + closeCarry === undefined + ) { + insertionBoundary = true } } - function safeError(error: unknown) { - if (isDone()) return - state = MergeState.Done - try { - controller?.error(error) - } catch { - // ignore + function loadNextAppStringChunk() { + const value = appString! + const encoded = encodeStringSource(value, appStringOffset) + appStringOffset += encoded.read + appBytes = encoded.bytes + appOffset = 0 + if (appStringOffset === value.length) { + appString = undefined } } - /** - * Cleanup with guards; must be idempotent. - */ - function cleanup(reason?: unknown, cancelReader = true) { - if (cleanedUp) return resolvedPromise - cleanedUp = true + function processUntilBarrier() { + const value = appBytes! + if (!hydrationScripts.isInitialTaken()) { + // The rendered boundary can only exist after the initial script take, + // so earlier renderer bytes pass through without scanning. This skips + // the barrier scan for the whole pre- document and prevents + // barrier-lookalike bytes in application content from lifting the + // barrier early. + const remainder = appOffset === 0 ? value : value.subarray(appOffset) + finishAppChunk() + return enqueueAppBytes(remainder) + } + const matchEnd = advanceByteMatcher(barrierMatcher, value, appOffset) + if (matchEnd === undefined) { + const remainder = appOffset === 0 ? value : value.subarray(appOffset) + finishAppChunk() + return enqueueAppBytes(remainder) + } - try { - stopListeningToInjectedHtml?.() - stopListeningToSerializationFinished?.() - } catch { - // ignore + const throughBarrier = + appOffset === 0 && matchEnd === value.length + ? value + : value.subarray(appOffset, matchEnd) + appOffset = matchEnd + const emitted = enqueueAppBytes(throughBarrier) + applicationPhase = ApplicationPhase.Merge + insertionBoundary = true + hydrationScripts.liftBarrier() + if (appOffset === value.length) { + finishAppChunk() } - stopListeningToInjectedHtml = undefined - stopListeningToSerializationFinished = undefined - stopListeningToAbort?.() - stopListeningToAbort = undefined + return emitted + } - if (serializationTimeoutHandle !== undefined) { - clearTimeout(serializationTimeoutHandle) - serializationTimeoutHandle = undefined + // Advance past a canonical close that ends `consumed` bytes into the current + // chunk and hold it until the transform completes. + function enterHeldClose(consumed: number, prefix: Uint8Array | undefined) { + appOffset = consumed + applicationPhase = ApplicationPhase.HeldClose + if (safePointMatcher) { + // The removed close breaks byte continuity with later renderer bytes. + safePointMatcher.matched = 0 } - if (lifetimeTimeoutHandle !== undefined) { - clearTimeout(lifetimeTimeoutHandle) - lifetimeTimeoutHandle = undefined + insertionBoundary = true + if (appOffset === appBytes!.length) { + finishAppChunk() } + return prefix ? enqueueAppBytes(prefix) : false + } - clearPendingRouterHtml() - leftover = '' - pendingTail = '' - clearPending() + function holdDocumentClose(matchStart: number) { + const value = appBytes! + return enterHeldClose( + matchStart + DOCUMENT_CLOSE_BYTES.length, + matchStart === appOffset + ? undefined + : value.subarray(appOffset, matchStart), + ) + } - if (cancelReader) { - // Notify the producer immediately. Reader cancellation may take time to - // settle, and upstream renderers must tolerate abort + cancel overlap. - notifyAbort(reason) + function processUntilSafePoint(endIndex: number) { + const value = appBytes! + const matchEnd = findSafePointEnd(value, appOffset, endIndex) + if (matchEnd === undefined) { + return false } - const readerDone = cancelReader - ? readerState.cancel(reason) - : (readerState.release(), resolvedPromise) - signalDrain() - try { - serverSsr.cleanup() - } catch (error) { - console.error('Error in SSR cleanup:', error) + + const throughSafePoint = + appOffset === 0 && matchEnd === value.length + ? value + : value.subarray(appOffset, matchEnd) + appOffset = matchEnd + insertionBoundary = true + if (appOffset === value.length) { + finishAppChunk() } - return readerDone + return enqueueAppBytes(throughSafePoint) } - const textDecoder = new TextDecoder() - - // Router-injected scripts/HTML waiting for the next safe body boundary. - // Keep chunks separate so flushing does not flatten a large rope string. - const pendingRouterHtml: Array = [] - let pendingRouterHtmlChars = 0 + function findSafePointEnd( + value: Uint8Array, + startIndex: number, + endIndex: number, + ) { + const hydrationState = hydrationOutput.state + if ( + endIndex === startIndex || + hydrationState === HydrationScriptOutputState.Done + ) { + return undefined + } - // between-chunk text buffer; keep bounded to avoid unbounded memory - let leftover = '' + const scanValue = + endIndex === value.length ? value : value.subarray(0, endIndex) + const matcher = (safePointMatcher ??= { + pattern: SCRIPT_CLOSE_BYTES, + anchorIndex: SCRIPT_CLOSE_ANCHOR_INDEX, + matched: 0, + }) + const outputReady = hydrationState === HydrationScriptOutputState.Ready + const matchEnd = advanceByteMatcher( + matcher, + scanValue, + startIndex, + !outputReady, + ) + if (matchEnd === undefined) { + return undefined + } + if (!outputReady) { + // Bytes after the last match are processed again on the next pull. + matcher.matched = 0 + } + return matchEnd + } - // captured bytes from onward; must stay behind router scripts. - let pendingTail = '' + function processCloseCarry() { + const value = appBytes! + const carry = closeCarry! + const headLength = Math.min( + value.length - appOffset, + DOCUMENT_CLOSE_BYTES.length, + ) + const combined = new Uint8Array(carry.length + headLength) + combined.set(carry) + combined.set( + value.subarray(appOffset, appOffset + headLength), + carry.length, + ) - let streamBarrierLifted = false - let streamBarrierMarkerSeen = false - let serializationFinished = false + const matchStart = findExactBytes( + combined, + DOCUMENT_CLOSE_BYTES, + 0, + DOCUMENT_CLOSE_ANCHOR_INDEX, + ) + const partial = + matchStart < 0 + ? getExactBytesPrefixAtEnd(combined, DOCUMENT_CLOSE_BYTES) + : undefined + const safeEnd = matchStart >= 0 ? matchStart : (partial ?? combined.length) + + if (useScriptCloseSafePoints) { + const safePointEnd = findSafePointEnd(combined, 0, safeEnd) + if (safePointEnd !== undefined) { + appOffset += safePointEnd - carry.length + closeCarry = undefined + insertionBoundary = true + if (appOffset === value.length) { + finishAppChunk() + } + return enqueueAppBytes(combined.subarray(0, safePointEnd)) + } + } - function noteBarrierMarker(chunk: string) { - if (streamBarrierMarkerSeen) return - if (chunk.includes(TSR_SCRIPT_BARRIER_ID)) { - streamBarrierMarkerSeen = true + if (matchStart >= 0) { + closeCarry = undefined + return enterHeldClose( + appOffset + matchStart + DOCUMENT_CLOSE_BYTES.length - carry.length, + matchStart === 0 ? undefined : combined.subarray(0, matchStart), + ) } - } - function liftBarrierAfterBoundary() { - if (streamBarrierLifted) return - if (!streamBarrierMarkerSeen) return - streamBarrierLifted = true - serverSsr.liftScriptBarrier() + closeCarry = partial === undefined ? undefined : combined.slice(partial) + appOffset += headLength + return enqueueAppBytes( + safeEnd === combined.length ? combined : combined.subarray(0, safeEnd), + ) } - const stream = new ReadableStream({ - start(c) { - controller = c - // If anything queued before start (shouldn't happen but be safe), drain. - drainPending() - }, - pull() { - // Consumer has capacity; flush queue then unblock read loop. - drainPending() - signalDrain() - }, - cancel(reason) { - state = MergeState.Done - return cleanup(reason) - }, - }) + function processUntilDocumentClose() { + const value = appBytes! - function drainRouterHtml() { - if (cleanedUp || isDone()) return - let html: string | undefined - try { - html = serverSsr.takeBufferedHtml() - } catch (error) { - safeError(error) - cleanup(error) - return - } - if (!html) return - if (state >= MergeState.Draining) { - // At this point final tail/close has already been queued. Emitting late - // router HTML would put scripts after or drop them silently. - const err = new Error( - 'SSR router HTML injected after stream finalization', - ) - safeError(err) - cleanup(err) - return + if (closeCarry) { + if (processCloseCarry()) { + return true + } + if (applicationPhase === ApplicationPhase.HeldClose) { + return false + } + if (appOffset >= value.length) { + finishAppChunk() + return false + } } - if (state === MergeState.HoldingTail) { - flushPendingRouterHtml() - writeChunk(html) - } else { - if (pendingRouterHtmlChars + html.length > MAX_ROUTER_HTML_CHARS) { - const err = new Error('SSR router HTML exceeded maximum buffer') - safeError(err) - cleanup(err) - return + + // Safe points only advance within this chunk, so keep its close or miss. + const matchStart = (documentCloseIndex ??= findExactBytes( + value, + DOCUMENT_CLOSE_BYTES, + appOffset, + DOCUMENT_CLOSE_ANCHOR_INDEX, + )) + if (matchStart >= 0) { + if (useScriptCloseSafePoints && processUntilSafePoint(matchStart)) { + return true } - pendingRouterHtml.push(html) - pendingRouterHtmlChars += html.length + return holdDocumentClose(matchStart) } - } - function flushPendingRouterHtml() { - if (!pendingRouterHtml.length) return - for (const html of pendingRouterHtml) { - writeChunk(html) + const partial = getExactBytesPrefixAtEnd( + value, + DOCUMENT_CLOSE_BYTES, + appOffset, + ) + const safeEnd = partial ?? value.length + if (useScriptCloseSafePoints && processUntilSafePoint(safeEnd)) { + return true } - clearPendingRouterHtml() + const output = + appOffset === 0 && safeEnd === value.length + ? value + : value.subarray(appOffset, safeEnd) + closeCarry = partial === undefined ? undefined : value.slice(partial) + finishAppChunk() + return enqueueAppBytes(output) } - function clearPendingRouterHtml() { - pendingRouterHtml.length = 0 - pendingRouterHtmlChars = 0 + function processAppChunk() { + if (appOffset >= appBytes!.length) { + finishAppChunk() + return false + } + insertionBoundary = false + if (applicationPhase === ApplicationPhase.BeforeBoundary) { + return processUntilBarrier() + } + if (applicationPhase === ApplicationPhase.Merge) { + return processUntilDocumentClose() + } + const value = appBytes! + if (useScriptCloseSafePoints && processUntilSafePoint(value.length)) { + return true + } + + const remainder = appOffset === 0 ? value : value.subarray(appOffset) + finishAppChunk() + return enqueueAppBytes(remainder) } - function appendTail(chunk: string) { - pendingTail += chunk - if (pendingTail.length > MAX_TAIL_CHARS) { - throw new Error('SSR stream tail exceeded maximum buffer') + function terminate(kind: Termination, reason?: unknown) { + if (terminal) { + return } + terminal = true + + stopHydrationOutputListener?.() + stopHydrationOutputListener = undefined + disarmLifecycle() + + settledAppRead = undefined + appBytes = undefined + documentCloseIndex = undefined + appString = undefined + closeCarry = undefined + wakePump() + + return finalizeSsrStream( + kind, + reason, + controller, + reader, + serverSsr, + opts?.onAbort, + ) } - function waitForBackpressure() { - return !!( - controller && - controller.desiredSize !== null && - controller.desiredSize <= 0 + function startAppRead() { + if (appReadPending || settledAppRead || terminal) { + return + } + appReadPending = true + void reader.read().then( + (result) => { + appReadPending = false + if (!terminal) { + if (result.done) { + acceptAppRead(result) + } else { + settledAppRead = result + } + wakePump() + } + }, + (error) => { + appReadPending = false + if (!terminal) { + handlePumpError(error) + } + }, ) } - function startSerializationTimeout() { - if (cleanedUp || isDone()) return - if (serializationTimeoutHandle !== undefined) return - const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS - serializationTimeoutHandle = setTimeout(() => { - if (!cleanedUp && !isDone()) { - const err = new Error('Serialization timeout after app render finished') - console.error('Serialization timeout after app render finished') - safeError(err) - cleanup(err) + function acceptAppRead(result: ReadableStreamReadResult) { + if (result.done) { + appDone = true + insertionBoundary = closeCarry === undefined + // The serialization deadline is a transport concern of this merge + // path; the lifecycle signal below stays a plain notification. + hydrationScripts.startSerializationTimeout( + opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS, + ) + serverSsr.setRenderFinished() + return + } + const value = result.value + if (typeof value === 'string') { + if (value.length === 0) { + return } - }, timeoutMs) - } - - /** - * Finish only when app done and serialization complete. Queues final - * output and requests close-when-drained so we don't close ahead of - * pending writes still waiting on downstream capacity. - */ - function tryFinish() { - if (state !== MergeState.AppDone || !serializationFinished) return - if (cleanedUp || isDone()) return - - if (serializationTimeoutHandle !== undefined) { - clearTimeout(serializationTimeoutHandle) - serializationTimeoutHandle = undefined + appString = value + appStringOffset = 0 + insertionBoundary = false + loadNextAppStringChunk() + return } - - drainRouterHtml() - if (cleanedUp || isDone()) return - - // Flush any remaining bytes in the TextDecoder - const decoderRemainder = textDecoder.decode() - - if (leftover) writeChunk(leftover) - if (cleanedUp || isDone()) return - if (decoderRemainder) writeChunk(decoderRemainder) - if (cleanedUp || isDone()) return - flushPendingRouterHtml() - if (cleanedUp || isDone()) return - if (pendingTail) writeChunk(pendingTail) - if (cleanedUp || isDone()) return - - leftover = '' - pendingTail = '' - - state = MergeState.Draining - closeWhenDrained = true - // Try immediately; if queue not drained yet, pull() will retry. - drainPending() + if (value.byteLength === 0) { + return + } + appBytes = value + appOffset = 0 + insertionBoundary = false } - function finishAppRendering() { - if (state >= MergeState.AppDone) return - state = MergeState.AppDone - try { - serverSsr.setRenderFinished() - } catch (error) { - safeError(error) - cleanup(error) + async function loadNextAppChunk() { + if (appString !== undefined) { + loadNextAppStringChunk() return } - drainRouterHtml() - if (cleanedUp || isDone()) return - serializationFinished = - serializationFinished || serverSsr.isSerializationFinished() - if (serializationFinished) { - tryFinish() - } else { - startSerializationTimeout() - } - } - - // Safety net: cleanup even if consumer never reads - const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS - const lifetimeMs = opts?.lifetimeMs ?? timeoutMs * 2 - lifetimeTimeoutHandle = setTimeout(() => { - if (!cleanedUp && !isDone()) { - const err = new Error('Stream lifetime exceeded') - console.warn( - `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`, - ) - safeError(err) - cleanup(err) + if (settledAppRead) { + const settled = settledAppRead + settledAppRead = undefined + acceptAppRead(settled) + return } - }, lifetimeMs) - - stopListeningToInjectedHtml = serverSsr.onInjectedHtml(() => { - drainRouterHtml() - }) - stopListeningToSerializationFinished = serverSsr.onSerializationFinished( - () => { - serializationFinished = true - drainRouterHtml() - tryFinish() - }, - ) + const scriptsCanInterruptRead = + applicationPhase !== ApplicationPhase.BeforeBoundary && + insertionBoundary && + hydrationOutput.state !== HydrationScriptOutputState.Done + if (!scriptsCanInterruptRead && !appReadPending) { + const result = await reader.read() + if (terminal) { + return + } + acceptAppRead(result) + return + } - // Subscriptions are installed before snapshots, so missed events are - // recovered by these synchronous drains/rechecks. - drainRouterHtml() - if (cleanedUp || isDone()) return stream - serializationFinished = - serializationFinished || serverSsr.isSerializationFinished() - if (serializationFinished) { - drainRouterHtml() - if (cleanedUp || isDone()) return stream + const wake = waitForWake() + startAppRead() + await wake } - stopListeningToAbort = listenToAbort(opts?.signal, (reason) => { - safeError(reason) - cleanup(reason) - }) - if (cleanedUp || isDone()) - return stream - - // Transform the appStream - ;(async () => { - try { - while (true) { - // Backpressure: pause upstream reads while downstream is full. - if (waitForBackpressure()) { - await waitForDrain() - if (cleanedUp || isDone()) return + async function pump() { + while (!terminal) { + if (applicationPhase === ApplicationPhase.PassThrough) { + if (appBytes) { + const remainder = + appOffset === 0 ? appBytes : appBytes.subarray(appOffset) + appBytes = undefined + if (enqueueAppBytes(remainder)) { + return + } + continue } + if (appDone) { + terminate('complete') + return + } + await loadNextAppChunk() + continue + } - const { done, value } = await readerState.reader.read() - if (done) break - - if (cleanedUp || isDone()) return - - const text = - typeof value === 'string' - ? value - : textDecoder.decode(value as ArrayBufferView, { stream: true }) - - const chunkString = leftover ? leftover + text : text - - // If we already saw , everything else is tail. Keep it bounded - // and held until router scripts are ready so injection remains before . - if (state >= MergeState.HoldingTail) { - appendTail(chunkString) - leftover = '' - continue + const hydrationState = hydrationOutput.state + if (hydrationState === HydrationScriptOutputState.Active) { + controller.enqueue(hydrationOutput.pullChunk()) + return + } + if ( + applicationPhase !== ApplicationPhase.BeforeBoundary && + insertionBoundary && + hydrationState === HydrationScriptOutputState.Ready + ) { + if (!appDone && !appBytes && appString === undefined) { + startAppRead() + } + controller.enqueue(hydrationOutput.pullChunk()) + return + } + if ( + applicationPhase === ApplicationPhase.Merge && + hydrationState === HydrationScriptOutputState.Done && + closeCarry === undefined && + hydrationScripts.reserveFastPath(hydrationOutput) + ) { + applicationPhase = ApplicationPhase.PassThrough + stopHydrationOutputListener?.() + stopHydrationOutputListener = undefined + continue + } + if (appBytes) { + if (processAppChunk()) { + return } + continue + } - const boundary = findHtmlBoundary(chunkString) - if (boundary < -1) { - const bodyEndIndex = -boundary - 2 - state = MergeState.HoldingTail - appendTail(chunkString.slice(bodyEndIndex)) - const bodyChunk = chunkString.slice(0, bodyEndIndex) - writeChunk(bodyChunk) - if (cleanedUp || isDone()) return - noteBarrierMarker(bodyChunk) - liftBarrierAfterBoundary() - if (cleanedUp || isDone()) return - flushPendingRouterHtml() - leftover = '' + if (appDone) { + if (applicationPhase === ApplicationPhase.BeforeBoundary) { + // No boundary was rendered. The document is already + // delivered, so emit the hydration sources after it instead of + // failing the response. + hydrationScripts.skipInitialTake() + applicationPhase = ApplicationPhase.Merge + insertionBoundary = true continue } - - const lastClosingTagEnd = boundary - - if (lastClosingTagEnd > 0) { - const safeChunk = chunkString.slice(0, lastClosingTagEnd) - writeChunk(safeChunk) - if (cleanedUp || isDone()) return - noteBarrierMarker(safeChunk) - liftBarrierAfterBoundary() - if (cleanedUp || isDone()) return - flushPendingRouterHtml() - - leftover = chunkString.slice(lastClosingTagEnd) - if (leftover.length > MAX_LEFTOVER_CHARS) { - // Ensure bounded memory even if a consumer streams long text sequences - // without any closing tags. This may reduce injection granularity but is correct. - noteBarrierMarker(leftover) - const flushed = leftover.slice( - 0, - leftover.length - MAX_LEFTOVER_CHARS, - ) - writeChunk(flushed) - leftover = leftover.slice(-MAX_LEFTOVER_CHARS) - } - } else { - // No closing tag found; keep small tail to handle split closing tags, - // but stream older bytes to prevent unbounded buffering. - const combined = chunkString - if (combined.length > MAX_LEFTOVER_CHARS) { - noteBarrierMarker(combined) - const flushUpto = combined.length - MAX_LEFTOVER_CHARS - const flushed = combined.slice(0, flushUpto) - writeChunk(flushed) - leftover = combined.slice(flushUpto) - } else { - leftover = combined + if (closeCarry) { + // A close prefix that never completed is ordinary content, exactly + // like a document without a canonical close. + const carry = closeCarry + closeCarry = undefined + insertionBoundary = true + if (enqueueAppBytes(carry)) { + return } + continue } + if (hydrationState === HydrationScriptOutputState.Waiting) { + await waitForWake() + continue + } + if (applicationPhase === ApplicationPhase.HeldClose) { + controller.enqueue(DOCUMENT_CLOSE_BYTES.slice()) + terminate('complete') + return + } + terminate('complete') + return } - if (cleanedUp || isDone()) return + await loadNextAppChunk() + } + } - finishAppRendering() - } catch (error) { - if (cleanedUp) return - console.error('Error reading appStream:', error) - if (state < MergeState.AppDone) { - try { - serverSsr.setRenderFinished() - } catch { - // ignore - } - } - safeError(error) - cleanup(error) - } finally { - readerState.release() + function handlePumpError(error: unknown) { + if (terminal) { + return + } + console.error('Error processing appStream:', error) + terminate('failure', error) + } + + const stream = new ReadableStream({ + start(c) { + controller = c + }, + pull() { + return pump().catch(handlePumpError) + }, + cancel(reason) { + return terminate('cancel', reason) + }, + }) + + stopHydrationOutputListener = hydrationOutput.subscribe(() => { + if (hydrationOutput.state === HydrationScriptOutputState.Failed) { + terminate('failure', hydrationOutput.error) + return } - })().catch((error) => { - if (cleanedUp) return - console.error('Error in stream transform:', error) - safeError(error) - cleanup(error) + wakePump() }) + disarmLifecycle = armStreamLifecycle( + serverSsr, + opts, + () => terminal, + terminate, + ) return stream } diff --git a/packages/router-core/tests/RawStream.test.ts b/packages/router-core/tests/RawStream.test.ts index 106f0a7c218..aa3a2aca48d 100644 --- a/packages/router-core/tests/RawStream.test.ts +++ b/packages/router-core/tests/RawStream.test.ts @@ -1,10 +1,100 @@ -import { describe, expect, it } from 'vitest' -import { toCrossJSONAsync, fromCrossJSON } from 'seroval' +import { describe, expect, it, vi } from 'vitest' +import { + createStream, + crossSerializeStream, + toCrossJSONAsync, + toCrossJSONStream, + fromCrossJSON, +} from 'seroval' +import type { SerovalNode } from 'seroval' +import { RawStream } from '../src/ssr/serializer/RawStream' +import { + RawStreamJSONDeserializePlugin, + RawStreamJSONPlugin, + createRawStreamJSONPlugin, +} from '../src/ssr/serializer/RawStreamJSONPlugin' import { - RawStream, - createRawStreamRPCPlugin, createRawStreamDeserializePlugin, -} from '../src/ssr/serializer/RawStream' + createRawStreamRPCPlugin, +} from '../src/ssr/serializer/RawStreamRPCPlugin' +import { RawStreamSSRPlugin } from '../src/ssr/serializer/RawStreamSSRPlugin' +import { + defaultSerovalDeserializerPlugins, + defaultSerovalPlugins, +} from '../src/ssr/serializer/seroval-plugins' + +type EncodedStream = ReturnType> +type EncodedStreamListener = Parameters[0] +type RawStreamFactory = (stream: EncodedStream) => ReadableStream + +function getRawStreamFactory( + pluginIndex: number, + emitted: boolean, +): RawStreamFactory { + if (emitted) { + const plugin = RawStreamSSRPlugin.extends![pluginIndex]! + const source = (plugin.serialize as () => string)() + return new Function(`return ${source}`)() as RawStreamFactory + } + + return (stream) => { + const textNode = {} as SerovalNode + const streamNode = {} as SerovalNode + return (RawStreamJSONDeserializePlugin.deserialize as any)( + { text: textNode, stream: streamNode }, + { + deserialize(node: SerovalNode) { + return node === textNode ? pluginIndex === 1 : stream + }, + }, + ) + } +} + +function createTrackedEncodedStream() { + const listeners = new Set() + let unsubscribeCalls = 0 + const stream: EncodedStream = { + __SEROVAL_STREAM__: true, + on(listener) { + listeners.add(listener) + let subscribed = true + return () => { + if (subscribed) { + subscribed = false + unsubscribeCalls++ + listeners.delete(listener) + } + } + }, + next(value) { + for (const listener of listeners) { + listener.next(value) + } + }, + throw(value) { + for (const listener of listeners) { + listener.throw(value) + } + listeners.clear() + }, + return(value) { + for (const listener of listeners) { + listener.return(value) + } + listeners.clear() + }, + } + return { + stream, + get listenerCount() { + return listeners.size + }, + get unsubscribeCalls() { + return unsubscribeCalls + }, + } +} describe('RawStream', () => { describe('RawStream class', () => { @@ -45,7 +135,7 @@ describe('RawStream', () => { }) describe('createRawStreamRPCPlugin', () => { - it('should call onRawStream callback with stream id and stream', async () => { + it('should call onRawStream callback with stream id and stream', () => { const collectedStreams = new Map>() const plugin = createRawStreamRPCPlugin((id, stream) => { @@ -61,9 +151,10 @@ describe('RawStream', () => { const rawStream = new RawStream(testStream) - await toCrossJSONAsync(rawStream, { + toCrossJSONStream(rawStream, { refs: new Map(), plugins: [plugin], + onParse() {}, }) expect(collectedStreams.size).toBe(1) @@ -73,15 +164,19 @@ describe('RawStream', () => { expect(streamEntry![1]).toBe(testStream) }) - it('should serialize with tss/RawStream tag', async () => { + it('should serialize with tss/RawStream tag', () => { const plugin = createRawStreamRPCPlugin(() => {}) const testStream = new ReadableStream() const rawStream = new RawStream(testStream) - const serialized = await toCrossJSONAsync(rawStream, { + const serialized = new Array() + toCrossJSONStream(rawStream, { refs: new Map(), plugins: [plugin], + onParse(value) { + serialized.push(value) + }, }) // The serialized output should have the plugin tag and contain streamId @@ -90,7 +185,7 @@ describe('RawStream', () => { expect(jsonStr).toContain('streamId') }) - it('should collect multiple streams with unique ids', async () => { + it('should collect multiple streams with unique ids', () => { const collectedStreams = new Map>() const plugin = createRawStreamRPCPlugin((id, stream) => { @@ -105,9 +200,10 @@ describe('RawStream', () => { second: new RawStream(stream2), } - await toCrossJSONAsync(data, { + toCrossJSONStream(data, { refs: new Map(), plugins: [plugin], + onParse() {}, }) expect(collectedStreams.size).toBe(2) @@ -116,62 +212,190 @@ describe('RawStream', () => { }) }) - describe('createRawStreamDeserializePlugin', () => { - it('should reconstruct stream from getOrCreateStream function', () => { - const mockStream = new ReadableStream({ + describe('round-trip serialization', () => { + it('does not acquire the source reader when hint parsing fails', async () => { + const getReader = vi.fn() + const stream = { getReader } as unknown as ReadableStream + const failure = new Error('hint parse failed') + const parse = RawStreamJSONPlugin.parse.async! as any + + await expect( + parse(new RawStream(stream), { + parse: () => Promise.reject(failure), + }), + ).rejects.toBe(failure) + expect(getReader).not.toHaveBeenCalled() + }) + + it('does not acquire the source reader when request serialization is already aborted', async () => { + const getReader = vi.fn() + const stream = { getReader } as unknown as ReadableStream + const controller = new AbortController() + const reason = new Error('request aborted') + controller.abort(reason) + + await expect( + toCrossJSONAsync(new RawStream(stream), { + refs: new Map(), + plugins: [createRawStreamJSONPlugin(controller.signal)], + }), + ).rejects.toMatchObject({ cause: reason }) + expect(getReader).not.toHaveBeenCalled() + }) + + it('preserves malformed UTF-8 and BOM bytes in text chunks', async () => { + const cancel = vi.fn() + const stream = new ReadableStream({ start(controller) { - controller.enqueue(new Uint8Array([42])) + controller.enqueue(Uint8Array.of(0x41, 0xe2)) + controller.enqueue(Uint8Array.of(0x28)) + controller.enqueue(Uint8Array.of(0xef, 0xbb, 0xbf, 0x42)) controller.close() }, + cancel, }) - const streams = new Map>() - streams.set(5, mockStream) - - // getOrCreateStream function that returns from map - const getOrCreateStream = (id: number) => { - let stream = streams.get(id) - if (!stream) { - stream = new ReadableStream() - streams.set(id, stream) - } - return stream - } + const serialized = await new Promise>((resolve, reject) => { + const sources = new Array() + crossSerializeStream(new RawStream(stream, { hint: 'text' }), { + refs: new Map(), + plugins: [RawStreamSSRPlugin], + scopeId: 'raw-stream-test', + onSerialize(source) { + sources.push(source) + }, + onError: reject, + onDone() { + expect(stream.locked).toBe(false) + resolve(sources) + }, + }) + }) - const plugin = createRawStreamDeserializePlugin(getOrCreateStream) + const output = serialized.join(';') + expect(output).toContain('QeI=') + expect(output).toContain('\ufeffB') + expect(cancel).not.toHaveBeenCalled() + expect(stream.locked).toBe(false) + }) - // Simulate seroval calling deserialize with a node - const node = { streamId: 5 } + it('preserves a UTF-8 character split across text chunks', async () => { + // Each half is invalid UTF-8 by itself. Text mode must encode both as + // binary instead of retaining decoder state across chunk boundaries. + const expected = [Uint8Array.of(0xf0, 0x9f), Uint8Array.of(0x98, 0x80)] + const input = new ReadableStream({ + start(controller) { + for (const chunk of expected) { + controller.enqueue(chunk) + } + controller.close() + }, + }) - // Access the deserialize function directly - const deserializedStream = (plugin as any).deserialize(node, {}) + const serialized = await toCrossJSONAsync( + new RawStream(input, { hint: 'text' }), + { + refs: new Map(), + plugins: [RawStreamJSONPlugin], + }, + ) + const output = fromCrossJSON(serialized, { + refs: new Map(), + plugins: [RawStreamJSONDeserializePlugin], + }) as ReadableStream + const reader = output.getReader() - expect(deserializedStream).toBe(mockStream) + await expect(reader.read()).resolves.toEqual({ + done: false, + value: expected[0], + }) + await expect(reader.read()).resolves.toEqual({ + done: false, + value: expected[1], + }) + await expect(reader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) }) - it('should create stream if not found', () => { - const streams = new Map>() - - const getOrCreateStream = (id: number) => { - let stream = streams.get(id) - if (!stream) { - stream = new ReadableStream() - streams.set(id, stream) + it.each([ + ['default', 'binary', defaultSerovalDeserializerPlugins], + ['default', 'text', defaultSerovalDeserializerPlugins], + ['deserialize-only', 'binary', [RawStreamJSONDeserializePlugin]], + ['deserialize-only', 'text', [RawStreamJSONDeserializePlugin]], + ] as const)( + 'preserves every %s-plugin decoded %s-hinted chunk through async JSON', + async (_, hint, deserializePlugins) => { + const expected = [ + new Uint8Array(), + Uint8Array.of(0x41, 0x42), + Uint8Array.of(0x41, 0xe2), + Uint8Array.of(0xef, 0xbb, 0xbf, 0x42), + ] + const input = new ReadableStream({ + start(controller) { + for (const chunk of expected) { + controller.enqueue(chunk) + } + controller.close() + }, + }) + + const serialized = await toCrossJSONAsync( + new RawStream(input, { hint }), + { + refs: new Map(), + plugins: [RawStreamJSONPlugin], + }, + ) + const output = fromCrossJSON(serialized, { + refs: new Map(), + plugins: [...deserializePlugins], + }) as ReadableStream + const reader = output.getReader() + const actual: Array = [] + + while (true) { + const next = await reader.read() + if (next.done) { + break + } + actual.push(next.value) } - return stream - } - const plugin = createRawStreamDeserializePlugin(getOrCreateStream) + expect(actual.map((chunk) => Array.from(chunk))).toEqual( + expected.map((chunk) => Array.from(chunk)), + ) + }, + ) - const node = { streamId: 999 } - - const result = (plugin as any).deserialize(node, {}) - expect(result).toBeInstanceOf(ReadableStream) - expect(streams.get(999)).toBe(result) + it('round-trips: client default plugins write, deserializer plugins read', async () => { + const input = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2, 3)) + controller.close() + }, + }) + const serialized = await toCrossJSONAsync( + { stream: new RawStream(input), error: new Error('boom') }, + { refs: new Map(), plugins: defaultSerovalPlugins }, + ) + const output = fromCrossJSON(serialized, { + refs: new Map(), + plugins: defaultSerovalDeserializerPlugins, + }) as { stream: ReadableStream; error: Error } + + expect(output.error).toBeInstanceOf(Error) + expect(output.error.message).toBe('boom') + const reader = output.stream.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: Uint8Array.of(1, 2, 3), + }) + await expect(reader.read()).resolves.toMatchObject({ done: true }) }) - }) - describe('round-trip serialization', () => { it('should serialize and deserialize RawStream correctly', async () => { // Collect streams during serialization const collectedStreams = new Map>() @@ -193,9 +417,13 @@ describe('RawStream', () => { // Serialize using RPC plugin const refs = new Map() - const serialized = await toCrossJSONAsync(data, { + let serialized: SerovalNode | undefined + toCrossJSONStream(data, { refs, plugins: [rpcPlugin], + onParse(value) { + serialized = value + }, }) // Verify we collected the stream @@ -216,7 +444,7 @@ describe('RawStream', () => { createRawStreamDeserializePlugin(getOrCreateStream) // Deserialize - const deserialized = fromCrossJSON(serialized, { + const deserialized = fromCrossJSON(serialized!, { refs: new Map(), plugins: [deserializePlugin], }) as any @@ -225,4 +453,176 @@ describe('RawStream', () => { expect(deserialized.rawData).toBe(testStream) }) }) + + describe('SSR stream lifecycle', () => { + it.each(['chunk', 'eof', 'error'] as const)( + 'ignores a late %s read result after disposal', + async (settlement) => { + let resolveRead!: (result: ReadableStreamReadResult) => void + let rejectRead!: (error: unknown) => void + const readResult = new Promise>( + (resolve, reject) => { + resolveRead = resolve + rejectRead = reject + }, + ) + const reader = { + read: vi.fn(() => readResult), + cancel: vi.fn(() => Promise.resolve()), + releaseLock: vi.fn(), + } + const readable = { + getReader: () => reader, + } as unknown as ReadableStream + const sources = new Array() + const dispose = crossSerializeStream(new RawStream(readable), { + refs: new Map(), + plugins: [RawStreamSSRPlugin], + scopeId: 'raw-stream-disposal-test', + onSerialize(source) { + sources.push(source) + }, + }) + + expect(reader.read).toHaveBeenCalledTimes(1) + const sourceCount = sources.length + dispose() + dispose() + expect(reader.cancel).toHaveBeenCalledTimes(1) + expect(reader.releaseLock).toHaveBeenCalledTimes(1) + + if (settlement === 'error') { + rejectRead(new Error('late read failure')) + } else { + resolveRead( + settlement === 'eof' + ? { done: true, value: undefined } + : { done: false, value: Uint8Array.of(1) }, + ) + } + await Promise.resolve() + await Promise.resolve() + + expect(sources).toHaveLength(sourceCount) + expect(reader.read).toHaveBeenCalledTimes(1) + expect(reader.cancel).toHaveBeenCalledTimes(1) + expect(reader.releaseLock).toHaveBeenCalledTimes(1) + }, + ) + }) + + describe.each([ + { hint: 'binary', pluginIndex: 0, encoded: 'QQ==' }, + { hint: 'text', pluginIndex: 1, encoded: 'tA' }, + ] as const)('$hint browser factory', ({ pluginIndex, encoded }) => { + it.each([ + ['local', false], + ['emitted', true], + ] as const)( + 'unsubscribes the %s factory on cancellation', + async (_, emitted) => { + const factory = getRawStreamFactory(pluginIndex, emitted) + const encodedStream = createTrackedEncodedStream() + const output = factory(encodedStream.stream) + const reader = output.getReader() + + expect(encodedStream.listenerCount).toBe(1) + encodedStream.stream.next(encoded) + const chunk = await reader.read() + expect(chunk.done).toBe(false) + expect(Array.from(chunk.value!)).toEqual([65]) + + await reader.cancel() + await reader.cancel() + expect(encodedStream.unsubscribeCalls).toBe(1) + expect(encodedStream.listenerCount).toBe(0) + + encodedStream.stream.next(encoded) + expect(encodedStream.listenerCount).toBe(0) + }, + ) + + it.each([ + ['local', false], + ['emitted', true], + ] as const)( + 'does not retain a synchronous terminal disposer in the %s factory', + async (_, emitted) => { + const unsubscribe = vi.fn() + const stream = { + __SEROVAL_STREAM__: true, + on(listener: EncodedStreamListener) { + listener.next(encoded) + listener.return(undefined) + return unsubscribe + }, + next() {}, + throw() {}, + return() {}, + } as EncodedStream + const reader = getRawStreamFactory( + pluginIndex, + emitted, + )(stream).getReader() + + expect(unsubscribe).toHaveBeenCalledTimes(emitted ? 0 : 1) + await reader.cancel() + expect(unsubscribe).toHaveBeenCalledTimes(emitted ? 0 : 1) + }, + ) + + it.each([false, true])( + 'disposes a malformed buffered JSON stream (ended: %s)', + async (ended) => { + const stream = createStream() + stream.next(pluginIndex === 1 ? 'b%' : '%') + stream.next(encoded) + if (ended) { + stream.return(undefined) + } + const on = stream.on.bind(stream) + const unsubscribe = vi.fn() + vi.spyOn(stream, 'on').mockImplementation((listener) => { + unsubscribe.mockImplementation(on(listener)) + return unsubscribe + }) + + const reader = getRawStreamFactory( + pluginIndex, + false, + )(stream).getReader() + + expect(unsubscribe).toHaveBeenCalledOnce() + await expect(reader.read()).rejects.toMatchObject({ + name: 'InvalidCharacterError', + }) + stream.next(encoded) + await expect(reader.cancel()).rejects.toMatchObject({ + name: 'InvalidCharacterError', + }) + expect(unsubscribe).toHaveBeenCalledOnce() + reader.releaseLock() + }, + ) + + it.each([ + ['local', false], + ['emitted', true], + ] as const)( + 'does not retain a live terminal disposer in the %s factory', + async (_, emitted) => { + const encodedStream = createTrackedEncodedStream() + const reader = getRawStreamFactory( + pluginIndex, + emitted, + )(encodedStream.stream).getReader() + + encodedStream.stream.next(encoded) + encodedStream.stream.return(undefined) + await reader.cancel() + + expect(encodedStream.unsubscribeCalls).toBe(0) + }, + ) + }) }) diff --git a/packages/router-core/tests/client-lane-adversarial.test.ts b/packages/router-core/tests/client-lane-adversarial.test.ts index dbcc15fed84..4924576759c 100644 --- a/packages/router-core/tests/client-lane-adversarial.test.ts +++ b/packages/router-core/tests/client-lane-adversarial.test.ts @@ -674,21 +674,27 @@ describe('adversarial client lane ownership', () => { expect(contextWorkAborted).toBe(true) }) - test.each( - ([false, true] as const).flatMap((isServer) => [ - { - isServer, - thrownType: 'AbortSignal', - createThrownValue: (signal: AbortSignal) => signal, - }, - { - isServer, - thrownType: 'AbortError', - createThrownValue: () => - new DOMException('The operation was aborted.', 'AbortError'), - }, - ]), - )( + test.each([ + { + isServer: false, + thrownType: 'AbortSignal', + createThrownValue: (signal: AbortSignal) => signal, + }, + { + isServer: false, + thrownType: 'AbortError', + createThrownValue: () => + new DOMException('The operation was aborted.', 'AbortError'), + }, + { + isServer: true, + thrownType: 'AbortError', + createThrownValue: () => + Object.assign(new Error('The operation was aborted.'), { + name: 'AbortError', + }), + }, + ])( 'treats a user-thrown $thrownType in beforeLoad as an ordinary route error (isServer=$isServer)', async ({ isServer, createThrownValue }) => { let matchSignal: AbortSignal | undefined @@ -718,7 +724,8 @@ describe('adversarial client lane ownership', () => { } const match = router.state.matches.at(-1) - expect(matchSignal?.aborted).toBe(isServer) + // The payload finished, so the controller is left alone on both sides. + expect(matchSignal?.aborted).toBe(false) expect(match).toMatchObject({ routeId: brokenRoute.id, status: 'error', diff --git a/packages/router-core/tests/htmlBoundaryScanner.test.ts b/packages/router-core/tests/htmlBoundaryScanner.test.ts new file mode 100644 index 00000000000..c739ea710a7 --- /dev/null +++ b/packages/router-core/tests/htmlBoundaryScanner.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from 'vitest' +import { + HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX, + HYDRATION_SCRIPT_BOUNDARY_BYTES, + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + HYDRATION_SCRIPT_BOUNDARY_SUFFIX, +} from '../src/ssr/hydrationScripts' +import { + DOCUMENT_CLOSE, + DOCUMENT_CLOSE_ANCHOR_INDEX, + DOCUMENT_CLOSE_BYTES, + SCRIPT_CLOSE, + SCRIPT_CLOSE_ANCHOR_INDEX, + SCRIPT_CLOSE_BYTES, + advanceByteMatcher, + findExactBytes, + getExactBytesPrefixAtEnd, +} from '../src/ssr/htmlBoundaryScanner' +import type { ByteMatcherState } from '../src/ssr/htmlBoundaryScanner' + +const encoder = new TextEncoder() + +function chunksAtEverySplit(value: Uint8Array) { + return Array.from({ length: value.length + 1 }, (_, split) => [ + value.subarray(0, split), + value.subarray(split), + ]) +} + +function matcherFor(pattern: Uint8Array, anchorIndex = 0): ByteMatcherState { + return { pattern, anchorIndex, matched: 0 } +} + +describe('SSR exact byte matcher', () => { + test.each([ + [ + 'router boundary', + HYDRATION_SCRIPT_BOUNDARY_BYTES, + HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX, + ], + ['script close', SCRIPT_CLOSE_BYTES, SCRIPT_CLOSE_ANCHOR_INDEX], + ])('matches %s at every two-chunk split', (_, pattern, anchorIndex) => { + for (const chunks of chunksAtEverySplit(pattern)) { + const matcher = matcherFor(pattern, anchorIndex) + let matches = 0 + for (const chunk of chunks) { + if (advanceByteMatcher(matcher, chunk) !== undefined) { + matches++ + } + } + expect(matches).toBe(1) + } + }) + + test.each([ + [ + 'router boundary', + HYDRATION_SCRIPT_BOUNDARY_BYTES, + HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX, + ], + ['script close', SCRIPT_CLOSE_BYTES, SCRIPT_CLOSE_ANCHOR_INDEX], + ])('matches byte-at-a-time %s input', (_, pattern, anchorIndex) => { + const matcher = matcherFor(pattern, anchorIndex) + let matches = 0 + for (const byte of pattern) { + if (advanceByteMatcher(matcher, Uint8Array.of(byte)) !== undefined) { + matches++ + } + } + expect(matches).toBe(1) + }) + + test('returns the local end offset and keeps the unconsumed suffix', () => { + const matcher = matcherFor(SCRIPT_CLOSE_BYTES) + const prefix = encoder.encode('prefix') + const suffix = encoder.encode('') + const value = new Uint8Array( + prefix.length + SCRIPT_CLOSE_BYTES.length + suffix.length, + ) + value.set(prefix) + value.set(SCRIPT_CLOSE_BYTES, prefix.length) + value.set(suffix, prefix.length + SCRIPT_CLOSE_BYTES.length) + + const end = advanceByteMatcher(matcher, value) + expect(end).toBe(prefix.length + SCRIPT_CLOSE_BYTES.length) + expect(new TextDecoder().decode(value.subarray(end))).toBe('') + }) + + test('can find the last complete match without stopping at the first', () => { + const matcher = matcherFor(SCRIPT_CLOSE_BYTES, SCRIPT_CLOSE_ANCHOR_INDEX) + const value = encoder.encode('
') + + const end = advanceByteMatcher(matcher, value, 0, true) + expect(new TextDecoder().decode(value.subarray(0, end))).toBe( + '
', + ) + }) + + test.each([ + [ + 'router boundary', + HYDRATION_SCRIPT_BOUNDARY_BYTES, + HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX, + ], + ['script close', SCRIPT_CLOSE_BYTES, SCRIPT_CLOSE_ANCHOR_INDEX], + ])('%s satisfies the matcher invariants', (_, pattern, anchorIndex) => { + expect(pattern.length).toBeGreaterThan(0) + expect(pattern.indexOf(pattern[0]!, 1)).toBe(-1) + expect(anchorIndex).toBeGreaterThanOrEqual(0) + expect(anchorIndex).toBeLessThan(pattern.length) + }) + + test.each(['one chunk', 'split after the old overlapping prefix'])( + 'ignores marker text without the fixed semicolon in %s', + (shape) => { + const markerAndClose = HYDRATION_SCRIPT_BOUNDARY_BYTES.subarray(1) + const value = new Uint8Array(24 + markerAndClose.length - 1) + value.set(markerAndClose.subarray(0, 24)) + value.set(markerAndClose.subarray(1), 24) + const chunks = + shape === 'one chunk' + ? [value] + : [value.subarray(0, 24), value.subarray(24)] + const matcher = matcherFor( + HYDRATION_SCRIPT_BOUNDARY_BYTES, + HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX, + ) + + let consumed = 0 + let matchEnd: number | undefined + for (const chunk of chunks) { + const localEnd = advanceByteMatcher(matcher, chunk) + if (localEnd !== undefined) { + matchEnd = consumed + localEnd + } + consumed += chunk.length + } + + expect(matchEnd).toBeUndefined() + }, + ) + + test.each(['one chunk', 'split before the valid boundary'])( + 'restarts at a valid boundary after a near match in %s', + (shape) => { + const nearMatch = HYDRATION_SCRIPT_BOUNDARY_BYTES.subarray(0, 12) + const value = new Uint8Array( + nearMatch.length + HYDRATION_SCRIPT_BOUNDARY_BYTES.length, + ) + value.set(nearMatch) + value.set(HYDRATION_SCRIPT_BOUNDARY_BYTES, nearMatch.length) + const chunks = + shape === 'one chunk' + ? [value] + : [ + value.subarray(0, nearMatch.length), + value.subarray(nearMatch.length), + ] + const matcher = matcherFor( + HYDRATION_SCRIPT_BOUNDARY_BYTES, + HYDRATION_SCRIPT_BOUNDARY_ANCHOR_INDEX, + ) + + let consumed = 0 + let matchEnd: number | undefined + for (const chunk of chunks) { + const localEnd = advanceByteMatcher(matcher, chunk) + if (localEnd !== undefined) { + matchEnd = consumed + localEnd + } + consumed += chunk.length + } + + expect(matchEnd).toBe(value.length) + }, + ) + + test('reset drops an incomplete match', () => { + const matcher = matcherFor(SCRIPT_CLOSE_BYTES) + expect( + advanceByteMatcher(matcher, SCRIPT_CLOSE_BYTES.subarray(0, 5)), + ).toBeUndefined() + matcher.matched = 0 + expect( + advanceByteMatcher(matcher, SCRIPT_CLOSE_BYTES.subarray(5)), + ).toBeUndefined() + }) +}) + +describe('SSR exact byte helpers', () => { + test('scanner boundary is the exact end of the emitted script', () => { + expect(HYDRATION_SCRIPT_BOUNDARY_SUFFIX.endsWith(SCRIPT_CLOSE)).toBe(true) + expect( + HYDRATION_SCRIPT_BOUNDARY_SOURCE.endsWith( + HYDRATION_SCRIPT_BOUNDARY_SUFFIX.slice(0, -SCRIPT_CLOSE.length), + ), + ).toBe(true) + expect(HYDRATION_SCRIPT_BOUNDARY_BYTES).toEqual( + encoder.encode(HYDRATION_SCRIPT_BOUNDARY_SUFFIX), + ) + }) + + test('anchors document-close scans on the uncommon y byte', () => { + expect(DOCUMENT_CLOSE_ANCHOR_INDEX).toBe(DOCUMENT_CLOSE.indexOf('y')) + }) + + test('finds a complete sequence without decoding or copying', () => { + const prefix = encoder.encode('abc') + const value = new Uint8Array( + prefix.length + DOCUMENT_CLOSE_BYTES.length + 1, + ) + value.set(prefix) + value.set(DOCUMENT_CLOSE_BYTES, prefix.length) + value[value.length - 1] = 120 + + expect(findExactBytes(value, DOCUMENT_CLOSE_BYTES)).toBe(prefix.length) + expect(findExactBytes(value, DOCUMENT_CLOSE_BYTES, prefix.length + 1)).toBe( + -1, + ) + }) + + test('reports every incomplete document-close suffix', () => { + for (let length = 1; length < DOCUMENT_CLOSE_BYTES.length; length++) { + const prefix = encoder.encode('application') + const value = new Uint8Array(prefix.length + length) + value.set(prefix) + value.set(DOCUMENT_CLOSE_BYTES.subarray(0, length), prefix.length) + + expect(getExactBytesPrefixAtEnd(value, DOCUMENT_CLOSE_BYTES)).toBe( + prefix.length, + ) + } + }) + + test('uses the longest suffix when the pattern prefix overlaps', () => { + const pattern = encoder.encode('abab') + const value = encoder.encode('xxaba') + expect(getExactBytesPrefixAtEnd(value, pattern)).toBe(2) + }) + + test('returns no suffix for a final mismatch', () => { + expect( + getExactBytesPrefixAtEnd( + encoder.encode('application'), + SCRIPT_CLOSE_BYTES, + ), + ).toBeUndefined() + }) +}) diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts index 7ecec4a4e5b..4f035689632 100644 --- a/packages/router-core/tests/hydrate.test.ts +++ b/packages/router-core/tests/hydrate.test.ts @@ -1,4 +1,3 @@ -import { runInNewContext } from 'node:vm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { @@ -10,9 +9,8 @@ import { notFound, } from '../src' import { hydrate } from '../src/ssr/client' -import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' -import { createTestRouter } from './routerTestUtils' +import { createTestRouter, dehydrateToBootstrap } from './routerTestUtils' import type { AnyRouteMatch, AnyRouter, LocationRewrite } from '../src' import type { ServerManifest } from '../src/manifest' import type { TsrSsrGlobal } from '../src/ssr/types' @@ -35,35 +33,6 @@ function createMockBootstrap( } } -async function dehydrateToBootstrap( - router: AnyRouter, - manifest: ServerManifest = testManifest, -): Promise { - attachRouterServerSsrUtils({ router, manifest }) - try { - await router.load() - await router.serverSsr!.dehydrate() - - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - - const context: Record = { - document: { - currentScript: { - remove() {}, - }, - }, - } - context.self = context - runInNewContext(script!.children!, context) - - expect(context.$_TSR).toBeDefined() - return context.$_TSR - } finally { - router.serverSsr?.cleanup() - } -} - describe('hydrate', () => { let mockWindow: { $_TSR?: TsrSsrGlobal } let mockRouter: any @@ -232,7 +201,7 @@ describe('hydrate', () => { isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) expect(mockWindow.$_TSR.router?.matches[1]).not.toHaveProperty('l') const clientLoader = vi.fn(() => undefined) @@ -278,7 +247,7 @@ describe('hydrate', () => { isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) expect(mockWindow.$_TSR.router?.matches[1]).not.toHaveProperty('l') const parentError = new Error('parent reload failed') @@ -342,7 +311,7 @@ describe('hydrate', () => { isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) mockWindow.$_TSR.router!.matches[1]!.i = identity as string const clientLoader = vi.fn(() => 'client data') @@ -400,7 +369,7 @@ describe('hydrate', () => { isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) mockWindow.$_TSR.router!.matches[2]!.i = dehydrateSsrMatchId( '/different-child-match', ) @@ -478,7 +447,7 @@ describe('hydrate', () => { isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) expect(mockWindow.$_TSR.router?.matches).toHaveLength(3) const clientLoader = vi.fn(() => 'client data') @@ -536,7 +505,7 @@ describe('hydrate', () => { }) serverRouter.options.serializationAdapters = [adapter] - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) const clientLoader = vi.fn(() => new AdaptedValue('client loader data')) const clientRootRoute = new BaseRootRoute({}) @@ -577,7 +546,7 @@ describe('hydrate', () => { isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverRouter.state.matches).toHaveLength(2) expect(serverRouter.state.matches[0]).toMatchObject({ @@ -635,7 +604,7 @@ describe('hydrate', () => { isServer: true, isShell: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) const clientRootRoute = new BaseRootRoute({ notFoundComponent: () => 'Not Found', @@ -690,7 +659,7 @@ describe('hydrate', () => { isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) const clientLoader = vi.fn(() => 'client internal data') const clientRootRoute = new BaseRootRoute({}) @@ -699,13 +668,12 @@ describe('hydrate', () => { path: '/internal', loader: clientLoader, }) - let clientRouter: AnyRouter const customHydrate = vi.fn((dehydrated: { rewrite?: boolean }) => { if (dehydrated.rewrite) { clientRouter.update({ rewrite }) } }) - clientRouter = createTestRouter({ + const clientRouter: AnyRouter = createTestRouter({ routeTree: clientRootRoute.addChildren([clientInternalRoute]), history: createMemoryHistory({ initialEntries: ['/public'] }), hydrate: customHydrate, @@ -774,3 +742,60 @@ describe('hydrate', () => { ) }) }) + +describe('createSerializationAdapter key validation', () => { + function createAdapter(key: string) { + return createSerializationAdapter({ + key, + test: (value): value is string => typeof value === 'string', + toSerializable: (value) => value, + fromSerializable: (value) => value, + }) + } + + it('throws in development for empty or unsupported keys', () => { + for (const key of [ + '', + 'nul\0key', + 'bad"key', + 'back\\slash', + 'lt createAdapter(key)).toThrowError( + /createSerializationAdapter: key .* is invalid/s, + ) + } + }) + + it('accepts a key that Seroval preserves verbatim', () => { + expect(() => createAdapter('safe-key/v1.0_$-')).not.toThrow() + }) + + it('does not inspect the key in production', () => { + vi.stubEnv('NODE_ENV', 'production') + try { + const options = { + get key(): string { + throw new Error('production key was inspected') + }, + test: (value: unknown): value is string => typeof value === 'string', + toSerializable: (value: string) => value, + fromSerializable: (value: string) => value, + } + + expect(createSerializationAdapter(options)).toBe(options) + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/packages/router-core/tests/hydrated-stay-match-data.test.ts b/packages/router-core/tests/hydrated-stay-match-data.test.ts index 5f8333310ac..6e269e86c31 100644 --- a/packages/router-core/tests/hydrated-stay-match-data.test.ts +++ b/packages/router-core/tests/hydrated-stay-match-data.test.ts @@ -1,38 +1,13 @@ -import { runInNewContext } from 'node:vm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { hydrate } from '../src/ssr/client' -import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' -import { createTestRouter } from './routerTestUtils' -import type { AnyRouter } from '../src' +import { createTestRouter, dehydrateToBootstrap } from './routerTestUtils' import type { TsrSsrGlobal } from '../src/ssr/types' import type { ServerManifest } from '../src/manifest' const testManifest: ServerManifest = { routes: {} } -async function dehydrateToBootstrap(router: AnyRouter): Promise { - attachRouterServerSsrUtils({ router, manifest: testManifest }) - try { - await router.load() - await router.serverSsr!.dehydrate() - - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - - const context: Record = { - document: { currentScript: { remove() {} } }, - } - context.self = context - runInNewContext(script!.children!, context) - - expect(context.$_TSR).toBeDefined() - return context.$_TSR - } finally { - router.serverSsr?.cleanup() - } -} - describe('hydrated stay match data preservation', () => { let mockWindow: { $_TSR?: TsrSsrGlobal } @@ -70,7 +45,7 @@ describe('hydrated stay match data preservation', () => { isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverRootBeforeLoad).toHaveBeenCalledTimes(1) expect(serverRootLoader).toHaveBeenCalledTimes(1) diff --git a/packages/router-core/tests/hydration-asset-context-order.test.ts b/packages/router-core/tests/hydration-asset-context-order.test.ts index 64ea28971eb..014e338b321 100644 --- a/packages/router-core/tests/hydration-asset-context-order.test.ts +++ b/packages/router-core/tests/hydration-asset-context-order.test.ts @@ -1,38 +1,14 @@ -import { runInNewContext } from 'node:vm' import { afterEach, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' import { hydrate } from '../src/ssr/client' -import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' -import { createTestRouter } from './routerTestUtils' -import type { AnyRouteMatch, AnyRouter } from '../src' +import { createTestRouter, dehydrateToBootstrap } from './routerTestUtils' +import type { AnyRouteMatch } from '../src' import type { ServerManifest } from '../src/manifest' import type { TsrSsrGlobal } from '../src/ssr/types' const testManifest: ServerManifest = { routes: {} } -async function dehydrateToBootstrap(router: AnyRouter): Promise { - attachRouterServerSsrUtils({ router, manifest: testManifest }) - try { - await router.load() - await router.serverSsr!.dehydrate() - - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - - const context: Record = { - document: { currentScript: { remove() {} } }, - } - context.self = context - runInNewContext(script!.children!, context) - - expect(context.$_TSR).toBeDefined() - return context.$_TSR - } finally { - router.serverSsr?.cleanup() - } -} - afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() @@ -54,7 +30,7 @@ test('hydration reconstructs every match context before ancestor head reads the isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverBeforeLoad).toHaveBeenCalledTimes(1) expect(serverRouter.state.matches.at(-1)?.context).toMatchObject({ diff --git a/packages/router-core/tests/hydration-boundary-chunks.test.ts b/packages/router-core/tests/hydration-boundary-chunks.test.ts index fe760e074a1..0d5c9a0af67 100644 --- a/packages/router-core/tests/hydration-boundary-chunks.test.ts +++ b/packages/router-core/tests/hydration-boundary-chunks.test.ts @@ -1,38 +1,13 @@ -import { runInNewContext } from 'node:vm' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, isNotFound, notFound } from '../src' import { hydrate } from '../src/ssr/client' -import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' -import { createTestRouter } from './routerTestUtils' -import type { AnyRouter } from '../src' +import { createTestRouter, dehydrateToBootstrap } from './routerTestUtils' import type { TsrSsrGlobal } from '../src/ssr/types' import type { ServerManifest } from '../src/manifest' const testManifest: ServerManifest = { routes: {} } -async function dehydrateToBootstrap(router: AnyRouter): Promise { - attachRouterServerSsrUtils({ router, manifest: testManifest }) - try { - await router.load() - await router.serverSsr!.dehydrate() - - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - - const context: Record = { - document: { currentScript: { remove() {} } }, - } - context.self = context - runInNewContext(script!.children!, context) - - expect(context.$_TSR).toBeDefined() - return context.$_TSR - } finally { - router.serverSsr?.cleanup() - } -} - describe('hydration route chunks below a server boundary', () => { let mockWindow: { $_TSR?: TsrSsrGlobal } @@ -64,7 +39,7 @@ describe('hydration route chunks below a server boundary', () => { isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverBeforeLoad).toHaveBeenCalledTimes(1) expect(serverRouter.state.location.pathname).toBe('/app') @@ -163,7 +138,7 @@ describe('hydration route chunks below a server boundary', () => { isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverChildLoader).toHaveBeenCalledTimes(1) expect(serverRouter.state.matches.map((match) => match.routeId)).toEqual([ diff --git a/packages/router-core/tests/hydration-currentness.test.ts b/packages/router-core/tests/hydration-currentness.test.ts index a9d52f0c97b..03ad11998bd 100644 --- a/packages/router-core/tests/hydration-currentness.test.ts +++ b/packages/router-core/tests/hydration-currentness.test.ts @@ -1,4 +1,3 @@ -import { runInNewContext } from 'node:vm' import { afterEach, beforeEach, @@ -16,37 +15,14 @@ import { createControlledPromise, } from '../src' import { hydrate } from '../src/ssr/client' -import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' -import { createTestRouter } from './routerTestUtils' +import { createTestRouter, dehydrateToBootstrap } from './routerTestUtils' import type { AnyRouteMatch, AnyRouter, NavigateFn } from '../src' import type { DehydratedRouter, TsrSsrGlobal } from '../src/ssr/types' import type { ServerManifest } from '../src/manifest' const testManifest: ServerManifest = { routes: {} } -async function dehydrateToBootstrap(router: AnyRouter): Promise { - attachRouterServerSsrUtils({ router, manifest: testManifest }) - try { - await router.load() - await router.serverSsr!.dehydrate() - - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - - const context: Record = { - document: { currentScript: { remove() {} } }, - } - context.self = context - runInNewContext(script!.children!, context) - - expect(context.$_TSR).toBeDefined() - return context.$_TSR - } finally { - router.serverSsr?.cleanup() - } -} - // These tests install the hydration protocol directly so client-only hooks can // be paused at deterministic ownership boundaries. function installHydrationPayload( @@ -155,7 +131,7 @@ describe('hydration asset currentness', () => { }), isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverChildLoader).not.toHaveBeenCalled() const childContext = vi.fn(() => ({ assetSource: 'child context' })) @@ -227,7 +203,7 @@ describe('hydration asset currentness', () => { history: createMemoryHistory({ initialEntries: ['/parent/child'] }), isServer: true, }) - mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter) + mockWindow.$_TSR = await dehydrateToBootstrap(serverRouter, testManifest) const childLoaderResult = createControlledPromise() let continuationAssetEnd: number | undefined @@ -597,7 +573,7 @@ describe('hydration asset currentness', () => { isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverBeforeLoad).toHaveBeenCalledTimes(1) expect(serverChildLoader).not.toHaveBeenCalled() diff --git a/packages/router-core/tests/hydration-terminal-error-child-head.test.ts b/packages/router-core/tests/hydration-terminal-error-child-head.test.ts index 1deb5334756..e0acbd9780a 100644 --- a/packages/router-core/tests/hydration-terminal-error-child-head.test.ts +++ b/packages/router-core/tests/hydration-terminal-error-child-head.test.ts @@ -1,38 +1,13 @@ -import { runInNewContext } from 'node:vm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, notFound } from '../src' import { hydrate } from '../src/ssr/client' -import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' -import { createTestRouter } from './routerTestUtils' -import type { AnyRouter } from '../src' +import { createTestRouter, dehydrateToBootstrap } from './routerTestUtils' import type { TsrSsrGlobal } from '../src/ssr/types' import type { ServerManifest } from '../src/manifest' const testManifest: ServerManifest = { routes: {} } -async function dehydrateToBootstrap(router: AnyRouter): Promise { - attachRouterServerSsrUtils({ router, manifest: testManifest }) - try { - await router.load() - await router.serverSsr!.dehydrate() - - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - - const context: Record = { - document: { currentScript: { remove() {} } }, - } - context.self = context - runInNewContext(script!.children!, context) - - expect(context.$_TSR).toBeDefined() - return context.$_TSR - } finally { - router.serverSsr?.cleanup() - } -} - // This terminal-prefix case is an internal hydration protocol invariant, not // an issue #7635 reproduction. The user-visible same-route navigation // regression is covered in react-router, where the rendered boundary and @@ -76,7 +51,7 @@ describe('hydrated terminal error prefix', () => { isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverBeforeLoad).toHaveBeenCalledTimes(1) expect(serverChildLoader).not.toHaveBeenCalled() @@ -210,7 +185,7 @@ describe('hydrated terminal error prefix', () => { const server = setup(true) const client = setup(false) try { - mockWindow.$_TSR = await dehydrateToBootstrap(server) + mockWindow.$_TSR = await dehydrateToBootstrap(server, testManifest) await hydrate(client) expect(client.state.matches).toHaveLength(3) await client.invalidate() diff --git a/packages/router-core/tests/hydrationQueue.bench.ts b/packages/router-core/tests/hydrationQueue.bench.ts new file mode 100644 index 00000000000..821cbeb5adc --- /dev/null +++ b/packages/router-core/tests/hydrationQueue.bench.ts @@ -0,0 +1,103 @@ +import { bench, describe } from 'vitest' + +const MAX_DYNAMIC_RECORD_CODE_UNITS = 64 * 1024 +const SOURCE_SEPARATOR = ';' +const SCRIPT_OPENING_CODE_UNITS = '' + .length + +function getBatchLength( + values: ReadonlyArray, + head: number, +) { + let codeUnits = SCRIPT_OPENING_CODE_UNITS + DYNAMIC_CLOSE_CODE_UNITS + let batchLength = 0 + for (let index = head; index < values.length; index++) { + const nextCodeUnits = + codeUnits + SOURCE_SEPARATOR.length + values[index]!.length + if (batchLength > 0 && nextCodeUnits > MAX_DYNAMIC_RECORD_CODE_UNITS) { + break + } + codeUnits = nextCodeUnits + batchLength++ + if (codeUnits > MAX_DYNAMIC_RECORD_CODE_UNITS) { + break + } + } + return batchLength +} + +function drainWithSplice(values: Array) { + let drained = 0 + let consumedCodeUnits = 0 + while (values.length > 0) { + const batchLength = getBatchLength(values, 0) + const batch = values.splice(0, batchLength) + drained += batchLength + for (const part of batch) { + consumedCodeUnits += part.length + } + } + return { drained, consumedCodeUnits } +} + +function drainWithHead(values: Array) { + let head = 0 + let drained = 0 + let consumedCodeUnits = 0 + while (head < values.length) { + const batchLength = getBatchLength(values, head) + if (head === 0 && batchLength === values.length) { + const batch = values + drained += values.length + for (const part of batch) { + consumedCodeUnits += part!.length + } + values = [] + head = 0 + continue + } + const end = head + batchLength + const batch = values.slice(head, end) + for (let index = head; index < end; index++) { + values[index] = undefined + } + drained += batch.length + for (const part of batch) { + consumedCodeUnits += part!.length + } + head = end + if (head === values.length) { + values = [] + head = 0 + } else if (head >= 1024 && head >= values.length - head) { + values = values.slice(head) + head = 0 + } + } + return { drained, consumedCodeUnits } +} + +const source = 'x'.repeat(4 * 1024) +const sources = Array.from({ length: 4_095 }, () => source) +let benchmarkSink: { drained: number; consumedCodeUnits: number } | undefined + +const expected = drainWithSplice(sources.slice()) +const actual = drainWithHead(sources.slice()) +if ( + expected.drained !== sources.length || + actual.drained !== sources.length || + actual.consumedCodeUnits !== expected.consumedCodeUnits +) { + throw new Error('Hydration queue benchmark discarded a source') +} + +describe('maximum hydration source queue with 4 KiB sources', () => { + bench('front splice', () => { + return void (benchmarkSink = drainWithSplice(sources.slice())) + }) + + bench('head index with compaction', () => { + return void (benchmarkSink = drainWithHead(sources.slice())) + }) +}) diff --git a/packages/router-core/tests/hydrationScripts.test.ts b/packages/router-core/tests/hydrationScripts.test.ts new file mode 100644 index 00000000000..fe4b289bcb2 --- /dev/null +++ b/packages/router-core/tests/hydrationScripts.test.ts @@ -0,0 +1,800 @@ +import { createHash } from 'node:crypto' +import { describe, expect, test, vi } from 'vitest' +import { + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + HydrationScriptOutputState, + MAX_HYDRATION_OUTPUT_CHUNK_BYTES, + createHydrationScripts, +} from '../src/ssr/hydrationScripts' +import type { HydrationScriptOutput } from '../src/ssr/hydrationScripts' + +const decoder = new TextDecoder() +const encoder = new TextEncoder() + +function createReadyOutput(nonce?: string) { + const scripts = createHydrationScripts(nonce, ['boot()']) + const initial = scripts.takeInitialHydrationScriptTags()! + const output = scripts.claimOutput() + scripts.liftBarrier() + return { scripts, initial, output } +} + +function drainChunks(output: HydrationScriptOutput) { + const chunks: Array = [] + while ( + output.state === HydrationScriptOutputState.Ready || + output.state === HydrationScriptOutputState.Active + ) { + chunks.push(output.pullChunk()) + } + return chunks +} + +function decodeChunks(chunks: ReadonlyArray) { + let text = '' + for (const chunk of chunks) { + text += decoder.decode(chunk, { stream: true }) + } + return text + decoder.decode() +} + +async function flushMicrotasks() { + await Promise.resolve() +} + +describe('hydration script ownership', () => { + test('takes initial sources before the output is claimed', () => { + const scripts = createHydrationScripts(undefined, ['first()', 'second()']) + const initial = scripts.takeInitialHydrationScriptTags()! + + expect(initial.before[0]).toMatchObject({ + attrs: { 'data-tsr-stream-part': '' }, + }) + expect(initial.before[0]!.children).toMatch(/^first\(\);second\(\);/) + expect(initial.boundary.children).toBe(HYDRATION_SCRIPT_BOUNDARY_SOURCE) + expect(initial.boundary.attrs).not.toHaveProperty('id') + + const output = scripts.claimOutput() + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + scripts.cleanup() + }) + + test('supports taking initial tags through the ServerSsr callback', () => { + const scripts = createHydrationScripts(undefined, ['boot()']) + const takeInitialHydrationScriptTags = + scripts.takeInitialHydrationScriptTags + + expect(takeInitialHydrationScriptTags()?.before[0]?.children).toMatch( + /^boot\(\);/, + ) + scripts.cleanup() + }) + + test('notifies the subscriber and pulls late records', () => { + const scripts = createHydrationScripts(undefined, []) + scripts.takeInitialHydrationScriptTags() + const output = scripts.claimOutput() + const onChange = vi.fn() + const unsubscribe = output.subscribe(onChange) + + scripts.liftBarrier() + scripts.pushSource('late()') + + expect(onChange).toHaveBeenCalledOnce() + expect(decoder.decode(output.pullChunk())).toContain('late()') + unsubscribe() + scripts.cleanup() + }) + + test('skipInitialTake releases initial sources as late records', () => { + const scripts = createHydrationScripts(undefined, ['boot()']) + const output = scripts.claimOutput() + scripts.pushSource('late()') + scripts.liftBarrier() + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + + scripts.skipInitialTake() + + expect(output.state).toBe(HydrationScriptOutputState.Ready) + expect(decoder.decode(output.pullChunk())).toBe( + '', + ) + expect(scripts.takeInitialHydrationScriptTags()).toBeUndefined() + scripts.cleanup() + }) + + test('empty initial output keeps only the stream boundary', () => { + const scripts = createHydrationScripts(undefined, []) + const initial = scripts.takeInitialHydrationScriptTags()! + + expect(initial.before).toEqual([]) + expect(initial.boundary).toEqual({ + tag: 'script', + attrs: { nonce: undefined }, + children: HYDRATION_SCRIPT_BOUNDARY_SOURCE, + }) + scripts.cleanup() + }) + + test('does not append cleanup to a large initial source', () => { + const source = 'x'.repeat(20 * 1024) + const scripts = createHydrationScripts(undefined, [source]) + const initial = scripts.takeInitialHydrationScriptTags()! + + expect(initial.before).toHaveLength(2) + expect(initial.before[0]).toEqual({ + tag: 'script', + attrs: { nonce: undefined, 'data-tsr-stream-part': '' }, + children: source, + }) + expect(initial.before[1]).toMatchObject({ + tag: 'script', + attrs: { nonce: undefined, 'data-tsr-stream-part': '' }, + children: expect.stringContaining('document.currentScript'), + }) + scripts.cleanup() + }) + + test('does not move pending initial sources into an early claimant', () => { + const scripts = createHydrationScripts(undefined, ['boot()']) + const output = scripts.claimOutput() + + scripts.pushSource('beforeScripts()') + scripts.liftBarrier() + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + + const initial = scripts.takeInitialHydrationScriptTags()! + expect( + initial.before.some((tag) => tag.children?.includes('beforeScripts()')), + ).toBe(true) + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + scripts.cleanup() + }) + + test('releases initial source accounting when tags take ownership', () => { + const sources = Array.from({ length: 4_096 }, (_, index) => `${index}`) + const scripts = createHydrationScripts(undefined, sources) + expect(scripts.takeInitialHydrationScriptTags()).toBeDefined() + const output = scripts.claimOutput() + scripts.liftBarrier() + + for (const source of sources) { + scripts.pushSource(source) + } + + expect(output.state).toBe(HydrationScriptOutputState.Ready) + expect(output.error).toBeUndefined() + scripts.cleanup() + }) + + test('transfers buffered sources in order only after the barrier lifts', async () => { + const scripts = createHydrationScripts(undefined, ['boot()']) + scripts.takeInitialHydrationScriptTags() + const output = scripts.claimOutput() + + scripts.pushSource('first()') + scripts.pushSource('second()') + await flushMicrotasks() + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + + scripts.liftBarrier() + expect(output.state).toBe(HydrationScriptOutputState.Ready) + expect(decodeChunks(drainChunks(output))).toBe( + '', + ) + scripts.cleanup() + }) + + test('emits exact nonce, separators, cleanup, boundary, and closing protocol', async () => { + const nonce = `a&"'<>` + const { scripts, initial, output } = createReadyOutput(nonce) + + expect(initial.before).toEqual([ + { + tag: 'script', + attrs: { nonce, 'data-tsr-stream-part': '' }, + children: + "boot();{let s=document.currentScript,p;while((p=s.previousElementSibling)&&p.hasAttribute('data-tsr-stream-part'))p.remove();s.remove()}", + }, + ]) + expect(initial.boundary).toEqual({ + tag: 'script', + attrs: { nonce }, + children: HYDRATION_SCRIPT_BOUNDARY_SOURCE, + }) + + scripts.pushSource('first()') + scripts.pushSource('second()') + await flushMicrotasks() + expect(decodeChunks(drainChunks(output))).toBe( + '', + ) + scripts.cleanup() + }) + + test.each([ + { + name: 'plain continuation', + initial: false, + wrap: false, + expected: 'value()', + }, + { + name: 'initial router value', + initial: true, + wrap: false, + expected: '$_TSR.router=value()', + }, + { + name: 'wrapped continuation', + initial: false, + wrap: true, + expected: '$_TSR.p(()=>value())', + }, + { + name: 'wrapped initial router value', + initial: true, + wrap: true, + expected: '$_TSR.p(()=>$_TSR.router=value())', + }, + ])('frames $name serialization exactly', ({ initial, wrap, expected }) => { + const { scripts, output } = createReadyOutput() + + scripts.pushSerializedSource('value()', initial, wrap) + + expect(decodeChunks(drainChunks(output))).toBe( + ``, + ) + scripts.cleanup() + }) +}) + +describe('hydration script encoding', () => { + test('uses one direct chunk for a small record', async () => { + const { scripts, output } = createReadyOutput() + scripts.pushSource('small()') + await flushMicrotasks() + + const chunks = drainChunks(output) + expect(chunks).toHaveLength(1) + expect(decodeChunks(chunks)).toBe( + '', + ) + scripts.cleanup() + }) + + test('can produce a record that is exactly one maximum-sized chunk', async () => { + const prefix = '' + const source = 'x'.repeat( + MAX_HYDRATION_OUTPUT_CHUNK_BYTES - prefix.length - suffix.length, + ) + const { scripts, output } = createReadyOutput() + scripts.pushSource(source) + await flushMicrotasks() + + const chunks = drainChunks(output) + expect(chunks).toHaveLength(1) + expect(chunks[0]).toHaveLength(MAX_HYDRATION_OUTPUT_CHUNK_BYTES) + expect(decodeChunks(chunks)).toBe(prefix + source + suffix) + scripts.cleanup() + }) + + test.each([ + ['CJK', '漢字語'.repeat(30_000)], + ['emoji', '🦄🚀'.repeat(30_000)], + ])( + 'encodes a large %s record without corrupting UTF-8', + async (_, source) => { + const { scripts, output } = createReadyOutput() + scripts.pushSource(source) + scripts.pushSource('tail()') + await flushMicrotasks() + + const chunks = drainChunks(output) + expect( + Math.max(...chunks.map((chunk) => chunk.byteLength)), + ).toBeLessThanOrEqual(MAX_HYDRATION_OUTPUT_CHUNK_BYTES) + expect(decodeChunks(chunks)).toBe( + `` + + '', + ) + scripts.cleanup() + }, + ) + + test('drains one 17 MiB record incrementally without retaining the response', async () => { + const source = 'x'.repeat(17 * 1024 * 1024) + const expectedHash = createHash('sha256') + .update('') + .digest('hex') + const { scripts, output } = createReadyOutput() + scripts.pushSource(source) + await flushMicrotasks() + + const actualHash = createHash('sha256') + let outputBytes = 0 + let maximumChunkBytes = 0 + while ( + output.state === HydrationScriptOutputState.Ready || + output.state === HydrationScriptOutputState.Active + ) { + const chunk = output.pullChunk() + actualHash.update(chunk) + outputBytes += chunk.byteLength + maximumChunkBytes = Math.max(maximumChunkBytes, chunk.byteLength) + } + + expect(outputBytes).toBe( + encoder.encode('').byteLength, + ) + expect(actualHash.digest('hex')).toBe(expectedHash) + expect(maximumChunkBytes).toBeLessThanOrEqual( + MAX_HYDRATION_OUTPUT_CHUNK_BYTES, + ) + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + scripts.cleanup() + }) + + test('keeps a large record Active until its closing tag drains', async () => { + const { scripts, output } = createReadyOutput() + scripts.pushSource('x'.repeat(MAX_HYDRATION_OUTPUT_CHUNK_BYTES * 2)) + await flushMicrotasks() + + expect(output.state).toBe(HydrationScriptOutputState.Ready) + const first = output.pullChunk() + expect(first.byteLength).toBeLessThanOrEqual( + MAX_HYDRATION_OUTPUT_CHUNK_BYTES, + ) + expect(output.state).toBe(HydrationScriptOutputState.Active) + + drainChunks(output) + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + scripts.cleanup() + }) + + test('closes one multi-chunk source before draining the next independent source', async () => { + const { scripts, output } = createReadyOutput() + const first = `first("${'a'.repeat(MAX_HYDRATION_OUTPUT_CHUNK_BYTES * 2)}")` + const second = `second("${'b'.repeat( + MAX_HYDRATION_OUTPUT_CHUNK_BYTES * 2, + )}")` + + try { + scripts.pushSource(first) + scripts.pushSource(second) + await flushMicrotasks() + + const recordDecoder = new TextDecoder() + let firstRecord = '' + while ( + (output.state === HydrationScriptOutputState.Ready || + output.state === HydrationScriptOutputState.Active) && + !firstRecord.includes('') + ) { + firstRecord += recordDecoder.decode(output.pullChunk(), { + stream: true, + }) + } + firstRecord += recordDecoder.decode() + + expect(firstRecord).toContain('first("') + expect(firstRecord).not.toContain('second("') + expect(output.state).toBe(HydrationScriptOutputState.Ready) + + const secondRecord = decodeChunks(drainChunks(output)) + expect(secondRecord).toContain('second("') + expect(secondRecord).not.toContain('first("') + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + } finally { + scripts.cleanup() + } + }) + + test('splits sources whose combined framed record exceeds the record budget', async () => { + const { scripts, output } = createReadyOutput() + const first = `first("${'a'.repeat(40 * 1024)}")` + const second = `second("${'b'.repeat(40 * 1024)}")` + + try { + scripts.pushSource(first) + scripts.pushSource(second) + await flushMicrotasks() + + const firstRecord = decodeChunks([output.pullChunk()]) + expect(firstRecord).toContain(first) + expect(firstRecord).not.toContain(second) + expect(output.state).toBe(HydrationScriptOutputState.Ready) + + const secondRecord = decodeChunks([output.pullChunk()]) + expect(secondRecord).toContain(second) + expect(secondRecord).not.toContain(first) + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + } finally { + scripts.cleanup() + } + }) + + test('preserves FIFO when sources append after queue compaction', () => { + const { scripts, output } = createReadyOutput() + const initialSources = Array.from({ length: 1_200 }, (_, index) => { + const prefix = `r${index.toString().padStart(4, '0')}:` + return prefix + 'x'.repeat(1024 - prefix.length) + }) + const appendedSources = Array.from({ length: 16 }, (_, offset) => { + const index = initialSources.length + offset + const prefix = `r${index.toString().padStart(4, '0')}:` + return prefix + 'x'.repeat(1024 - prefix.length) + }) + + try { + for (const source of initialSources) { + scripts.pushSource(source) + } + + const chunks = Array.from({ length: 17 }, () => output.pullChunk()) + expect(output.state).toBe(HydrationScriptOutputState.Ready) + + for (const source of appendedSources) { + scripts.pushSource(source) + } + scripts.finish() + chunks.push(...drainChunks(output)) + + const text = decodeChunks(chunks) + const markers = Array.from(text.matchAll(/r(\d{4}):/g), (match) => + Number(match[1]), + ) + expect(markers).toEqual( + [...initialSources, ...appendedSources].map((_, index) => index), + ) + expect(output.state).toBe(HydrationScriptOutputState.Done) + } finally { + scripts.cleanup() + } + }) + + test('reuses source-part capacity after queue compaction', () => { + const { scripts, output } = createReadyOutput() + const source = 'x'.repeat(1024) + + try { + for (let index = 0; index < 4_096; index++) { + scripts.pushSource(source) + } + + for (let record = 0; record < 33; record++) { + output.pullChunk() + } + expect(output.state).toBe(HydrationScriptOutputState.Ready) + + for (let index = 0; index < 2_079; index++) { + scripts.pushSource(source) + } + expect(output.state).toBe(HydrationScriptOutputState.Ready) + expect(output.error).toBeUndefined() + + scripts.pushSource(source) + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect((output.error as Error).message).toContain('source-part count') + } finally { + scripts.cleanup() + } + }) + + test('a mostly-empty final chunk does not pin the full output buffer', () => { + const { scripts, output } = createReadyOutput() + scripts.pushSource(`big("${'x'.repeat(80 * 1024)}")`) + scripts.finish() + + const chunks = drainChunks(output) + expect(chunks.length).toBeGreaterThan(1) + for (const chunk of chunks) { + // No chunk may retain more than twice its own bytes through its + // backing ArrayBuffer; small tails must be copied out of the 64 KiB + // output buffer. + expect(chunk.buffer.byteLength).toBeLessThanOrEqual(chunk.byteLength * 2) + } + }) +}) + +describe('disabled hydration', () => { + test('makes the fast path reservable without a take or a boundary', () => { + vi.useFakeTimers() + try { + const scripts = createHydrationScripts(undefined) + scripts.disableHydration() + + expect(scripts.isInitialTaken()).toBe(true) + expect(scripts.takeInitialHydrationScriptTags()).toBeUndefined() + expect(scripts.reserveFastPath()).toBe(true) + + // Late producer activity is inert: no sources, no timers. + scripts.pushSource('late()') + scripts.startSerializationTimeout(10) + scripts.finish() + expect(vi.getTimerCount()).toBe(0) + + scripts.cleanup() + } finally { + vi.useRealTimers() + } + }) + + test('is safe after cleanup', () => { + const scripts = createHydrationScripts(undefined) + scripts.disableHydration() + scripts.cleanup() + expect(() => scripts.disableHydration()).not.toThrow() + }) + + test('rejects disabling after the initial take', () => { + const scripts = createHydrationScripts(undefined, ['boot()']) + scripts.takeInitialHydrationScriptTags() + expect(() => scripts.disableHydration()).toThrow( + 'hydration output is already committed', + ) + scripts.cleanup() + }) + + test('rejects disabling after the output channel was claimed', () => { + const scripts = createHydrationScripts(undefined, ['boot()']) + scripts.claimOutput() + expect(() => scripts.disableHydration()).toThrow( + 'hydration output is already committed', + ) + scripts.cleanup() + }) +}) + +describe('hydration script completion and failure', () => { + test('enqueues $_TSR.e() before it reports Done', () => { + const { scripts, output } = createReadyOutput() + const observedStates: Array = [] + output.subscribe(() => observedStates.push(output.state)) + + scripts.pushSource('value()') + scripts.finish() + expect(output.state).toBe(HydrationScriptOutputState.Ready) + + const text = decodeChunks(drainChunks(output)) + expect(text).toBe( + '', + ) + expect(output.state).toBe(HydrationScriptOutputState.Done) + expect(observedStates).toContain(HydrationScriptOutputState.Ready) + scripts.cleanup() + }) + + test('reports the source-part guard synchronously', () => { + const { scripts, output } = createReadyOutput() + const notifications: Array = [] + output.subscribe(() => { + notifications.push(output.state) + }) + + let pushed = 0 + while ( + output.state !== HydrationScriptOutputState.Failed && + pushed < 10_000 + ) { + scripts.pushSource(`${pushed++}`) + } + + expect(pushed).toBeLessThan(10_000) + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect((output.error as Error).message).toContain('source-part count') + expect( + notifications.filter( + (value) => value === HydrationScriptOutputState.Failed, + ), + ).toHaveLength(1) + scripts.cleanup() + }) + + test('applies the source-part guard before the initial take', () => { + const scripts = createHydrationScripts(undefined, []) + + try { + for (let index = 0; index <= 4_096; index++) { + scripts.pushSource(`${index}`) + } + + const output = scripts.claimOutput() + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect((output.error as Error).message).toContain('source-part count') + expect(scripts.takeInitialHydrationScriptTags()).toBeUndefined() + } finally { + scripts.cleanup() + } + }) + + test('applies the source-part guard to constructor sources', () => { + const sources = Array.from({ length: 4_097 }, (_, index) => `${index}`) + const scripts = createHydrationScripts(undefined, sources) + + try { + const output = scripts.claimOutput() + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect((output.error as Error).message).toContain('source-part count') + expect(scripts.takeInitialHydrationScriptTags()).toBeUndefined() + } finally { + scripts.cleanup() + } + }) + + test('reports the regular backlog guard synchronously', () => { + const { scripts, output } = createReadyOutput() + const source = 'x'.repeat(1024 * 1024) + + let pushed = 0 + while (output.state !== HydrationScriptOutputState.Failed && pushed < 100) { + scripts.pushSource(source + pushed++) + } + + expect(pushed).toBeLessThan(100) + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect((output.error as Error).message).toContain('code-unit count') + scripts.cleanup() + }) + + test('applies the regular backlog guard before the initial take', () => { + const scripts = createHydrationScripts(undefined, []) + const source = 'x'.repeat(1024 * 1024) + + try { + for (let index = 0; index < 17; index++) { + scripts.pushSource(source + index) + } + + const output = scripts.claimOutput() + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect((output.error as Error).message).toContain('code-unit count') + } finally { + scripts.cleanup() + } + }) + + test('allows one oversized source but rejects a second one', () => { + const { scripts, output } = createReadyOutput() + const first = 'a'.repeat(17 * 1024 * 1024) + const second = 'b'.repeat(17 * 1024 * 1024) + + scripts.pushSource(first) + expect(output.state).not.toBe(HydrationScriptOutputState.Failed) + scripts.pushSource(second) + + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect((output.error as Error).message).toContain('code-unit count') + scripts.cleanup() + }) + + test('releases oversized-source accounting as soon as its source drains', () => { + const openingBytes = encoder.encode('', + ) + expect(output.state).toBe(HydrationScriptOutputState.Ready) + drainChunks(output) + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + scripts.cleanup() + }) + + test('rejects a second consumer', () => { + const scripts = createHydrationScripts(undefined, ['boot()']) + scripts.claimOutput() + expect(() => scripts.claimOutput()).toThrow( + 'SSR hydration script output already has a consumer', + ) + scripts.cleanup() + }) + + test('rejects an output claim after the fast path owns the consumer', () => { + const scripts = createHydrationScripts(undefined) + scripts.disableHydration() + expect(scripts.reserveFastPath()).toBe(true) + expect(() => scripts.claimOutput()).toThrow( + 'SSR hydration script output already has a consumer', + ) + scripts.cleanup() + }) + + test('cleanup clears active state and does not retain a new subscriber', async () => { + const { scripts, output } = createReadyOutput() + scripts.pushSource('x'.repeat(MAX_HYDRATION_OUTPUT_CHUNK_BYTES * 2)) + await flushMicrotasks() + output.pullChunk() + expect(output.state).toBe(HydrationScriptOutputState.Active) + + scripts.cleanup() + + expect(output.state).toBe(HydrationScriptOutputState.Done) + expect(output.error).toBeUndefined() + expect(() => output.pullChunk()).toThrow('not ready') + const onChange = vi.fn() + const unsubscribe = output.subscribe(onChange) + scripts.pushSource('ignored()') + scripts.finish() + unsubscribe() + expect(onChange).not.toHaveBeenCalled() + }) + + test('a render timeout fails immediately while a record is Active', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { scripts, output } = createReadyOutput() + const onChange = vi.fn() + output.subscribe(onChange) + + try { + scripts.pushSource('x'.repeat(MAX_HYDRATION_OUTPUT_CHUNK_BYTES * 2)) + await flushMicrotasks() + output.pullChunk() + expect(output.state).toBe(HydrationScriptOutputState.Active) + + scripts.startSerializationTimeout(10) + await vi.advanceTimersByTimeAsync(10) + + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect((output.error as Error).message).toContain('Serialization timeout') + expect(onChange).toHaveBeenCalled() + } finally { + scripts.cleanup() + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + test('serialization completion clears the timeout during a slow active drain', async () => { + vi.useFakeTimers() + const { scripts, output } = createReadyOutput() + + try { + scripts.pushSource('x'.repeat(MAX_HYDRATION_OUTPUT_CHUNK_BYTES * 2)) + await flushMicrotasks() + output.pullChunk() + expect(output.state).toBe(HydrationScriptOutputState.Active) + + scripts.startSerializationTimeout(10) + scripts.finish() + await vi.advanceTimersByTimeAsync(100) + + expect(output.state).toBe(HydrationScriptOutputState.Active) + expect(decodeChunks(drainChunks(output))).toContain('$_TSR.e()') + expect(output.state).toBe(HydrationScriptOutputState.Done) + } finally { + scripts.cleanup() + vi.useRealTimers() + } + }) +}) diff --git a/packages/router-core/tests/issue-7942-repro.test.ts b/packages/router-core/tests/issue-7942-repro.test.ts new file mode 100644 index 00000000000..c57b8db02fc --- /dev/null +++ b/packages/router-core/tests/issue-7942-repro.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + MAX_HYDRATION_OUTPUT_CHUNK_BYTES, +} from '../src/ssr/hydrationScripts' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { transformReadableStreamWithRouter } from '../src/ssr/transformStreamWithRouter' +import { createTestRouter } from './routerTestUtils' +import type { RouterManagedTag } from '../src/manifest' +import type { InitialHydrationScriptTags } from '../src/ssr/hydrationScripts' + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function createLoaderSsrRouter(loader: () => unknown) { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + loader, + }) + + return createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) +} + +function createManualUpstream() { + let controllerRef: ReadableStreamDefaultController | undefined + const cancelled = { value: false } + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller + }, + cancel() { + cancelled.value = true + }, + }) + + return { + stream, + cancelled, + push(value: string) { + controllerRef!.enqueue(encoder.encode(value)) + }, + close() { + controllerRef!.close() + }, + } +} + +function renderManagedScript(tag: RouterManagedTag) { + const id = tag.attrs?.id ? ` id="${tag.attrs.id}"` : '' + return `${tag.children ?? ''}` +} + +function renderManagedScripts(tags: Array) { + return tags.map(renderManagedScript).join('') +} + +function expectInitialScripts( + scripts: InitialHydrationScriptTags | undefined, +): InitialHydrationScriptTags { + expect(scripts?.boundary.children).toBe(HYDRATION_SCRIPT_BOUNDARY_SOURCE) + expect(scripts?.boundary.attrs).not.toHaveProperty('id') + expect(scripts?.before.length).toBeGreaterThan(0) + for (const script of scripts!.before) { + expect(script.attrs?.['data-tsr-stream-part']).toBe('') + } + return scripts! +} + +async function readAll(stream: ReadableStream) { + const reader = stream.getReader() + const decoder = new TextDecoder() + const chunks: Array = [] + const chunkSizes: Array = [] + for (;;) { + const { done, value } = await reader.read() + if (done) { + chunks.push(decoder.decode()) + return { + text: chunks.join(''), + chunkSizes, + } + } + chunkSizes.push(value.byteLength) + chunks.push(decoder.decode(value, { stream: true })) + } +} + +async function waitFor(check: () => boolean) { + for (let index = 0; index < 20; index++) { + if (check()) { + return + } + await Promise.resolve() + } + throw new Error('condition was not reached') +} + +describe('issue #7942', () => { + test('eager loader data stays in the segmented initial scripts', async () => { + const router = createLoaderSsrRouter(() => ({ value: 'eager-loader-data' })) + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + await router.serverSsr!.dehydrate() + + const initialScripts = expectInitialScripts( + router.serverSsr!.takeInitialHydrationScriptTags(), + ) + expect( + initialScripts.before.some((script) => + script.children?.includes('eager-loader-data'), + ), + ).toBe(true) + expect(router.serverSsr!.takeInitialHydrationScriptTags()).toBeUndefined() + + router.serverSsr!.cleanup() + }) + + test('keeps a large eager serialization part isolated from small initial scripts', async () => { + const payload = 'initial-large-value-'.repeat(2_000) + const router = createLoaderSsrRouter(() => ({ value: payload })) + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + await router.serverSsr!.dehydrate() + + const initialScripts = expectInitialScripts( + router.serverSsr!.takeInitialHydrationScriptTags(), + ) + const streamParts = initialScripts.before + const payloadScripts = streamParts.filter((script) => + script.children?.includes(payload), + ) + + expect(payloadScripts).toHaveLength(1) + expect(streamParts.indexOf(payloadScripts[0]!)).toBeGreaterThan(0) + expect( + streamParts + .filter((script) => script !== payloadScripts[0]) + .every((script) => !script.children?.includes(payload)), + ).toBe(true) + + router.serverSsr!.cleanup() + }) + + test('streams a large deferred loader result in bounded chunks after the next safe boundary', async () => { + const deferred = createDeferred() + const router = createLoaderSsrRouter(() => ({ value: deferred.promise })) + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + await router.serverSsr!.dehydrate() + + const initialScripts = expectInitialScripts( + router.serverSsr!.takeInitialHydrationScriptTags(), + ) + const liftBarrier = vi.spyOn( + router.serverSsr!.hydrationScripts, + 'liftBarrier', + ) + const upstream = createManualUpstream() + const output = transformReadableStreamWithRouter(router, upstream.stream) + const outputPromise = readAll(output) + + const appShell = + `${renderManagedScripts([ + ...initialScripts.before, + initialScripts.boundary, + ])}` + `
app
` + expect(appShell.length).toBeLessThan(MAX_HYDRATION_OUTPUT_CHUNK_BYTES) + upstream.push(appShell) + await waitFor(() => liftBarrier.mock.calls.length === 1) + + const payload = 'x'.repeat(17 * 1024 * 1024) + deferred.resolve(payload) + + const tail = '' + upstream.push(tail) + upstream.close() + + const result = await outputPromise + const renderedInitialScripts = renderManagedScripts([ + ...initialScripts.before, + initialScripts.boundary, + ]) + const earliestInjectionOffset = + ''.length + renderedInitialScripts.length + const payloadOffset = result.text.indexOf(payload) + const injectedStart = result.text.lastIndexOf('' + const appMainOffset = result.text.indexOf('
app
') + const injectedEnd = + result.text.lastIndexOf(dynamicClose, appMainOffset) + dynamicClose.length + expect(payloadOffset).toBeGreaterThan(injectedStart) + expect(injectedStart).toBeGreaterThanOrEqual(earliestInjectionOffset) + expect(injectedStart).toBeLessThanOrEqual(appShell.length) + expect(injectedEnd).toBeGreaterThan(payloadOffset) + expect( + result.text.slice(0, injectedStart) + result.text.slice(injectedEnd), + ).toBe(appShell + tail) + expect(result.text.endsWith(tail)).toBe(true) + expect(Math.max(...result.chunkSizes)).toBeLessThanOrEqual( + MAX_HYDRATION_OUTPUT_CHUNK_BYTES, + ) + expect(upstream.cancelled.value).toBe(false) + expect(router.serverSsr).toBeUndefined() + }) + + test('rejects multiple router fragments that genuinely accumulate past the guard', async () => { + const payloadCharsPerValue = 1024 * 1024 + const deferredValues = Array.from({ length: 17 }, () => + createDeferred(), + ) + const router = createLoaderSsrRouter(() => ({ + values: deferredValues.map((deferred) => deferred.promise), + })) + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + await router.serverSsr!.dehydrate() + + const initialScripts = expectInitialScripts( + router.serverSsr!.takeInitialHydrationScriptTags(), + ) + + const liftBarrier = vi.spyOn( + router.serverSsr!.hydrationScripts, + 'liftBarrier', + ) + const upstream = createManualUpstream() + const output = transformReadableStreamWithRouter(router, upstream.stream) + const outputResult = readAll(output).then( + (value) => ({ value, error: undefined }), + (error: unknown) => ({ value: undefined, error }), + ) + + upstream.push( + `${renderManagedScripts([ + ...initialScripts.before, + initialScripts.boundary, + ])}
app
`, + ) + await waitFor(() => liftBarrier.mock.calls.length === 1) + + for (let index = 0; index < deferredValues.length; index++) { + deferredValues[index]!.resolve( + `${index}:` + 'y'.repeat(payloadCharsPerValue), + ) + } + + const result = await outputResult + expect(result.value).toBeUndefined() + expect(result.error).toBeInstanceOf(Error) + expect((result.error as Error).message).toContain( + 'SSR hydration backlog exceeded maximum code-unit count', + ) + expect(upstream.cancelled.value).toBe(true) + expect(router.serverSsr).toBeUndefined() + }) +}) diff --git a/packages/router-core/tests/load-client-wait-for.test.ts b/packages/router-core/tests/load-client-wait-for.test.ts index 9ae49b89bd9..fdf5397a16a 100644 --- a/packages/router-core/tests/load-client-wait-for.test.ts +++ b/packages/router-core/tests/load-client-wait-for.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test, vi } from 'vitest' import { waitForReason } from '../src/await-signal' import { waitFor } from '../src/load-client' -import { waitForRequest } from '../src/ssr/createRequestHandler' +import { waitForRequest } from '../src/ssr/server' + +test('waitForRequest aliases waitForReason through the server barrel', () => { + expect(waitForRequest).toBe(waitForReason) +}) describe('waitFor', () => { test.each(['resolve', 'reject'] as const)( @@ -99,40 +103,49 @@ describe('waitFor', () => { }) }) -describe('waitForRequest', () => { - test('shares one abort listener across concurrent and sequential waits', async () => { +describe('waitForReason', () => { + test('handles nested abort dispatch before its listener runs', async () => { + const source = new AbortController() + const nested = new AbortController() + const signal = AbortSignal.any([source.signal]) + signal.addEventListener('abort', () => nested.abort()) + + const reason = new Error('request canceled') + const result = waitForReason(Promise.resolve('late'), signal) + source.abort(reason) + + await expect(result).rejects.toBe(reason) + }) + + test('removes its abort listener once the wait settles', async () => { const controller = new AbortController() const addEventListener = vi.spyOn(controller.signal, 'addEventListener') + const removeEventListener = vi.spyOn( + controller.signal, + 'removeEventListener', + ) let resolveFirst!: (value: string) => void - let resolveSecond!: (value: string) => void - let resolveThird!: (value: string) => void + let rejectSecond!: (error: unknown) => void const first = new Promise((resolve) => { resolveFirst = resolve }) - const second = new Promise((resolve) => { - resolveSecond = resolve - }) - const third = new Promise((resolve) => { - resolveThird = resolve + const second = new Promise((_resolve, reject) => { + rejectSecond = reject }) - const firstResult = waitForRequest(first, controller.signal) - const secondResult = waitForRequest(second, controller.signal) + const firstResult = waitForReason(first, controller.signal) + const secondResult = waitForReason(second, controller.signal) + expect(addEventListener).toHaveBeenCalledTimes(2) - expect(addEventListener).toHaveBeenCalledTimes(1) resolveFirst('first') - resolveSecond('second') await expect(firstResult).resolves.toBe('first') - await expect(secondResult).resolves.toBe('second') - - const thirdResult = waitForRequest(third, controller.signal) - expect(addEventListener).toHaveBeenCalledTimes(1) + const failure = new Error('wait failed') + rejectSecond(failure) + await expect(secondResult).rejects.toBe(failure) - const reason = new Error('request canceled') - controller.abort(reason) - await expect(thirdResult).rejects.toBe(reason) - resolveThird('third') - await Promise.resolve() + for (const [, listener] of addEventListener.mock.calls) { + expect(removeEventListener).toHaveBeenCalledWith('abort', listener) + } }) test('rejects all active waits and observes their late values', async () => { @@ -148,8 +161,8 @@ describe('waitForRequest', () => { const second = new Promise((resolve) => { resolveSecond = resolve }) - const firstResult = waitForRequest(first, controller.signal, onFirstLate) - const secondResult = waitForRequest(second, controller.signal, onSecondLate) + const firstResult = waitForReason(first, controller.signal, onFirstLate) + const secondResult = waitForReason(second, controller.signal, onSecondLate) controller.abort(reason) await expect(firstResult).rejects.toBe(reason) @@ -167,7 +180,9 @@ describe('waitForRequest', () => { const controller = new AbortController() const reason = new Error('request canceled') const failure = new Error('wait failed') + const lateFailure = new Error('late failure') const onLate = vi.fn() + const onLateError = vi.fn() let resolveFirst!: (value: string) => void let rejectSecond!: (error: Error) => void let rejectThird!: (error: Error) => void @@ -180,9 +195,14 @@ describe('waitForRequest', () => { const third = new Promise((_, reject) => { rejectThird = reject }) - const firstResult = waitForRequest(first, controller.signal, onLate) - const secondResult = waitForRequest(second, controller.signal) - const thirdResult = waitForRequest(third, controller.signal) + const firstResult = waitForReason(first, controller.signal, onLate) + const secondResult = waitForReason(second, controller.signal) + const thirdResult = waitForReason( + third, + controller.signal, + undefined, + onLateError, + ) rejectSecond(failure) await expect(secondResult).rejects.toBe(failure) @@ -192,11 +212,14 @@ describe('waitForRequest', () => { await expect(thirdResult).rejects.toBe(reason) resolveFirst('late') - rejectThird(new Error('late failure')) - await vi.waitFor(() => expect(onLate).toHaveBeenCalledWith('late')) + rejectThird(lateFailure) + await vi.waitFor(() => { + expect(onLate).toHaveBeenCalledWith('late') + expect(onLateError).toHaveBeenCalledWith(lateFailure) + }) }) - test('isolates waits for different request signals', async () => { + test('isolates waits for different signals', async () => { const firstController = new AbortController() const secondController = new AbortController() const firstReason = new Error('first request canceled') @@ -216,8 +239,8 @@ describe('waitForRequest', () => { const second = new Promise((resolve) => { resolveSecond = resolve }) - const firstResult = waitForRequest(first, firstController.signal) - const secondResult = waitForRequest(second, secondController.signal) + const firstResult = waitForReason(first, firstController.signal) + const secondResult = waitForReason(second, secondController.signal) expect(firstAddEventListener).toHaveBeenCalledOnce() expect(secondAddEventListener).toHaveBeenCalledOnce() @@ -236,16 +259,25 @@ describe('waitForRequest', () => { const reason = new Error('request canceled') const lateError = new Error('late failure') const onLate = vi.fn() + const onLateError = vi.fn() controller.abort(reason) await expect( - waitForRequest(Promise.resolve('late'), controller.signal, onLate), + waitForReason(Promise.resolve('late'), controller.signal, onLate), ).rejects.toBe(reason) await expect( - waitForRequest(Promise.reject(lateError), controller.signal), + waitForReason( + Promise.reject(lateError), + controller.signal, + undefined, + onLateError, + ), ).rejects.toBe(reason) - await vi.waitFor(() => expect(onLate).toHaveBeenCalledWith('late')) + await vi.waitFor(() => { + expect(onLate).toHaveBeenCalledWith('late') + expect(onLateError).toHaveBeenCalledWith(lateError) + }) }) test('handles an abort during listener registration', async () => { @@ -266,10 +298,80 @@ describe('waitForRequest', () => { resolve = resolveValue }) - const result = waitForRequest(value, controller.signal, onLate) + const result = waitForReason(value, controller.signal, onLate) await expect(result).rejects.toBe(reason) resolve('late') await vi.waitFor(() => expect(onLate).toHaveBeenCalledWith('late')) }) + + test('observes errors from late callbacks', async () => { + const unhandled: Array = [] + const onUnhandled = (error: unknown) => { + unhandled.push(error) + } + process.on('unhandledRejection', onUnhandled) + + try { + const reason = new Error('request canceled') + const thrownError = new Error('late callback threw') + const rejectedError = new Error('late callback rejected') + const throwLate = vi.fn(() => { + throw thrownError + }) + const rejectLate = vi.fn(() => { + return Promise.reject(rejectedError) + }) + const expectCanceled = (promise: Promise) => { + return expect(promise).rejects.toBe(reason) + } + const alreadyAborted = new AbortController() + alreadyAborted.abort(reason) + + await Promise.all([ + expectCanceled( + waitForReason( + Promise.resolve('late'), + alreadyAborted.signal, + throwLate, + ), + ), + expectCanceled( + waitForReason( + Promise.reject(new Error('late failure')), + alreadyAborted.signal, + undefined, + rejectLate, + ), + ), + ]) + + const controller = new AbortController() + const fulfilledWait = waitForReason( + Promise.resolve('late'), + controller.signal, + rejectLate, + ) + const rejectedWait = waitForReason( + Promise.reject(new Error('late failure')), + controller.signal, + undefined, + throwLate, + ) + controller.abort(reason) + + await Promise.all([ + expectCanceled(fulfilledWait), + expectCanceled(rejectedWait), + ]) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(throwLate).toHaveBeenCalledTimes(2) + expect(rejectLate).toHaveBeenCalledTimes(2) + expect(unhandled).not.toContain(thrownError) + expect(unhandled).not.toContain(rejectedError) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) }) diff --git a/packages/router-core/tests/public-hydration-contract.test.ts b/packages/router-core/tests/public-hydration-contract.test.ts index c593bdf0206..aea458b3f57 100644 --- a/packages/router-core/tests/public-hydration-contract.test.ts +++ b/packages/router-core/tests/public-hydration-contract.test.ts @@ -1,4 +1,3 @@ -import { runInNewContext } from 'node:vm' import { afterEach, beforeEach, @@ -16,37 +15,15 @@ import { redirect, } from '../src' import { hydrate } from '../src/ssr/client' -import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' -import { createTestRouter } from './routerTestUtils' -import type { AnyRouteMatch, AnyRouter, LocationRewrite } from '../src' +import { createTestRouter, dehydrateToBootstrap } from './routerTestUtils' +import type { AnyRouteMatch, LocationRewrite } from '../src' import type { DehydratedRouter, TsrSsrGlobal } from '../src/ssr/types' import type { ServerManifest } from '../src/manifest' const testManifest: ServerManifest = { routes: {} } const browserWindow = window -async function dehydrateToBootstrap(router: AnyRouter): Promise { - attachRouterServerSsrUtils({ router, manifest: testManifest }) - try { - await router.load() - await router.serverSsr!.dehydrate() - - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - - const context: Record = { - document: { currentScript: { remove() {} } }, - } - context.self = context - runInNewContext(script!.children!, context) - - return context.$_TSR - } finally { - router.serverSsr?.cleanup() - } -} - function installHydrationPayload( mockWindow: { $_TSR?: TsrSsrGlobal }, matches: DehydratedRouter['matches'], @@ -155,7 +132,7 @@ describe('public hydration contracts', () => { }), isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) expect(bootstrap.router?.matches).toHaveLength(2) expect(bootstrap.router?.matches.at(-1)).toMatchObject({ s: 'pending', @@ -241,7 +218,7 @@ describe('public hydration contracts', () => { history: createMemoryHistory({ initialEntries: ['/client-only/child'] }), isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) const contextLanes: Array> = [] const headLanes: Array> = [] @@ -567,7 +544,7 @@ describe('public hydration contracts', () => { history: createMemoryHistory({ initialEntries: ['/page'] }), isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) const head = vi.fn(({ loaderData }) => ({ meta: [{ title: String(loaderData) }], @@ -646,7 +623,7 @@ describe('public hydration contracts', () => { history: createMemoryHistory({ initialEntries: ['/parent/child'] }), isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) expect(serverParentBeforeLoad).toHaveBeenCalledTimes(1) expect(serverChildBeforeLoad).toHaveBeenCalledTimes(1) @@ -729,7 +706,7 @@ describe('public hydration contracts', () => { history: createMemoryHistory({ initialEntries: ['/page'] }), isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) const transportedError = bootstrap.router?.matches.at(-1)?.e expect(transportedError).toMatchObject({ @@ -1408,7 +1385,7 @@ describe('public hydration contracts', () => { history: createMemoryHistory({ initialEntries: ['/page'] }), isServer: true, }) - const bootstrap = await dehydrateToBootstrap(serverRouter) + const bootstrap = await dehydrateToBootstrap(serverRouter, testManifest) const transportedError = bootstrap.router?.matches.at(-1)?.e const clientContextError = new Error('client context failed') diff --git a/packages/router-core/tests/routerTestUtils.ts b/packages/router-core/tests/routerTestUtils.ts index 413b810b95a..8484981305e 100644 --- a/packages/router-core/tests/routerTestUtils.ts +++ b/packages/router-core/tests/routerTestUtils.ts @@ -1,3 +1,5 @@ +import { runInNewContext } from 'node:vm' +import { expect } from 'vitest' import { batch, createAtom } from '@tanstack/store' import { isServer } from '@tanstack/router-core/isServer' import { @@ -6,6 +8,10 @@ import { createNonReactiveReadonlyStore, } from '../src' import { createRequestHandler } from '../src/ssr/createRequestHandler' +import { HYDRATION_SCRIPT_BOUNDARY_SOURCE } from '../src/ssr/hydrationScripts' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import type { ServerManifest } from '../src/manifest' +import type { TsrSsrGlobal } from '../src/ssr/types' import type { RouterHistory } from '@tanstack/history' import type { AnyRouter, @@ -69,3 +75,38 @@ export function loadServerResponse( }) }) } + +export async function dehydrateToBootstrap( + router: AnyRouter, + manifest: ServerManifest, +): Promise { + attachRouterServerSsrUtils({ router, manifest }) + try { + await router.load() + await router.serverSsr!.dehydrate() + + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.before.length).toBeGreaterThan(0) + expect(scripts?.boundary.children).toBe(HYDRATION_SCRIPT_BOUNDARY_SOURCE) + expect(scripts?.boundary.attrs).not.toHaveProperty('id') + + const context: Record = { + document: { + currentScript: { + remove() {}, + }, + }, + } + context.self = context + for (const script of scripts!.before) { + expect(script.attrs?.['data-tsr-stream-part']).toBe('') + expect(script.children).toBeTruthy() + runInNewContext(script.children!, context) + } + + expect(context.$_TSR).toBeDefined() + return context.$_TSR + } finally { + router.serverSsr?.cleanup() + } +} diff --git a/packages/router-core/tests/server-loader-abort-error.test.ts b/packages/router-core/tests/server-loader-abort-error.test.ts index bc079ce2395..4aa9e72436b 100644 --- a/packages/router-core/tests/server-loader-abort-error.test.ts +++ b/packages/router-core/tests/server-loader-abort-error.test.ts @@ -5,21 +5,27 @@ import { createRequestHandler } from '../src/ssr/server' import { createTestRouter, loadServerResponse } from './routerTestUtils' describe('loader user-thrown abort values', () => { - test.each( - ([false, true] as const).flatMap((isServer) => [ - { - isServer, - thrownType: 'AbortSignal', - createThrownValue: (signal: AbortSignal) => signal, - }, - { - isServer, - thrownType: 'AbortError', - createThrownValue: () => - new DOMException('The operation was aborted.', 'AbortError'), - }, - ]), - )( + test.each([ + { + isServer: false, + thrownType: 'AbortSignal', + createThrownValue: (signal: AbortSignal) => signal, + }, + { + isServer: false, + thrownType: 'AbortError', + createThrownValue: () => + new DOMException('The operation was aborted.', 'AbortError'), + }, + { + isServer: true, + thrownType: 'AbortError', + createThrownValue: () => + Object.assign(new Error('The operation was aborted.'), { + name: 'AbortError', + }), + }, + ])( 'treats a user-thrown $thrownType as an ordinary route error (isServer=$isServer)', async ({ isServer, createThrownValue }) => { let matchSignal: AbortSignal | undefined @@ -50,9 +56,9 @@ describe('loader user-thrown abort values', () => { const match = router.state.matches.find( (item) => item.routeId === abortingRoute.id, ) - // A server match remains ordinary route failure during reduction, then - // its controller is retired when the request response is cleaned up. - expect(matchSignal?.aborted).toBe(isServer) + // A server match remains ordinary route failure during reduction. Its + // controller is left alone: the payload finished, so nothing is pending. + expect(matchSignal?.aborted).toBe(false) expect(match?.status).toBe('error') expect(match?.error).toBe(thrownValue) }, @@ -228,15 +234,17 @@ describe('loader user-thrown abort values', () => { path: '/work', loader, }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([route]), - history: createMemoryHistory({ initialEntries: ['/work'] }), - isServer: true, - }) + const createRouter = vi.fn(() => + createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/work'] }), + isServer: true, + }), + ) const requestController = new AbortController() const render = vi.fn(() => new Response('must not render')) const handler = createRequestHandler({ - createRouter: () => router, + createRouter, request: new Request('http://localhost/work', { signal: requestController.signal, }), @@ -252,10 +260,39 @@ describe('loader user-thrown abort values', () => { requestController.abort(cancellation) await expect(response).rejects.toBe(cancellation) + expect(createRouter).not.toHaveBeenCalled() expect(loader).not.toHaveBeenCalled() expect(render).not.toHaveBeenCalled() }) + test('request cancellation wins after manifest lookup resolves', async () => { + const rootRoute = new BaseRootRoute({}) + const createRouter = vi.fn(() => + createTestRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }), + ) + const requestController = new AbortController() + const render = vi.fn(() => new Response('must not render')) + const handler = createRequestHandler({ + createRouter, + request: new Request('http://localhost/', { + signal: requestController.signal, + }), + getRouterManifest: () => Promise.resolve({ routes: {} }), + }) + + const response = handler(render) + const cancellation = new Error('request disconnected') + queueMicrotask(() => requestController.abort(cancellation)) + + await expect(response).rejects.toBe(cancellation) + expect(createRouter).not.toHaveBeenCalled() + expect(render).not.toHaveBeenCalled() + }) + test('request cancellation does not wait for custom dehydration', async () => { const dehydrateStarted = createControlledPromise() const dehydrate = createControlledPromise() diff --git a/packages/router-core/tests/ssr-server-cleanup.test.ts b/packages/router-core/tests/ssr-server-cleanup.test.ts index a4d0b965882..4c941e66be2 100644 --- a/packages/router-core/tests/ssr-server-cleanup.test.ts +++ b/packages/router-core/tests/ssr-server-cleanup.test.ts @@ -1,14 +1,31 @@ +import { runInNewContext } from 'node:vm' import { createMemoryHistory } from '@tanstack/history' -import { afterEach, describe, expect, onTestFinished, test, vi } from 'vitest' -import { BaseRootRoute, BaseRoute } from '../src' +import { createStream } from 'seroval' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute, RawStream } from '../src' import { createRequestHandler } from '../src/ssr/createRequestHandler' import { bindSsrResponseToRequest, createSsrStreamResponse, } from '../src/ssr/handlerCallback' +import { + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + HYDRATION_SCRIPT_BOUNDARY_SUFFIX, + HydrationScriptOutputState, + createHydrationScripts, +} from '../src/ssr/hydrationScripts' import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' -import { transformStreamWithRouter } from '../src/ssr/transformStreamWithRouter' +import { + transformHtmlStringWithRouter, + transformReadableStreamWithRouter, +} from '../src/ssr/transformStreamWithRouter' import { createTestRouter } from './routerTestUtils' +import type { + HydrationScriptOutput, + InitialHydrationScriptTags, +} from '../src/ssr/hydrationScripts' + +type HydrationScripts = ReturnType /** * CI-stable tests for the SSR cleanup contract. These do not rely on GC @@ -45,20 +62,53 @@ afterEach(() => { vi.restoreAllMocks() }) +function renderInitialScripts(scripts: InitialHydrationScriptTags) { + return [...scripts.before, scripts.boundary] + .map((script) => { + const id = script.attrs?.id ? ` id="${script.attrs.id}"` : '' + return `${script.children ?? ''}` + }) + .join('') +} + +async function waitFor(check: () => boolean) { + for (let index = 0; index < 20; index++) { + if (check()) { + return + } + await Promise.resolve() + } + throw new Error('condition was not reached') +} + +function drainHydrationOutput(output: HydrationScriptOutput) { + const decoder = new TextDecoder() + let text = '' + while ( + output.state === HydrationScriptOutputState.Ready || + output.state === HydrationScriptOutputState.Active + ) { + text += decoder.decode(output.pullChunk(), { stream: true }) + } + return text + decoder.decode() +} + describe('serverSsr.cleanup', () => { test('onCleanup listeners run exactly once', () => { const router = buildRouter() attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! let calls = 0 - router.serverSsr!.onCleanup(() => { + serverSsr.onCleanup(() => { calls++ }) - router.serverSsr!.cleanup() - // Second call: serverSsr is undefined now, must short-circuit safely. - router.serverSsr?.cleanup() + serverSsr.cleanup() + serverSsr.cleanup() expect(calls).toBe(1) + expect(router.ssr).toBeUndefined() + expect(router.serverSsr).toBeUndefined() }) test('listener that re-enters cleanup() does not re-fire siblings', () => { @@ -113,6 +163,148 @@ describe('serverSsr.cleanup', () => { expect(router.serverSsr).toBeUndefined() }) + test('cleanup cancels and unlocks a pending RawStream reader', async () => { + const pullStarted = deferred() + let cancelCalls = 0 + const stream = new ReadableStream( + { + pull() { + pullStarted.resolve() + return new Promise(() => {}) + }, + cancel() { + cancelCalls++ + return new Promise(() => {}) + }, + }, + { highWaterMark: 0 }, + ) + const router = buildRouter({ value: new RawStream(stream) }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + const serverSsr = router.serverSsr! + await serverSsr.dehydrate() + await pullStarted.promise + expect(stream.locked).toBe(true) + + serverSsr.cleanup() + expect(cancelCalls).toBe(1) + expect(stream.locked).toBe(false) + + serverSsr.cleanup() + expect(cancelCalls).toBe(1) + expect(router.ssr).toBeUndefined() + expect(router.serverSsr).toBeUndefined() + }) + + test('hydration output notifies once before cleanup', async () => { + const value = deferred() + const router = buildRouter({ value: value.promise }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + const serverSsr = router.serverSsr! + await serverSsr.dehydrate() + serverSsr.takeInitialHydrationScriptTags() + const output = serverSsr.hydrationScripts.claimOutput() + let calls = 0 + output.subscribe(() => { + calls++ + }) + + serverSsr.hydrationScripts.liftBarrier() + value.resolve('done') + await waitFor(() => output.state === HydrationScriptOutputState.Ready) + expect(calls).toBe(1) + serverSsr.cleanup() + await Promise.resolve() + + expect(calls).toBe(1) + expect(output.state).toBe(HydrationScriptOutputState.Done) + expect(router.serverSsr).toBeUndefined() + }) + + test('cleanup stops work from late serialization values', async () => { + const value = deferred() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const router = buildRouter({ value: value.promise }) + + try { + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr!.dehydrate() + router.serverSsr!.cleanup() + + value.resolve(() => {}) + await Promise.resolve() + await Promise.resolve() + + expect(errorSpy).not.toHaveBeenCalled() + } finally { + router.serverSsr?.cleanup() + errorSpy.mockRestore() + } + }) + + test('cleanup while custom dehydration waits does not start serialization', async () => { + const dehydratedData = deferred>() + const router = buildRouter() + router.options.dehydrate = () => dehydratedData.promise + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + const serverSsr = router.serverSsr! + const dehydration = serverSsr.dehydrate() + + serverSsr.cleanup() + dehydratedData.resolve({ late: 'ignored' }) + await dehydration + + expect(router.serverSsr).toBeUndefined() + }) + + test('request abort unwinds an argument-free custom dehydration hook', async () => { + const abortController = new AbortController() + const reason = new Error('request-aborted') + const dehydratedData = deferred>() + const router = buildRouter() + const dehydrate = vi.fn(() => dehydratedData.promise) + router.options.dehydrate = dehydrate + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + const dehydration = router.serverSsr!.dehydrate({ + signal: abortController.signal, + }) + expect(dehydrate).toHaveBeenCalledWith() + abortController.abort(reason) + + await expect(dehydration).rejects.toBe(reason) + router.serverSsr?.cleanup() + dehydratedData.resolve({ late: 'ignored' }) + }) + + test('request abort stops dehydration after an already-settled custom hook', async () => { + const abortController = new AbortController() + const reason = new Error('request-aborted') + const router = buildRouter() + router.options.dehydrate = () => Promise.resolve({ ready: true }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + const serverSsr = router.serverSsr! + const scripts = serverSsr.hydrationScripts as HydrationScripts + const pushSerializedSource = vi.spyOn(scripts, 'pushSerializedSource') + const dehydration = serverSsr.dehydrate({ + signal: abortController.signal, + }) + queueMicrotask(() => abortController.abort(reason)) + + await expect(dehydration).rejects.toBe(reason) + expect(pushSerializedSource).not.toHaveBeenCalled() + serverSsr.cleanup() + }) + test('serialization completion does not clear render-finished listeners', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) @@ -126,12 +318,14 @@ describe('serverSsr.cleanup', () => { renderFinishedCalls++ }) - const serializationDone = new Promise((resolve) => { - router.serverSsr!.onSerializationFinished(resolve) - }) + router.serverSsr!.takeInitialHydrationScriptTags() + const output = router.serverSsr!.hydrationScripts.claimOutput() + router.serverSsr!.hydrationScripts.liftBarrier() value.resolve('done') - await serializationDone + await waitFor(() => output.state === HydrationScriptOutputState.Ready) + drainHydrationOutput(output) + expect(output.state).toBe(HydrationScriptOutputState.Done) expect(renderFinishedCalls).toBe(0) router.serverSsr!.setRenderFinished() @@ -140,6 +334,39 @@ describe('serverSsr.cleanup', () => { router.serverSsr?.cleanup() }) + test('waits for the initial record after a completed Seroval stream', async () => { + const completedStream = createStream() + completedStream.next('stream value') + completedStream.return('stream done') + const lateValue = deferred() + const router = buildRouter({ + completedStream, + lateValue: lateValue.promise, + }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + const serverSsr = router.serverSsr! + await serverSsr.dehydrate() + const initialHydration = renderInitialScripts( + serverSsr.takeInitialHydrationScriptTags()!, + ) + expect(initialHydration).toContain('stream value') + + const output = serverSsr.hydrationScripts.claimOutput() + serverSsr.setRenderFinished() + expect(output.state).toBe(HydrationScriptOutputState.Waiting) + + lateValue.resolve('late value') + await waitFor(() => output.state === HydrationScriptOutputState.Ready) + const hydration = drainHydrationOutput(output) + expect(hydration).toContain('late value') + expect(hydration).toContain('$_TSR.e()') + expect(output.state).toBe(HydrationScriptOutputState.Done) + + serverSsr.cleanup() + }) + test('render-finished listeners can synchronously finish serialization', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) @@ -147,36 +374,56 @@ describe('serverSsr.cleanup', () => { await router.load() await router.serverSsr!.dehydrate() - router.serverSsr!.takeBufferedScripts() + router.serverSsr!.takeInitialHydrationScriptTags() + const output = router.serverSsr!.hydrationScripts.claimOutput() router.serverSsr!.onRenderFinished(() => { value.resolve('done') }) - const serializationDone = new Promise((resolve) => { - router.serverSsr!.onSerializationFinished(resolve) - }) router.serverSsr!.setRenderFinished() - await serializationDone + await waitFor(() => output.state === HydrationScriptOutputState.Ready) - expect(router.serverSsr!.takeBufferedHtml()).toContain('$_TSR.e()') + expect(drainHydrationOutput(output)).toContain('$_TSR.e()') + expect(output.state).toBe(HydrationScriptOutputState.Done) router.serverSsr?.cleanup() }) - test('late serialization listener runs safely and returns unsubscribe', async () => { + test('render-finished is one-shot and invokes late listeners immediately', () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! + const earlyListener = vi.fn() + const lateListener = vi.fn() + + serverSsr.onRenderFinished(earlyListener) + serverSsr.setRenderFinished() + serverSsr.setRenderFinished() + serverSsr.onRenderFinished(lateListener) + serverSsr.setRenderFinished() + + expect(earlyListener).toHaveBeenCalledOnce() + expect(lateListener).toHaveBeenCalledOnce() + serverSsr.cleanup() + }) + + test('a late output claim exposes completed serialization', async () => { const router = buildRouter() attachRouterServerSsrUtils({ router, manifest: undefined }) await router.load() await router.serverSsr!.dehydrate() + router.serverSsr!.takeInitialHydrationScriptTags() + const output = router.serverSsr!.hydrationScripts.claimOutput() let calls = 0 - const unsubscribe = router.serverSsr!.onSerializationFinished(() => { + const unsubscribe = output.subscribe(() => { calls++ }) - expect(calls).toBe(1) + expect(output.state).toBe(HydrationScriptOutputState.Done) + expect(calls).toBe(0) expect(() => unsubscribe()).not.toThrow() router.serverSsr?.cleanup() }) @@ -187,16 +434,129 @@ describe('serverSsr.cleanup', () => { await router.load() await router.serverSsr!.dehydrate() - const barrierScript = router.serverSsr!.takeBufferedScripts() - expect(barrierScript).toBeDefined() + const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(initialScripts?.boundary.children).toBe( + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + ) + expect(initialScripts?.boundary.attrs).not.toHaveProperty('id') + expect(router.serverSsr!.hydrationScripts.reserveFastPath()).toBe(true) + let renderFinishedCalls = 0 + router.serverSsr!.onRenderFinished(() => { + renderFinishedCalls++ + }) router.serverSsr!.setRenderFinished() - expect(router.serverSsr!.reserveStreamFastPath()).toBe(true) - expect(router.serverSsr!.reserveStreamFastPath()).toBe(false) + expect(renderFinishedCalls).toBe(1) + expect(router.serverSsr!.hydrationScripts.reserveFastPath()).toBe(false) router.serverSsr?.cleanup() }) + test('initial boundary ends with the exact scanner suffix', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + await router.serverSsr!.dehydrate() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags()! + const boundaryHtml = renderInitialScripts({ + before: [], + boundary: scripts.boundary, + }) + + expect(boundaryHtml).toBe( + ``, + ) + expect(boundaryHtml.endsWith(HYDRATION_SCRIPT_BOUNDARY_SUFFIX)).toBe(true) + + router.serverSsr?.cleanup() + }) + + test('the final hydration tag cleans adjacent parts before adapter scripts', () => { + const hydrationScripts = createHydrationScripts(undefined, ['void 0']) + const scripts = hydrationScripts.takeInitialHydrationScriptTags()! + const cleanupTag = scripts.before.at(-1)! + const nodes: Array = [] + const createNode = (marked: boolean) => { + const node = { + hasAttribute(name: string) { + return marked && name === 'data-tsr-stream-part' + }, + remove() { + nodes.splice(nodes.indexOf(this), 1) + }, + } + Object.defineProperty(node, 'previousElementSibling', { + get() { + return nodes[nodes.indexOf(node) - 1] ?? null + }, + }) + return node + } + const olderPart = createNode(true) + const unrelated = createNode(false) + const firstPart = createNode(true) + const cleanup = createNode(true) + nodes.push(olderPart, unrelated, firstPart, cleanup) + + runInNewContext(cleanupTag.children!, { + document: { currentScript: cleanup }, + }) + + const routeScript = createNode(false) + const assetScript = createNode(false) + nodes.push(routeScript, assetScript) + + expect(nodes).toEqual([olderPart, unrelated, routeScript, assetScript]) + hydrationScripts.cleanup() + }) + + test('a failed hydration tag leaves its transport node', () => { + for (const brokenSource of [ + 'throw new Error("runtime failure")', + 'const = "syntax failure"', + ]) { + const hydrationScripts = createHydrationScripts(undefined, [brokenSource]) + const scripts = hydrationScripts.takeInitialHydrationScriptTags()! + const nodes: Array<{ remove: () => void }> = [] + const part = { + previousElementSibling: null, + hasAttribute: () => true, + remove() { + nodes.splice(nodes.indexOf(this), 1) + }, + } + nodes.push(part) + + expect(() => + runInNewContext(scripts.before[0]!.children!, { + document: { currentScript: part }, + }), + ).toThrow() + expect(nodes).toEqual([part]) + hydrationScripts.cleanup() + } + }) + + test('the boundary removes only itself when there are no initial parts', () => { + const hydrationScripts = createHydrationScripts(undefined, []) + const scripts = hydrationScripts.takeInitialHydrationScriptTags()! + const nodes: Array<{ remove: () => void }> = [] + const boundary = { + remove() { + nodes.splice(nodes.indexOf(this), 1) + }, + } + nodes.push(boundary) + + runInNewContext(scripts.boundary.children!, { + document: { currentScript: boundary }, + }) + + expect(nodes).toEqual([]) + hydrationScripts.cleanup() + }) + test('stream fast path rejects while SSR work is pending', async () => { const value = deferred() const router = buildRouter({ value: value.promise }) @@ -205,49 +565,228 @@ describe('serverSsr.cleanup', () => { await router.load() await router.serverSsr!.dehydrate() - expect(router.serverSsr!.reserveStreamFastPath()).toBe(false) - const barrierScript = router.serverSsr!.takeBufferedScripts() - expect(barrierScript).toBeDefined() - expect(router.serverSsr!.reserveStreamFastPath()).toBe(false) + expect(router.serverSsr!.hydrationScripts.reserveFastPath()).toBe(false) + const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(initialScripts?.boundary.children).toBe( + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + ) + expect(initialScripts?.boundary.attrs).not.toHaveProperty('id') + expect(router.serverSsr!.hydrationScripts.reserveFastPath()).toBe(false) - const serializationDone = new Promise((resolve) => { - router.serverSsr!.onSerializationFinished(resolve) - }) value.resolve('done') - await serializationDone + await Promise.resolve() + await Promise.resolve() - expect(router.serverSsr!.reserveStreamFastPath()).toBe(false) + expect(router.serverSsr!.hydrationScripts.reserveFastPath()).toBe(false) + const output = router.serverSsr!.hydrationScripts.claimOutput() router.serverSsr!.setRenderFinished() - expect(router.serverSsr!.reserveStreamFastPath()).toBe(false) - expect(router.serverSsr!.takeBufferedHtml()).toContain(' output.state === HydrationScriptOutputState.Ready) + const hydration = drainHydrationOutput(output) + expect(hydration).toContain('done') + expect(output.state).toBe(HydrationScriptOutputState.Done) + expect(router.serverSsr!.hydrationScripts.reserveFastPath(output)).toBe( + true, + ) + expect(router.serverSsr!.hydrationScripts.reserveFastPath(output)).toBe( + false, + ) router.serverSsr?.cleanup() }) - test('throwing injected listener does not skip later listeners', () => { + test('an initial serialization error fails hydration output', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const router = buildRouter() + const router = buildRouter({ unsupported: () => {} }) + try { attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await expect(router.serverSsr!.dehydrate()).rejects.toBeInstanceOf(Error) - const calls: Array = [] - router.serverSsr!.onInjectedHtml(() => { - calls.push('a') - throw new Error('boom') - }) - router.serverSsr!.onInjectedHtml(() => { - calls.push('b') - }) + expect(errorSpy).toHaveBeenCalledWith( + 'Serialization error:', + expect.any(Error), + ) + } finally { + router.serverSsr?.cleanup() + errorSpy.mockRestore() + } + }) - router.serverSsr!.injectHtml('') + test('an invalid deferred value fails instead of leaving hydration pending', async () => { + const invalidValue = deferred() + const validValue = deferred() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const router = buildRouter({ + invalidValue: invalidValue.promise, + validValue: validValue.promise, + }) + + try { + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr!.dehydrate() + router.serverSsr!.takeInitialHydrationScriptTags() + const output = router.serverSsr!.hydrationScripts.claimOutput() + router.serverSsr!.hydrationScripts.liftBarrier() + invalidValue.resolve(() => {}) + await waitFor(() => output.state === HydrationScriptOutputState.Failed) + + expect(output.error).toBeInstanceOf(Error) + expect(errorSpy).toHaveBeenCalledWith( + 'Serialization error:', + output.error, + ) - expect(calls).toEqual(['a', 'b']) + validValue.resolve('ignored') + await Promise.resolve() + await Promise.resolve() + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect(router.serverSsr!.hydrationScripts.reserveFastPath(output)).toBe( + false, + ) } finally { router.serverSsr?.cleanup() errorSpy.mockRestore() } }) + test('a rejected hydration source stops serialization before stream creation', async () => { + const lateValue = deferred() + const router = buildRouter({ lateValue: lateValue.promise }) + const onCleanup = vi.fn() + + try { + attachRouterServerSsrUtils({ router, manifest: undefined }) + router.serverSsr!.onCleanup(onCleanup) + await router.load() + const scripts = router.serverSsr!.hydrationScripts as HydrationScripts + const backlogError = new Error('hydration backlog full') + const pushSerializedSource = vi + .spyOn(scripts, 'pushSerializedSource') + .mockImplementation(() => { + scripts.fail(backlogError) + return false + }) + + await router.serverSsr!.dehydrate() + + const output = scripts.claimOutput() + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect(output.error).toBe(backlogError) + expect(pushSerializedSource).toHaveBeenCalledOnce() + + lateValue.resolve('ignored') + await Promise.resolve() + await Promise.resolve() + expect(pushSerializedSource).toHaveBeenCalledOnce() + router.serverSsr!.cleanup() + expect(onCleanup).toHaveBeenCalledExactlyOnceWith(false) + } finally { + router.serverSsr?.cleanup() + } + }) + + test('a hydration backlog failure aborts pending loader work during cleanup', async () => { + const pending = deferred() + const onCleanup = vi.fn() + const cancelSource = vi.fn() + let loaderSignal: AbortSignal | undefined + let chunks = 0 + const source = new ReadableStream( + { + pull(controller) { + // Bound the producer even if backlog protection regresses. + if (chunks++ < 5_000) { + controller.enqueue(Uint8Array.of(1)) + } + }, + cancel: cancelSource, + }, + { highWaterMark: 0 }, + ) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: ({ abortController }) => { + loaderSignal = abortController.signal + return { pending: pending.promise, stream: new RawStream(source) } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! + serverSsr.onCleanup(onCleanup) + + try { + await router.load() + await serverSsr.dehydrate() + // The renderer has not returned its stream, so nothing drains the backlog. + const output = serverSsr.hydrationScripts.claimOutput() + await vi.waitFor(() => { + expect(output.state).toBe(HydrationScriptOutputState.Failed) + expect(cancelSource).toHaveBeenCalledOnce() + }) + expect(String(output.error)).toContain( + 'backlog exceeded maximum source-part count', + ) + expect(loaderSignal?.aborted).toBe(false) + + serverSsr.cleanup() + + expect(onCleanup).toHaveBeenCalledExactlyOnceWith(false) + expect(loaderSignal?.aborted).toBe(true) + expect(source.locked).toBe(false) + expect(router.serverSsr).toBeUndefined() + expect(router.ssr).toBeUndefined() + } finally { + serverSsr.cleanup() + pending.resolve('done') + if (!source.locked) { + await source.cancel() + } + } + }) + + test('the hydration channel permits only one subscriber', () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const output = router.serverSsr!.hydrationScripts.claimOutput() + const unsubscribe = output.subscribe(() => {}) + + expect(() => output.subscribe(() => {})).toThrow('already has a subscriber') + unsubscribe() + expect(() => output.subscribe(() => {})).not.toThrow() + router.serverSsr!.cleanup() + }) + + test('the claimed output receives scripts buffered before the claim', async () => { + const value = deferred() + const router = buildRouter({ value: value.promise }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr!.dehydrate() + router.serverSsr!.takeInitialHydrationScriptTags() + + router.serverSsr!.hydrationScripts.liftBarrier() + value.resolve('buffered') + await Promise.resolve() + await Promise.resolve() + + const output = router.serverSsr!.hydrationScripts.claimOutput() + + expect(output.state).toBe(HydrationScriptOutputState.Ready) + const hydration = drainHydrationOutput(output) + expect(hydration).toContain('buffered') + expect(hydration).toContain('$_TSR.e()') + router.serverSsr!.cleanup() + }) + test('server SSR attach lifecycle runs listeners at attach time', () => { const router = buildRouter() const calls: Array = [] @@ -267,9 +806,9 @@ describe('serverSsr.cleanup', () => { expect(calls).toEqual(['attach', 'cleanup']) }) - test('request handler cleans plain response body after drain', async () => { + test('request handler cleans SSR state before returning a plain body', async () => { const router = buildRouter() - let cleanupCalls = 0 + let cleanupEffects = 0 const handler = createRequestHandler({ createRouter: () => router, request: new Request('http://localhost/'), @@ -277,21 +816,19 @@ describe('serverSsr.cleanup', () => { const response = await handler(({ router: requestRouter }) => { const serverSsr = requestRouter.serverSsr! - const cleanup = serverSsr.cleanup - serverSsr.cleanup = () => { - cleanupCalls++ - cleanup() - } + serverSsr.onCleanup(() => { + cleanupEffects++ + }) return Promise.resolve(new Response('plain')) }) - expect(cleanupCalls).toBe(1) + expect(cleanupEffects).toBe(1) await response.text() - expect(cleanupCalls).toBe(1) + expect(cleanupEffects).toBe(1) expect(router.serverSsr).toBeUndefined() }) - test('request handler cleans plain response body on cancel', async () => { + test('request handler cleans SSR state before returning a plain stream', async () => { const router = buildRouter() let cleanupCalls = 0 const handler = createRequestHandler({ @@ -350,7 +887,7 @@ describe('serverSsr.cleanup', () => { const requestController = new AbortController() const renderStarted = deferred() const renderResult = deferred>() - let cleanupCalls = 0 + let cleanupEffects = 0 let cancelCalls = 0 let lateStreamResponse!: ReturnType const handler = createRequestHandler({ @@ -362,11 +899,9 @@ describe('serverSsr.cleanup', () => { const response = handler(({ router: requestRouter }) => { const serverSsr = requestRouter.serverSsr! - const cleanup = serverSsr.cleanup - serverSsr.cleanup = () => { - cleanupCalls++ - cleanup() - } + serverSsr.onCleanup(() => { + cleanupEffects++ + }) lateStreamResponse = createSsrStreamResponse( requestRouter, new Response( @@ -387,13 +922,13 @@ describe('serverSsr.cleanup', () => { requestController.abort(cancellation) await expect(response).rejects.toBe(cancellation) - expect(cleanupCalls).toBe(1) + expect(cleanupEffects).toBe(1) expect(router.serverSsr).toBeUndefined() renderResult.resolve(lateStreamResponse) await Promise.resolve() await Promise.resolve() - expect(cleanupCalls).toBe(1) + expect(cleanupEffects).toBe(1) expect(cancelCalls).toBe(1) expect(router.serverSsr).toBeUndefined() }) @@ -428,58 +963,6 @@ describe('serverSsr.cleanup', () => { }) }) - test.each(['throw', 'reject'] as const)( - 'reports a %s from disposal of a late render response', - async (failureMode) => { - const router = buildRouter() - const requestController = new AbortController() - const renderStarted = deferred() - const renderResult = deferred() - const cleanupError = new Error('late stream cleanup failed') - const dispose = vi.fn(() => { - if (failureMode === 'throw') { - throw cleanupError - } - return Promise.reject(cleanupError) - }) - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined) - const handler = createRequestHandler({ - createRouter: () => router, - request: new Request('http://localhost/', { - signal: requestController.signal, - }), - }) - onTestFinished(() => { - consoleError.mockRestore() - }) - onTestFinished(() => { - router.serverSsr?.cleanup() - }) - - const response = handler(() => { - renderStarted.resolve() - return renderResult.promise - }) - - await renderStarted.promise - const cancellation = new Error('request disconnected') - requestController.abort(cancellation) - await expect(response).rejects.toBe(cancellation) - - renderResult.resolve({ - response: new Response('stream'), - serverSsrCleanup: 'stream', - dispose, - }) - await vi.waitFor(() => { - expect(consoleError).toHaveBeenCalledWith(cleanupError) - }) - expect(dispose).toHaveBeenCalledOnce() - }, - ) - test('request abort disposes a stream after response handoff', async () => { const router = buildRouter() const requestController = new AbortController() @@ -522,53 +1005,11 @@ describe('serverSsr.cleanup', () => { expect(router.serverSsr).toBeUndefined() }) - test.each(['throw', 'reject'] as const)( - 'reports a custom stream disposal %s after request abort', - async (failureMode) => { - const router = buildRouter() - attachRouterServerSsrUtils({ router, manifest: undefined }) - let cleanupCalls = 0 - const cleanup = router.serverSsr!.cleanup - router.serverSsr!.cleanup = () => { - cleanupCalls++ - cleanup() - } - const cleanupError = new Error('custom stream cleanup failed') - const dispose = vi.fn(() => { - if (failureMode === 'throw') { - throw cleanupError - } - return Promise.reject(cleanupError) - }) - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined) - const requestController = new AbortController() - - bindSsrResponseToRequest( - router, - { - response: new Response('stream'), - serverSsrCleanup: 'stream', - dispose, - }, - requestController.signal, - ) - requestController.abort(new Error('request disconnected')) - - await vi.waitFor(() => { - expect(consoleError).toHaveBeenCalledWith(cleanupError) - }) - expect(dispose).toHaveBeenCalledOnce() - expect(cleanupCalls).toBe(1) - expect(router.serverSsr).toBeUndefined() - }, - ) - test('request handler defers cleanup for stream response metadata', async () => { const router = buildRouter() let cleanupCalls = 0 let controller!: ReadableStreamDefaultController + let initialScriptHtml = '' const handler = createRequestHandler({ createRouter: () => router, request: new Request('http://localhost/'), @@ -581,12 +1022,18 @@ describe('serverSsr.cleanup', () => { cleanupCalls++ cleanup() } + const initialScripts = serverSsr.takeInitialHydrationScriptTags() + expect(initialScripts?.boundary.children).toBe( + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + ) + expect(initialScripts?.boundary.attrs).not.toHaveProperty('id') + initialScriptHtml = renderInitialScripts(initialScripts!) const appStream = new ReadableStream({ start(c) { controller = c }, }) - const responseStream = transformStreamWithRouter( + const responseStream = transformReadableStreamWithRouter( requestRouter, appStream as any, ) @@ -598,10 +1045,334 @@ describe('serverSsr.cleanup', () => { }) expect(cleanupCalls).toBe(0) - controller.enqueue(new TextEncoder().encode('ok')) + controller.enqueue( + new TextEncoder().encode( + `${initialScriptHtml}ok`, + ), + ) controller.close() await response.text() expect(cleanupCalls).toBe(1) expect(router.serverSsr).toBeUndefined() }) + + test('external cleanup releases a discarded stream transform immediately', async () => { + // Mirrors a request handler that discards a stream response (for example a + // middleware throwing after the handler returned): the bare cleanup() in + // its finally block must tear the transform down at once instead of + // leaving the renderer pinned until the lifetime timer. + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! + serverSsr.takeInitialHydrationScriptTags() + + const upstreamCancelled = { value: false, reason: undefined as unknown } + const appStream = new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode('')) + }, + cancel(reason) { + upstreamCancelled.value = true + upstreamCancelled.reason = reason + }, + }) + const aborts: Array = [] + const responseStream = transformReadableStreamWithRouter( + router, + appStream as any, + { + onAbort: (reason) => aborts.push(reason), + }, + ) + + // The response is never read and never cancelled. + serverSsr.cleanup() + + expect(upstreamCancelled.value).toBe(true) + expect((upstreamCancelled.reason as Error).name).toBe('AbortError') + expect(aborts).toHaveLength(1) + expect(router.serverSsr).toBeUndefined() + await expect(responseStream.getReader().read()).rejects.toMatchObject({ + name: 'AbortError', + }) + }) + + test('concurrent dehydrate calls throw instead of double-serializing', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! + + const first = serverSsr.dehydrate() + await expect(serverSsr.dehydrate()).rejects.toThrow( + 'router is already dehydrated', + ) + await first + await expect(serverSsr.dehydrate()).rejects.toThrow( + 'router is already dehydrated', + ) + serverSsr.cleanup() + }) + + test('onCleanup after cleanup invokes the listener immediately', () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! + serverSsr.cleanup() + + const listener = vi.fn() + serverSsr.onCleanup(listener) + expect(listener).toHaveBeenCalledTimes(1) + }) + + test('a late onCleanup listener that throws is contained', () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! + serverSsr.cleanup() + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => + serverSsr.onCleanup(() => { + throw new Error('late-listener-boom') + }), + ).not.toThrow() + expect(errorSpy).toHaveBeenCalledWith( + 'Error in SSR cleanup listener:', + expect.objectContaining({ message: 'late-listener-boom' }), + ) + } finally { + errorSpy.mockRestore() + } + }) + + test('binding a stream response after cleanup still observes request abort', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const cancel = vi.fn() + const streamResponse = createSsrStreamResponse( + router, + new Response(new ReadableStream({ cancel })), + ) + const dispose = vi.spyOn(streamResponse, 'dispose') + router.serverSsr!.cleanup() + + const controller = new AbortController() + const addSpy = vi.spyOn(controller.signal, 'addEventListener') + const result = bindSsrResponseToRequest( + router, + streamResponse, + controller.signal, + ) + + expect(result.serverSsrCleanup).toBe('stream') + expect(addSpy).toHaveBeenCalledOnce() + + const reason = new Error('request disconnected') + controller.abort(reason) + await Promise.resolve() + + expect(dispose).toHaveBeenCalledExactlyOnceWith(reason) + expect(cancel).toHaveBeenCalledExactlyOnceWith(reason) + }) + + test.each(['pass-through', 'merge'])( + 'shares the %s transform abort listener with final response disposal', + async (mode) => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! + if (mode === 'pass-through') { + serverSsr.disableHydration() + } + const cleanup = vi.fn() + serverSsr.onCleanup(cleanup) + const requestAbort = new AbortController() + const add = vi.spyOn(requestAbort.signal, 'addEventListener') + const remove = vi.spyOn(requestAbort.signal, 'removeEventListener') + const cancelSource = vi.fn(() => new Promise(() => {})) + const source = new ReadableStream({ cancel: cancelSource }) + const transformed = transformReadableStreamWithRouter(router, source, { + signal: requestAbort.signal, + }) + const original = createSsrStreamResponse( + router, + new Response(transformed), + ) + const cancelFinal = vi.fn(() => new Promise(() => {})) + const response = new Response(new ReadableStream({ cancel: cancelFinal })) + const dispose = vi.fn((reason?: unknown): undefined => { + original.dispose(reason) + void response.body!.cancel(reason).catch(() => {}) + }) + bindSsrResponseToRequest( + router, + { ...original, response, dispose }, + requestAbort.signal, + ) + + expect(add).toHaveBeenCalledOnce() + const reason = new Error('request disconnected') + const reader = transformed.getReader() + const rejected = expect(reader.read()).rejects.toBe(reason) + requestAbort.abort(reason) + await rejected + + expect(dispose).toHaveBeenCalledExactlyOnceWith(reason) + expect(cancelFinal).toHaveBeenCalledExactlyOnceWith(reason) + expect(cancelSource).toHaveBeenCalledExactlyOnceWith(reason) + expect(source.locked).toBe(false) + expect(cleanup).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + reader.releaseLock() + }, + ) + + test('successful transform completion removes abort handling without disposing the response', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + router.serverSsr!.disableHydration() + const requestAbort = new AbortController() + const add = vi.spyOn(requestAbort.signal, 'addEventListener') + const remove = vi.spyOn(requestAbort.signal, 'removeEventListener') + const cancel = vi.fn() + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('done')) + controller.close() + }, + cancel, + }) + const transformed = transformReadableStreamWithRouter(router, source, { + signal: requestAbort.signal, + }) + const result = createSsrStreamResponse(router, new Response(transformed)) + const dispose = vi.spyOn(result, 'dispose') + bindSsrResponseToRequest(router, result, requestAbort.signal) + + expect(await result.response.text()).toBe('done') + requestAbort.abort(new Error('request ended after completion')) + expect(add).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledOnce() + expect(dispose).not.toHaveBeenCalled() + expect(cancel).not.toHaveBeenCalled() + expect(source.locked).toBe(false) + expect(router.serverSsr).toBeUndefined() + }) + + test.each(['custom response', 'no signal', 'different signal'])( + 'keeps request binding for a %s', + async (mode) => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + router.serverSsr!.disableHydration() + const requestAbort = new AbortController() + const add = vi.spyOn(requestAbort.signal, 'addEventListener') + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + const body = + mode === 'custom response' + ? source + : transformReadableStreamWithRouter(router, source, { + signal: + mode === 'different signal' + ? new AbortController().signal + : undefined, + }) + const result = createSsrStreamResponse(router, new Response(body)) + const dispose = vi.spyOn(result, 'dispose') + bindSsrResponseToRequest(router, result, requestAbort.signal) + const reason = new Error('request disconnected') + requestAbort.abort(reason) + await Promise.resolve() + + expect(add).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledExactlyOnceWith(reason) + expect(cancel).toHaveBeenCalledOnce() + expect(source.locked).toBe(false) + expect(router.serverSsr).toBeUndefined() + }, + ) + + test('already-aborted response handoff disposes immediately without adding a listener', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const cancel = vi.fn() + const result = createSsrStreamResponse( + router, + new Response(new ReadableStream({ cancel })), + ) + const dispose = vi.spyOn(result, 'dispose') + const requestAbort = new AbortController() + const reason = new Error('request already disconnected') + requestAbort.abort(reason) + const add = vi.spyOn(requestAbort.signal, 'addEventListener') + bindSsrResponseToRequest(router, result, requestAbort.signal) + await Promise.resolve() + + expect(add).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledExactlyOnceWith(reason) + expect(cancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(router.serverSsr).toBeUndefined() + }) + + test('disabled hydration streams through the fast path without a boundary', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const serverSsr = router.serverSsr! + serverSsr.disableHydration() + + // No boundary anywhere in the document. + const html = '
static page
' + const appStream = new ReadableStream({ + start(c) { + c.enqueue(new TextEncoder().encode(html)) + c.close() + }, + }) + + const responseStream = transformReadableStreamWithRouter( + router, + appStream as any, + ) + const text = await new Response(responseStream as any).text() + + expect(text).toBe(html) + expect(text).not.toContain('$tsr-stream-boundary') + expect(text).not.toContain('data-tsr-stream-part') + expect(router.serverSsr).toBeUndefined() + }) + + test('disabled hydration renders strings through the eager fast path', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + router.serverSsr!.disableHydration() + + const html = 'no hydration' + const text = await transformHtmlStringWithRouter(router, html) + + expect(text).toBe('' + html) + expect(router.serverSsr).toBeUndefined() + }) + + test('disableHydration and dehydrate are mutually exclusive', async () => { + const disabledFirst = buildRouter() + attachRouterServerSsrUtils({ router: disabledFirst, manifest: undefined }) + disabledFirst.serverSsr!.disableHydration() + // Idempotent second call. + expect(() => disabledFirst.serverSsr!.disableHydration()).not.toThrow() + await expect(disabledFirst.serverSsr!.dehydrate()).rejects.toThrow( + 'hydration is disabled for this request', + ) + disabledFirst.serverSsr!.cleanup() + + const dehydratedFirst = buildRouter() + attachRouterServerSsrUtils({ router: dehydratedFirst, manifest: undefined }) + await dehydratedFirst.serverSsr!.dehydrate() + expect(() => dehydratedFirst.serverSsr!.disableHydration()).toThrow( + 'cannot disable hydration after dehydrate()', + ) + dehydratedFirst.serverSsr!.cleanup() + }) }) diff --git a/packages/router-core/tests/ssr-server-manifest.test.ts b/packages/router-core/tests/ssr-server-manifest.test.ts index 6cdc98a87b7..e22e82e7d59 100644 --- a/packages/router-core/tests/ssr-server-manifest.test.ts +++ b/packages/router-core/tests/ssr-server-manifest.test.ts @@ -1,15 +1,18 @@ -import { createMemoryHistory } from '@tanstack/history' import { runInNewContext } from 'node:vm' +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' -import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { GLOBAL_TSR } from '../src/ssr/constants' +import { HYDRATION_SCRIPT_BOUNDARY_SOURCE } from '../src/ssr/hydrationScripts' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { createTestRouter } from './routerTestUtils' -import { describe, expect, test } from 'vitest' +import type { AnyRouter } from '../src' import type { ManifestCssLink, ManifestRouteAssets, ServerManifest, } from '../src/manifest' +import type { InitialHydrationScriptTags } from '../src/ssr/hydrationScripts' import type { DehydratedRouter } from '../src/ssr/types' function buildRouter() { @@ -79,14 +82,16 @@ async function dehydrateManifest() { await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.tag).toBe('script') - expect(script?.children).toBeTruthy() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.boundary.children).toBe(HYDRATION_SCRIPT_BOUNDARY_SOURCE) + expect(scripts?.boundary.attrs).not.toHaveProperty('id') - return parseSerializedRouter(script!.children!).manifest! + return parseSerializedRouter(scripts!).manifest! } -function parseSerializedRouter(serialized: string): DehydratedRouter { +function parseSerializedRouter( + scripts: InitialHydrationScriptTags, +): DehydratedRouter { const context: Record = { document: { currentScript: { @@ -96,7 +101,17 @@ function parseSerializedRouter(serialized: string): DehydratedRouter { } context.self = context - runInNewContext(serialized, context) + expect(scripts.boundary.children).toBe(HYDRATION_SCRIPT_BOUNDARY_SOURCE) + expect(scripts.boundary.attrs).not.toHaveProperty('id') + + const streamParts = scripts.before + expect(streamParts.length).toBeGreaterThan(0) + for (const script of streamParts) { + expect(script.tag).toBe('script') + expect(script.attrs?.['data-tsr-stream-part']).toBe('') + expect(script.children).toBeTruthy() + runInNewContext(script.children!, context) + } const router = context[GLOBAL_TSR]?.router expect(router).toBeDefined() @@ -104,6 +119,40 @@ function parseSerializedRouter(serialized: string): DehydratedRouter { } describe('attachRouterServerSsrUtils manifest dehydration', () => { + test.each([ + { label: 'false', value: false, expectedProperty: true }, + { label: 'zero', value: 0, expectedProperty: true }, + { label: 'an empty string', value: '', expectedProperty: true }, + { label: 'null', value: null, expectedProperty: true }, + { label: 'undefined', value: undefined, expectedProperty: false }, + ])( + 'preserves $label custom dehydration values', + async ({ value, expectedProperty }) => { + const router: AnyRouter = buildRouter() + router.options.dehydrate = () => value + attachRouterServerSsrUtils({ router, manifest: undefined }) + + try { + await router.load() + await router.serverSsr!.dehydrate() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + const dehydratedRouter = parseSerializedRouter(scripts!) + + expect( + Object.prototype.hasOwnProperty.call( + dehydratedRouter, + 'dehydratedData', + ), + ).toBe(expectedProperty) + if (expectedProperty) { + expect(dehydratedRouter.dehydratedData).toBe(value) + } + } finally { + router.serverSsr?.cleanup() + } + }, + ) + test('omits unmatched route assets by default', async () => { const manifest = await dehydrateManifest() @@ -126,11 +175,8 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - const dehydratedManifest = parseSerializedRouter( - script!.children!, - ).manifest! + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + const dehydratedManifest = parseSerializedRouter(scripts!).manifest! expect(dehydratedManifest.scriptFormat).toBe('iife') }) @@ -233,11 +279,8 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { }, }) - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - const dehydratedManifest = parseSerializedRouter( - script!.children!, - ).manifest! + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + const dehydratedManifest = parseSerializedRouter(scripts!).manifest! expect(dehydratedManifest.routes.__root__).toMatchObject({ preloads: [ @@ -280,11 +323,8 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { }, }) - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - const dehydratedManifest = parseSerializedRouter( - script!.children!, - ).manifest! + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + const dehydratedManifest = parseSerializedRouter(scripts!).manifest! expect(dehydratedManifest.routes.__root__?.preloads).toEqual([ { href: '/assets/rsc-client.js', crossOrigin: 'anonymous' }, @@ -310,9 +350,8 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - const dehydratedRouter = parseSerializedRouter(script!.children!) + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + const dehydratedRouter = parseSerializedRouter(scripts!) const dehydratedManifest = dehydratedRouter.manifest! const rootInlineCss = dehydratedManifest.inlineStyle const allLinks = Object.values(dehydratedManifest.routes).flatMap( @@ -337,6 +376,73 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { ]) }) + test('memoizes prepared inline CSS while composing request assets freshly', async () => { + const router = buildRouter() + const manifest = buildInlineManifest() + const requestAssets: ManifestRouteAssets = { + preloads: ['/assets/request.js'], + } + + attachRouterServerSsrUtils({ + router, + manifest, + getRequestAssets: () => requestAssets, + }) + + await router.load() + + const first = router.ssr!.manifest + const second = router.ssr!.manifest + // The composed manifest is fresh because request assets are mutable, while + // the immutable inline CSS preparation is reused for this route set. + expect(second).not.toBe(first) + expect(second?.inlineStyle).toBe(first?.inlineStyle) + expect(first?.inlineStyle).toMatchObject({ + children: '.shared{color:red}', + }) + expect(first?.routes.__root__?.preloads).toEqual([ + '/assets/request.js', + '/assets/root.js', + ]) + }) + + test('refreshes the composed manifest when a stable request-assets object changes', async () => { + const router = buildRouter() + const manifest = buildManifest() + const requestAssets: ManifestRouteAssets = { + preloads: ['/assets/discovered-early.js'], + } + + attachRouterServerSsrUtils({ + router, + manifest, + getRequestAssets: () => requestAssets, + }) + + try { + await router.load() + + const beforeDiscovery = router.ssr!.manifest + expect(beforeDiscovery?.routes.__root__?.preloads).toEqual([ + '/assets/discovered-early.js', + '/assets/root.js', + ]) + + // React Start RSC retains this object and replaces these members as it + // discovers client references during the request. + requestAssets.preloads = ['/assets/discovered-late.js'] + + const afterDiscovery = router.ssr!.manifest + expect(afterDiscovery).not.toBe(beforeDiscovery) + expect(afterDiscovery?.routes.__root__?.preloads).toEqual([ + '/assets/discovered-late.js', + '/assets/root.js', + ]) + } finally { + router.serverSsr?.cleanup() + } + }) + test('strips only inlinable stylesheet links from dehydrated manifest data', async () => { const router = buildRouter() const manifest: ServerManifest = { @@ -382,11 +488,8 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - const dehydratedManifest = parseSerializedRouter( - script!.children!, - ).manifest! + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + const dehydratedManifest = parseSerializedRouter(scripts!).manifest! expect(dehydratedManifest.routes.__root__?.css).toEqual([ { @@ -445,11 +548,9 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { expect(router.ssr!.manifest?.inlineStyle?.children).toBe('.root{}.parent{}') await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - const dehydratedManifest = parseSerializedRouter( - script!.children!, - ).manifest! + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts).toBeDefined() + const dehydratedManifest = parseSerializedRouter(scripts!).manifest! expect(dehydratedManifest.routes[childRoute.id]).toBeUndefined() }) diff --git a/packages/router-core/tests/transformStreamBackpressure.perf.test.ts b/packages/router-core/tests/transformStreamBackpressure.perf.test.ts index 267241f4164..eed454ee751 100644 --- a/packages/router-core/tests/transformStreamBackpressure.perf.test.ts +++ b/packages/router-core/tests/transformStreamBackpressure.perf.test.ts @@ -1,19 +1,24 @@ -// On-demand backpressure test for transformStreamWithRouter. +// On-demand backpressure test for transformReadableStreamWithRouter. // // NOT run in CI by default. To execute: // RUN_BACKPRESSURE_PERF=1 pnpm --filter @tanstack/router-core test:unit -- transformStreamBackpressure // // Validates fix for TanStack/router#7402 // without a backpressure gate the read loop in -// transformStreamWithRouter calls controller.enqueue unconditionally, +// transformReadableStreamWithRouter calls controller.enqueue unconditionally, // allowing controller.[[queue]] to accumulate Uint8Array chunks (external // memory) when the consumer drains slower than the producer. -import { ReadableStream } from 'node:stream/web' import { describe, expect, it } from 'vitest' -import { transformStreamWithRouter } from '../src/ssr/transformStreamWithRouter' +import { + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + HydrationScriptOutputState, +} from '../src/ssr/hydrationScripts' +import { transformReadableStreamWithRouter } from '../src/ssr/transformStreamWithRouter' +import type { HydrationScriptOutput } from '../src/ssr/hydrationScripts' const enabled = process.env.RUN_BACKPRESSURE_PERF === '1' const requiresGc = typeof (globalThis as any).gc === 'function' +const SCRIPT_BARRIER_HTML = `` function createFastProducer( chunkCount: number, @@ -49,242 +54,211 @@ function createFastProducer( } function makeRouter() { + const output: HydrationScriptOutput = { + state: HydrationScriptOutputState.Done, + error: undefined, + pullChunk() { + throw new Error('Finished hydration output cannot be pulled') + }, + subscribe() { + return () => {} + }, + } return { serverSsr: { - isSerializationFinished: () => true, - reserveStreamFastPath: () => true, - onInjectedHtml: () => () => {}, - onSerializationFinished: () => () => {}, - takeBufferedHtml: () => undefined, + hydrationScripts: { + reserveFastPath: () => true, + claimOutput: () => output, + liftBarrier: () => {}, + isInitialTaken: () => true, + startSerializationTimeout: () => {}, + }, setRenderFinished: () => {}, + onCleanup: () => {}, cleanup: () => {}, }, } as any } // Main-path router fake: serialization NOT finished synchronously, so the -// transform runs the full scanner + pending HTML/scripts path. Exposes -// hooks to inject HTML and signal serialization finished. +// transform runs the full scanner + script merger path. Exposes hooks to +// emit router script batches and signal serialization finished. function makeMainPathRouter(): { router: any - injectHtml: (html: string) => void finishSerialization: () => void } { - let buffered = '' - const injectedListeners: Array<() => void> = [] - const serializationListeners: Array<() => void> = [] + let state: HydrationScriptOutput['state'] = HydrationScriptOutputState.Waiting + let listener: (() => void) | undefined + const output: HydrationScriptOutput = { + get state() { + return state + }, + error: undefined, + pullChunk() { + throw new Error('No hydration output is ready') + }, + subscribe(onChange) { + listener = onChange + return () => { + if (listener === onChange) { + listener = undefined + } + } + }, + } const router: any = { serverSsr: { - isSerializationFinished: () => false, - reserveStreamFastPath: () => false, - onInjectedHtml: (cb: () => void) => { - injectedListeners.push(cb) - return () => { - const i = injectedListeners.indexOf(cb) - if (i >= 0) injectedListeners.splice(i, 1) - } - }, - onSerializationFinished: (cb: () => void) => { - serializationListeners.push(cb) - return () => { - const i = serializationListeners.indexOf(cb) - if (i >= 0) serializationListeners.splice(i, 1) - } - }, - takeBufferedHtml: () => { - const v = buffered - buffered = '' - return v || undefined + hydrationScripts: { + reserveFastPath: () => false, + claimOutput: () => output, + liftBarrier: () => {}, + isInitialTaken: () => true, + startSerializationTimeout: () => {}, }, setRenderFinished: () => {}, + onCleanup: () => {}, cleanup: () => {}, - liftScriptBarrier: () => {}, }, } return { router, - injectHtml: (html) => { - buffered += html - for (const l of injectedListeners) l() - }, finishSerialization: () => { - for (const l of serializationListeners) l() + state = HydrationScriptOutputState.Done + listener?.() }, } } -describe.runIf(enabled)('transformStreamWithRouter backpressure', () => { - it('does not run producer arbitrarily ahead of slow consumer', async () => { - const CHUNKS = 200 - const CHUNK_BYTES = 8 * 1024 // 8KB per chunk - const producer = createFastProducer(CHUNKS, CHUNK_BYTES) - const router = makeRouter() - - const out = transformStreamWithRouter(router, producer.stream) - - const reader = out.getReader() - let consumed = 0 - let maxLead = 0 - while (true) { - // Throttled consumer: ~5ms per chunk. - await new Promise((r) => setTimeout(r, 5)) - const { done } = await reader.read() - if (done) break - consumed++ - const lead = producer.getProduced() - consumed - if (lead > maxLead) maxLead = lead - } - - // Producer should never be more than a small constant number of chunks - // ahead of the consumer. ReadableStream default HWM is 1 for byte - // streams; allow generous slack for scheduling jitter. - expect(maxLead).toBeLessThanOrEqual(8) - expect(consumed).toBe(CHUNKS) - }) - - it('bounded external memory under sustained load', async () => { - expect(requiresGc, 'Run with node --expose-gc').toBe(true) - const CHUNKS = 1000 - const CHUNK_BYTES = 16 * 1024 - const producer = createFastProducer(CHUNKS, CHUNK_BYTES, { - freshChunks: true, - }) - const router = makeRouter() +describe.runIf(enabled)( + 'transformReadableStreamWithRouter backpressure', + () => { + it('does not run producer arbitrarily ahead of slow consumer', async () => { + const CHUNKS = 200 + const CHUNK_BYTES = 8 * 1024 // 8KB per chunk + const producer = createFastProducer(CHUNKS, CHUNK_BYTES) + const router = makeRouter() - const out = transformStreamWithRouter(router, producer.stream) - const reader = out.getReader() + const out = transformReadableStreamWithRouter(router, producer.stream) - let consumed = 0 - let peakExternal = 0 - const baseline = process.memoryUsage().external - while (true) { - await new Promise((r) => setTimeout(r, 2)) - const { done } = await reader.read() - if (done) break - consumed++ - if (consumed % 50 === 0) { - ;(globalThis as any).gc() - const ext = process.memoryUsage().external - baseline - if (ext > peakExternal) peakExternal = ext + const reader = out.getReader() + let consumed = 0 + let maxLead = 0 + for (;;) { + // Throttled consumer: ~5ms per chunk. + await new Promise((r) => setTimeout(r, 5)) + const { done } = await reader.read() + if (done) { + break + } + consumed++ + const lead = producer.getProduced() - consumed + if (lead > maxLead) { + maxLead = lead + } } - } - - // Without backpressure, peak external memory scales w/ CHUNKS*CHUNK_BYTES - // (~16MB). With backpressure, only a few chunks worth. - expect(peakExternal).toBeLessThan(2 * 1024 * 1024) // 2MB ceiling - expect(consumed).toBe(CHUNKS) - }) - - it('main path: scanner+inject path also honors backpressure', async () => { - // Same shape as fast-path test but with a router that forces the - // scanner+inject code path. Producer emits valid HTML fragments - // (ending at closing tags) so the scanner can release them. - const CHUNKS = 200 - const CHUNK_BYTES = 8 * 1024 - const filler = 'a'.repeat(CHUNK_BYTES - '

'.length) - const chunkStr = `

${filler}

` - const encoded = new TextEncoder().encode(chunkStr) - let produced = 0 - const stream = new ReadableStream({ - pull(controller) { - if (produced >= CHUNKS) { - controller.close() - return - } - produced++ - controller.enqueue(encoded) - }, + // Producer should never be more than a small constant number of chunks + // ahead of the consumer. ReadableStream default HWM is 1 for byte + // streams; allow generous slack for scheduling jitter. + expect(maxLead).toBeLessThanOrEqual(8) + expect(consumed).toBe(CHUNKS) }) - const { router, finishSerialization } = makeMainPathRouter() - const out = transformStreamWithRouter(router, stream) - // Mark serialization finished immediately so tryFinish() proceeds once - // upstream closes. - finishSerialization() + it('bounded external memory under sustained load', async () => { + expect(requiresGc, 'Run with node --expose-gc').toBe(true) + const CHUNKS = 1000 + const CHUNK_BYTES = 16 * 1024 + const producer = createFastProducer(CHUNKS, CHUNK_BYTES, { + freshChunks: true, + }) + const router = makeRouter() - const reader = out.getReader() - let consumed = 0 - let bytes = 0 - let maxLead = 0 - while (true) { - await new Promise((r) => setTimeout(r, 5)) - const { done, value } = await reader.read() - if (done) break - consumed++ - bytes += value.byteLength - const lead = produced - consumed - if (lead > maxLead) maxLead = lead - } + const out = transformReadableStreamWithRouter(router, producer.stream) + const reader = out.getReader() - expect(maxLead).toBeLessThanOrEqual(8) - expect(consumed).toBe(CHUNKS) - expect(bytes).toBe(CHUNKS * encoded.byteLength) - }) + ;(globalThis as any).gc() + ;(globalThis as any).gc() + let consumed = 0 + let peakExternal = 0 + const baseline = process.memoryUsage().external + for (;;) { + await new Promise((r) => setTimeout(r, 2)) + const { done } = await reader.read() + if (done) { + break + } + consumed++ + if (consumed % 50 === 0) { + ;(globalThis as any).gc() + const ext = process.memoryUsage().external - baseline + if (ext > peakExternal) { + peakExternal = ext + } + } + } - it('main path: injected scripts under slow consumer do not balloon memory', async () => { - expect(requiresGc, 'Run with node --expose-gc').toBe(true) + // Without backpressure, peak external memory scales w/ CHUNKS*CHUNK_BYTES + // (~16MB). With backpressure, only a few chunks worth. + expect(peakExternal).toBeLessThan(2 * 1024 * 1024) // 2MB ceiling + expect(consumed).toBe(CHUNKS) + }) - // Producer emits a steady stream of app HTML. While that streams, the - // router injects many large '.length) - const scriptStr = `` + let produced = 0 + const stream = new ReadableStream({ + pull(controller) { + if (produced >= CHUNKS) { + controller.close() + return + } + produced++ + controller.enqueue(produced === 1 ? firstEncoded : encoded) + }, + }) - const { router, injectHtml, finishSerialization } = makeMainPathRouter() + const { router, finishSerialization } = makeMainPathRouter() + const out = transformReadableStreamWithRouter(router, stream) + // Mark serialization finished immediately so tryFinish() proceeds once + // upstream closes. + finishSerialization() - let produced = 0 - const stream = new ReadableStream({ - pull(controller) { - if (produced >= APP_CHUNKS) { - controller.close() - return + const reader = out.getReader() + let consumed = 0 + let bytes = 0 + let maxLead = 0 + for (;;) { + await new Promise((r) => setTimeout(r, 5)) + const { done, value } = await reader.read() + if (done) { + break + } + consumed++ + bytes += value.byteLength + const lead = produced - consumed + if (lead > maxLead) { + maxLead = lead } - produced++ - // Inject some scripts as part of producing this chunk to simulate - // a render that emits HTML AND injects head scripts concurrently. - for (let i = 0; i < INJECT_PER_APP_CHUNK; i++) injectHtml(scriptStr) - controller.enqueue(appBytes) - }, - }) - - const out = transformStreamWithRouter(router, stream) - finishSerialization() - const reader = out.getReader() - - const baseline = process.memoryUsage().external - let peakExternal = 0 - let consumed = 0 - let totalText = '' - while (true) { - await new Promise((r) => setTimeout(r, 2)) - const { done, value } = await reader.read() - if (done) break - consumed++ - totalText += Buffer.from(value).toString('utf8') - if (consumed % 25 === 0) { - ;(globalThis as any).gc() - const ext = process.memoryUsage().external - baseline - if (ext > peakExternal) peakExternal = ext } - } - // Total bytes produced ≈ APP_CHUNKS * (APP_CHUNK_BYTES + 5*SCRIPT_BYTES) - // ≈ 200 * (4KB + 20KB) ≈ 4.8MB - // With backpressure peak external should stay well below total. - expect(peakExternal).toBeLessThan(2 * 1024 * 1024) - expect(totalText.split(appChunkStr).length - 1).toBe(APP_CHUNKS) - expect(totalText.split(scriptStr).length - 1).toBe( - APP_CHUNKS * INJECT_PER_APP_CHUNK, - ) - }) -}) + expect(maxLead).toBeLessThanOrEqual(8) + // The merger splits the first renderer record at the router boundary. + expect(consumed).toBe(CHUNKS + 1) + expect(bytes).toBe(CHUNKS * encoded.byteLength) + }) + }, +) diff --git a/packages/router-core/tests/transformStreamWithRouter.test.ts b/packages/router-core/tests/transformStreamWithRouter.test.ts index 903efeb0c95..adf5977c3f1 100644 --- a/packages/router-core/tests/transformStreamWithRouter.test.ts +++ b/packages/router-core/tests/transformStreamWithRouter.test.ts @@ -1,111 +1,246 @@ -// CI-stable tests for transformStreamWithRouter behavior. +// CI-stable tests for transformReadableStreamWithRouter behavior. // // These exercise the deterministic side-effects of the SSR memory fix // (TanStack/router#7402) without relying on GC, timing of real I/O, or // process.memoryUsage(). On-demand backpressure/external-memory // assertions live in transformStreamBackpressure.perf.test.ts. -import { ReadableStream } from 'node:stream/web' -import { PassThrough } from 'node:stream' +import { PassThrough, Readable } from 'node:stream' import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' -import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from '../src/ssr/constants' -import { createSsrStreamResponse } from '../src/ssr/handlerCallback' +import { GLOBAL_TSR } from '../src/ssr/constants' +import { + HYDRATION_SCRIPT_BOUNDARY_SOURCE, + HYDRATION_SCRIPT_BOUNDARY_SUFFIX, + HydrationScriptOutputState, + MAX_HYDRATION_OUTPUT_CHUNK_BYTES, +} from '../src/ssr/hydrationScripts' +import { + createSsrStreamResponse, + replaceSsrResponse, +} from '../src/ssr/handlerCallback' import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { + transformHtmlStringWithRouter, transformPipeableStreamWithRouter, transformReadableStreamWithRouter, - transformStreamWithRouter, } from '../src/ssr/transformStreamWithRouter' +import { DOCUMENT_CLOSE, SCRIPT_CLOSE } from '../src/ssr/htmlBoundaryScanner' import { createTestRouter } from './routerTestUtils' import type { RouterManagedTag } from '../src/manifest' +import type { HydrationScriptOutput } from '../src/ssr/hydrationScripts' + +const SCRIPT_BARRIER_HTML = `` + +function internalSplitOffsets(value: string) { + return Array.from({ length: value.length - 1 }, (_, index) => index + 1) +} -const MAX_LEFTOVER_CHARS = 2048 -const MAX_ROUTER_HTML_CHARS = 16 * 1024 * 1024 +type FakeHydrationScripts = { + reserveFastPath: (output?: HydrationScriptOutput) => boolean + claimOutput: () => HydrationScriptOutput + liftBarrier: () => void + isInitialTaken: () => boolean + startSerializationTimeout: (timeoutMs: number) => void +} type FakeServerSsr = { - isSerializationFinished: () => boolean - reserveStreamFastPath: () => boolean - onInjectedHtml: (listener: () => void) => () => void - onSerializationFinished: (listener: () => void) => () => void - takeBufferedHtml: () => string | undefined + hydrationScripts: FakeHydrationScripts setRenderFinished: () => void + onCleanup: (listener: () => void) => void cleanup: () => void - liftScriptBarrier?: () => void } +type MakeRouterOptions = Partial> & + Partial + type FakeRouter = { serverSsr?: FakeServerSsr } -function makeRouter(opts: Partial = {}): { +type FakeHydrationRecord = { + bytes: Uint8Array + offset: number +} + +function makeRouter(opts: MakeRouterOptions = {}): { router: FakeRouter cleanupCalls: { count: number } - injectHtml: (html: string) => void + claimCalls: { count: number } + emitHydrationRecord: (record: string) => void + emitScriptBatch: (parts: ReadonlyArray) => void finishSerialization: () => void + failHydrationOutput: (error: unknown) => void } { const cleanupCalls = { count: 0 } + const claimCalls = { count: 0 } + const cleanupListeners: Array<() => void> = [] let cleanedUp = false - let buffered = '' - const injectedListeners: Array<() => void> = [] - const serializationListeners: Array<() => void> = [] + let producerDone = false + let claimed = false + let failure: unknown + let activeRecord: FakeHydrationRecord | undefined + const records: Array = [] + let listener: (() => void) | undefined + let state: HydrationScriptOutput['state'] = HydrationScriptOutputState.Waiting + let serializationTimeout: ReturnType | undefined + const encoder = new TextEncoder() + + function updateState() { + const nextState = + failure !== undefined + ? HydrationScriptOutputState.Failed + : activeRecord + ? HydrationScriptOutputState.Active + : records.length > 0 + ? HydrationScriptOutputState.Ready + : producerDone + ? HydrationScriptOutputState.Done + : HydrationScriptOutputState.Waiting + if (state !== nextState) { + state = nextState + listener?.() + } + } + + const output: HydrationScriptOutput = { + get state() { + return state + }, + get error() { + return failure + }, + pullChunk() { + if (state === HydrationScriptOutputState.Ready) { + activeRecord = records.shift()! + } else if (state !== HydrationScriptOutputState.Active) { + throw new Error('Fake hydration output is not ready') + } + + const record = activeRecord! + const end = Math.min( + record.offset + MAX_HYDRATION_OUTPUT_CHUNK_BYTES, + record.bytes.length, + ) + const chunk = record.bytes.subarray(record.offset, end) + record.offset = end + if (end === record.bytes.length) { + activeRecord = undefined + } + updateState() + return chunk + }, + subscribe(onChange) { + if (listener) { + throw new Error('Fake hydration output already has a subscriber') + } + listener = onChange + return () => { + if (listener === onChange) { + listener = undefined + } + } + }, + } + + const { + reserveFastPath = () => false, + claimOutput = () => { + claimCalls.count++ + if (claimed) { + throw new Error('Fake hydration output already has a consumer') + } + claimed = true + return output + }, + liftBarrier = () => {}, + isInitialTaken = () => true, + startSerializationTimeout = (timeoutMs: number) => { + if ( + producerDone || + failure !== undefined || + serializationTimeout !== undefined + ) { + return + } + serializationTimeout = setTimeout(() => { + serializationTimeout = undefined + failure = new Error('Serialization timeout after app render finished') + console.error('Serialization timeout after app render finished') + updateState() + }, timeoutMs) + }, + ...serverSsrOverrides + } = opts const router: FakeRouter = { serverSsr: { - isSerializationFinished: () => false, - reserveStreamFastPath: () => false, - onInjectedHtml: (cb) => { - injectedListeners.push(cb) - return () => { - const i = injectedListeners.indexOf(cb) - if (i >= 0) injectedListeners.splice(i, 1) - } + hydrationScripts: { + reserveFastPath, + claimOutput, + liftBarrier, + isInitialTaken, + startSerializationTimeout, }, - onSerializationFinished: (cb) => { - serializationListeners.push(cb) - return () => { - const i = serializationListeners.indexOf(cb) - if (i >= 0) serializationListeners.splice(i, 1) + setRenderFinished: () => {}, + onCleanup: (cleanupListener: () => void) => { + if (cleanedUp) { + return } + cleanupListeners.push(cleanupListener) }, - takeBufferedHtml: () => { - const v = buffered - buffered = '' - return v || undefined - }, - setRenderFinished: () => {}, cleanup: () => { - if (cleanedUp) return + if (cleanedUp) { + return + } cleanedUp = true cleanupCalls.count++ + if (serializationTimeout !== undefined) { + clearTimeout(serializationTimeout) + serializationTimeout = undefined + } + records.length = 0 + activeRecord = undefined + failure = undefined + listener = undefined + state = HydrationScriptOutputState.Done + // Mirror the real implementation: snapshot + clear, then notify. + const pendingCleanupListeners = cleanupListeners.slice() + cleanupListeners.length = 0 + for (const cleanupListener of pendingCleanupListeners) { + cleanupListener() + } router.serverSsr = undefined }, - liftScriptBarrier: () => {}, - ...opts, + ...serverSsrOverrides, }, } + function emitHydrationRecord(record: string) { + records.push({ + bytes: encoder.encode(record), + offset: 0, + }) + updateState() + } + return { router, cleanupCalls, - injectHtml: (html: string) => { - buffered += html - for (const l of injectedListeners.slice()) { - try { - l() - } catch (err) { - console.error('SSR injected HTML listener error:', err) - } - } - }, + claimCalls, + emitHydrationRecord, + emitScriptBatch: (parts) => emitHydrationRecord(renderScriptBatch(parts)), finishSerialization: () => { - for (const l of serializationListeners.slice()) { - try { - l() - } catch (err) { - console.error('Serialization listener error:', err) - } + producerDone = true + if (serializationTimeout !== undefined) { + clearTimeout(serializationTimeout) + serializationTimeout = undefined } + updateState() + }, + failHydrationOutput: (error) => { + failure = error + updateState() }, } } @@ -114,6 +249,7 @@ function makeManualUpstream(): { stream: ReadableStream push: (s: string) => void close: () => void + error: (reason: unknown) => void cancelled: { value: boolean; reason: unknown } } { let controllerRef: ReadableStreamDefaultController | undefined @@ -132,30 +268,74 @@ function makeManualUpstream(): { stream, push: (s) => controllerRef!.enqueue(encoder.encode(s)), close: () => controllerRef!.close(), + error: (reason) => controllerRef!.error(reason), cancelled, } } async function readAll(s: ReadableStream): Promise { const reader = s.getReader() + const decoder = new TextDecoder() let out = '' - while (true) { + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + out += decoder.decode(value, { stream: true }) + } + return out + decoder.decode() +} + +async function readAllBytes( + stream: ReadableStream, +): Promise { + const reader = stream.getReader() + const chunks: Array = [] + for (;;) { const { done, value } = await reader.read() - if (done) break - // Buffer.from accepts any ArrayBufferView regardless of realm (jsdom env). - out += Buffer.from( - value.buffer, - value.byteOffset, - value.byteLength, - ).toString('utf8') + if (done) { + break + } + chunks.push(Buffer.from(value)) } - return out + return Buffer.concat(chunks) +} + +async function readAllPipeableChunks(stream: Readable): Promise> { + const chunks: Array = [] + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)) + } + return chunks +} + +async function readAllPipeableBytes(stream: Readable): Promise { + return Buffer.concat(await readAllPipeableChunks(stream)) +} + +function makeLargeStringRecord(prefix: string, suffix: string) { + return ( + prefix + + 'x'.repeat( + MAX_HYDRATION_OUTPUT_CHUNK_BYTES - 1 - Buffer.byteLength(prefix), + ) + + '😀' + + '漢x'.repeat(20_000) + + suffix + ) } // Yield to the microtask queue a few times so async stream operations can // drain. Avoids reliance on real timers. async function flush(n = 5) { - for (let i = 0; i < n; i++) await Promise.resolve() + for (let i = 0; i < n; i++) { + await Promise.resolve() + } +} + +function createIssuePayload() { + return 'x'.repeat(17 * 1024 * 1024) } function createDeferred() { @@ -183,7 +363,24 @@ function renderManagedScript(tag: RouterManagedTag) { const id = attrs.id ? ` id="${attrs.id}"` : '' const className = attrs.className ? ` class="${attrs.className}"` : '' const nonce = attrs.nonce ? ` nonce="${attrs.nonce}"` : '' - return `${tag.children ?? ''}` + const streamPart = + 'data-tsr-stream-part' in attrs ? ' data-tsr-stream-part=""' : '' + return `${tag.children ?? ''}` +} + +function renderManagedScripts(tags: Array) { + return tags.map(renderManagedScript).join('') +} + +function renderInitialScripts(scripts: { + before: Array + boundary: RouterManagedTag +}) { + return renderManagedScripts([...scripts.before, scripts.boundary]) +} + +function renderScriptBatch(parts: ReadonlyArray) { + return `` } function createRealSsrRouter(dehydratedData: Record) { @@ -202,7 +399,37 @@ function createRealSsrRouter(dehydratedData: Record) { }) } -describe('transformStreamWithRouter — real SSR scripts', () => { +describe('transformReadableStreamWithRouter — real SSR scripts', () => { + test('uses the fast path after Scripts takes all eager hydration scripts', async () => { + const router = createRealSsrRouter({ eager: 'loader-data' }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + + await router.load() + await router.serverSsr!.dehydrate() + + // This is the server-side work performed by during rendering. + const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(initialScripts).toBeDefined() + + const serverSsr = router.serverSsr! + const setRenderFinished = vi.spyOn(serverSsr, 'setRenderFinished') + const upstream = makeManualUpstream() + const html = `
app
${renderInitialScripts( + initialScripts!, + )}` + + const output = transformReadableStreamWithRouter(router, upstream.stream) + + // Fast-path reservation must not require render completion in advance. + expect(setRenderFinished).not.toHaveBeenCalled() + upstream.push(html) + upstream.close() + + await expect(readAll(output)).resolves.toBe(html) + expect(setRenderFinished).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) + test('flushes stream-end scripts before body close when serialization finishes before transform starts', async () => { const streamed = createDeferred() const router = createRealSsrRouter({ streamed: streamed.promise }) @@ -211,28 +438,24 @@ describe('transformStreamWithRouter — real SSR scripts', () => { await router.load() await router.serverSsr!.dehydrate() - const barrierScript = router.serverSsr!.takeBufferedScripts() + const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(initialScripts).toBeDefined() + const barrierScript = initialScripts!.boundary + expect(barrierScript.attrs).not.toHaveProperty('id') expect(barrierScript).toBeDefined() - expect(barrierScript!.attrs?.id).toBe(TSR_SCRIPT_BARRIER_ID) - expect(barrierScript!.children).toContain(`${GLOBAL_TSR}.router=`) - expect(barrierScript!.children).not.toContain(`${GLOBAL_TSR}.e()`) + const initialHtml = renderInitialScripts(initialScripts!) + expect(initialHtml).toContain(`${GLOBAL_TSR}.router=`) + expect(initialHtml).not.toContain(`${GLOBAL_TSR}.e()`) - const serializationDone = new Promise((resolve) => { - router.serverSsr!.onSerializationFinished(resolve) - }) streamed.resolve('done') - await serializationDone + await flush(20) const upstream = makeManualUpstream() - const output = transformStreamWithRouter( + const output = transformReadableStreamWithRouter( router as any, upstream.stream as any, ) - upstream.push( - `
app
${renderManagedScript( - barrierScript!, - )}`, - ) + upstream.push(`
app
${initialHtml}`) upstream.close() const html = await readAll(output as any) @@ -252,25 +475,22 @@ describe('transformStreamWithRouter — real SSR scripts', () => { await router.load() await router.serverSsr!.dehydrate() - const barrierScript = router.serverSsr!.takeBufferedScripts() - expect(barrierScript).toBeDefined() + const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(initialScripts).toBeDefined() const upstream = makeManualUpstream() - const output = transformStreamWithRouter( + const output = transformReadableStreamWithRouter( router as any, upstream.stream as any, ) upstream.push( - `
app
${renderManagedScript( - barrierScript!, + `
app
${renderInitialScripts( + initialScripts!, )}`, ) - const serializationDone = new Promise((resolve) => { - router.serverSsr!.onSerializationFinished(resolve) - }) streamed.controller.close() - await serializationDone + await flush(20) upstream.close() const html = await readAll(output as any) @@ -283,89 +503,105 @@ describe('transformStreamWithRouter — real SSR scripts', () => { expect(endIndex).toBeLessThan(html.indexOf('')) }) - test('flushes stream-end scripts even when no barrier marker was emitted', async () => { - const streamed = createDeferred() - const router = createRealSsrRouter({ streamed: streamed.promise }) - attachRouterServerSsrUtils({ router, manifest: undefined }) - - await router.load() - await router.serverSsr!.dehydrate() - - const serializationDone = new Promise((resolve) => { - router.serverSsr!.onSerializationFinished(resolve) - }) - streamed.resolve('done') - await serializationDone + test.each([ + ['completes', false], + ['is cancelled before the payload finishes', true], + ] as const)( + 'loader controllers are aborted only when the response %s', + async (_name, cancelled) => { + let loaderSignal: AbortSignal | undefined + const deferred = createDeferred() + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + loader: ({ abortController }) => { + loaderSignal = abortController.signal + return { deferred: deferred.promise } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr!.dehydrate() + const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags()! - const upstream = makeManualUpstream() - const output = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) - upstream.push('
app
') - upstream.close() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter(router, upstream.stream) + upstream.push( + `${renderInitialScripts(initialScripts)}`, + ) + upstream.close() + expect(loaderSignal?.aborted).toBe(false) - const html = await readAll(output as any) + if (cancelled) { + await output.cancel(new Error('client gone')) + } else { + deferred.resolve('late') + await readAll(output as any) + } - expect(html).toContain(`${GLOBAL_TSR}.router=`) - expect(html).toContain(`${GLOBAL_TSR}.e()`) - expect(html.indexOf(`${GLOBAL_TSR}.e()`)).toBeLessThan( - html.indexOf(''), - ) - }) + expect(router.serverSsr).toBeUndefined() + expect(loaderSignal?.aborted).toBe(cancelled) + }, + ) - test('keeps stream scripts before uppercase body close', async () => { + test('emits router scripts after the document when no Scripts barrier was rendered', async () => { const streamed = createDeferred() const router = createRealSsrRouter({ streamed: streamed.promise }) attachRouterServerSsrUtils({ router, manifest: undefined }) await router.load() await router.serverSsr!.dehydrate() - const barrierScript = router.serverSsr!.takeBufferedScripts()! + + streamed.resolve('done') + await flush(20) const upstream = makeManualUpstream() - const output = transformStreamWithRouter( + const output = transformReadableStreamWithRouter( router as any, upstream.stream as any, ) - upstream.push( - `${renderManagedScript(barrierScript)}
app
`, - ) - - const serializationDone = new Promise((resolve) => { - router.serverSsr!.onSerializationFinished(resolve) - }) - streamed.resolve('done') - await serializationDone + upstream.push('
app
') upstream.close() const html = await readAll(output as any) - expect(html).toContain(`${GLOBAL_TSR}.e()`) - expect(html.indexOf(`${GLOBAL_TSR}.e()`)).toBeLessThan( - html.indexOf(''), + const documentEnd = html.indexOf('') + ''.length + expect(html.slice(0, documentEnd)).toBe( + '
app
', ) + const tail = html.slice(documentEnd) + expect(tail).toContain(`${GLOBAL_TSR}.router=`) + expect(tail).toContain(`${GLOBAL_TSR}.e()`) + expect(tail).toMatch(/<\/script>$/) + expect(router.serverSsr).toBeUndefined() }) test('detects a barrier marker split across chunks', async () => { let liftCalls = 0 const { router, finishSerialization } = makeRouter({ - liftScriptBarrier: () => { + liftBarrier: () => { liftCalls++ }, }) const upstream = makeManualUpstream() - const output = transformStreamWithRouter( + const output = transformReadableStreamWithRouter( router as any, upstream.stream as any, ) - const splitAt = Math.floor(TSR_SCRIPT_BARRIER_ID.length / 2) - - upstream.push( - `
app
`, + const markerStart = SCRIPT_BARRIER_HTML.indexOf( + HYDRATION_SCRIPT_BOUNDARY_SUFFIX, ) + const splitAt = + markerStart + Math.floor(HYDRATION_SCRIPT_BOUNDARY_SUFFIX.length / 2) + + upstream.push(`${SCRIPT_BARRIER_HTML.slice(0, splitAt)}`) + upstream.push(`${SCRIPT_BARRIER_HTML.slice(splitAt)}
app
`) upstream.push('') upstream.close() finishSerialization() @@ -375,85 +611,239 @@ describe('transformStreamWithRouter — real SSR scripts', () => { }) test('does not inject stream scripts inside a split barrier script', async () => { - const { router, injectHtml, finishSerialization } = makeRouter({ - liftScriptBarrier: () => { - injectHtml('') + const { router, emitScriptBatch, finishSerialization } = makeRouter({ + liftBarrier: () => { + emitScriptBatch(['streamed()']) }, }) const upstream = makeManualUpstream() - const output = transformStreamWithRouter( + const output = transformReadableStreamWithRouter( router as any, upstream.stream as any, ) + const splitAt = + SCRIPT_BARRIER_HTML.indexOf(HYDRATION_SCRIPT_BOUNDARY_SUFFIX) + 4 + upstream.push( + `
app
${SCRIPT_BARRIER_HTML.slice(0, splitAt)}`, + ) upstream.push( - `
app
after
') finishSerialization() upstream.push('') upstream.close() const html = await readAll(output as any) - expect(html.indexOf('')).toBeGreaterThan( - html.indexOf('
after
'), + expect(html.indexOf(renderScriptBatch(['streamed()']))).toBeGreaterThan( + html.indexOf(SCRIPT_BARRIER_HTML), + ) + expect(html.indexOf(renderScriptBatch(['streamed()']))).toBeLessThan( + html.indexOf('
after
'), ) - expect(html.indexOf('')).toBeLessThan( + expect(html.indexOf(renderScriptBatch(['streamed()']))).toBeLessThan( html.indexOf(''), ) }) - test('remembers a flushed barrier marker until a later closing-tag boundary', async () => { - let liftCalls = 0 - const { router, finishSerialization } = makeRouter({ - liftScriptBarrier: () => { - liftCalls++ + test('uses a complete renderer record as an insertion boundary', async () => { + const { router, emitScriptBatch, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + rendererSafePoint: 'record-end', }, - }) + ) + const reader = output.getReader() + const decoder = new TextDecoder() + let html = '' + + const rendererRecord = 'content' + upstream.push(`${SCRIPT_BARRIER_HTML}${rendererRecord}`) + for (let index = 0; index < 2; index++) { + const { value } = await reader.read() + html += decoder.decode(value, { stream: true }) + } + + emitScriptBatch(['streamed()']) + upstream.push('
after
') + upstream.close() + finishSerialization() + + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + html += decoder.decode(value, { stream: true }) + } + html += decoder.decode() + + expect(html).toBe( + `${SCRIPT_BARRIER_HTML}${rendererRecord}${renderScriptBatch([ + 'streamed()', + ])}
after
`, + ) + }) + + test('does not split a held document close at a record-end safe point', async () => { + const { router, emitScriptBatch, finishSerialization } = makeRouter() const upstream = makeManualUpstream() - const output = transformStreamWithRouter( + const output = transformReadableStreamWithRouter( router as any, upstream.stream as any, + { + rendererSafePoint: 'record-end', + }, ) - const filler = 'x'.repeat(MAX_LEFTOVER_CHARS + 32) + const reader = output.getReader() + const splitAt = 5 + const prefix = `${SCRIPT_BARRIER_HTML}` - upstream.push( - `
app
', + ) + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) - const out = transformStreamWithRouter(router as any, upstream as any) - const reader = ( - out as any - ).getReader() as ReadableStreamDefaultReader + upstream.controller.enqueue(rendererBytes) + upstream.controller.close() - let cancelSettled = false - const cancelPromise = reader.cancel('consumer-gone').then(() => { - cancelSettled = true + await expect(readAllBytes(output)).resolves.toEqual( + Buffer.from(rendererBytes), + ) + expect(claimCalls.count).toBe(0) + expect(cleanupCalls.count).toBe(1) + }) + + test('dynamic pass-through preserves renderer bytes after document closes', async () => { + let allowFastPath = false + let reserveCalls = 0 + const { router, cleanupCalls, claimCalls, finishSerialization } = + makeRouter({ + reserveFastPath: () => { + reserveCalls++ + return allowFastPath + }, + }) + finishSerialization() + const upstream = createControlledStream() + const encoder = new TextEncoder() + const prefix = '
shell
' + const suffix = '' + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = output.getReader() + + expect(reserveCalls).toBe(1) + allowFastPath = true + upstream.controller.enqueue(encoder.encode(prefix)) + expect(Buffer.from((await reader.read()).value!).toString()).toBe(prefix) + + // Finishing serialization alone cannot bypass the rendered boundary. + expect(reserveCalls).toBe(1) + upstream.controller.enqueue(encoder.encode(SCRIPT_BARRIER_HTML + suffix)) + expect(Buffer.from((await reader.read()).value!).toString()).toBe( + SCRIPT_BARRIER_HTML, + ) + expect(reserveCalls).toBe(1) + + // The next pull enters the scanner-free post-boundary pass-through phase. + expect(Buffer.from((await reader.read()).value!).toString()).toBe(suffix) + expect(reserveCalls).toBe(2) + expect(claimCalls.count).toBe(1) + upstream.controller.close() + + expect((await reader.read()).done).toBe(true) + expect(cleanupCalls.count).toBe(1) + }) + + test('generic streams wait for the document close before a late batch', async () => { + const { router, emitScriptBatch, cleanupCalls, finishSerialization } = + makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = output.getReader() + const firstAppChunk = '
first
' + + upstream.push(`${SCRIPT_BARRIER_HTML}${firstAppChunk}`) + const barrier = await reader.read() + const first = await reader.read() + expect(Buffer.from(barrier.value!).toString()).toBe(SCRIPT_BARRIER_HTML) + expect(Buffer.from(first.value!).toString()).toBe(firstAppChunk) + + const parts = ['lateBatch()'] + emitScriptBatch(parts) + upstream.push('next') + finishSerialization() + upstream.close() + + const remaining: Array = [] + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + remaining.push(value) + } + + expect( + Buffer.concat([barrier.value!, first.value!, ...remaining]).toString(), + ).toBe( + `${SCRIPT_BARRIER_HTML}${firstAppChunk}next${renderScriptBatch( + parts, + )}`, + ) + expect(cleanupCalls.count).toBe(1) + }) + + test.each([ + ['record-end safe points', { rendererSafePoint: 'record-end' }], + ['conservative safe points', undefined], + ] as const)( + '%s preserve a non-canonical document close when a batch arrives later', + async (_, options) => { + const { router, emitScriptBatch, cleanupCalls, finishSerialization } = + makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + options, + ) + const reader = output.getReader() + const rendererHtml = + `
app
${SCRIPT_BARRIER_HTML}` + + '\n' + + upstream.push(rendererHtml) + let received = '' + while (received.length < rendererHtml.length) { + const result = await reader.read() + expect(result.done).toBe(false) + received += Buffer.from(result.value!).toString() + } + expect(received).toBe(rendererHtml) + + const parts = ['lateBatch()'] + emitScriptBatch(parts) + finishSerialization() + upstream.close() + + let remaining = '' + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + remaining += Buffer.from(value).toString() + } + + expect(received + remaining).toBe(rendererHtml + renderScriptBatch(parts)) + expect(cleanupCalls.count).toBe(1) + }, + ) + + test('an aborted request stops eager string rendering', async () => { + const reason = new Error('request aborted') + const abortController = new AbortController() + abortController.abort(reason) + const onAbort = vi.fn() + const setRenderFinished = vi.fn() + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => true, + setRenderFinished, + }) + + await expect( + transformHtmlStringWithRouter( + router as any, + 'ready', + { signal: abortController.signal, onAbort }, + ), + ).rejects.toBe(reason) + expect(setRenderFinished).not.toHaveBeenCalled() + expect(onAbort).toHaveBeenCalledOnce() + expect(onAbort).toHaveBeenCalledWith(reason) + expect(cleanupCalls.count).toBe(1) + }) + + test('a locked input stream fails synchronously and cleans SSR state', () => { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => true, + }) + const upstream = new ReadableStream() + const upstreamReader = upstream.getReader() + const onAbort = vi.fn() + + expect(() => + transformReadableStreamWithRouter(router as any, upstream as any, { + onAbort, + }), + ).toThrow() + expect(onAbort).toHaveBeenCalledOnce() + expect(cleanupCalls.count).toBe(1) + + upstreamReader.releaseLock() + }) + + test('a setup failure cancels an acquired input stream', async () => { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => { + throw new Error('setup failed') + }, }) + const upstream = makeManualUpstream() + const onAbort = vi.fn() + expect(() => + transformReadableStreamWithRouter(router as any, upstream.stream as any, { + onAbort, + }), + ).toThrow('setup failed') await flush() - expect(cancelSettled).toBe(false) + + expect(upstream.cancelled.value).toBe(true) + expect(onAbort).toHaveBeenCalledOnce() expect(cleanupCalls.count).toBe(1) + }) - resolveCancel() - await cancelPromise - expect(cancelSettled).toBe(true) + test('a pre-aborted request fails setup before selecting a stream path', async () => { + const reserveFastPath = vi.fn(() => true) + const { router, cleanupCalls } = makeRouter({ reserveFastPath }) + const upstream = makeManualUpstream() + const requestAbort = new AbortController() + const reason = new Error('request already ended') + const onAbort = vi.fn() + requestAbort.abort(reason) + + expect(() => + transformReadableStreamWithRouter(router as any, upstream.stream as any, { + signal: requestAbort.signal, + onAbort, + }), + ).toThrow(reason) + await flush() + + expect(reserveFastPath).not.toHaveBeenCalled() + expect(upstream.cancelled).toEqual({ value: true, reason }) + expect(onAbort).toHaveBeenCalledOnce() + expect(cleanupCalls.count).toBe(1) }) - test('natural close calls cleanup exactly once', async () => { + test('an already-failed hydration channel fails stream setup', async () => { + const state = makeRouter() + const upstream = makeManualUpstream() + const reason = new Error('hydration already failed') + const onAbort = vi.fn() + state.failHydrationOutput(reason) + + expect(() => + transformReadableStreamWithRouter( + state.router as any, + upstream.stream as any, + { + onAbort, + }, + ), + ).toThrow(reason) + await flush() + + expect(upstream.cancelled).toEqual({ value: true, reason }) + expect(onAbort).toHaveBeenCalledOnce() + expect(state.cleanupCalls.count).toBe(1) + }) + + test.each([ + ['fast', true], + ['merge', false], + ] as const)( + 'request abort immediately closes the %s path', + async (_, reserveFastPath) => { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => reserveFastPath, + }) + const upstream = makeManualUpstream() + const requestAbort = new AbortController() + const onAbort = vi.fn() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + signal: requestAbort.signal, + onAbort, + }, + ) + const reader = output.getReader() + const pendingRead = reader.read() + const reason = new Error('request ended') + + requestAbort.abort(reason) + + await expect(pendingRead).rejects.toBe(reason) + await flush() + expect(upstream.cancelled).toEqual({ value: true, reason }) + expect(onAbort).toHaveBeenCalledOnce() + expect(cleanupCalls.count).toBe(1) + }, + ) + + test('downstream cancel propagates upstream and calls serverSsr.cleanup once', async () => { + // Fast path: simpler, no scanner. Verifies cancel + cleanup contract. const { router, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - reserveStreamFastPath: () => true, - takeBufferedHtml: () => undefined, + reserveFastPath: () => true, }) const upstream = makeManualUpstream() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = ( + out as any + ).getReader() as ReadableStreamDefaultReader - upstream.push('done') - upstream.close() + // Cancel before any data flows. Should still trigger upstream cancel + // and exactly one cleanup invocation. + await reader.cancel('consumer-gone') + await flush() - const text = await readAll(out as any) - expect(text).toContain('done') + expect(upstream.cancelled.value).toBe(true) + expect(cleanupCalls.count).toBe(1) + }) + + test('downstream cancel releases the upstream reader while cancellation settles', async () => { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => true, + }) + + let resolveCancel!: () => void + const upstream = new ReadableStream({ + pull() { + // Keep the transform's upstream read pending until cancellation. + }, + cancel() { + return new Promise((resolve) => { + resolveCancel = resolve + }) + }, + }) + + const out = transformReadableStreamWithRouter( + router as any, + upstream as any, + ) + const reader = ( + out as any + ).getReader() as ReadableStreamDefaultReader + + let cancelSettled = false + const cancelPromise = reader.cancel('consumer-gone').then(() => { + cancelSettled = true + }) + + await flush() + expect(cancelSettled).toBe(false) + expect(upstream.locked).toBe(false) expect(cleanupCalls.count).toBe(1) + + resolveCancel() + await cancelPromise + expect(cancelSettled).toBe(true) }) test('stream response metadata defers cleanup until body drains', async () => { const { router, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - reserveStreamFastPath: () => true, - takeBufferedHtml: () => undefined, + reserveFastPath: () => true, }) const upstream = makeManualUpstream() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) const response = new Response(out as any) const result = createSsrStreamResponse(router as any, response) @@ -571,19 +1299,19 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { test('stream response dispose cancels body and cleans once', async () => { const { router, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - reserveStreamFastPath: () => true, - takeBufferedHtml: () => undefined, + reserveFastPath: () => true, }) const upstream = makeManualUpstream() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) const result = createSsrStreamResponse( router as any, new Response(out as any), ) expect(result.serverSsrCleanup).toBe('stream') - if (result.serverSsrCleanup !== 'stream') return await result.dispose('dropped') await result.dispose('dropped-again') @@ -592,113 +1320,281 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { expect(cleanupCalls.count).toBe(1) }) + test('stream response replacement does not wait for cancellation', async () => { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => true, + }) + let cancelCalls = 0 + const stream = new ReadableStream({ + cancel() { + cancelCalls++ + return new Promise(() => {}) + }, + }) + const result = createSsrStreamResponse( + router as any, + new Response(stream as any), + ) + const replacement = new Response('replacement') + + const next = replaceSsrResponse(result, replacement, 'replaced') + + expect(next.response).toBe(replacement) + expect(cancelCalls).toBe(1) + expect(cleanupCalls.count).toBe(1) + }) + + test('external serverSsr cleanup releases a never-read merge transform immediately', async () => { + vi.useFakeTimers() + try { + const { router, cleanupCalls } = makeRouter() + const upstream = makeManualUpstream() + const aborts: Array = [] + + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + onAbort: (reason) => aborts.push(reason), + }, + ) + expect(vi.getTimerCount()).toBe(1) + + // A discarded response is never read and never cancelled; external + // cleanup must not leave the transform pinned until the lifetime timer. + router.serverSsr!.cleanup() + + expect(cleanupCalls.count).toBe(1) + expect(aborts).toHaveLength(1) + expect((aborts[0] as Error).name).toBe('AbortError') + expect(upstream.cancelled.value).toBe(true) + expect(vi.getTimerCount()).toBe(0) + await expect(out.getReader().read()).rejects.toMatchObject({ + name: 'AbortError', + }) + } finally { + vi.useRealTimers() + } + }) + + test('external serverSsr cleanup releases a never-read fast-path transform immediately', async () => { + vi.useFakeTimers() + try { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => true, + }) + const upstream = makeManualUpstream() + const aborts: Array = [] + + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + onAbort: (reason) => aborts.push(reason), + }, + ) + expect(vi.getTimerCount()).toBe(1) + + router.serverSsr!.cleanup() + + expect(cleanupCalls.count).toBe(1) + expect(aborts).toHaveLength(1) + expect(upstream.cancelled.value).toBe(true) + expect(vi.getTimerCount()).toBe(0) + await expect(out.getReader().read()).rejects.toMatchObject({ + name: 'AbortError', + }) + } finally { + vi.useRealTimers() + } + }) + + test('external serverSsr cleanup promptly fails a merge stream parked on hydration output', async () => { + const { router, cleanupCalls } = makeRouter() + const upstream = makeManualUpstream() + const aborts: Array = [] + + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + onAbort: (reason) => aborts.push(reason), + }, + ) + const reader = out.getReader() + + upstream.push(`${SCRIPT_BARRIER_HTML}`) + const first = await reader.read() + expect(first.done).toBe(false) + + // Renderer EOF with hydration output still Waiting parks the pump. + upstream.close() + const parked = reader.read() + await new Promise((resolve) => setTimeout(resolve, 0)) + + router.serverSsr!.cleanup() + + await expect(parked).rejects.toMatchObject({ name: 'AbortError' }) + expect(cleanupCalls.count).toBe(1) + expect(aborts).toHaveLength(1) + }) + test('SSR fast path is used when explicitly safe', async () => { - let takeBufferedHtmlCalls = 0 let setRenderFinishedCalls = 0 const { router, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - reserveStreamFastPath: () => true, - takeBufferedHtml: () => { - takeBufferedHtmlCalls++ - return undefined - }, + reserveFastPath: () => true, setRenderFinished: () => { setRenderFinishedCalls++ }, }) const upstream = makeManualUpstream() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) upstream.push('done') upstream.close() const text = await readAll(out as any) expect(text).toBe('done') - expect(takeBufferedHtmlCalls).toBe(0) expect(setRenderFinishedCalls).toBe(1) expect(cleanupCalls.count).toBe(1) }) test('SSR fast path is bypassed when not explicitly safe', async () => { - let takeBufferedHtmlCalls = 0 - let pendingHtml: string | undefined = '' - const { router, injectHtml, finishSerialization } = makeRouter({ - isSerializationFinished: () => true, - reserveStreamFastPath: () => false, - takeBufferedHtml: () => { - takeBufferedHtmlCalls++ - const html = pendingHtml - pendingHtml = undefined - return html - }, + const { router, emitScriptBatch, finishSerialization } = makeRouter({ + reserveFastPath: () => false, }) const upstream = makeManualUpstream() - const out = transformStreamWithRouter(router as any, upstream.stream as any) - upstream.push('done') + emitScriptBatch(['pending()']) + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + upstream.push(`done${SCRIPT_BARRIER_HTML}`) upstream.close() finishSerialization() const text = await readAll(out as any) - expect(text).toContain('') - expect(text.indexOf('')).toBeLessThan( + expect(text).toContain(renderScriptBatch(['pending()'])) + expect(text.indexOf(renderScriptBatch(['pending()']))).toBeLessThan( text.indexOf(''), ) - expect(takeBufferedHtmlCalls).toBeGreaterThan(0) }) - test('SSR fast path errors on unexpected late injection', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const { router, injectHtml, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - reserveStreamFastPath: () => true, - }) - const upstream = makeManualUpstream() - - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) - injectHtml('') + const lifetimeTimeoutCases = [ + { + name: 'fast path with an active reader', + reserveFastPath: true, + activeReader: true, + }, + { + name: 'fast path without an active reader', + reserveFastPath: true, + activeReader: false, + }, + { + name: 'main path with an active reader', + reserveFastPath: false, + activeReader: true, + }, + { + name: 'main path without an active reader', + reserveFastPath: false, + activeReader: false, + }, + ] as const + + test.each(lifetimeTimeoutCases)( + 'lifetime timeout errors $name and cleans up', + async ({ reserveFastPath, activeReader }) => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => reserveFastPath, + }) + const upstream = makeManualUpstream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + lifetimeMs: 10, + }, + ) + + if (activeReader) { + const reader = out.getReader() + const pendingRead = reader.read() + const readError = expect(pendingRead).rejects.toThrow( + 'Stream lifetime exceeded', + ) + + await vi.advanceTimersByTimeAsync(15) + + await readError + reader.releaseLock() + } else { + await vi.advanceTimersByTimeAsync(15) + + const reader = out.getReader() + await expect(reader.read()).rejects.toThrow( + 'Stream lifetime exceeded', + ) + reader.releaseLock() + } - await expect(readAll(out as any)).rejects.toThrow( - 'SSR router HTML injected during fast path', - ) - expect(cleanupCalls.count).toBe(1) - } finally { - errorSpy.mockRestore() - } - }) + expect(upstream.cancelled.value).toBe(true) + expect(upstream.cancelled.reason).toEqual( + new Error('Stream lifetime exceeded'), + ) + expect(cleanupCalls.count).toBe(1) + expect(warnSpy).toHaveBeenCalledOnce() + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }, + ) - test('lifetime timeout cancels upstream and runs cleanup once', async () => { + test('serialization timeout fails a completed render exactly once', async () => { vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { - const { router, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - takeBufferedHtml: () => undefined, - }) + const onAbort = vi.fn() + const { router, cleanupCalls } = makeRouter() const upstream = makeManualUpstream() - - const out = transformStreamWithRouter( + const output = transformReadableStreamWithRouter( router as any, upstream.stream as any, - { lifetimeMs: 10 }, + { + timeoutMs: 10, + lifetimeMs: 1_000, + onAbort, + }, + ) + const outputPromise = readAll(output) + const outputError = expect(outputPromise).rejects.toThrow( + 'Serialization timeout after app render finished', ) - // Do NOT consume. Advance fake time past lifetimeMs deterministically. + upstream.push(`${SCRIPT_BARRIER_HTML}${DOCUMENT_CLOSE}`) + upstream.close() + await flush(10) await vi.advanceTimersByTimeAsync(15) - expect(upstream.cancelled.value).toBe(true) + await outputError + expect(onAbort).toHaveBeenCalledOnce() + expect(onAbort.mock.calls[0]![0]).toEqual( + new Error('Serialization timeout after app render finished'), + ) expect(cleanupCalls.count).toBe(1) - - // Drain (read errors silently) so vitest doesn't see an unhandled error. - const reader = ( - out as any - ).getReader() as ReadableStreamDefaultReader - reader.read().catch(() => {}) - reader.releaseLock() + expect(errorSpy).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) } finally { + errorSpy.mockRestore() vi.useRealTimers() } }) @@ -712,10 +1608,7 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { } process.on('unhandledRejection', onUnhandled) try { - const { router, cleanupCalls } = makeRouter({ - isSerializationFinished: () => true, - takeBufferedHtml: () => undefined, - }) + const { router, cleanupCalls } = makeRouter() const stream = new ReadableStream({ start() {}, @@ -725,7 +1618,10 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { }, }) - const out = transformStreamWithRouter(router as any, stream as any) + const out = transformReadableStreamWithRouter( + router as any, + stream as any, + ) const reader = ( out as any ).getReader() as ReadableStreamDefaultReader @@ -746,173 +1642,628 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { } }) - test('server cleanup throwing does not prevent terminal stream cleanup', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const { router, finishSerialization } = makeRouter({ - cleanup: () => { - throw new Error('cleanup-boom') - }, - }) - const upstream = makeManualUpstream() + test('an output-channel failure terminates without another downstream pull', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const { router, cleanupCalls, failHydrationOutput } = makeRouter() + const upstream = makeManualUpstream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reason = new Error('hydration-output-failed') + + failHydrationOutput(reason) + await flush() + + await expect(readAll(out as any)).rejects.toBe(reason) + expect(upstream.cancelled.value).toBe(true) + expect(cleanupCalls.count).toBe(1) + } finally { + errorSpy.mockRestore() + } + }) + + test('places a late router batch after a post-document script patch and before the relocated close', async () => { + const { router, emitScriptBatch, cleanupCalls, finishSerialization } = + makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + rendererSafePoint: 'script-close', + }, + ) + const reader = output.getReader() + const shell = `

loading

${SCRIPT_BARRIER_HTML}` + // Minimal resolved segment and replacement call. The runtime helper can be + // installed by the shell. + const rendererPatch = + '' + + '' + const appHtml = `${shell}${rendererPatch}` + + upstream.push(appHtml) + + const shellChunk = await reader.read() + expect(shellChunk.done).toBe(false) + expect(Buffer.from(shellChunk.value!).toString('utf8')).toBe(shell) + + const patchChunk = await reader.read() + expect(patchChunk.done).toBe(false) + expect(Buffer.from(patchChunk.value!).toString('utf8')).toBe(rendererPatch) + + // The original close suffix has now been scanned and held. This batch is + // deliberately emitted afterward to cover the late-arrival ordering. + const routerParts = ['routerPayload()'] + emitScriptBatch(routerParts) + finishSerialization() + + const routerChunk = await reader.read() + expect(routerChunk.done).toBe(false) + expect(Buffer.from(routerChunk.value!).toString('utf8')).toBe( + renderScriptBatch(routerParts), + ) + + upstream.close() + + const chunks = [shellChunk.value!, patchChunk.value!, routerChunk.value!] + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + chunks.push(value) + } + + const actual = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) + const routerScript = renderScriptBatch(routerParts) + const expected = Buffer.from( + `${shell}${rendererPatch}${routerScript}`, + ) + + expect(actual).toEqual(expected) + expect(actual.indexOf(Buffer.from(rendererPatch))).toBeLessThan( + actual.indexOf(Buffer.from(routerScript)), + ) + expect(actual.indexOf(Buffer.from(routerScript))).toBeLessThan( + actual.indexOf(Buffer.from('')), + ) + expect(cleanupCalls.count).toBe(1) + }) + + test.each(internalSplitOffsets(SCRIPT_CLOSE))( + 'waits for a script-close safe point split at byte %s before a router batch', + async (splitAt) => { + const { router, emitScriptBatch, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + rendererSafePoint: 'script-close', + }, + ) + const reader = output.getReader() + const patchPrefix = + '' + + upstream.push(`${SCRIPT_BARRIER_HTML}${patchPrefix}`) + expect(Buffer.from((await reader.read()).value!).toString()).toBe( + SCRIPT_BARRIER_HTML, + ) + expect(Buffer.from((await reader.read()).value!).toString()).toBe( + patchPrefix.slice(0, -1), + ) + + const parts = ['streamed()'] + emitScriptBatch(parts) + upstream.push(`${patchSuffix}${DOCUMENT_CLOSE}`) + upstream.close() + finishSerialization() + + let remaining = '' + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + remaining += Buffer.from(value).toString() + } + + expect(remaining).toBe( + `<${patchSuffix}${renderScriptBatch(parts)}${DOCUMENT_CLOSE}`, + ) + }) + + test('places a router batch after a script-terminated patch and before its final close', async () => { + const { router, emitScriptBatch, cleanupCalls, finishSerialization } = + makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { + rendererSafePoint: 'script-close', + }, + ) + const reader = output.getReader() + const shell = + `` + + `

loading

${SCRIPT_BARRIER_HTML}` + + '' + const rendererPatch = + '' + + '' + + upstream.push(shell) + const shellChunks = [await reader.read(), await reader.read()] + expect( + Buffer.concat( + shellChunks.map((chunk) => Buffer.from(chunk.value!)), + ).toString(), + ).toBe(shell) + + upstream.push(rendererPatch) + const patchChunk = await reader.read() + expect(Buffer.from(patchChunk.value!).toString()).toBe(rendererPatch) + + const routerParts = ['routerPayload()'] + emitScriptBatch(routerParts) + finishSerialization() + const routerChunk = await reader.read() + expect(Buffer.from(routerChunk.value!).toString()).toBe( + renderScriptBatch(routerParts), + ) + + upstream.push(DOCUMENT_CLOSE) + upstream.close() + + const closeChunk = await reader.read() + expect(Buffer.from(closeChunk.value!).toString()).toBe(DOCUMENT_CLOSE) + expect((await reader.read()).done).toBe(true) + expect(cleanupCalls.count).toBe(1) + }) + + test.each(['one byte chunk', 'byte chunks', 'one string record'] as const)( + 'relocates the document close after script safe points in %s', + async (input) => { + const { router, finishSerialization, cleanupCalls } = makeRouter() + const upstream = createControlledStream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream, + { rendererSafePoint: 'script-close' }, + ) + const reader = output.getReader() + const patch = '' + const prefix = SCRIPT_BARRIER_HTML + patch + const filler = 'x'.repeat( + MAX_HYDRATION_OUTPUT_CHUNK_BYTES - prefix.length, + ) + const afterClose = '' + const first = prefix + filler + const last = DOCUMENT_CLOSE + afterClose + const encoder = new TextEncoder() + + if (input === 'one string record') { + upstream.controller.enqueue(first + last) + } else if (input === 'byte chunks') { + upstream.controller.enqueue(encoder.encode(first)) + upstream.controller.enqueue(encoder.encode(last)) + } else { + upstream.controller.enqueue(encoder.encode(first + last)) + } + upstream.controller.close() + + expect(Buffer.from((await reader.read()).value!).toString()).toBe( + SCRIPT_BARRIER_HTML, + ) + // Keep hydration waiting until a script safe point has interrupted the + // document-close search. The byte/string variants then advance chunks. + expect(Buffer.from((await reader.read()).value!).toString()).toBe(patch) + finishSerialization() + reader.releaseLock() + + await expect(readAll(output)).resolves.toBe( + filler + afterClose + DOCUMENT_CLOSE, + ) + expect(cleanupCalls.count).toBe(1) + }, + ) + + test.each( + ['', ''].flatMap((prefix) => + internalSplitOffsets(prefix + DOCUMENT_CLOSE).map((splitAt) => ({ + prefix, + splitAt, + })), + ), + )( + 'relocates the structural document close after "$prefix" split at byte $splitAt', + async ({ prefix, splitAt }) => { + const { router, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + { rendererSafePoint: 'script-close' }, + ) + const shell = `${SCRIPT_BARRIER_HTML}` + const patch = '' + const beforeClose = `${shell}${patch}
app
` + const afterClose = '' + const close = prefix + DOCUMENT_CLOSE + + upstream.push(beforeClose + close.slice(0, splitAt)) + upstream.push(close.slice(splitAt) + afterClose) + upstream.close() + const reader = output.getReader() + expect(Buffer.from((await reader.read()).value!).toString()).toBe(shell) + expect(Buffer.from((await reader.read()).value!).toString()).toBe(patch) + finishSerialization() + reader.releaseLock() + + await expect(readAll(output)).resolves.toBe( + '
app
' + prefix + afterClose + DOCUMENT_CLOSE, + ) + }, + ) + + test('reconstructs one emoji split across upstream UTF-8 byte chunks', async () => { + const { router, finishSerialization } = makeRouter() + const upstream = createControlledStream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const encoder = new TextEncoder() + const emoji = encoder.encode('😀') + const html = `

😀

${SCRIPT_BARRIER_HTML}` + + upstream.controller.enqueue(encoder.encode('

')) + upstream.controller.enqueue(emoji.slice(0, 2)) + upstream.controller.enqueue(emoji.slice(2)) + upstream.controller.enqueue( + encoder.encode(`

${SCRIPT_BARRIER_HTML}`), + ) + upstream.controller.close() + finishSerialization() + + await expect(readAll(out)).resolves.toBe(html) + }) + + test('does not interleave application bytes while an opaque record is active', async () => { + const { router, emitHydrationRecord, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = output.getReader() + const record = 'R'.repeat(MAX_HYDRATION_OUTPUT_CHUNK_BYTES * 2 + 1) + const patch = '' + + upstream.push(`${SCRIPT_BARRIER_HTML}`) + expect(Buffer.from((await reader.read()).value!).toString()).toBe( + `${SCRIPT_BARRIER_HTML}`, + ) + + emitHydrationRecord(record) + upstream.push(patch + DOCUMENT_CLOSE) + upstream.close() + finishSerialization() + + let rest = '' + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + rest += Buffer.from(value).toString() + } + + expect(rest).toBe(record + patch + DOCUMENT_CLOSE) + }) + + test('drains an active opaque record after prefetched renderer EOF', async () => { + const setRenderFinished = vi.fn() + const startSerializationTimeout = vi.fn() + const { router, emitHydrationRecord, finishSerialization } = makeRouter({ + setRenderFinished, + startSerializationTimeout, + }) + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = output.getReader() + const record = 'R'.repeat(MAX_HYDRATION_OUTPUT_CHUNK_BYTES + 1) - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) - upstream.push('done') - upstream.close() - finishSerialization() + upstream.push(SCRIPT_BARRIER_HTML) + expect(Buffer.from((await reader.read()).value!).toString()).toBe( + SCRIPT_BARRIER_HTML, + ) + emitHydrationRecord(record) + finishSerialization() + upstream.close() + await flush() - await expect(readAll(out as any)).resolves.toContain('done') - expect(errorSpy).toHaveBeenCalled() - } finally { - errorSpy.mockRestore() + expect(startSerializationTimeout).toHaveBeenCalledOnce() + expect(startSerializationTimeout).toHaveBeenCalledWith(60_000) + expect(setRenderFinished).toHaveBeenCalledOnce() + + const first = await reader.read() + expect(first.done).toBe(false) + + let received = Buffer.from(first.value!).toString() + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + received += Buffer.from(value).toString() } + expect(received).toBe(record) }) - test('throwing injected listener does not skip transform drain', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - const { router, injectHtml, finishSerialization } = makeRouter() - router.serverSsr!.onInjectedHtml(() => { - throw new Error('external-listener-boom') - }) - const upstream = makeManualUpstream() - - const out = transformStreamWithRouter( - router as any, - upstream.stream as any, - ) + test('draining a large source does not read more app chunks per transport slice', async () => { + const { router, emitScriptBatch } = makeRouter() + const encoder = new TextEncoder() + let produced = 0 + const appStream = new ReadableStream({ + pull(controller) { + produced++ + controller.enqueue( + encoder.encode( + produced === 1 + ? `
app
${SCRIPT_BARRIER_HTML}` + : `

app-${produced}

`, + ), + ) + }, + }) + const out = transformReadableStreamWithRouter( + router as any, + appStream as any, + ) + emitScriptBatch([createIssuePayload()]) + const reader = out.getReader() - upstream.push('
app
') - injectHtml('') - upstream.push('') - upstream.close() - finishSerialization() + const first = await reader.read() + expect(first.done).toBe(false) + await flush() + const producedBeforeRouterDrain = produced - const text = await readAll(out as any) - expect(text).toContain('') - expect(text.indexOf('')).toBeLessThan( - text.indexOf(''), - ) - } finally { - errorSpy.mockRestore() + for (let index = 0; index < 8; index++) { + const next = await reader.read() + expect(next.done).toBe(false) } + await flush() + + expect(produced).toBe(producedBeforeRouterDrain) + await reader.cancel() }) - test('tail overflow errors and runs cleanup', async () => { + test('a prefetched upstream error immediately stops an active large-source drain', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { - const { router, cleanupCalls, finishSerialization } = makeRouter() + const { router, emitScriptBatch, cleanupCalls } = makeRouter() const upstream = makeManualUpstream() - - const out = transformStreamWithRouter( + const onAbort = vi.fn() + const out = transformReadableStreamWithRouter( router as any, upstream.stream as any, + { onAbort }, ) - upstream.push(`done${'x'.repeat(64 * 1024 + 1)}`) - upstream.close() - finishSerialization() + const reader = out.getReader() + const upstreamError = new Error('renderer-failed') + + emitScriptBatch([createIssuePayload()]) + upstream.push(`${SCRIPT_BARRIER_HTML}`) + expect((await reader.read()).done).toBe(false) + expect((await reader.read()).done).toBe(false) + + upstream.error(upstreamError) + await flush() - await expect(readAll(out as any)).rejects.toThrow( - 'SSR stream tail exceeded maximum buffer', - ) expect(cleanupCalls.count).toBe(1) + expect(onAbort).toHaveBeenCalledOnce() + expect(onAbort).toHaveBeenCalledWith(upstreamError) + await expect(reader.read()).rejects.toBe(upstreamError) } finally { errorSpy.mockRestore() } }) - test('router HTML overflow errors and runs cleanup', async () => { - const { router, injectHtml, cleanupCalls } = makeRouter() + test('cancellation tears down an active large-source drain', async () => { + const { router, emitScriptBatch, cleanupCalls } = makeRouter() const upstream = makeManualUpstream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = out.getReader() - const out = transformStreamWithRouter(router as any, upstream.stream as any) - injectHtml('x'.repeat(MAX_ROUTER_HTML_CHARS + 1)) + emitScriptBatch([createIssuePayload()]) + upstream.push(`
app
${SCRIPT_BARRIER_HTML}`) - await expect(readAll(out as any)).rejects.toThrow( - 'SSR router HTML exceeded maximum buffer', - ) + expect((await reader.read()).done).toBe(false) + expect((await reader.read()).done).toBe(false) + expect(cleanupCalls.count).toBe(0) + + await reader.cancel('consumer-gone') + + expect(upstream.cancelled.value).toBe(true) expect(cleanupCalls.count).toBe(1) }) - test('pending output overflow errors and runs cleanup', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + test('lifetime timeout errors an active large-source drain', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() try { - const { router, cleanupCalls, finishSerialization } = makeRouter() + const { router, emitScriptBatch, cleanupCalls } = makeRouter() const upstream = makeManualUpstream() - - const out = transformStreamWithRouter( + const out = transformReadableStreamWithRouter( router as any, upstream.stream as any, + { + lifetimeMs: 10, + }, ) - upstream.push(`${'x'.repeat(16 * 1024 * 1024 + 1)}`) - upstream.close() - finishSerialization() + const reader = out.getReader() - await expect(readAll(out as any)).rejects.toThrow( - 'SSR stream pending output exceeded maximum buffer', + emitScriptBatch([createIssuePayload()]) + upstream.push(`
app
${SCRIPT_BARRIER_HTML}`) + + expect((await reader.read()).done).toBe(false) + expect((await reader.read()).done).toBe(false) + + await vi.advanceTimersByTimeAsync(15) + + await expect(reader.read()).rejects.toThrow('Stream lifetime exceeded') + expect(upstream.cancelled.value).toBe(true) + expect(upstream.cancelled.reason).toEqual( + new Error('Stream lifetime exceeded'), ) expect(cleanupCalls.count).toBe(1) + expect(warnSpy).toHaveBeenCalledOnce() } finally { - errorSpy.mockRestore() + vi.useRealTimers() + warnSpy.mockRestore() } }) - test('long text without closing tags partially flushes and keeps bounded leftover', async () => { + test('forwards a large application byte chunk unchanged', async () => { const { router, finishSerialization } = makeRouter() const upstream = makeManualUpstream() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) const reader = (out as any).getReader() - const text = 'x'.repeat(MAX_LEFTOVER_CHARS + 32) + const text = '😀a'.repeat(50_000) upstream.push(text) const first = await reader.read() expect(first.done).toBe(false) const firstText = Buffer.from(first.value).toString('utf8') - expect(firstText).toBe('x'.repeat(32)) + expect(firstText).toBe(text) + upstream.push(SCRIPT_BARRIER_HTML) upstream.close() finishSerialization() let rest = '' for (;;) { const { done, value } = await reader.read() - if (done) break + if (done) { + break + } rest += Buffer.from(value).toString('utf8') } - expect(firstText + rest).toBe(text) + expect(firstText + rest).toBe(text + SCRIPT_BARRIER_HTML) }) - test('takeBufferedHtml throwing errors stream and runs cleanup once', async () => { - const { router, cleanupCalls } = makeRouter({ - takeBufferedHtml: () => { - throw new Error('take-buffered-html-boom') - }, - }) + test('forwards an incomplete document close and drains pending router scripts', async () => { + const { router, emitScriptBatch, finishSerialization, cleanupCalls } = + makeRouter() const upstream = makeManualUpstream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = out.getReader() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + upstream.push(`${SCRIPT_BARRIER_HTML}${SCRIPT_BARRIER_HTML}`, + ) + + const pendingRead = reader.read() + await flush() + emitScriptBatch(['pending()']) + finishSerialization() + upstream.close() - await expect(readAll(out as any)).rejects.toThrow('take-buffered-html-boom') + expect(Buffer.from((await pendingRead).value!).toString()).toBe( + 'pending();document.currentScript.remove()', + ) expect(cleanupCalls.count).toBe(1) }) test('downstream backpressure delays close; no chunks are lost', async () => { - // Consumer reads slowly: we verify all router-injected scripts AND the - // tail closing tags arrive before the stream ends, even though - // tryFinish() requests close while writes are still queued. - const { router, injectHtml, finishSerialization } = makeRouter() + // Consumer reads slowly: all router scripts and relocated closing tags + // must arrive before the stream ends. + const { router, emitScriptBatch, finishSerialization } = makeRouter() const upstream = makeManualUpstream() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) - upstream.push('
a
') - // Queue many injected chunks while app is still rendering. - for (let i = 0; i < 50; i++) injectHtml(``) + upstream.push(`
a
${SCRIPT_BARRIER_HTML}`) + // Queue many script batches while app is still rendering. + for (let i = 0; i < 50; i++) { + emitScriptBatch([`S${i}`]) + } upstream.push('') upstream.close() finishSerialization() @@ -922,32 +2273,137 @@ describe('transformStreamWithRouter — cleanup side-effects', () => { expect(full).toContain('
a
') expect(full).toContain('') for (let i = 0; i < 50; i++) { - expect(full).toContain(``) + expect(full).toContain(renderScriptBatch([`S${i}`])) } // All scripts must appear before . - expect(full.indexOf('')).toBeLessThan( + expect(full.indexOf(renderScriptBatch(['S49']))).toBeLessThan( full.indexOf(''), ) }) }) -describe('transformStreamWithRouter — injected HTML ordering', () => { - test('router-injected HTML interleaves at scanner safe points, not mid-tag', async () => { - const { router, injectHtml, finishSerialization } = makeRouter() +describe('transformReadableStreamWithRouter — hydration output ordering', () => { + test('does not scan or lift the barrier before the initial script take', async () => { + let taken = false + const { router, emitScriptBatch, finishSerialization } = makeRouter({ + isInitialTaken: () => taken, + }) + const upstream = makeManualUpstream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = out.getReader() + const injected = 'late()' + const rendered = renderScriptBatch([injected]) + const decoder = new TextDecoder() + + // A Ready record plus barrier-lookalike bytes rendered before + // ran (initial take pending) must pass through without lifting. + emitScriptBatch([injected]) + upstream.push(`${SCRIPT_BARRIER_HTML}`) + const first = await reader.read() + expect(first.done).toBe(false) + expect(decoder.decode(first.value)).toBe( + `${SCRIPT_BARRIER_HTML}`, + ) + + // After the take, the real boundary lifts the barrier and the queued + // record is injected after it. + taken = true + upstream.push(`
x
${SCRIPT_BARRIER_HTML}${DOCUMENT_CLOSE}`) + upstream.close() + finishSerialization() + + let rest = '' + for (;;) { + const result = await reader.read() + if (result.done) { + break + } + rest += decoder.decode(result.value, { stream: true }) + } + const barrierEnd = + rest.indexOf(SCRIPT_BARRIER_HTML) + SCRIPT_BARRIER_HTML.length + expect(rest.indexOf(SCRIPT_BARRIER_HTML)).toBeGreaterThanOrEqual(0) + expect(rest.indexOf(rendered)).toBeGreaterThanOrEqual(barrierEnd) + expect(rest.endsWith(DOCUMENT_CLOSE)).toBe(true) + }) + + test('does not interpret close-looking bytes before the router boundary', async () => { + const { router, emitScriptBatch, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const injected = 'pending()' + const rendered = renderScriptBatch([injected]) + const inlineScript = '' + + emitScriptBatch([injected]) + upstream.push( + `${inlineScript}
x
${SCRIPT_BARRIER_HTML}`, + ) + upstream.close() + finishSerialization() + + const full = await readAll(out as any) + expect(full).toContain(inlineScript) + expect(full.indexOf(rendered)).toBeGreaterThan(full.indexOf('')) + expect(full.indexOf(rendered)).toBeLessThan(full.lastIndexOf('')) + }) + + test('keeps queued script batches ordered when lifting the barrier emits synchronously', async () => { + const beforeLift = 'beforeLift()' + const duringLift = 'duringLift()' + const duringLiftEmitter: { + current?: (html: string) => void + } = {} + const { router, emitScriptBatch, finishSerialization } = makeRouter({ + liftBarrier: () => { + duringLiftEmitter.current!(duringLift) + }, + }) + duringLiftEmitter.current = (script) => emitScriptBatch([script]) + const upstream = makeManualUpstream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + + emitScriptBatch([beforeLift]) + upstream.push(`${SCRIPT_BARRIER_HTML}`) + upstream.close() + finishSerialization() + + const full = await readAll(out as any) + expect(full.indexOf(renderScriptBatch([beforeLift]))).toBeGreaterThan( + full.indexOf(SCRIPT_BARRIER_HTML), + ) + expect(full.indexOf(renderScriptBatch([beforeLift]))).toBeLessThan( + full.indexOf(renderScriptBatch([duringLift])), + ) + expect(full.indexOf(renderScriptBatch([duringLift]))).toBeLessThan( + full.indexOf(''), + ) + }) + + test('drains queued batches after the exact boundary and before the close', async () => { + const { router, emitScriptBatch, finishSerialization } = makeRouter() const upstream = makeManualUpstream() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) - // App emits a chunk ending in a complete closing tag so the scanner - // can release content + flush pending router HTML at a safe boundary. - upstream.push('
app
') - // Injected during render — must be buffered, not enqueued ahead of - // pending app content, and not split across closing tags. - injectHtml('') - injectHtml('') + upstream.push(`
app
${SCRIPT_BARRIER_HTML}`) + // Router parts must not move ahead of pending application bytes. + emitScriptBatch(['X']) + emitScriptBatch(['Y']) - // Tail chunk: end of body. Triggers pendingClosingTags capture and - // flushPendingRouterHtml before the tail is emitted on tryFinish. + // The structural close is held until all router scripts finish. upstream.push('') upstream.close() finishSerialization() @@ -956,12 +2412,12 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { // Order: app div → scripts → body close. expect(full).toContain('
app
') - expect(full).toContain('') - expect(full).toContain('') + expect(full).toContain(renderScriptBatch(['X'])) + expect(full).toContain(renderScriptBatch(['Y'])) expect(full.indexOf('
app
')).toBeLessThan( - full.indexOf(''), + full.indexOf(renderScriptBatch(['X'])), ) - expect(full.indexOf('')).toBeLessThan( + expect(full.indexOf(renderScriptBatch(['Y']))).toBeLessThan( full.indexOf(''), ) }) @@ -982,7 +2438,9 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { const out = transformReadableStreamWithRouter( router as any, upstream as any, - { onAbort: () => aborts++ }, + { + onAbort: () => aborts++, + }, ) const reader = (out as any).getReader() @@ -997,7 +2455,9 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { }) test('onAbort: NOT called on natural successful completion', async () => { - const { router, finishSerialization } = makeRouter() + const { router, finishSerialization } = makeRouter({ + reserveFastPath: () => true, + }) const upstream = new ReadableStream({ start(controller) { @@ -1010,23 +2470,249 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { const out = transformReadableStreamWithRouter( router as any, upstream as any, - { onAbort: () => aborts++ }, + { + onAbort: () => aborts++, + }, ) - // tryFinish gates on serialization-finished; signal it AFTER transform - // has subscribed to the event. finishSerialization() // Drain to completion. const reader = (out as any).getReader() - while (true) { + for (;;) { const { done } = await reader.read() - if (done) break + if (done) { + break + } } expect(aborts).toBe(0) }) + test('readable wrapper accepts string chunks', async () => { + const { router } = makeRouter({ reserveFastPath: () => true }) + const source = 'string record' + const upstream = new ReadableStream({ + start(controller) { + controller.enqueue(source) + controller.close() + }, + }) + + const out = transformReadableStreamWithRouter(router as any, upstream) + + await expect(readAll(out)).resolves.toBe(source) + }) + + test('pipeable initial fast path encodes string records independently', async () => { + const { router } = makeRouter({ + reserveFastPath: () => true, + }) + const highSurrogate = String.fromCharCode(0xd83d) + const lowSurrogate = String.fromCharCode(0xde00) + const records = [ + '', + highSurrogate, + lowSurrogate, + '', + ] + const upstream = Readable.from(records) + const out = transformPipeableStreamWithRouter(router as any, upstream) + const actual = await readAllPipeableBytes(out) + const expected = Buffer.concat(records.map((record) => Buffer.from(record))) + + expect(actual).toEqual(expected) + expect(actual.includes(Buffer.from('😀'))).toBe(false) + }) + + test('pipeable initial fast path bounds one large string record', async () => { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => true, + }) + const source = makeLargeStringRecord('', '') + const upstream = Readable.from([source]) + const out = transformPipeableStreamWithRouter(router as any, upstream) + const chunks = await readAllPipeableChunks(out) + const actual = Buffer.concat(chunks) + + expect(source.indexOf('😀')).toBe(MAX_HYDRATION_OUTPUT_CHUNK_BYTES - 1) + expect(actual).toEqual(Buffer.from(source)) + expect( + Math.max(...chunks.map((chunk) => chunk.byteLength)), + ).toBeLessThanOrEqual(MAX_HYDRATION_OUTPUT_CHUNK_BYTES) + expect(cleanupCalls.count).toBe(1) + }) + + test('pipeable fast path handles an encoding set during the first read', async () => { + const { router } = makeRouter({ + reserveFastPath: () => true, + }) + const source = 'français' + const upstream = new Readable({ + read() { + this.setEncoding('utf8') + this.push(Buffer.from(source)) + this.push(null) + }, + }) + + const out = transformPipeableStreamWithRouter(router as any, upstream) + const actual = await readAllPipeableBytes(out) + + expect(actual).toEqual(Buffer.from(source)) + }) + + test('pipeable merge path encodes string records independently', async () => { + const { router, emitScriptBatch, finishSerialization } = makeRouter() + const highSurrogate = String.fromCharCode(0xd83d) + const lowSurrogate = String.fromCharCode(0xde00) + const prefix = `${SCRIPT_BARRIER_HTML}` + const records = [prefix, highSurrogate, lowSurrogate, DOCUMENT_CLOSE] + const upstream = Readable.from(records) + const out = transformPipeableStreamWithRouter(router as any, upstream) + const parts = ['streamed()'] + + emitScriptBatch(parts) + finishSerialization() + + const actual = await readAllPipeableBytes(out) + const expected = Buffer.concat( + [ + prefix, + renderScriptBatch(parts), + highSurrogate, + lowSurrogate, + DOCUMENT_CLOSE, + ].map((record) => Buffer.from(record)), + ) + + expect(actual).toEqual(expected) + expect(actual.includes(Buffer.from('😀'))).toBe(false) + }) + + test('pipeable merge path bounds one large string record', async () => { + const { router, cleanupCalls, emitScriptBatch, finishSerialization } = + makeRouter() + const source = makeLargeStringRecord( + '
', + `
${SCRIPT_BARRIER_HTML}${DOCUMENT_CLOSE}`, + ) + const parts = ['streamed()'] + const routerScript = renderScriptBatch(parts) + const expected = + source.slice(0, -DOCUMENT_CLOSE.length) + routerScript + DOCUMENT_CLOSE + const upstream = Readable.from([source]) + const out = transformPipeableStreamWithRouter(router as any, upstream) + + emitScriptBatch(parts) + finishSerialization() + + const chunks = await readAllPipeableChunks(out) + const actual = Buffer.concat(chunks) + + expect(source.indexOf('😀')).toBe(MAX_HYDRATION_OUTPUT_CHUNK_BYTES - 1) + expect(actual).toEqual(Buffer.from(expected)) + expect(actual.indexOf(Buffer.from(routerScript))).toBeGreaterThan( + actual.indexOf(Buffer.from(SCRIPT_BARRIER_HTML)), + ) + expect(actual.indexOf(Buffer.from(routerScript))).toBeLessThan( + actual.lastIndexOf(Buffer.from(DOCUMENT_CLOSE)), + ) + expect( + Math.max(...chunks.map((chunk) => chunk.byteLength)), + ).toBeLessThanOrEqual(MAX_HYDRATION_OUTPUT_CHUNK_BYTES) + expect(cleanupCalls.count).toBe(1) + }) + + test('record-end does not split one large Node string record', async () => { + const { router, emitScriptBatch, finishSerialization, cleanupCalls } = + makeRouter() + const prefix = `${SCRIPT_BARRIER_HTML}` + const largeRecord = 'x'.repeat(MAX_HYDRATION_OUTPUT_CHUNK_BYTES * 2) + const parts = ['streamed()'] + const routerScript = renderScriptBatch(parts) + const upstream = Readable.from([prefix, largeRecord, DOCUMENT_CLOSE]) + const out = transformPipeableStreamWithRouter(router as any, upstream, { + rendererSafePoint: 'record-end', + }) + const iterator = out[Symbol.asyncIterator]() + const chunks = [Buffer.from((await iterator.next()).value)] + + chunks.push(Buffer.from((await iterator.next()).value)) + expect(chunks[0]).toEqual(Buffer.from(prefix)) + expect(chunks[1]).toEqual( + Buffer.alloc(MAX_HYDRATION_OUTPUT_CHUNK_BYTES, 'x'), + ) + + emitScriptBatch(parts) + finishSerialization() + + for (;;) { + const { done, value } = await iterator.next() + if (done) { + break + } + chunks.push(Buffer.from(value)) + } + + const actual = Buffer.concat(chunks) + const expected = Buffer.from( + prefix + largeRecord + routerScript + DOCUMENT_CLOSE, + ) + + expect(actual).toEqual(expected) + expect(actual.indexOf(Buffer.from(routerScript))).toBe( + Buffer.byteLength(prefix + largeRecord), + ) + expect(cleanupCalls.count).toBe(1) + }) + + test('cancelling a large Node string drain tears down once', async () => { + const { router, cleanupCalls } = makeRouter({ + reserveFastPath: () => true, + }) + const source = makeLargeStringRecord('', '') + let upstreamCancellations = 0 + let pushed = false + const upstream = new Readable({ + objectMode: true, + read() { + if (!pushed) { + pushed = true + this.push(source) + } + }, + destroy(_error, callback) { + upstreamCancellations++ + callback() + }, + }) + const upstreamClosed = new Promise((resolve) => { + upstream.once('close', resolve) + }) + const onAbort = vi.fn() + const out = transformReadableStreamWithRouter( + router as any, + Readable.toWeb(upstream) as any, + { + onAbort, + }, + ) + const reader = out.getReader() + const first = await reader.read() + + expect(first.value!.byteLength).toBe(MAX_HYDRATION_OUTPUT_CHUNK_BYTES - 1) + + const reason = new Error('consumer stopped') + await reader.cancel(reason) + await upstreamClosed + await flush() + + expect(upstreamCancellations).toBe(1) + expect(onAbort).toHaveBeenCalledOnce() + expect(cleanupCalls.count).toBe(1) + }) + test('onAbort: fires when pipeable wrapper consumer destroys', async () => { const { router, finishSerialization } = makeRouter() finishSerialization() @@ -1053,12 +2739,13 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { expect(aborts).toBe(1) // Cleanup: destroy upstream so we don't leak. - if (!pass.destroyed) pass.destroy() + if (!pass.destroyed) { + pass.destroy() + } }) test('onAbort: lifetime timeout triggers abort exactly once', async () => { vi.useFakeTimers() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const { router, finishSerialization } = makeRouter() @@ -1076,19 +2763,24 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { const out = transformReadableStreamWithRouter( router as any, upstream as any, - { onAbort: () => aborts++, lifetimeMs: 1000 }, + { + onAbort: () => aborts++, + lifetimeMs: 1000, + }, ) - // Start reading (which may reject when stream is errored) + // Start reading before the lifetime watchdog errors the stream. const reader = (out as any).getReader() - const readP = reader.read().catch(() => undefined) + const readP = reader.read() + const readError = expect(readP).rejects.toThrow( + 'Stream lifetime exceeded', + ) await vi.advanceTimersByTimeAsync(1500) - await readP + await readError expect(aborts).toBe(1) } finally { - errorSpy.mockRestore() warnSpy.mockRestore() vi.useRealTimers() } @@ -1111,17 +2803,23 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { const out = transformReadableStreamWithRouter( router as any, upstream as any, - { onAbort: () => aborts++, timeoutMs: 10 }, + { + onAbort: () => aborts++, + timeoutMs: 10, + }, ) const reader = (out as any).getReader() - const readP = reader.read().catch(() => undefined) + const readP = reader.read() + const readError = expect(readP).rejects.toThrow( + 'Stream lifetime exceeded', + ) await vi.advanceTimersByTimeAsync(15) expect(aborts).toBe(0) await vi.advanceTimersByTimeAsync(6) - await readP + await readError expect(aborts).toBe(1) } finally { errorSpy.mockRestore() @@ -1134,10 +2832,10 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { const { router, finishSerialization } = makeRouter() finishSerialization() - // Simulate the Vue sync-setup-throw recovery path: a TransformStream is + // Simulate a synchronous producer setup failure: a TransformStream is // handed to the router transform, and the producer never writes before - // aborting the writable side. The router transform's readable must - // resolve (with done or an error) rather than wait for lifetimeMs. + // aborting the writable side. The router transform's readable must resolve + // (with done or an error) rather than wait for lifetimeMs. const ts = new TransformStream() const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { @@ -1145,7 +2843,9 @@ describe('transformStreamWithRouter — injected HTML ordering', () => { const out = transformReadableStreamWithRouter( router as any, ts.readable as any, - { onAbort: () => aborts++ }, + { + onAbort: () => aborts++, + }, ) void ts.writable.abort(new Error('setup-throw')).catch(() => {}) diff --git a/packages/router-core/tsconfig.json b/packages/router-core/tsconfig.json index faf64b93825..34cb3c4dc90 100644 --- a/packages/router-core/tsconfig.json +++ b/packages/router-core/tsconfig.json @@ -6,5 +6,11 @@ "@tanstack/router-core/isServer": ["./src/isServer/development.ts"] } }, - "include": ["src", "vite.config.ts", "tests", "vite-minify-plugin.ts"] + "include": [ + "src", + "vite.config.ts", + "tests", + "vite-minify-plugin.ts", + "benchmarks/ssr-streaming/worker.ts" + ] } diff --git a/packages/router-ssr-query-core/package.json b/packages/router-ssr-query-core/package.json index bb47f7f2993..9e9038431c6 100644 --- a/packages/router-ssr-query-core/package.json +++ b/packages/router-ssr-query-core/package.json @@ -66,7 +66,7 @@ "node": ">=20.19" }, "devDependencies": { - "@tanstack/router-core": ">=1.171.7", + "@tanstack/router-core": "workspace:*", "@tanstack/query-core": ">=5.102.0", "vite": "catalog:", "@tanstack/vite-config": "catalog:", @@ -77,7 +77,7 @@ "rimraf": "catalog:" }, "peerDependencies": { - "@tanstack/router-core": ">=1.127.0", + "@tanstack/router-core": ">=1.171.28", "@tanstack/query-core": ">=5.102.0" } } diff --git a/packages/router-ssr-query-core/src/index.ts b/packages/router-ssr-query-core/src/index.ts index 830e6037932..a36eeddba18 100644 --- a/packages/router-ssr-query-core/src/index.ts +++ b/packages/router-ssr-query-core/src/index.ts @@ -225,35 +225,39 @@ export function setupCoreRouterSsrQueryIntegration({ } const originalHydrate = router.options.hydrate router.options.hydrate = async (dehydrated: DehydratedRouterQueryState) => { - await originalHydrate?.(dehydrated) - const query = dehydrated.query - if (query.initial) { - hydrateQueryClient( - queryClient, - { queries: query.initial }, - hydrateOptions, - ) + try { + await originalHydrate?.(dehydrated) + + if (query.initial) { + hydrateQueryClient( + queryClient, + { queries: query.initial }, + hydrateOptions, + ) + } + } catch (error) { + void query.stream.cancel(error).catch(() => {}) + throw error } const reader = query.stream.getReader() - reader - .read() - .then(function handle({ - done, - value, - }: ReadableStreamReadResult< - Array - >): void | Promise { - if (done) { - return + void (async () => { + try { + for (;;) { + const { done, value } = await reader.read() + if (done) { + return + } + hydrateQueryClient(queryClient, { queries: value }, hydrateOptions) } - hydrateQueryClient(queryClient, { queries: value }, hydrateOptions) - return reader.read().then(handle) - }) - .catch((error) => { + } catch (error) { console.error('Error reading query stream:', error) - }) + void reader.cancel(error).catch(() => {}) + } finally { + reader.releaseLock() + } + })() } if (handleRedirects) { const originalMutationCacheConfig = queryClient.getMutationCache().config diff --git a/packages/router-ssr-query-core/tests/index.test.ts b/packages/router-ssr-query-core/tests/index.test.ts index 4adb7a822cd..af76a7579fb 100644 --- a/packages/router-ssr-query-core/tests/index.test.ts +++ b/packages/router-ssr-query-core/tests/index.test.ts @@ -275,6 +275,116 @@ describe('setupCoreRouterSsrQueryIntegration', () => { expect(queryClient.getQueryData(['initial'])).toBe('initial-hydrated') expect(queryClient.getQueryData(['streamed'])).toBe('stream-hydrated') expect(queryClient.getQueryData(['streamed-batch'])).toBe('batch-hydrated') + expect(stream.locked).toBe(false) + }) + + it('releases a failed query stream reader', async () => { + const queryClient = track(new QueryClient()) + const router: TestRouter = { isServer: false, options: {} } + const error = new Error('stream failed') + const stream = new ReadableStream>({ + start(controller) { + controller.error(error) + }, + }) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + setupCoreRouterSsrQueryIntegration({ + router: router as any, + queryClient, + }) + await router.options.hydrate?.({ query: { stream } }) + await vi.waitFor(() => { + expect(stream.locked).toBe(false) + }) + + expect(consoleError).toHaveBeenCalledWith( + 'Error reading query stream:', + error, + ) + }) + + it('cancels the query stream when hydration fails', async () => { + const queryClient = track(new QueryClient()) + const router: TestRouter = { isServer: false, options: {} } + const error = new Error('hydration failed') + const cancel = vi.fn(() => new Promise(() => {})) + const stream = new ReadableStream({ + start(controller) { + controller.enqueue([ + { + queryHash: '["streamed"]', + queryKey: ['streamed'], + state: createDehydratedQueryState('stream'), + }, + ]) + }, + cancel, + }) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + setupCoreRouterSsrQueryIntegration({ + router: router as any, + queryClient, + hydrateOptions: { + defaultOptions: { + deserializeData: () => { + throw error + }, + }, + }, + }) + await router.options.hydrate?.({ query: { stream } }) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith(error) + expect(stream.locked).toBe(false) + }) + + expect(consoleError).toHaveBeenCalledWith( + 'Error reading query stream:', + error, + ) + }) + + it('cancels the query stream when initial hydration fails', async () => { + const queryClient = track(new QueryClient()) + const router: TestRouter = { isServer: false, options: {} } + const error = new Error('initial hydration failed') + const cancel = vi.fn(() => new Promise(() => {})) + const stream = new ReadableStream({ cancel }) + + setupCoreRouterSsrQueryIntegration({ + router: router as any, + queryClient, + hydrateOptions: { + defaultOptions: { + deserializeData: () => { + throw error + }, + }, + }, + }) + await expect( + router.options.hydrate?.({ + query: { + initial: [ + { + queryHash: '["initial"]', + queryKey: ['initial'], + state: createDehydratedQueryState('initial'), + }, + ], + stream, + }, + }), + ).rejects.toBe(error) + + expect(cancel).toHaveBeenCalledWith(error) + expect(stream.locked).toBe(false) }) it('subscribes after initial dehydration and releases after rendering', async () => { diff --git a/packages/solid-router-ssr-query/package.json b/packages/solid-router-ssr-query/package.json index f2627a13d5d..4eff3ab36f8 100644 --- a/packages/solid-router-ssr-query/package.json +++ b/packages/solid-router-ssr-query/package.json @@ -83,7 +83,7 @@ "peerDependencies": { "@tanstack/query-core": ">=5.102.0", "@tanstack/solid-query": ">=5.102.0", - "@tanstack/solid-router": ">=1.127.0", + "@tanstack/solid-router": ">=1.170.31", "solid-js": "^1.9.10" } } diff --git a/packages/solid-router/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx index 998115c10dc..9f74cadc87b 100644 --- a/packages/solid-router/src/CatchBoundary.tsx +++ b/packages/solid-router/src/CatchBoundary.tsx @@ -20,23 +20,34 @@ export function CatchBoundary( Solid.on(props.getResetKey, () => reset(), { defer: true }), ) - return process.env.NODE_ENV !== 'production' ? ( - renderInNonRouteComponentContext( - () => ( - - ), - 'errorComponent', + // A lazy error component can suspend after the route enters its + // error state. Keep that suspension here so it does not render the + // route's pending fallback again. + if (process.env.NODE_ENV !== 'production') { + return ( + + {renderInNonRouteComponentContext( + () => ( + + ), + 'errorComponent', + )} + ) - ) : ( - + } + + return ( + + + ) }} > diff --git a/packages/solid-router/src/Scripts.tsx b/packages/solid-router/src/Scripts.tsx index 0a547521e29..dcc4102c662 100644 --- a/packages/solid-router/src/Scripts.tsx +++ b/packages/solid-router/src/Scripts.tsx @@ -5,6 +5,10 @@ import { Asset } from './Asset' import { useRouter } from './useRouter' import type { RouterManagedTag } from '@tanstack/router-core' +/** + * During streaming SSR, `` marks where late hydration scripts may + * begin to be inserted. + */ export const Scripts = () => { const router = useRouter() const nonce = router.options.ssr?.nonce @@ -39,15 +43,19 @@ export const Scripts = () => { return previous ? replaceEqualDeep(previous, next) : next }, ) - const serverBufferedScript = + const initialHydrationScripts = (isServer ?? router.isServer) && router.serverSsr - ? router.serverSsr.takeBufferedScripts() + ? router.serverSsr.takeInitialHydrationScriptTags() : undefined - return ( <> - {serverBufferedScript && } + + {(asset) => } + {(asset) => } + {initialHydrationScripts && ( + + )} ) } diff --git a/packages/solid-router/src/awaited.tsx b/packages/solid-router/src/awaited.tsx index 148f45eddaf..ecc5fe2a5d6 100644 --- a/packages/solid-router/src/awaited.tsx +++ b/packages/solid-router/src/awaited.tsx @@ -31,19 +31,7 @@ export function Await( }, ) { if (!('fallback' in props)) { - const [resource] = Solid.createResource( - () => defer(props.promise), - (p) => p, - { - deferStream: true, - }, - ) - - return ( - - {(data) => props.children(data())} - - ) + return } return ( @@ -61,8 +49,12 @@ function AwaitInner( ) { const [resource] = Solid.createResource( () => defer(props.promise), - (p) => p, + async (promise) => ({ value: await promise }), ) - return props.children(resource() as T) + return ( + + {(result) => props.children(result().value)} + + ) } diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 31be74086fd..f875a634771 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -179,7 +179,6 @@ export type { RouterOptions, RouterConstructorOptions, ControllablePromise, - InjectedHtmlEntry, CreateFileRoute, CreateLazyFileRoute, AnySerializationAdapter, diff --git a/packages/solid-router/src/lazyRouteComponent.tsx b/packages/solid-router/src/lazyRouteComponent.tsx index ad47717b0df..32d4f071102 100644 --- a/packages/solid-router/src/lazyRouteComponent.tsx +++ b/packages/solid-router/src/lazyRouteComponent.tsx @@ -1,5 +1,5 @@ import { Dynamic } from 'solid-js/web' -import { createResource } from 'solid-js' +import { lazy } from 'solid-js' import { isModuleNotFoundError } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import type { AsyncRouteComponent } from './route' @@ -25,7 +25,7 @@ export function lazyRouteComponent< // Resolved clients have no preload work; SSR can reuse the import. if (!(isServer ?? typeof window === 'undefined')) { loadPromise = undefined - ;(lazyComp as any).preload = undefined + ;(Lazy as any).preload = undefined } comp = res[exportName ?? 'default'] return comp @@ -38,7 +38,16 @@ export function lazyRouteComponent< return loadPromise } - const lazyComp = function Lazy(props: any) { + function Lazy(props: any) { + if (comp || error) { + return render(props) + } + + return + } + + // Both preload and lazy resolution end here without starting another load. + function render(props: any) { // Now that we're out of preload and into actual render path, // throw the error if it was a module not found error during preload if (error) { @@ -67,10 +76,8 @@ export function lazyRouteComponent< sessionStorage.setItem(storageKey, '1') window.location.reload() - // Return empty component while we wait for window to reload - return { - default: () => null, - } + // Return no content while we wait for window to reload. + return null } } } @@ -79,18 +86,20 @@ export function lazyRouteComponent< throw error } - if (!comp) { - const [compResource] = createResource(load, { - initialValue: comp, - ssrLoadFrom: 'initial', - }) - return - } - return } - ;(lazyComp as any).preload = load + const Loadable = lazy(async () => { + await load() + if (process.env.NODE_ENV !== 'production' && !comp && !error) { + throw new Error( + `lazyRouteComponent: export "${String(exportName ?? 'default')}" not found`, + ) + } + return { default: render } + }) + + ;(Lazy as any).preload = load - return lazyComp as any + return Lazy as any } diff --git a/packages/solid-router/src/ssr/RouterServer.tsx b/packages/solid-router/src/ssr/RouterServer.tsx index baa1c455029..1cdc85e8c10 100644 --- a/packages/solid-router/src/ssr/RouterServer.tsx +++ b/packages/solid-router/src/ssr/RouterServer.tsx @@ -2,7 +2,6 @@ import { Hydration, HydrationScript, NoHydration, - ssr, useAssets, } from 'solid-js/web' import { MetaProvider } from '@solidjs/meta' @@ -26,14 +25,11 @@ export function ServerHeadContent() { return null } -const docType = ssr('') - export function RouterServer(props: { router: TRouter }) { return ( - {docType as any} diff --git a/packages/solid-router/src/ssr/renderOptions.ts b/packages/solid-router/src/ssr/renderOptions.ts new file mode 100644 index 00000000000..c91554de2fe --- /dev/null +++ b/packages/solid-router/src/ssr/renderOptions.ts @@ -0,0 +1,29 @@ +import { makeSsrSerovalPlugin } from '@tanstack/router-core/ssr/server' +import type { renderToStream } from 'solid-js/web' +import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core' + +// Adapters are module-level singletons, so their SSR plugins are cached per +// adapter rather than rebuilt for every request. +const pluginCache = new WeakMap< + AnySerializationAdapter, + ReturnType +>() + +function getPlugin(adapter: AnySerializationAdapter) { + let plugin = pluginCache.get(adapter) + if (!plugin) { + plugin = makeSsrSerovalPlugin(adapter) + pluginCache.set(adapter, plugin) + } + return plugin +} + +/** Solid renderer options shared by the stream and string paths. */ +export function getSolidRenderOptions(router: AnyRouter) { + return { + nonce: router.options.ssr?.nonce, + // `plugins` is honoured by Solid's server runtime but absent from its + // public option type. + plugins: router.options.serializationAdapters?.map(getPlugin), + } as Parameters[1] +} diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 7432f195105..cd4d0f2cd64 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -2,37 +2,14 @@ import * as Solid from 'solid-js/web' import { isbot } from 'isbot' import { createSsrStreamResponse, + getSsrStatus, transformReadableStreamWithRouter, + waitForRequest, } from '@tanstack/router-core/ssr/server' -import { makeSsrSerovalPlugin } from '@tanstack/router-core' +import { getSolidRenderOptions } from './renderOptions' import type { JSXElement } from 'solid-js' -import type { ReadableStream } from 'node:stream/web' import type { AnyRouter } from '@tanstack/router-core' -const noop = () => {} - -// Bot responses wait for the server renderer before streaming. If the request -// disconnects during that wait, unblock so the pipe can abort and clean up. -async function waitForReadyOrAbort( - ready: Promise, - signal: AbortSignal, -) { - let cleanup = noop - try { - await Promise.race([ - ready, - new Promise((resolve) => { - const onAbort = () => resolve() - cleanup = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) resolve() - }), - ]) - } finally { - cleanup() - } -} - export const renderRouterToStream = async ({ request, router, @@ -44,139 +21,59 @@ export const renderRouterToStream = async ({ responseHeaders: Headers children: () => JSXElement }) => { - const { writable, readable } = new TransformStream() - - const docType = Solid.ssr('') + const signal = request.signal + if (signal.aborted) { + router.serverSsr?.cleanup() + throw signal.reason + } - const serializationAdapters = - (router.options as any)?.serializationAdapters || - (router.options.ssr as any)?.serializationAdapters - const serovalPlugins = serializationAdapters?.map((adapter: any) => { - const plugin = makeSsrSerovalPlugin(adapter, { didRun: false }) - return plugin - }) + try { + const docType = Solid.ssr('') + const stream = Solid.renderToStream( + () => ( + <> + {docType} + {children()} + + ), + getSolidRenderOptions(router), + ) - const stream = Solid.renderToStream( - () => ( - <> - {docType} - {children()} - - ), - { - nonce: router.options.ssr?.nonce, - plugins: serovalPlugins, - } as any, - ) + // Cancelling the transform's reader errors this writable, which makes + // Solid's later writes reject and be ignored. Solid exposes no disposal + // handle for unresolved renderer work; see router-core's STREAMING.md. + const { readable, writable } = new TransformStream() + const rendererAbort = isbot(request.headers.get('User-Agent')) + ? new AbortController() + : undefined + const responseStream = transformReadableStreamWithRouter(router, readable, { + rendererSafePoint: 'record-end', + signal, + onAbort: rendererAbort + ? (reason) => rendererAbort.abort(reason) + : undefined, + }) - // Solid's `pipeTo(w)` takes a single arg (no signal overload) and locks - // `w` via `w.getWriter()`. To still own the lifecycle we hand Solid a - // proxy WritableStream that forwards into an inner writer we control on - // the real TransformStream writable. Aborting the inner writer errors - // the underlying readable (which our router transform reads from), - // surfacing the cancel through the response pipeline. - // - // RESIDUAL RISK: solid-js@1.x does NOT expose a disposal hook on - // `renderToStream`, and its internal write loop swallows writer - // rejections (`writer.write(...).catch(() => {})` in - // solid-js/web/dist/server.js). So aborting the inner writer stops - // outbound bytes but does not terminate Solid's render continuation - // if a Suspense/resource never resolves — those pending promise - // continuations remain scheduled and can retain children/context/ - // request references via captured closures until natural completion - // or process exit. The request-scoped router graph itself is released - // by ServerSsr.cleanup() through the router stream lifecycle, so the leak - // is bounded to whatever the user's Suspense/resource closures capture. - // A hard upstream-abort guarantee would require a disposal API in solid-js. - const innerWriter = writable.getWriter() - let writerDone = false - const releaseWriter = () => { - try { - innerWriter.releaseLock() - } catch { - // already released / errored + if (rendererAbort) { + await waitForRequest(stream as unknown, rendererAbort.signal) } - } - const abortSolidPipe = (reason?: unknown) => { - if (writerDone) return - writerDone = true - void innerWriter - .abort(reason) - .catch(() => {}) - .finally(releaseWriter) - } - - const onRequestAbort = () => { - abortSolidPipe(request.signal.reason) - } - // Wire request abort before the bot all-ready wait. Otherwise a disconnect - // during `await stream` can leave this callback pending forever. - if (request.signal.aborted) { - onRequestAbort() - } else { - request.signal.addEventListener('abort', onRequestAbort, { once: true }) - router.serverSsr?.onCleanup(() => { - request.signal.removeEventListener('abort', onRequestAbort) + // The server export returns Promise. Solid's client declaration + // returns void, and this file is also checked under that export condition. + void Promise.resolve(stream.pipeTo(writable)).catch((error: unknown) => { + console.error('Error in Solid render stream:', error) + void writable.abort(error).catch(() => {}) }) - } - if (isbot(request.headers.get('User-Agent'))) { - await waitForReadyOrAbort( - Promise.resolve(stream as unknown), - request.signal, + return createSsrStreamResponse( + router, + new Response(responseStream, { + status: getSsrStatus(router), + headers: responseHeaders, + }), ) + } catch (error) { + router.serverSsr?.cleanup() + throw error } - - const solidWritable = new WritableStream({ - write(chunk) { - return innerWriter.write(chunk) - }, - close() { - writerDone = true - return innerWriter.close().finally(releaseWriter) - }, - abort(reason) { - writerDone = true - return innerWriter.abort(reason).finally(releaseWriter) - }, - }) - - if (!request.signal.aborted) { - try { - void Promise.resolve(stream.pipeTo(solidWritable) as unknown).catch( - (err: any) => { - if ( - writerDone || - err?.name === 'AbortError' || - err?.code === 'ABORT_ERR' - ) - return - console.error('Error in Solid render stream:', err) - abortSolidPipe(err) - }, - ) - } catch (err: any) { - if (err?.name !== 'AbortError' && err?.code !== 'ABORT_ERR') { - console.error('Error in Solid render stream:', err) - } - abortSolidPipe(err) - } - } - - const responseStream = transformReadableStreamWithRouter( - router, - readable as unknown as ReadableStream, - { signal: request.signal, onAbort: abortSolidPipe }, - ) - return createSsrStreamResponse( - router, - new Response(responseStream as any, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, - headers: responseHeaders, - }), - ) } diff --git a/packages/solid-router/src/ssr/renderRouterToString.tsx b/packages/solid-router/src/ssr/renderRouterToString.tsx index 421ac234bc8..bcdb323b9bf 100644 --- a/packages/solid-router/src/ssr/renderRouterToString.tsx +++ b/packages/solid-router/src/ssr/renderRouterToString.tsx @@ -1,9 +1,13 @@ import * as Solid from 'solid-js/web' -import { makeSsrSerovalPlugin } from '@tanstack/router-core' +import { + getSsrStatus, + transformHtmlStringWithRouter, +} from '@tanstack/router-core/ssr/server' +import { getSolidRenderOptions } from './renderOptions' import type { AnyRouter } from '@tanstack/router-core' import type { JSXElement } from 'solid-js' -export const renderRouterToString = ({ +export const renderRouterToString = async ({ router, responseHeaders, children, @@ -13,29 +17,12 @@ export const renderRouterToString = ({ children: () => JSXElement }) => { try { - const serializationAdapters = - (router.options as any)?.serializationAdapters || - (router.options.ssr as any)?.serializationAdapters - const serovalPlugins = serializationAdapters?.map((adapter: any) => { - const plugin = makeSsrSerovalPlugin(adapter, { didRun: false }) - return plugin - }) - - let html = Solid.renderToString(children, { - nonce: router.options.ssr?.nonce, - plugins: serovalPlugins, - } as any) - router.serverSsr!.setRenderFinished() - - const injectedHtml = router.serverSsr!.takeBufferedHtml() - if (injectedHtml) { - html = html.replace(``, () => `${injectedHtml}`) - } - return new Response(`${html}`, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, + const html = await transformHtmlStringWithRouter( + router, + Solid.renderToString(children, getSolidRenderOptions(router)), + ) + return new Response(html, { + status: getSsrStatus(router), headers: responseHeaders, }) } catch (error) { diff --git a/packages/solid-router/tests/Await.test.tsx b/packages/solid-router/tests/Await.test.tsx new file mode 100644 index 00000000000..748a552e91e --- /dev/null +++ b/packages/solid-router/tests/Await.test.tsx @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, waitFor } from '@solidjs/testing-library' +import { Await } from '../src/awaited' + +afterEach(cleanup) + +describe('Await', () => { + it.each([ + ['zero', 0], + ['false', false], + ['empty string', ''], + ['null', null], + ['undefined', undefined], + ])('renders a resolved %s value', async (_name, value) => { + const children = vi.fn(() => resolved) + + render(() => ( + pending}> + {children} + + )) + + await waitFor(() => { + expect(children).toHaveBeenCalledOnce() + }) + expect(children).toHaveBeenCalledWith(value) + }) +}) diff --git a/packages/solid-router/tests/CatchBoundary.test.tsx b/packages/solid-router/tests/CatchBoundary.test.tsx new file mode 100644 index 00000000000..4c1ad0a835d --- /dev/null +++ b/packages/solid-router/tests/CatchBoundary.test.tsx @@ -0,0 +1,131 @@ +import { resolve } from 'node:path' +import { Suspense, lazy } from 'solid-js' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' +import { cleanup, render, screen } from '@solidjs/testing-library' +import { createControlledPromise } from '@tanstack/router-core' +import { build } from 'vite' +import solid from 'vite-plugin-solid' +import { + CatchBoundary, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +test('keeps the Outlet diagnostic context after a route render throws', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + throw new Error('Render failed') + }, + errorComponent: () => ( + <> + Render fallback + + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + + expect(await screen.findByText('Render fallback')).toBeInTheDocument() + expect(warn).toHaveBeenCalledWith( + 'Warning: An was rendered inside a errorComponent. should only be rendered inside a route component.', + ) +}) + +test('keeps a lazy error fallback inside its own Suspense boundary', async () => { + const renderError = new Error('Render failed') + const onCatch = vi.fn() + const ErrorFallback = (props: ErrorComponentProps) => ( + Lazy fallback: {props.error.message} + ) + const pending = createControlledPromise<{ default: typeof ErrorFallback }>() + const importer = vi.fn(() => pending) + const LazyError = lazy(importer) + onTestFinished(() => pending.resolve({ default: ErrorFallback })) + + function Broken(): never { + throw renderError + } + + render(() => ( + Outer pending}> + Ready shell + 0} + errorComponent={(props) => } + onCatch={onCatch} + > + + + + )) + + expect(importer).toHaveBeenCalledOnce() + expect(screen.getByText('Ready shell')).toBeInTheDocument() + expect(screen.queryByText('Outer pending')).not.toBeInTheDocument() + expect( + screen.queryByText('Lazy fallback: Render failed'), + ).not.toBeInTheDocument() + expect(onCatch).toHaveBeenCalledOnce() + expect(onCatch).toHaveBeenCalledWith(renderError) + + pending.resolve({ default: ErrorFallback }) + + expect( + await screen.findByText('Lazy fallback: Render failed'), + ).toBeInTheDocument() + expect(screen.getByText('Ready shell')).toBeInTheDocument() + expect(screen.queryByText('Outer pending')).not.toBeInTheDocument() +}) + +test('removes the diagnostic context from the production bundle', async () => { + const result = await build({ + configFile: false, + logLevel: 'silent', + plugins: [solid({ dev: false, hot: false })], + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + build: { + write: false, + minify: true, + lib: { + entry: resolve(import.meta.dirname, '../src/CatchBoundary.tsx'), + formats: ['es'], + }, + rollupOptions: { external: ['solid-js', 'solid-js/web'] }, + }, + }) + const outputs = Array.isArray(result) ? result : [result] + const code = outputs + .flatMap((output) => { + if (!('output' in output)) { + throw new Error('Expected an in-memory build result') + } + return output.output.flatMap((chunk) => + chunk.type === 'chunk' ? [chunk.code] : [], + ) + }) + .join('\n') + + expect(code).toContain('CatchBoundary') + expect(code).toContain('Suspense') + expect(code).not.toContain('.Provider') +}) diff --git a/packages/solid-router/tests/component-preload-retry.test.tsx b/packages/solid-router/tests/component-preload-retry.test.tsx index 47cee1d0d6a..fda9f2ca3b9 100644 --- a/packages/solid-router/tests/component-preload-retry.test.tsx +++ b/packages/solid-router/tests/component-preload-retry.test.tsx @@ -1,4 +1,5 @@ -import { afterEach, expect, test, vi } from 'vitest' +import { ErrorBoundary, Suspense } from 'solid-js' +import { afterEach, expect, onTestFinished, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' import { createControlledPromise } from '@tanstack/router-core' import { @@ -118,14 +119,42 @@ test('revisiting a resolved lazy component skips pending UI', async () => { expect(importer).toHaveBeenCalledOnce() }) +test('a missing export fails through the route error UI instead of recursing', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const importer = vi.fn().mockResolvedValue({ Other: () => null }) + const Page = lazyRouteComponent(importer, 'Missing' as never) + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: (props: ErrorComponentProps) => ( +
{props.error.message}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + + expect( + await screen.findByText('lazyRouteComponent: export "Missing" not found'), + ).toBeInTheDocument() +}) + test('a failed component download is retried from the route error UI', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) const PageContent = () =>
Page content
+ const retryImport = createControlledPromise<{ default: typeof PageContent }>() + onTestFinished(() => retryImport.resolve({ default: PageContent })) const importer = vi .fn<() => Promise<{ default: typeof PageContent }>>() .mockRejectedValueOnce(new Error('component download failed')) - .mockResolvedValue({ default: PageContent }) + .mockImplementation(() => retryImport) const Page = lazyRouteComponent(importer) function RouteError(props: ErrorComponentProps) { @@ -159,5 +188,62 @@ test('a failed component download is retried from the route error UI', async () fireEvent.click(await screen.findByRole('button', { name: 'Retry' })) + await vi.waitFor(() => expect(importer).toHaveBeenCalledTimes(2)) + expect(screen.queryByText('Page content')).not.toBeInTheDocument() + retryImport.resolve({ default: PageContent }) + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) + expect(Page.preload).toBeUndefined() +}) + +test('a component first loaded during render recovers after an awaited preload retry', async () => { + const PageContent = () =>
Recovered component
+ const retryImport = createControlledPromise<{ default: typeof PageContent }>() + onTestFinished(() => retryImport.resolve({ default: PageContent })) + const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(new Error('component download failed')) + .mockImplementation(() => retryImport) + const Page = lazyRouteComponent(importer) + let retry: Promise | undefined + + render(() => ( + ( + + )} + > + Loading component}> + + + + )) + + const button = await screen.findByRole('button', { + name: 'Retry component download failed', + }) + expect(importer).toHaveBeenCalledOnce() + fireEvent.click(button) + + expect(importer).toHaveBeenCalledTimes(2) + expect(retry).toBeDefined() + expect(Page.preload?.()).toBe(retry) + expect(button).toBeInTheDocument() + expect(screen.queryByText('Recovered component')).not.toBeInTheDocument() + + retryImport.resolve({ default: PageContent }) + await retry + + expect(await screen.findByText('Recovered component')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) + expect(Page.preload).toBeUndefined() }) diff --git a/packages/solid-router/tests/issue-8115-context.test.tsx b/packages/solid-router/tests/issue-8115-context.test.tsx index 659cb59309a..114935549c0 100644 --- a/packages/solid-router/tests/issue-8115-context.test.tsx +++ b/packages/solid-router/tests/issue-8115-context.test.tsx @@ -446,11 +446,14 @@ test('#8115: hydration does not render a successful route with missing context w expect(serverRouter.state.matches.at(-1)?.status).toBe('success') await serverRouter.serverSsr!.dehydrate() - const script = serverRouter.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() - currentScriptSpy.mockReturnValue(document.createElement('script')) - // This script comes exclusively from the router's production SSR serializer. - new Function(script!.children!)() + const scripts = serverRouter.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.before.length).toBeGreaterThan(0) + // These scripts come exclusively from the router's production SSR serializer. + for (const script of scripts!.before) { + expect(script.children).toBeTruthy() + currentScriptSpy.mockReturnValue(document.createElement('script')) + new Function(script.children!)() + } } finally { currentScriptSpy.mockRestore() serverRouter.serverSsr?.cleanup() diff --git a/packages/solid-router/tests/lazyRouteComponent.production.test.tsx b/packages/solid-router/tests/lazyRouteComponent.production.test.tsx new file mode 100644 index 00000000000..34ccb971028 --- /dev/null +++ b/packages/solid-router/tests/lazyRouteComponent.production.test.tsx @@ -0,0 +1,135 @@ +import { resolve } from 'node:path' +import { setImmediate } from 'node:timers/promises' +import * as Solid from 'solid-js' +import * as SolidWeb from 'solid-js/web' +import { afterEach, beforeAll, expect, test, vi } from 'vitest' +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { build } from 'vite' +import solid from 'vite-plugin-solid' +import type { lazyRouteComponent } from '../src/lazyRouteComponent' + +let productionLazyRouteComponent: typeof lazyRouteComponent + +beforeAll(async () => { + const result = await build({ + configFile: false, + logLevel: 'silent', + plugins: [solid({ dev: false, hot: false })], + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + build: { + write: false, + minify: true, + lib: { + entry: resolve(import.meta.dirname, '../src/lazyRouteComponent.tsx'), + formats: ['cjs'], + }, + rollupOptions: { external: ['solid-js', 'solid-js/web'] }, + }, + }) + const outputs = Array.isArray(result) ? result : [result] + const code = outputs + .flatMap((output) => { + if (!('output' in output)) { + throw new Error('Expected an in-memory build result') + } + return output.output.flatMap((chunk) => + chunk.type === 'chunk' ? [chunk.code] : [], + ) + }) + .join('\n') + const exports: { lazyRouteComponent?: typeof lazyRouteComponent } = {} + + // Use the test's Solid owner graph with the production-compiled router code. + new Function('require', 'exports', code)((id: string) => { + if (id === 'solid-js') { + return Solid + } + if (id === 'solid-js/web') { + return SolidWeb + } + throw new Error(`Unexpected external module: ${id}`) + }, exports) + + expect(exports.lazyRouteComponent).toBeTypeOf('function') + productionLazyRouteComponent = exports.lazyRouteComponent! +}, 60_000) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +test.each([ + { exportName: undefined, preload: false }, + { exportName: 'Missing', preload: false }, + { exportName: undefined, preload: true }, + { exportName: 'Missing', preload: true }, +])( + 'a missing $exportName export renders blank with preload=$preload in production', + async ({ exportName, preload }) => { + const importer = vi.fn().mockResolvedValue({ Other: () => null }) + const Page = productionLazyRouteComponent(importer, exportName as never) + const onError = vi.fn(() => Render failed) + if (preload) { + await Page.preload?.() + } + + const { container } = render(() => ( + + Loading component}> + + + + )) + + await waitFor(() => + expect(screen.queryByText('Loading component')).not.toBeInTheDocument(), + ) + // Finish queued lazy-import continuations before checking for render errors. + await setImmediate() + expect(importer).toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + expect(container).toBeEmptyDOMElement() + }, +) + +test('a preloaded production component renders synchronously with its props', async () => { + const importer = vi.fn(() => + Promise.resolve({ + Page: (props: { label: string }) => {props.label}, + }), + ) + const Page = productionLazyRouteComponent(importer, 'Page') + await Page.preload?.() + + render(() => ( + Loading component}> + + + )) + + expect(screen.getByText('Ready component')).toBeInTheDocument() + expect(screen.queryByText('Loading component')).not.toBeInTheDocument() + expect(importer).toHaveBeenCalledOnce() + expect(Page.preload).toBeUndefined() +}) + +test('a rejected production import reaches the error boundary', async () => { + const importer = vi + .fn() + .mockRejectedValue(new Error('component download failed')) + const Page = productionLazyRouteComponent(importer) + + render(() => ( + {error.message}}> + Loading component}> + + + + )) + + expect( + await screen.findByText('component download failed'), + ).toBeInTheDocument() + expect(importer).toHaveBeenCalledOnce() +}) diff --git a/packages/solid-router/tests/renderRouterToStream.test.tsx b/packages/solid-router/tests/renderRouterToStream.test.tsx index 6351d83ebd0..5986fea88b5 100644 --- a/packages/solid-router/tests/renderRouterToStream.test.tsx +++ b/packages/solid-router/tests/renderRouterToStream.test.tsx @@ -1,12 +1,29 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' import { createMemoryHistory, createRootRoute, createRouter } from '../src' +import type * as RouterSsrServer from '@tanstack/router-core/ssr/server' import type * as SolidWeb from 'solid-js/web' const solidMocks = vi.hoisted(() => ({ renderToStream: vi.fn(), pipeTo: vi.fn(), })) +const routerTransformMocks = vi.hoisted(() => ({ + readableOptions: vi.fn(), +})) + +vi.mock('@tanstack/router-core/ssr/server', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + transformReadableStreamWithRouter: ( + ...args: Parameters + ) => { + routerTransformMocks.readableOptions(args[2]) + return actual.transformReadableStreamWithRouter(...args) + }, + } +}) vi.mock('solid-js/web', async () => { const actual = await vi.importActual('solid-js/web') @@ -28,6 +45,7 @@ function unwrapResponse( afterEach(() => { solidMocks.renderToStream.mockReset() solidMocks.pipeTo.mockReset() + routerTransformMocks.readableOptions.mockReset() vi.restoreAllMocks() }) @@ -50,22 +68,36 @@ function drainBody(response: Response) { return (async () => { for (;;) { const { done } = await reader.read() - if (done) return true + if (done) { + return true + } } })().catch(() => true) } -describe('renderRouterToStream - bot abort', () => { - test('request abort during bot wait terminates before rendering starts', async () => { +describe('renderRouterToStream', () => { + test('already-aborted requests do not start the Solid producer', async () => { + const abortController = new AbortController() + abortController.abort(new Error('already-gone')) + const router = await buildRouter() + + await expect( + renderRouterToStream({ + request: new Request('http://localhost/', { + signal: abortController.signal, + }), + router, + responseHeaders: new Headers(), + children: () => null, + }), + ).rejects.toThrow('already-gone') + + expect(solidMocks.renderToStream).not.toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() + }) + + test('request abort during bot wait terminates before piping starts', async () => { const neverReady = new Promise(() => {}) - solidMocks.pipeTo.mockImplementationOnce( - (writable: WritableStream) => { - const writer = writable.getWriter() - return writer - .write(new TextEncoder().encode('solid')) - .catch(() => {}) - }, - ) solidMocks.renderToStream.mockImplementationOnce( () => ({ @@ -76,7 +108,7 @@ describe('renderRouterToStream - bot abort', () => { const router = await buildRouter() const abortController = new AbortController() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const reason = new Error('client-gone') try { const responsePromise = renderRouterToStream({ request: new Request('http://localhost/', { @@ -89,59 +121,311 @@ describe('renderRouterToStream - bot abort', () => { }) await Promise.resolve() - abortController.abort(new Error('client-gone')) + abortController.abort(reason) - const result = await Promise.race([ - responsePromise, - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - - expect(result).not.toBe(false) + await expect(responsePromise).rejects.toBe(reason) expect(solidMocks.pipeTo).not.toHaveBeenCalled() - const response = unwrapResponse(result as Exclude) - expect(response.body).not.toBeNull() + expect(router.serverSsr).toBeUndefined() + } finally { + router.serverSsr?.cleanup() + } + }) - const terminated = await Promise.race([ - drainBody(response), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) + test.each(['lifetime expiry', 'external cleanup'] as const)( + '%s rejects the bot wait and prevents late piping', + async (termination) => { + let resolveReady!: () => void + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + solidMocks.renderToStream.mockImplementationOnce( + () => + ({ + then: ready.then.bind(ready), + pipeTo: solidMocks.pipeTo, + }) as any, + ) + const router = await buildRouter() + const owner = router.serverSsr! + const cleanup = vi.fn() + owner.onCleanup(cleanup) + const request = new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + }) + const resolved = vi.fn() + const rejected = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + const pending = renderRouterToStream({ + request, + router, + responseHeaders: new Headers(), + children: () => null, + }).then(resolved, rejected) - expect(terminated).toBe(true) + try { + await Promise.resolve() + expect(rejected).not.toHaveBeenCalled() + if (termination === 'lifetime expiry') { + await vi.advanceTimersByTimeAsync(120_000) + } else { + owner.cleanup() + await vi.advanceTimersByTimeAsync(0) + } + + expect(rejected).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + message: + termination === 'lifetime expiry' + ? 'Stream lifetime exceeded' + : 'SSR stream transform aborted by router SSR cleanup', + }), + ) + expect(request.signal.aborted).toBe(false) + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + expect(solidMocks.pipeTo).not.toHaveBeenCalled() + + resolveReady() + await vi.advanceTimersByTimeAsync(0) + expect(resolved).not.toHaveBeenCalled() + expect(solidMocks.pipeTo).not.toHaveBeenCalled() + } finally { + resolveReady() + await pending + owner.cleanup() + vi.useRealTimers() + warn.mockRestore() + } + }, + ) + + test('bots wait for renderer readiness before piping', async () => { + let resolveReady!: () => void + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + solidMocks.renderToStream.mockImplementationOnce( + () => + ({ + then: ready.then.bind(ready), + pipeTo: solidMocks.pipeTo.mockImplementation( + () => new Promise(() => {}), + ), + }) as any, + ) + const router = await buildRouter() + try { + let settled = false + const responsePromise = renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + }), + router, + responseHeaders: new Headers(), + children: () => null, + }).then((result) => { + settled = true + return result + }) + + await Promise.resolve() + expect(settled).toBe(false) + expect(solidMocks.pipeTo).not.toHaveBeenCalled() + + resolveReady() + const result = await responsePromise + expect(unwrapResponse(result)).toBeInstanceOf(Response) + expect(solidMocks.pipeTo).toHaveBeenCalledOnce() } finally { - errorSpy.mockRestore() router.serverSsr?.cleanup() } }) - test('pipeTo rejection aborts writer and terminates response stream', async () => { + test('bot response does not wait for a backpressured pipe', async () => { + const html = new TextEncoder().encode( + 'solid', + ) + let pipeCompletion: Promise | undefined solidMocks.renderToStream.mockImplementationOnce( () => ({ - pipeTo: () => Promise.reject(new Error('solid-pipe-failed')), + then(resolve: () => void) { + resolve() + }, + pipeTo(writable: WritableStream) { + const writer = writable.getWriter() + pipeCompletion = writer.write(html).then(() => writer.close()) + return pipeCompletion + }, }) as any, ) const router = await buildRouter() + router.serverSsr!.disableHydration() + try { + const result = await renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + }), + router, + responseHeaders: new Headers(), + children: () => null, + }) + + expect(pipeCompletion).toBeDefined() + const response = unwrapResponse(result) + await expect(response.text()).resolves.toBe( + new TextDecoder().decode(html), + ) + await expect(pipeCompletion).resolves.toBeUndefined() + } finally { + router.serverSsr?.cleanup() + } + }) + + test.each([ + ['ordinary', new Error('solid-pipe-failed')], + [ + 'named AbortError', + Object.assign(new Error('solid-pipe-aborted'), { name: 'AbortError' }), + ], + ] as const)( + '%s pipeTo rejection logs, aborts the writer, and terminates the response', + async (_kind, pipeError) => { + solidMocks.renderToStream.mockImplementationOnce( + () => + ({ + pipeTo: () => Promise.reject(pipeError), + }) as any, + ) + + const router = await buildRouter() + const request = new Request('http://localhost/') + const addEventListener = vi.spyOn(request.signal, 'addEventListener') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const response = unwrapResponse( + await renderRouterToStream({ + request, + router, + responseHeaders: new Headers(), + children: () => null, + }), + ) + expect(routerTransformMocks.readableOptions).toHaveBeenCalledWith( + expect.objectContaining({ rendererSafePoint: 'record-end' }), + ) + expect( + addEventListener.mock.calls.filter(([type]) => type === 'abort'), + ).toHaveLength(1) + + expect(await drainBody(response)).toBe(true) + expect(errorSpy).toHaveBeenCalledWith( + 'Error in Solid render stream:', + pipeError, + ) + } finally { + errorSpy.mockRestore() + router.serverSsr?.cleanup() + } + }, + ) + + test('async pipe failure cleans an unread response immediately', async () => { + let rejectPipe!: (reason: unknown) => void + const pipeCompletion = new Promise((_resolve, reject) => { + rejectPipe = reject + }) + solidMocks.renderToStream.mockImplementationOnce( + () => ({ pipeTo: () => pipeCompletion }) as any, + ) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const router = await buildRouter() + try { + const result = await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + children: () => null, + }) + expect(unwrapResponse(result).body).not.toBeNull() + + rejectPipe(new Error('solid-pipe-failed-unread')) + await vi.waitFor(() => expect(router.serverSsr).toBeUndefined()) + } finally { + errorSpy.mockRestore() + router.serverSsr?.cleanup() + } + }) + + test('request abort after attachment stops outgoing Solid writes', async () => { + let outboundWriter!: WritableStreamDefaultWriter + solidMocks.pipeTo.mockImplementationOnce( + (writable: WritableStream) => { + outboundWriter = writable.getWriter() + return new Promise(() => {}) + }, + ) + solidMocks.renderToStream.mockImplementationOnce( + () => ({ pipeTo: solidMocks.pipeTo }) as any, + ) + + const router = await buildRouter() + const abortController = new AbortController() + const request = new Request('http://localhost/', { + signal: abortController.signal, + }) + const addEventListener = vi.spyOn(request.signal, 'addEventListener') try { const response = unwrapResponse( await renderRouterToStream({ - request: new Request('http://localhost/'), + request, router, responseHeaders: new Headers(), children: () => null, }), ) - const terminated = await Promise.race([ - drainBody(response), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) + expect(solidMocks.pipeTo).toHaveBeenCalledOnce() + expect( + addEventListener.mock.calls.filter(([type]) => type === 'abort'), + ).toHaveLength(1) + abortController.abort(new Error('solid-request-gone')) - expect(terminated).toBe(true) - expect(errorSpy).toHaveBeenCalled() + await expect( + outboundWriter.write(new TextEncoder().encode('late Solid chunk')), + ).rejects.toBeDefined() + + expect(await drainBody(response)).toBe(true) + } finally { + router.serverSsr?.cleanup() + } + }) + + test('request abort during renderer setup is caught before transform attachment', async () => { + const abortController = new AbortController() + solidMocks.renderToStream.mockImplementationOnce(() => { + abortController.abort(new Error('solid-setup-request-gone')) + return { pipeTo: solidMocks.pipeTo } as any + }) + + const router = await buildRouter() + try { + await expect( + renderRouterToStream({ + request: new Request('http://localhost/', { + signal: abortController.signal, + }), + router, + responseHeaders: new Headers(), + children: () => null, + }), + ).rejects.toThrow('solid-setup-request-gone') + + expect(solidMocks.pipeTo).not.toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() } finally { - errorSpy.mockRestore() router.serverSsr?.cleanup() } }) diff --git a/packages/solid-router/tests/server/Await.test.tsx b/packages/solid-router/tests/server/Await.test.tsx new file mode 100644 index 00000000000..613f3018f20 --- /dev/null +++ b/packages/solid-router/tests/server/Await.test.tsx @@ -0,0 +1,72 @@ +import { ErrorBoundary, Suspense } from 'solid-js' +import { describe, expect, it, vi } from 'vitest' +import { renderToStream, renderToStringAsync } from 'solid-js/web' +import { Await } from '../../src/awaited' + +describe('Await (server)', () => { + it('flushes blank content while an Await without a fallback is pending', async () => { + let resolvePromise!: (value: string) => void + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + const records: Array = [] + const completed = renderToStream(() => ( + + + {(value) => {value}} + + + )).pipeTo( + new WritableStream({ + write(chunk) { + records.push(new TextDecoder().decode(chunk)) + }, + }), + ) + + await vi.waitFor(() => expect(records.length).toBeGreaterThan(0)) + expect(records.join('')).not.toContain('resolved') + + resolvePromise('resolved') + await completed + expect(records.join('')).toContain('resolved') + }) + + it('renders an error boundary when an Await without a fallback rejects', async () => { + const html = await renderToStringAsync(() => ( + outer pending}> + {error.message}}> + inner pending}> + + {(value) => {value}} + + + + + )) + + expect(html).toContain('rejected') + expect(html).not.toContain('pending') + }) + + it.each([ + ['zero', 0], + ['false', false], + ['empty string', ''], + ['null', null], + ['undefined', undefined], + ])('renders a resolved %s value', async (_name, value) => { + const children = vi.fn(() => resolved) + + const html = await renderToStringAsync(() => ( + pending}> + {children} + + )) + + expect(children).toHaveBeenCalledOnce() + expect(children).toHaveBeenCalledWith(value) + expect(html).toContain('resolved') + expect(html).not.toContain('pending') + }) +}) diff --git a/packages/solid-router/tests/server/doctype.test.tsx b/packages/solid-router/tests/server/doctype.test.tsx new file mode 100644 index 00000000000..229c17202f8 --- /dev/null +++ b/packages/solid-router/tests/server/doctype.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'vitest' +import { createRootRoute, createRouter } from '../../src' +import { + RouterServer, + createRequestHandler, + renderRouterToStream, + renderRouterToString, +} from '../../src/ssr/server' + +async function renderDocument(mode: 'stream' | 'string') { + const request = new Request('http://localhost/') + const rootRoute = createRootRoute({ + component: () =>
home
, + }) + const handler = createRequestHandler({ + request, + createRouter: () => createRouter({ routeTree: rootRoute, isServer: true }), + }) + + return handler(({ router, responseHeaders }) => { + const children = () => + return mode === 'stream' + ? renderRouterToStream({ + request, + router, + responseHeaders, + children, + }) + : renderRouterToString({ router, responseHeaders, children }) + }) +} + +describe('document type', () => { + test.each(['stream', 'string'] as const)( + '%s output contains one document type', + async (mode) => { + const response = await renderDocument(mode) + const html = await response.text() + + expect(html.startsWith('/g)).toHaveLength(1) + }, + ) +}) diff --git a/packages/solid-router/tests/server/errorComponent.test.tsx b/packages/solid-router/tests/server/errorComponent.test.tsx index 38c07310102..733ff7ba19a 100644 --- a/packages/solid-router/tests/server/errorComponent.test.tsx +++ b/packages/solid-router/tests/server/errorComponent.test.tsx @@ -1,12 +1,73 @@ +import { Suspense, onCleanup } from 'solid-js' import { describe, expect, it, vi } from 'vitest' -import { createRootRoute, createRoute, createRouter } from '../../src' +import { + Await, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, +} from '../../src' import { RouterServer, createRequestHandler, + renderRouterToStream, renderRouterToString, } from '../../src/ssr/server' +import type { AnyRouter } from '@tanstack/router-core' + +function createLazyErrorComponent() { + return lazyRouteComponent( + async () => ({ + ErrorComponent: ({ error }: { error: Error }) => ( +
Route error: {error.message}
+ ), + }), + 'ErrorComponent', + ) +} describe('errorComponent (server)', () => { + it('cleans up components and router state after a synchronous string-render error', async () => { + vi.useFakeTimers() + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + try { + const rootRoute = createRootRoute() + const handler = createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => + createRouter({ routeTree: rootRoute, isServer: true }), + }) + let requestRouter: AnyRouter | undefined + const componentCleanup = vi.fn() + + const response = await handler(({ router, responseHeaders }) => { + requestRouter = router + return renderRouterToString({ + router, + responseHeaders, + children: () => { + onCleanup(componentCleanup) + throw new Error('render boom') + }, + }) + }) + + expect(response.status).toBe(500) + expect(requestRouter?.serverSsr).toBeUndefined() + // Solid's synchronous renderer schedules disposal for the next task. + await vi.runOnlyPendingTimersAsync() + expect(componentCleanup).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } finally { + consoleError.mockRestore() + vi.clearAllTimers() + vi.useRealTimers() + } + }) + it.each([ ['Error', new Error('loader boom')], ['Error with cause', new Error('loader boom', { cause: false })], @@ -81,4 +142,88 @@ describe('errorComponent (server)', () => { expect(html).not.toContain('Index route') }, ) + + it.each(['string', 'stream'] as const)( + 'handles a lazy error component during %s rendering', + async (mode) => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + throw new Error('render boom') + }, + errorComponent: createLazyErrorComponent(), + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const request = new Request('http://localhost/') + const handler = createRequestHandler({ + request, + createRouter: () => createRouter({ routeTree, isServer: true }), + }) + + const response = await handler(({ router, responseHeaders }) => { + const children = () => + return mode === 'stream' + ? renderRouterToStream({ request, router, responseHeaders, children }) + : renderRouterToString({ router, responseHeaders, children }) + }) + const html = await response.text() + + if (mode === 'stream') { + expect(html).toContain('data-testid="error-component"') + expect(html).toContain('render boom') + } else { + // The error boundary's Suspense fallback is blank during string rendering. + expect(html.startsWith('')).toBe(true) + expect(html).not.toContain('data-testid="error-component"') + } + }, + ) + + it('renders the route error component when a streamed Await rejects', async () => { + let rejectDeferred!: (error: Error) => void + const deferred = new Promise((_resolve, reject) => { + rejectDeferred = reject + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ({ deferred }), + component: () => { + const data = indexRoute.useLoaderData() + return ( + pending}> + + {() => resolved} + + + ) + }, + errorComponent: createLazyErrorComponent(), + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const request = new Request('http://localhost/') + const handler = createRequestHandler({ + request, + createRouter: () => createRouter({ routeTree, isServer: true }), + }) + const response = await handler(({ router, responseHeaders }) => + renderRouterToStream({ + request, + router, + responseHeaders, + children: () => , + }), + ) + const htmlPromise = response.text() + + rejectDeferred(new Error('deferred boom')) + const html = await htmlPromise + + expect(html).toContain('data-testid="error-component"') + expect(html).toContain('deferred boom') + expect(html).not.toContain('pending') + }) }) diff --git a/packages/solid-router/tests/server/lazyRouteComponent.test.tsx b/packages/solid-router/tests/server/lazyRouteComponent.test.tsx new file mode 100644 index 00000000000..95ab8a5cdd0 --- /dev/null +++ b/packages/solid-router/tests/server/lazyRouteComponent.test.tsx @@ -0,0 +1,148 @@ +import { ErrorBoundary, Suspense, createContext, useContext } from 'solid-js' +import { renderToString, renderToStringAsync } from 'solid-js/web' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { lazyRouteComponent } from '../../src/lazyRouteComponent' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('lazyRouteComponent (server)', () => { + it.each([ + { exportName: undefined, preload: false }, + { exportName: 'Missing', preload: false }, + { exportName: undefined, preload: true }, + { exportName: 'Missing', preload: true }, + ])( + 'renders a missing $exportName export as blank in production (preload: $preload)', + async ({ exportName, preload }) => { + vi.stubEnv('NODE_ENV', 'production') + const importer = vi.fn( + (): Promise null>> => + Promise.resolve({ + Other: () => null, + }), + ) + const Page = lazyRouteComponent(importer, exportName) + const onError = vi.fn() + const onUnhandledRejection = vi.fn() + process.on('unhandledRejection', onUnhandledRejection) + + try { + if (preload) { + await Page.preload?.() + } + + const html = await renderToStringAsync( + () => ( + { + onError(error) + return Render failed + }} + > + Pending}> + + + + ), + { timeoutMs: 1_000 }, + ) + // Promise rejection events are dispatched after the current microtasks. + await new Promise((resolve) => setImmediate(resolve)) + + // Solid can retain hydration scripts and boundary comments without + // rendering content for the missing component. + const content = html + .replace(/]*>[\s\S]*?<\/script>/g, '') + .replace(//g, '') + expect(content).toBe('') + expect(importer).toHaveBeenCalledOnce() + expect(onError).not.toHaveBeenCalled() + expect(onUnhandledRejection).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', onUnhandledRejection) + } + }, + ) + + it('renders a preloaded component synchronously and reuses its import', async () => { + const importer = vi.fn(() => + Promise.resolve({ + default: (props: { label: string }) => {props.label}, + }), + ) + const Page = lazyRouteComponent(importer) + const preload = Page.preload?.() + await preload + + const html = renderToString(() => ( + Pending}> + + + )) + + expect(html).toContain('Preloaded page') + expect(html).not.toContain('Pending') + expect(Page.preload?.()).toBe(preload) + expect(importer).toHaveBeenCalledOnce() + }) + + it('renders synchronously after an awaited retry of a failed import', async () => { + const failure = new Error('component download failed') + const Content = (props: { label: string }) => {props.label} + const importer = vi + .fn<() => Promise<{ default: typeof Content }>>() + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce({ default: Content }) + const Page = lazyRouteComponent(importer) + + await Page.preload?.() + expect(() => renderToString(() => )).toThrow( + failure, + ) + + const retry = Page.preload?.() + await retry + const html = renderToString(() => ) + + expect(html).toContain('Recovered page') + expect(Page.preload?.()).toBe(retry) + expect(importer).toHaveBeenCalledTimes(2) + }) + + it.each([false, true])( + 'preserves provider context and component props (preload: %s)', + async (preload) => { + const Context = createContext('missing context') + const importer = vi.fn(() => + Promise.resolve({ + Page: (props: { label: string; children: string }) => ( + + {useContext(Context)}:{props.label}:{props.children} + + ), + }), + ) + const Page = lazyRouteComponent(importer, 'Page') + if (preload) { + await Page.preload?.() + } + + const html = await renderToStringAsync(() => ( + + Pending}> + child prop + + + )) + + expect(html).toContain('provided context') + expect(html).toContain('page prop') + expect(html).toContain('child prop') + expect(html).not.toContain('missing context') + expect(html).not.toContain('Pending') + expect(importer).toHaveBeenCalledOnce() + }, + ) +}) diff --git a/packages/solid-router/tests/server/renderRouterToStream.test.tsx b/packages/solid-router/tests/server/renderRouterToStream.test.tsx new file mode 100644 index 00000000000..b0a2bf3ad48 --- /dev/null +++ b/packages/solid-router/tests/server/renderRouterToStream.test.tsx @@ -0,0 +1,74 @@ +import { Suspense, createResource } from 'solid-js' +import { expect, test, vi } from 'vitest' +import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' +import { createMemoryHistory, createRootRoute, createRouter } from '../../src' +import { renderRouterToStream } from '../../src/ssr/renderRouterToStream' + +test('the lifetime limit rejects a native Solid bot wait before its resource settles', async () => { + let resolveResource!: (value: string) => void + const resource = new Promise((resolve) => { + resolveResource = resolve + }) + function Deferred() { + const [value] = createResource(() => resource) + return {value()} + } + + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + routeTree: createRootRoute({ component: () => null }), + isServer: true, + }) + attachRouterServerSsrUtils({ router, manifest: undefined }) + await router.load() + await router.serverSsr!.dehydrate() + + const owner = router.serverSsr! + const cleanup = vi.spyOn(owner, 'cleanup') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const request = new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + }) + const resolved = vi.fn() + const rejected = vi.fn() + vi.useFakeTimers() + const pending = renderRouterToStream({ + request, + router, + responseHeaders: new Headers(), + children: () => ( + + + waiting

}> + +
+ + + ), + }).then(resolved, rejected) + + try { + await Promise.resolve() + expect(resolved).not.toHaveBeenCalled() + expect(rejected).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(120_000) + + expect(rejected).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'Stream lifetime exceeded' }), + ) + expect(resolved).not.toHaveBeenCalled() + expect(request.signal.aborted).toBe(false) + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + } finally { + // Solid exposes no cancellation handle for this resource. Let its native + // renderer finish so the test does not retain unresolved component work. + resolveResource('finished') + await pending + router.serverSsr?.cleanup() + vi.useRealTimers() + warn.mockRestore() + cleanup.mockRestore() + } +}) diff --git a/packages/solid-router/tests/server/renderRouterToString.test.tsx b/packages/solid-router/tests/server/renderRouterToString.test.tsx new file mode 100644 index 00000000000..91a439ca1b9 --- /dev/null +++ b/packages/solid-router/tests/server/renderRouterToString.test.tsx @@ -0,0 +1,52 @@ +import { Suspense } from 'solid-js' +import { expect, it } from 'vitest' +import { Await, createRootRoute, createRoute, createRouter } from '../../src' +import { + RouterServer, + createRequestHandler, + renderRouterToString, +} from '../../src/ssr/server' + +it('renders the Suspense fallback while finishing deferred hydration data', async () => { + let resolveDeferred!: (value: string) => void + const deferred = new Promise((resolve) => { + resolveDeferred = resolve + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ({ deferred }), + component: () => { + const data = indexRoute.useLoaderData() + queueMicrotask(() => resolveDeferred('deferred value')) + return ( + pending}> + + {(value) => {String(value)}} + + + ) + }, + }) + const routeTree = rootRoute.addChildren([indexRoute]) + const handler = createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => createRouter({ routeTree, isServer: true }), + }) + + const response = await handler(({ router, responseHeaders }) => + renderRouterToString({ + router, + responseHeaders, + children: () => , + }), + ) + const html = await response.text() + + expect(response.status).toBe(200) + expect(html).toContain('data-testid="pending"') + expect(html).not.toContain('data-testid="resolved"') + expect(html).toContain('deferred value') + expect(html).toContain('$_TSR.e()') +}) diff --git a/packages/solid-router/tests/server/renderToStreamRecords.test.tsx b/packages/solid-router/tests/server/renderToStreamRecords.test.tsx new file mode 100644 index 00000000000..5ce71656180 --- /dev/null +++ b/packages/solid-router/tests/server/renderToStreamRecords.test.tsx @@ -0,0 +1,61 @@ +import { Suspense, createResource } from 'solid-js' +import { describe, expect, test } from 'vitest' +import { renderToStream } from 'solid-js/web' + +describe('installed Solid stream renderer', () => { + test('writes a resolved Suspense patch as complete records', async () => { + let resolveResource!: (value: string) => void + const resource = new Promise((resolve) => { + resolveResource = resolve + }) + + function Deferred() { + const [value] = createResource(() => resource) + return ( + solid-fallback

}> +

{value()}

+
+ ) + } + + const records: Array = [] + let resolveFirstRecord!: () => void + const firstRecord = new Promise((resolve) => { + resolveFirstRecord = resolve + }) + const stream = renderToStream(() => ( + + + + + + )) + const completed = stream.pipeTo( + new WritableStream({ + write(chunk) { + records.push(new TextDecoder('utf-8', { fatal: true }).decode(chunk)) + if (records.length === 1) { + resolveFirstRecord() + } + }, + }), + ) + + await firstRecord + expect(records[0]).toContain('solid-fallback') + + resolveResource('solid-resolved') + await completed + + const templateIndex = records.findIndex((record) => + record.includes('solid-resolved'), + ) + expect(templateIndex).toBeGreaterThan(0) + expect(records[templateIndex]).toMatch( + /^]*>[\s\S]*<\/template>$/, + ) + + const scriptRecord = records[templateIndex + 1] + expect(scriptRecord).toMatch(/^]*>[\s\S]*<\/script>$/) + }) +}) diff --git a/packages/solid-start-server/src/defaultStreamHandler.tsx b/packages/solid-start-server/src/defaultStreamHandler.tsx index 9575b801e0e..ea189a05969 100644 --- a/packages/solid-start-server/src/defaultStreamHandler.tsx +++ b/packages/solid-start-server/src/defaultStreamHandler.tsx @@ -5,8 +5,8 @@ import { import { StartServer } from './StartServer' export const defaultStreamHandler = defineHandlerCallback( - async ({ request, router, responseHeaders }) => - await renderRouterToStream({ + ({ request, router, responseHeaders }) => + renderRouterToStream({ request, router, responseHeaders, diff --git a/packages/start-client-core/src/client-rpc/frame-decoder.ts b/packages/start-client-core/src/client-rpc/frame-decoder.ts index cd1a0c719fc..3c925911e94 100644 --- a/packages/start-client-core/src/client-rpc/frame-decoder.ts +++ b/packages/start-client-core/src/client-rpc/frame-decoder.ts @@ -1,425 +1,278 @@ -/** - * Client-side frame decoder for multiplexed responses. - * - * Decodes binary frame protocol and reconstructs: - * - JSON stream (NDJSON lines for seroval) - * - Raw streams (binary data as ReadableStream) - */ - -import { FRAME_HEADER_SIZE, FrameType } from '../constants' - -/** Cached TextDecoder for frame decoding */ -const textDecoder = new TextDecoder() - -/** Shared empty buffer for empty buffer case - avoids allocation */ -const EMPTY_BUFFER = new Uint8Array(0) - -/** Hardening limits to prevent memory/CPU DoS */ -const MAX_FRAME_PAYLOAD_SIZE = 16 * 1024 * 1024 // 16MiB -const MAX_BUFFERED_BYTES = 32 * 1024 * 1024 // 32MiB -const MAX_STREAMS = 1024 -const MAX_FRAMES = 100_000 // Limit total frames to prevent CPU DoS - -/** - * Result of frame decoding. - */ -export interface FrameDecoderResult { - /** Gets or creates a raw stream by ID (for use by deserialize plugin) */ - getStream: (id: number) => ReadableStream - /** Stream of JSON strings (NDJSON lines) */ - chunks: ReadableStream -} +import { + FRAME_HEADER_SIZE, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + FRAME_TYPE_ERROR, + FRAME_TYPE_JSON, + MAX_FRAMED_STREAMS, + MAX_FRAME_PAYLOAD_SIZE, + MAX_UNREAD_RAW_STREAM_BYTES, +} from '../constants' + +const decoder = new TextDecoder() +const empty: Uint8Array = new Uint8Array() +// With a zero high-water mark, `-desiredSize` is the unread byte count. +const rawStreamQueue = new ByteLengthQueuingStrategy({ highWaterMark: 0 }) + +type Closed = 1 +type Failed = readonly [unknown] +type State = 0 | Closed | Failed +type RawController = + | ReadableStreamDefaultController + | null // canceled by its consumer + | false // ended by the wire or decoder +type RawEntry = [ReadableStream, RawController] + +export function createFrameDecoder(input: ReadableStream) { + const reader = input.getReader() + const rawStreams = new Map() + let state = 0 as State + let resume: (() => void) | undefined + let jsonController!: ReadableStreamDefaultController -/** - * Creates a frame decoder that processes a multiplexed response stream. - * - * @param input The raw response body stream - * @returns Decoded JSON stream and stream getter function - */ -export function createFrameDecoder( - input: ReadableStream, -): FrameDecoderResult { - const streamControllers = new Map< - number, - ReadableStreamDefaultController - >() - const streams = new Map>() - const cancelledStreamIds = new Set() + const wake = () => { + resume?.() + resume = undefined + } - let cancelled = false as boolean - let inputReader: ReadableStreamReader | null = null - let frameCount = 0 + const settleRaw = (entry: RawEntry, terminal: Closed | Failed) => { + const controller = entry[1] + entry[1] = false + if (controller) { + if (terminal === 1) { + controller.close() + } else { + controller.error(terminal[0]) + } + } + } - let jsonController!: ReadableStreamDefaultController - const jsonChunks = new ReadableStream({ + const chunks = new ReadableStream({ start(controller) { jsonController = controller }, - cancel() { - cancelled = true - try { - inputReader?.cancel() - } catch { - // Ignore + pull: wake, + cancel(reason) { + // Every raw stream, including one requested later, ends with this reason. + const failed: Failed = [ + reason === undefined ? new Error('Framed response cancelled') : reason, + ] + state = failed + wake() + void reader.cancel(reason).catch(() => {}) + for (const entry of rawStreams.values()) { + settleRaw(entry, failed) } - - streamControllers.forEach((ctrl) => { - try { - ctrl.error(new Error('Framed response cancelled')) - } catch { - // Ignore - } - }) - streamControllers.clear() - streams.clear() - cancelledStreamIds.clear() }, }) - /** - * Gets or creates a stream for a given stream ID. - * Called by deserialize plugin when it encounters a RawStream reference. - */ - function getOrCreateStream(id: number): ReadableStream { - const existing = streams.get(id) + function getRaw(id: number): RawEntry { + const existing = rawStreams.get(id) if (existing) { return existing } - - // If we already received an END/ERROR for this streamId, returning a fresh stream - // would hang consumers. Return an already-closed stream instead. - if (cancelledStreamIds.has(id)) { - return new ReadableStream({ - start(controller) { - controller.close() - }, - }) + if (rawStreams.size >= MAX_FRAMED_STREAMS) { + throw new Error('Too many raw streams') } - if (streams.size >= MAX_STREAMS) { - throw new Error( - `Too many raw streams in framed response (max ${MAX_STREAMS})`, - ) - } - - const stream = new ReadableStream({ - start(ctrl) { - streamControllers.set(id, ctrl) - }, - cancel() { - cancelledStreamIds.add(id) - streamControllers.delete(id) - streams.delete(id) + let controller!: ReadableStreamDefaultController + const stream = new ReadableStream( + { + start(value) { + controller = value + }, + cancel() { + if (entry[1] !== false) { + entry[1] = null + } + }, }, - }) - streams.set(id, stream) - return stream + rawStreamQueue, + ) + const entry: RawEntry = [stream, controller] + rawStreams.set(id, entry) + if (state !== 0) { + settleRaw(entry, state) + } + return entry } - /** - * Ensures stream exists and returns its controller for enqueuing data. - * Used for CHUNK frames where we need to ensure stream is created. - */ - function ensureController( - id: number, - ): ReadableStreamDefaultController | undefined { - getOrCreateStream(id) - return streamControllers.get(id) + function getStream(id: number): ReadableStream { + if (id === 0 || id >>> 0 !== id) { + throw new RangeError('Invalid raw stream ID') + } + return getRaw(id)[0] } - // Process frames asynchronously - ;(async () => { - const reader = input.getReader() - inputReader = reader - - const bufferList: Array = [] - // Index of the first un-consumed chunk in bufferList. Advancing this - // pointer is O(1); using bufferList.shift() to drop a consumed chunk is - // O(n) and degrades to O(n^2) when a single large frame is assembled from - // many small chunks (e.g. a big RawStream payload split across reads). - let bufferHead = 0 - let totalLength = 0 - - function advanceBufferHead(): void { - bufferList[bufferHead++] = EMPTY_BUFFER - - // Reset drained buffers immediately and compact long-lived buffers in batches. - if (bufferHead === bufferList.length) { - bufferList.length = 0 - bufferHead = 0 - } else if (bufferHead >= 32) { - bufferList.splice(0, bufferHead) - bufferHead = 0 + void (async () => { + let inputChunk = empty + let inputOffset = 0 + + async function more(): Promise { + while (inputOffset === inputChunk.byteLength) { + inputChunk = empty + inputOffset = 0 + const next = await reader.read() + if (state !== 0 || next.done) { + return false + } + inputChunk = next.value } + return true } - /** - * Reads header bytes from buffer chunks without flattening. - * Returns header data or null if not enough bytes available. - */ - function readHeader(): { - type: number - streamId: number - length: number - } | null { - if (totalLength < FRAME_HEADER_SIZE) return null - - const first = bufferList[bufferHead]! - - // Fast path: header fits entirely in first chunk (common case) - if (first.length >= FRAME_HEADER_SIZE) { - const type = first[0]! - const streamId = - ((first[1]! << 24) | - (first[2]! << 16) | - (first[3]! << 8) | - first[4]!) >>> - 0 - const length = - ((first[5]! << 24) | - (first[6]! << 16) | - (first[7]! << 8) | - first[8]!) >>> - 0 - return { type, streamId, length } + async function read( + length: number, + cleanEof?: boolean, + ): Promise { + if (length === 0) { + return empty } - - // Slow path: header spans multiple chunks - flatten header bytes only - const headerBytes = new Uint8Array(FRAME_HEADER_SIZE) - let offset = 0 - let remaining = FRAME_HEADER_SIZE - for (let i = bufferHead; i < bufferList.length && remaining > 0; i++) { - const chunk = bufferList[i]! - const toCopy = Math.min(chunk.length, remaining) - headerBytes.set(chunk.subarray(0, toCopy), offset) - offset += toCopy - remaining -= toCopy + if (!(await more())) { + if (cleanEof) { + return + } + throw new Error('Incomplete frame') } - const type = headerBytes[0]! - const streamId = - ((headerBytes[1]! << 24) | - (headerBytes[2]! << 16) | - (headerBytes[3]! << 8) | - headerBytes[4]!) >>> - 0 - const length = - ((headerBytes[5]! << 24) | - (headerBytes[6]! << 16) | - (headerBytes[7]! << 8) | - headerBytes[8]!) >>> - 0 - - return { type, streamId, length } - } - - /** - * Flattens buffer list into single Uint8Array and removes from list. - */ - function extractFlattened(count: number): Uint8Array { - if (count === 0) return EMPTY_BUFFER - - // Fast path: the requested bytes are fully contained in the first buffered - // chunk (the common case — most frames arrive within a single network - // read). Return a subarray view instead of allocating a new buffer and - // copying `count` bytes. The view shares the chunk's backing ArrayBuffer, - // which is safe because buffered chunks are never mutated in place after - // being read from the network. - const first = bufferList[bufferHead] - if (first && first.length >= count) { - const result = first.subarray(0, count) - if (first.length === count) { - advanceBufferHead() - } else { - bufferList[bufferHead] = first.subarray(count) + const available = inputChunk.byteLength - inputOffset + if (available >= length) { + const result = inputChunk.subarray(inputOffset, inputOffset + length) + inputOffset += length + if (inputOffset === inputChunk.byteLength) { + inputChunk = empty + inputOffset = 0 } - totalLength -= count return result } - // Slow path: the requested bytes span multiple chunks — flatten by copying. - const result = new Uint8Array(count) + const result = new Uint8Array(length) let offset = 0 - let remaining = count - - while (remaining > 0 && bufferHead < bufferList.length) { - const chunk = bufferList[bufferHead]! - const toCopy = Math.min(chunk.length, remaining) - result.set(chunk.subarray(0, toCopy), offset) - - offset += toCopy - remaining -= toCopy - - if (toCopy === chunk.length) { - advanceBufferHead() - } else { - bufferList[bufferHead] = chunk.subarray(toCopy) + while (offset < length) { + if (!(await more())) { + throw new Error('Incomplete frame') } + const size = Math.min( + length - offset, + inputChunk.byteLength - inputOffset, + ) + result.set(inputChunk.subarray(inputOffset, inputOffset + size), offset) + inputOffset += size + offset += size + } + if (inputOffset === inputChunk.byteLength) { + inputChunk = empty + inputOffset = 0 } - - totalLength -= count return result } try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const { done, value } = await reader.read() - if (cancelled) break - if (done) break - + while (state === 0) { + let header = await read(FRAME_HEADER_SIZE, true) + // Cancellation can run while the read is suspended. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!value) continue - - // Append incoming chunk to buffer list - if (totalLength + value.length > MAX_BUFFERED_BYTES) { - throw new Error( - `Framed response buffer exceeded ${MAX_BUFFERED_BYTES} bytes`, - ) + if (state !== 0) { + return } - bufferList.push(value) - totalLength += value.length - - // Parse complete frames from buffer - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const header = readHeader() - if (!header) break // Not enough bytes for header - - const { type, streamId, length } = header - - if ( - type !== FrameType.JSON && - type !== FrameType.CHUNK && - type !== FrameType.END && - type !== FrameType.ERROR - ) { - throw new Error(`Unknown frame type: ${type}`) - } - - // Enforce stream id conventions: JSON uses streamId 0, raw streams use non-zero ids - if (type === FrameType.JSON) { - if (streamId !== 0) { - throw new Error('Invalid JSON frame streamId (expected 0)') - } - } else { - if (streamId === 0) { - throw new Error('Invalid raw frame streamId (expected non-zero)') + if (!header) { + for (const entry of rawStreams.values()) { + if (entry[1] !== false) { + throw new Error('Incomplete raw stream') } } + state = 1 + jsonController.close() + return + } - if (length > MAX_FRAME_PAYLOAD_SIZE) { - throw new Error( - `Frame payload too large: ${length} bytes (max ${MAX_FRAME_PAYLOAD_SIZE})`, - ) - } + const type = header[0]! + const streamId = + ((header[1]! << 24) | + (header[2]! << 16) | + (header[3]! << 8) | + header[4]!) >>> + 0 + const length = + ((header[5]! << 24) | + (header[6]! << 16) | + (header[7]! << 8) | + header[8]!) >>> + 0 + header = empty + + if ( + type > FRAME_TYPE_ERROR || + (type === FRAME_TYPE_JSON) !== (streamId === 0) || + length > MAX_FRAME_PAYLOAD_SIZE || + (type === FRAME_TYPE_END && length !== 0) + ) { + throw new Error('Invalid frame') + } - const frameSize = FRAME_HEADER_SIZE + length - if (totalLength < frameSize) break // Wait for more data + const entry = type === FRAME_TYPE_JSON ? undefined : getRaw(streamId) + if (entry?.[1] === false) { + throw new Error('Raw stream already ended') + } - if (++frameCount > MAX_FRAMES) { - throw new Error( - `Too many frames in framed response (max ${MAX_FRAMES})`, - ) + let payload = (await read(length))! + // Cancellation can run while the read is suspended. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (state !== 0) { + return + } + if (!entry) { + const value = decoder.decode(payload) + payload = empty + jsonController.enqueue(value) + // Cancellation wakes this wait even when JSON demand stays at zero. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (state === 0 && jsonController.desiredSize! <= 0) { + await new Promise((resolve) => { + resume = resolve + }) } + continue + } - // Extract and consume header bytes - extractFlattened(FRAME_HEADER_SIZE) - - // Extract payload - const payload = extractFlattened(length) - - // Process frame by type - switch (type) { - case FrameType.JSON: { - try { - jsonController.enqueue(textDecoder.decode(payload)) - } catch { - // JSON stream may be cancelled/closed - } - break - } - - case FrameType.CHUNK: { - const ctrl = ensureController(streamId) - if (ctrl) { - ctrl.enqueue(payload) - } - break - } - - case FrameType.END: { - const ctrl = ensureController(streamId) - cancelledStreamIds.add(streamId) - if (ctrl) { - try { - ctrl.close() - } catch { - // Already closed - } - streamControllers.delete(streamId) - } - break - } - - case FrameType.ERROR: { - const ctrl = ensureController(streamId) - cancelledStreamIds.add(streamId) - if (ctrl) { - const message = textDecoder.decode(payload) - ctrl.error(new Error(message)) - streamControllers.delete(streamId) - } - break + if (type === FRAME_TYPE_CHUNK) { + const controller = entry[1] + if (controller) { + if (-controller.desiredSize! > MAX_UNREAD_RAW_STREAM_BYTES) { + throw new Error( + `Raw stream ${streamId} has too many unread bytes`, + ) } + // A small view would pin its whole network buffer; copy those. + const chunk = + payload.byteLength * 4 < payload.buffer.byteLength + ? payload.slice() + : payload + payload = empty + controller.enqueue(chunk) } + } else { + settleRaw( + entry, + type === FRAME_TYPE_END ? 1 : [new Error(decoder.decode(payload))], + ) } } - - if (totalLength !== 0) { - throw new Error('Incomplete frame at end of framed response') - } - - // Close JSON stream when done - try { - jsonController.close() - } catch { - // JSON stream may be cancelled/closed - } - - // Close any remaining streams (shouldn't happen in normal operation) - streamControllers.forEach((ctrl) => { - try { - ctrl.close() - } catch { - // Already closed - } - }) - streamControllers.clear() } catch (error) { - // Error reading - propagate to all streams - try { + if (state === 0) { + const failed: Failed = [error] + state = failed + void reader.cancel(error).catch(() => {}) jsonController.error(error) - } catch { - // Already errored/closed - } - streamControllers.forEach((ctrl) => { - try { - ctrl.error(error) - } catch { - // Already errored/closed + for (const entry of rawStreams.values()) { + settleRaw(entry, failed) } - }) - streamControllers.clear() - } finally { - try { - reader.releaseLock() - } catch { - // Ignore } - inputReader = null + } finally { + inputChunk = empty + reader.releaseLock() } })() - return { getStream: getOrCreateStream, chunks: jsonChunks } + return [chunks, getStream] as const } diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 2534b6bb938..7f8d807e608 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -1,24 +1,24 @@ import { - createRawStreamDeserializePlugin, encode, invariant, isNotFound, parseRedirect, } from '@tanstack/router-core' +import { createRawStreamDeserializePlugin } from '@tanstack/router-core/ssr/client' import { fromCrossJSON, toJSONAsync } from 'seroval' import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins' import { TSS_CONTENT_TYPE_FRAMED, TSS_FORMDATA_CONTEXT, + TSS_FRAMED_PROTOCOL_VERSION, X_TSS_RAW_RESPONSE, X_TSS_SERIALIZED, - validateFramedProtocolVersion, } from '../constants' import { createFrameDecoder } from './frame-decoder' import type { FunctionMiddlewareClientFnOptions } from '../createMiddleware' import type { Plugin as SerovalPlugin } from 'seroval' -let serovalPlugins: Array> | null = null +let serovalPlugins: Array> | undefined /** * Current async post-processing context for deserialization. @@ -30,34 +30,13 @@ let serovalPlugins: Array> | null = null * This uses a synchronous execution context pattern: * - Each call to `fromCrossJSON` is synchronous * - Within that synchronous execution, all `fromSerializable` calls happen - * - We set the context before `fromCrossJSON`, clear it after - * - For streaming chunks, we set/clear context around each `onMessage` call + * - We set the context before `fromCrossJSON`, then clear it afterward * * Even with concurrent server function calls, each individual deserialization * is atomic (synchronous), so promises are correctly scoped to their call. */ let currentPostProcessContext: Array> | null = null -/** - * Set the current post-processing context for async deserialization work. - * Called before deserialization starts. - * - * @param ctx - Array to collect async work promises, or null to clear - */ -export function setPostProcessContext( - ctx: Array> | null, -): void { - currentPostProcessContext = ctx -} - -/** - * Get the current post-processing context. - * Returns null if no deserialization is in progress. - */ -export function getPostProcessContext(): Array> | null { - return currentPostProcessContext -} - /** * Track an async post-processing promise in the current deserialization context. * Called by deserializers that need to perform async work after sync deserialization. @@ -72,6 +51,25 @@ export function trackPostProcessPromise(promise: Promise): void { } } +function deserialize( + value: any, + options: { + refs?: Map + plugins: Array> + }, + promises: Array>, +) { + currentPostProcessContext = promises + try { + return fromCrossJSON(value, options) + } catch (error) { + observePostProcessPromises(promises) + throw error + } finally { + currentPostProcessContext = null + } +} + /** * Helper to await all post-processing promises. * Uses Promise.allSettled to ensure all promises complete even if some reject. @@ -81,7 +79,15 @@ async function awaitPostProcessPromises( ): Promise { if (promises.length > 0) { await Promise.allSettled(promises) + promises.length = 0 + } +} + +function observePostProcessPromises(promises: Array>): void { + for (const promise of promises) { + void promise.catch(() => {}) } + promises.length = 0 } /** @@ -115,22 +121,20 @@ export async function serverFnFetcher( if (!serovalPlugins) { serovalPlugins = getDefaultSerovalPlugins() } - const _first = args[0] - - const first = _first as FunctionMiddlewareClientFnOptions & { + const first = args[0] as FunctionMiddlewareClientFnOptions & { headers?: HeadersInit } // Use custom fetch if provided, otherwise fall back to the passed handler (global fetch) const fetchImpl = first.fetch ?? handler - const type = first.data instanceof FormData ? 'formData' : 'payload' + const isFormData = first.data instanceof FormData // Arrange the headers - const headers = first.headers ? new Headers(first.headers) : new Headers() + const headers = new Headers(first.headers) headers.set('x-tsr-serverFn', 'true') - if (type === 'payload') { + if (!isFormData) { headers.set( 'accept', `${TSS_CONTENT_TYPE_FRAMED}, application/x-ndjson, application/json`, @@ -139,7 +143,7 @@ export async function serverFnFetcher( // If the method is GET, we need to move the payload to the query string if (first.method === 'GET') { - if (type === 'formData') { + if (isFormData) { throw new Error('FormData is not supported with GET requests') } const serializedPayload = await serializePayload(first) @@ -163,7 +167,7 @@ export async function serverFnFetcher( } } - return await getResponse(async () => + return getResponse(() => fetchImpl(url, { method: first.method, headers, @@ -176,29 +180,30 @@ export async function serverFnFetcher( async function serializePayload( opts: FunctionMiddlewareClientFnOptions, ): Promise { - let payloadAvailable = false - const payloadToSerialize: any = {} + let payload: any if (opts.data !== undefined) { - payloadAvailable = true - payloadToSerialize['data'] = opts.data + payload = { data: opts.data } } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (opts.context && hasOwnProperties(opts.context)) { - payloadAvailable = true - payloadToSerialize['context'] = opts.context + ;(payload ??= {}).context = opts.context } - if (payloadAvailable) { - return serialize(payloadToSerialize) - } - return undefined + return payload ? serialize(payload, opts.signal) : undefined } -async function serialize(data: any) { - return JSON.stringify( - await Promise.resolve(toJSONAsync(data, { plugins: serovalPlugins! })), - ) +async function serialize(data: any, signal?: AbortSignal) { + signal?.throwIfAborted() + let value + try { + value = await toJSONAsync(data, { + plugins: signal ? getDefaultSerovalPlugins(signal) : serovalPlugins!, + }) + } finally { + signal?.throwIfAborted() + } + return JSON.stringify(value) } async function getFetchBody( @@ -208,18 +213,14 @@ async function getFetchBody( let serializedContext = undefined // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (opts.context && hasOwnProperties(opts.context)) { - serializedContext = await serialize(opts.context) + serializedContext = await serialize(opts.context, opts.signal) } if (serializedContext !== undefined) { opts.data.set(TSS_FORMDATA_CONTEXT, serializedContext) } return opts.data } - const serializedBody = await serializePayload(opts) - if (serializedBody) { - return serializedBody - } - return undefined + return serializePayload(opts) } /** @@ -238,7 +239,6 @@ async function getResponse(fn: () => Promise) { if (error instanceof Response) { response = error } else { - console.log(error) throw error } } @@ -266,39 +266,33 @@ async function getResponse(fn: () => Promise) { // If it's a framed response (contains RawStream), use frame decoder if (contentType.includes(TSS_CONTENT_TYPE_FRAMED)) { - // Validate protocol version compatibility - validateFramedProtocolVersion(contentType) - + // A server without a version parameter predates versioning. + const version = /;\s*v=(\d+)/.exec(contentType)?.[1] + if (version && +version !== TSS_FRAMED_PROTOCOL_VERSION) { + throw new Error(`Unsupported framed protocol version ${version}`) + } if (!response.body) { throw new Error('No response body for framed response') } - const { getStream, chunks } = createFrameDecoder(response.body) + const [chunks, getStream] = createFrameDecoder(response.body) // Create deserialize plugin that wires up the raw streams const rawStreamPlugin = createRawStreamDeserializePlugin(getStream) - const plugins = [rawStreamPlugin, ...(serovalPlugins || [])] - - const refs = new Map() - result = await processFramedResponse({ - jsonStream: chunks, - onMessage: (msg: any) => fromCrossJSON(msg, { refs, plugins }), - onError(msg, error) { - console.error(msg, error) - }, - }) + const plugins = [rawStreamPlugin, ...serovalPlugins!] + + result = await processFramedResponse(chunks, plugins) } // If it's a JSON response, it can be simpler else if (contentType.includes('application/json')) { const jsonPayload = await response.json() // Track async post-processing work for this deserialization const postProcessPromises: Array> = [] - setPostProcessContext(postProcessPromises) - try { - result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! }) - } finally { - setPostProcessContext(null) - } + result = deserialize( + jsonPayload, + { plugins: serovalPlugins! }, + postProcessPromises, + ) // Await any async post-processing before returning await awaitPostProcessPromises(postProcessPromises) } @@ -340,102 +334,77 @@ async function getResponse(fn: () => Promise) { return response } -/** - * Processes a framed response where each JSON chunk is a complete JSON string - * (already decoded by frame decoder). - * - * Uses per-chunk post-processing context to ensure async deserialization work - * completes before the next chunk is processed. This prevents issues when - * streaming values require async post-processing (e.g., RSC decoding). - */ -async function processFramedResponse({ - jsonStream, - onMessage, - onError, -}: { - jsonStream: ReadableStream - onMessage: (msg: any) => any - onError?: (msg: string, error?: any) => void -}) { +/** Processes the complete JSON values emitted by the frame decoder. */ +async function processFramedResponse( + jsonStream: ReadableStream, + plugins: Array>, +) { const reader = jsonStream.getReader() - - // Read first JSON frame - this is the main result - const { value: firstValue, done: firstDone } = await reader.read() - if (firstDone || !firstValue) { - throw new Error('Stream ended before first object') - } - - // Each frame is a complete JSON string - const firstObject = JSON.parse(firstValue) - - // Process remaining frames for streaming refs like RawStream. - // Keep draining until the server closes the stream. - // Each chunk gets its own post-processing context to properly scope async work. - let drainCancelled = false as boolean - const drain = (async () => { - try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const { value, done } = await reader.read() - if (done) break - if (value) { - try { - // Set up post-processing context for this chunk - const chunkPostProcessPromises: Array> = [] - setPostProcessContext(chunkPostProcessPromises) - try { - onMessage(JSON.parse(value)) - } finally { - setPostProcessContext(null) - } - // Await any async post-processing from this chunk before processing next. - // This ensures values requiring async work are ready before their - // containing Promise/Stream resolves/emits to consumers. - await awaitPostProcessPromises(chunkPostProcessPromises) - } catch (e) { - onError?.(`Invalid JSON: ${value}`, e) - } + const refs = Object.assign(new Map(), { + types: new Map(), + }) + const options = { refs, plugins } + + const fail = (error: unknown) => { + void reader.cancel(error).catch(() => {}) + // Seroval tags its resolver handles (not the promises themselves) with + // PromiseConstructor's node type, 22. Other refs can be application data. + for (const [id, type] of refs.types) { + if (type === 22) { + const deferred = refs.get(id) as { + p: Promise + f: (reason: unknown) => void } - } - } catch (err) { - if (!drainCancelled) { - onError?.('Stream processing error:', err) + void deferred.p.catch(() => {}) + deferred.f(error) } } - })() + } - // Process first object with its own post-processing context let result: any const initialPostProcessPromises: Array> = [] - setPostProcessContext(initialPostProcessPromises) try { - result = onMessage(firstObject) - } catch (err) { - setPostProcessContext(null) - drainCancelled = true - reader.cancel().catch(() => {}) - throw err - } - setPostProcessContext(null) - - // Await initial post-processing promises before returning result - await awaitPostProcessPromises(initialPostProcessPromises) + const first = await reader.read() + if (first.done) { + throw new Error('Stream ended before first object') + } - // If the initial decode fails async, stop draining to avoid holding - // onto the response body and raw stream buffers unnecessarily. - Promise.resolve(result).catch(() => { - drainCancelled = true - reader.cancel().catch(() => {}) - }) + result = deserialize( + JSON.parse(first.value), + options, + initialPostProcessPromises, + ) + } catch (error) { + fail(error) + reader.releaseLock() + throw error + } - // Detach reader once draining completes. - drain.finally(() => { + // Keep consuming patches before awaiting root post-processing: that work may + // itself depend on raw frames which follow later JSON frames on the wire. + void (async () => { + const postProcessPromises: Array> = [] try { + for (;;) { + const next = await reader.read() + if (next.done) { + return + } + + deserialize(JSON.parse(next.value), options, postProcessPromises) + // Later patches publish their promise/stream values synchronously. + // Observe auxiliary work without blocking frames those values need. + observePostProcessPromises(postProcessPromises) + } + } catch (error) { + fail(error) + console.error('Stream processing error:', error) + } finally { reader.releaseLock() - } catch { - // Ignore } - }) + })() + + await awaitPostProcessPromises(initialPostProcessPromises) return result } diff --git a/packages/start-client-core/src/constants.ts b/packages/start-client-core/src/constants.ts index 3df983dfe78..1b7bd1077e3 100644 --- a/packages/start-client-core/src/constants.ts +++ b/packages/start-client-core/src/constants.ts @@ -14,58 +14,32 @@ export const TSS_CONTENT_TYPE_FRAMED = 'application/x-tss-framed' /** * Frame types for binary multiplexing protocol. */ -export const FrameType = { - /** Seroval JSON chunk (NDJSON line) */ - JSON: 0, - /** Raw stream data chunk */ - CHUNK: 1, - /** Raw stream end (EOF) */ - END: 2, - /** Raw stream error */ - ERROR: 3, -} as const - -export type FrameType = (typeof FrameType)[keyof typeof FrameType] +export const FRAME_TYPE_JSON = 0 +export const FRAME_TYPE_CHUNK = 1 +export const FRAME_TYPE_END = 2 +export const FRAME_TYPE_ERROR = 3 /** Header size in bytes: type(1) + streamId(4) + length(4) */ export const FRAME_HEADER_SIZE = 9 -/** Current protocol version for framed responses */ -export const TSS_FRAMED_PROTOCOL_VERSION = 1 +/** Largest payload accepted by one framed-protocol record. */ +export const MAX_FRAME_PAYLOAD_SIZE = 16 * 1024 * 1024 -/** Full Content-Type header value with version parameter */ -export const TSS_CONTENT_TYPE_FRAMED_VERSIONED = `${TSS_CONTENT_TYPE_FRAMED}; v=${TSS_FRAMED_PROTOCOL_VERSION}` +/** Largest number of raw streams accepted in one framed response. */ +export const MAX_FRAMED_STREAMS = 1024 /** - * Parses the version parameter from a framed Content-Type header. - * Returns undefined if no version parameter is present. + * Largest number of bytes one raw stream may hold unread. Raw streams share + * one ordered response, so an unread stream would otherwise buffer without + * bound while later frames arrive. */ -const FRAMED_VERSION_REGEX = /;\s*v=(\d+)/ -export function parseFramedProtocolVersion( - contentType: string, -): number | undefined { - // Match "v=" in the content-type parameters - const match = contentType.match(FRAMED_VERSION_REGEX) - return match ? parseInt(match[1]!, 10) : undefined -} +export const MAX_UNREAD_RAW_STREAM_BYTES = 128 * 1024 * 1024 -/** - * Validates that the server's protocol version is compatible with this client. - * Throws an error if versions are incompatible. - */ -export function validateFramedProtocolVersion(contentType: string): void { - const serverVersion = parseFramedProtocolVersion(contentType) - if (serverVersion === undefined) { - // No version specified - assume compatible (backwards compat) - return - } - if (serverVersion !== TSS_FRAMED_PROTOCOL_VERSION) { - throw new Error( - `Incompatible framed protocol version: server=${serverVersion}, client=${TSS_FRAMED_PROTOCOL_VERSION}. ` + - `Please ensure client and server are using compatible versions.`, - ) - } -} +/** Current protocol version for framed responses */ +export const TSS_FRAMED_PROTOCOL_VERSION = 1 + +/** Full Content-Type header value with version parameter */ +export const TSS_CONTENT_TYPE_FRAMED_VERSIONED = `${TSS_CONTENT_TYPE_FRAMED}; v=${TSS_FRAMED_PROTOCOL_VERSION}` /** * Minimal metadata about a server function, available to client middleware. diff --git a/packages/start-client-core/src/getDefaultSerovalPlugins.ts b/packages/start-client-core/src/getDefaultSerovalPlugins.ts index 8070137c205..3071e9924f4 100644 --- a/packages/start-client-core/src/getDefaultSerovalPlugins.ts +++ b/packages/start-client-core/src/getDefaultSerovalPlugins.ts @@ -1,18 +1,27 @@ import { + createDefaultSerovalPlugins, makeSerovalPlugin, - defaultSerovalPlugins as routerDefaultSerovalPlugins, -} from '@tanstack/router-core' +} from '@tanstack/router-core/ssr/client' import { getStartOptions } from './getStartOptions' -import type { AnySerializationAdapter } from '@tanstack/router-core' +import type { AnySerializationAdapter } from '@tanstack/router-core/ssr/client' import type { Plugin } from 'seroval' -export function getDefaultSerovalPlugins(): Array> { - const start = getStartOptions() - const adapters = start?.serializationAdapters as +/** Start's serialization adapters followed by `routerPlugins`. */ +export function getSerovalPlugins( + routerPlugins: Array>, +): Array> { + const adapters = getStartOptions()?.serializationAdapters as | Array | undefined - return [ - ...(adapters?.map(makeSerovalPlugin) ?? []), - ...routerDefaultSerovalPlugins, - ] + return [...(adapters?.map(makeSerovalPlugin) ?? []), ...routerPlugins] +} + +/** + * Plugins for client JSON transport. The optional signal stops RawStream + * pumps when the request is aborted. + */ +export function getDefaultSerovalPlugins( + signal?: AbortSignal, +): Array> { + return getSerovalPlugins(createDefaultSerovalPlugins(signal)) } diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index 5bf09c44401..6b926ddcbf7 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -1,6 +1,11 @@ export type { JsonResponse } from '@tanstack/router-core/ssr/client' -export { hydrate, json, mergeHeaders } from '@tanstack/router-core/ssr/client' +export { + defaultSerovalDeserializerPlugins, + hydrate, + json, + mergeHeaders, +} from '@tanstack/router-core/ssr/client' export { RawStream } from '@tanstack/router-core' export type { OnRawStreamCallback } from '@tanstack/router-core' @@ -102,18 +107,18 @@ export { TSS_CONTENT_TYPE_FRAMED, TSS_CONTENT_TYPE_FRAMED_VERSIONED, TSS_FRAMED_PROTOCOL_VERSION, - FrameType, + FRAME_TYPE_JSON, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + FRAME_TYPE_ERROR, FRAME_HEADER_SIZE, + MAX_FRAME_PAYLOAD_SIZE, + MAX_FRAMED_STREAMS, X_TSS_SERIALIZED, X_TSS_RAW_RESPONSE, X_TSS_CONTEXT, - validateFramedProtocolVersion, -} from './constants' -export type { - FrameType as FrameTypeValue, - ClientFnMeta, - ServerFnMeta, } from './constants' +export type { ClientFnMeta, ServerFnMeta } from './constants' export type * from './serverRoute' @@ -129,7 +134,10 @@ export type { export type { Register } from '@tanstack/router-core' export { getRouterInstance } from './getRouterInstance' -export { getDefaultSerovalPlugins } from './getDefaultSerovalPlugins' +export { + getDefaultSerovalPlugins, + getSerovalPlugins, +} from './getDefaultSerovalPlugins' export { getGlobalStartContext } from './getGlobalStartContext' export { safeObjectMerge, createNullProtoObject } from './safeObjectMerge' export { trackPostProcessPromise } from './client-rpc/serverFnFetcher' diff --git a/packages/start-client-core/tests/frame-decoder.test.ts b/packages/start-client-core/tests/frame-decoder.test.ts index 17862c480b8..8aa9374dfe8 100644 --- a/packages/start-client-core/tests/frame-decoder.test.ts +++ b/packages/start-client-core/tests/frame-decoder.test.ts @@ -1,6 +1,34 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { RawStream, createControlledPromise } from '@tanstack/router-core' +import { createRawStreamRPCPlugin } from '@tanstack/router-core/ssr/server' +import { runWithStartContext } from '@tanstack/start-storage-context' +import { SerovalDeserializationError, toCrossJSONStream } from 'seroval' import { createFrameDecoder } from '../src/client-rpc/frame-decoder' -import { FRAME_HEADER_SIZE, FrameType } from '../src/constants' +import { + serverFnFetcher, + trackPostProcessPromise, +} from '../src/client-rpc/serverFnFetcher' +import { + FRAME_HEADER_SIZE, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + FRAME_TYPE_ERROR, + FRAME_TYPE_JSON, + MAX_FRAME_PAYLOAD_SIZE, + MAX_UNREAD_RAW_STREAM_BYTES, + TSS_CONTENT_TYPE_FRAMED_VERSIONED, + X_TSS_SERIALIZED, +} from '../src/constants' + +const serovalMocks = vi.hoisted(() => ({ + fromCrossJSON: vi.fn(), +})) + +vi.mock('seroval', async (importOriginal) => { + const actual = await importOriginal() + serovalMocks.fromCrossJSON.mockImplementation(actual.fromCrossJSON) + return { ...actual, fromCrossJSON: serovalMocks.fromCrossJSON } +}) /** * Helper to encode a frame for testing @@ -20,44 +48,397 @@ function encodeFrame( } function encodeJSONFrame(json: string): Uint8Array { - return encodeFrame(FrameType.JSON, 0, new TextEncoder().encode(json)) + return encodeFrame(FRAME_TYPE_JSON, 0, new TextEncoder().encode(json)) } function encodeChunkFrame(streamId: number, data: Uint8Array): Uint8Array { - return encodeFrame(FrameType.CHUNK, streamId, data) + return encodeFrame(FRAME_TYPE_CHUNK, streamId, data) } function encodeEndFrame(streamId: number): Uint8Array { - return encodeFrame(FrameType.END, streamId, new Uint8Array(0)) + return encodeFrame(FRAME_TYPE_END, streamId, new Uint8Array(0)) } function encodeErrorFrame(streamId: number, message: string): Uint8Array { return encodeFrame( - FrameType.ERROR, + FRAME_TYPE_ERROR, streamId, new TextEncoder().encode(message), ) } describe('frame-decoder', () => { + it('cancels RawStream request serialization when the request is aborted', async () => { + const cancel = vi.fn(() => new Promise(() => {})) + const stream = new ReadableStream({ + pull: () => new Promise(() => {}), + cancel, + }) + const controller = new AbortController() + const reason = new Error('request aborted') + const handler = vi.fn() + + const request = runWithStartContext( + { startOptions: undefined } as any, + async () => + serverFnFetcher( + 'http://localhost/_serverFn/test', + [ + { + method: 'POST', + data: new RawStream(stream), + signal: controller.signal, + }, + ], + handler, + ), + ) + + await vi.waitFor(() => expect(stream.locked).toBe(true)) + controller.abort(reason) + + await expect(request).rejects.toBe(reason) + expect(cancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(handler).not.toHaveBeenCalled() + expect(stream.locked).toBe(false) + }) + + it('cancels a framed response whose first JSON value is invalid', async () => { + const cancel = vi.fn() + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encodeJSONFrame('{')) + }, + cancel, + }) + + await expect( + runWithStartContext({ startOptions: undefined } as any, async () => + serverFnFetcher( + 'http://localhost/_serverFn/test', + [{ method: 'POST' }], + async () => + new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }), + ), + ), + ).rejects.toBeInstanceOf(SyntaxError) + + expect(cancel).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(body.locked).toBe(false)) + }) + + it.each([ + ['JSON', 'application/json', false], + ['framed', TSS_CONTENT_TYPE_FRAMED_VERSIONED, true], + ])( + 'observes tracked post-processing when initial %s deserialization throws', + async (_name, contentType, framed) => { + const observe = vi.fn(() => Promise.resolve()) + const tracked = { catch: observe } as unknown as Promise + const error = new Error('deserialization failed') + serovalMocks.fromCrossJSON.mockImplementationOnce(() => { + trackPostProcessPromise(tracked) + throw error + }) + + const cancel = vi.fn() + const body = framed + ? new ReadableStream({ + start(controller) { + controller.enqueue(encodeJSONFrame('{}')) + }, + cancel, + }) + : '{}' + + await expect( + runWithStartContext({ startOptions: undefined } as any, async () => + serverFnFetcher( + 'http://localhost/_serverFn/test', + [{ method: 'POST' }], + async () => + new Response(body, { + headers: { + 'content-type': contentType, + [X_TSS_SERIALIZED]: 'true', + }, + }), + ), + ), + ).rejects.toBe(error) + + expect(observe).toHaveBeenCalledOnce() + if (framed) { + expect(cancel).toHaveBeenCalledOnce() + await vi.waitFor(() => + expect((body as ReadableStream).locked).toBe(false), + ) + } + }, + ) + + it('continues framed patches without awaiting their post-processing', async () => { + let releaseGate!: () => void + const gate = new Promise((resolve) => { + releaseGate = resolve + }) + let resolveLastPatch!: () => void + const lastPatch = new Promise((resolve) => { + resolveLastPatch = resolve + }) + const result = { ok: true } + + serovalMocks.fromCrossJSON + .mockImplementationOnce(() => result) + .mockImplementationOnce(() => { + trackPostProcessPromise(gate) + }) + .mockImplementationOnce(() => { + releaseGate() + resolveLastPatch() + }) + + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encodeJSONFrame('{}')) + controller.enqueue(encodeJSONFrame('{}')) + controller.enqueue(encodeJSONFrame('{}')) + controller.close() + }, + }) + + const request = runWithStartContext( + { startOptions: undefined } as any, + async () => + serverFnFetcher( + 'http://localhost/_serverFn/test', + [{ method: 'POST' }], + async () => + new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }), + ), + ) + + await lastPatch + await expect(request).resolves.toBe(result) + await vi.waitFor(() => expect(body.locked).toBe(false)) + }) + + it.each(['invalid JSON', 'invalid patch', 'read failure'] as const)( + 'rejects pending framed promises and raw streams after %s', + async (failure) => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const cancel = vi.fn() + let controller!: ReadableStreamDefaultController + const body = new ReadableStream({ + start(value) { + controller = value + }, + cancel, + }) + const ready = createControlledPromise() + const late = createControlledPromise<{ nested: Promise }>() + const dispose = toCrossJSONStream( + { + ready, + late, + pending: new Promise(() => {}), + raw: new RawStream(new ReadableStream()), + }, + { + refs: new Map(), + plugins: [createRawStreamRPCPlugin(() => {})], + onParse(value) { + controller.enqueue(encodeJSONFrame(JSON.stringify(value))) + }, + }, + ) + + try { + const result = await runWithStartContext( + { startOptions: undefined } as any, + async () => + serverFnFetcher( + 'http://localhost/_serverFn/test', + [{ method: 'POST' }], + async () => + new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }), + ), + ) + const rawReader = result.raw.getReader() + + ready.resolve('ready') + late.resolve({ nested: new Promise(() => {}) }) + await expect(result.ready).resolves.toBe('ready') + const { nested } = await result.late + const failures = Promise.allSettled([ + result.pending, + nested, + rawReader.read(), + ]) + + const transportError = new Error('transport failed') + if (failure === 'read failure') { + controller.error(transportError) + } else { + controller.enqueue( + encodeJSONFrame(failure === 'invalid JSON' ? '{' : '{}'), + ) + } + + await vi.waitFor(() => expect(log).toHaveBeenCalledOnce()) + const error = log.mock.calls[0]![1] + if (failure === 'read failure') { + expect(error).toBe(transportError) + } else { + expect(error).toBeInstanceOf( + failure === 'invalid JSON' + ? SyntaxError + : SerovalDeserializationError, + ) + expect(cancel).toHaveBeenCalledExactlyOnceWith(error) + } + await expect(failures).resolves.toEqual([ + { status: 'rejected', reason: error }, + { status: 'rejected', reason: error }, + { status: 'rejected', reason: error }, + ]) + await expect(result.ready).resolves.toBe('ready') + await expect(result.late).resolves.toEqual({ nested }) + rawReader.releaseLock() + await vi.waitFor(() => expect(body.locked).toBe(false)) + } finally { + dispose() + log.mockRestore() + } + }, + ) + + it('rejects only Seroval promise handles when initial framed deserialization fails', async () => { + const actual = await vi.importActual('seroval') + const error = new Error('initial deserialization failed') + const applicationReject = vi.fn() + let result!: { pending: Promise } + serovalMocks.fromCrossJSON.mockImplementationOnce((value, options) => { + result = actual.fromCrossJSON(value, options) + Object.assign(result, { p: result.pending, f: applicationReject }) + throw error + }) + + const cancel = vi.fn() + let controller!: ReadableStreamDefaultController + const body = new ReadableStream({ + start(value) { + controller = value + }, + cancel, + }) + const dispose = toCrossJSONStream( + { pending: new Promise(() => {}) }, + { + refs: new Map(), + onParse(value) { + controller.enqueue(encodeJSONFrame(JSON.stringify(value))) + }, + }, + ) + + try { + await expect( + runWithStartContext({ startOptions: undefined } as any, async () => + serverFnFetcher( + 'http://localhost/_serverFn/test', + [{ method: 'POST' }], + async () => + new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }), + ), + ), + ).rejects.toBe(error) + await expect(result.pending).rejects.toBe(error) + expect(applicationReject).not.toHaveBeenCalled() + expect(cancel).toHaveBeenCalledExactlyOnceWith(error) + await vi.waitFor(() => expect(body.locked).toBe(false)) + } finally { + dispose() + } + }) + describe('createFrameDecoder', () => { + it('should throw synchronously when the input is already locked', () => { + const input = new ReadableStream() + const reader = input.getReader() + + try { + expect(() => createFrameDecoder(input)).toThrow() + } finally { + reader.releaseLock() + } + }) + + it('should ignore empty input chunks', async () => { + const input = new ReadableStream({ + start(controller) { + for (let index = 0; index < 1_000; index++) { + controller.enqueue(new Uint8Array()) + } + controller.enqueue(encodeJSONFrame('{"ok":true}')) + controller.close() + }, + }) + + const [chunks] = createFrameDecoder(input) + const reader = chunks.getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: '{"ok":true}', + }) + await expect(reader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + it('should reject unknown frame type', async () => { const badFrame = encodeFrame(99, 0, new Uint8Array(0)) + let cancelReason: unknown const input = new ReadableStream({ start(controller) { controller.enqueue(badFrame) - controller.close() + }, + cancel(reason) { + cancelReason = reason }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() - await expect(reader.read()).rejects.toThrow('Unknown frame type') + await expect(reader.read()).rejects.toThrow('Invalid frame') + expect(cancelReason).toBeInstanceOf(Error) + expect(input.locked).toBe(false) }) it('should reject raw frames with streamId 0', async () => { - const badChunk = encodeFrame(FrameType.CHUNK, 0, new Uint8Array([1])) + const badChunk = encodeFrame(FRAME_TYPE_CHUNK, 0, new Uint8Array([1])) const input = new ReadableStream({ start(controller) { controller.enqueue(badChunk) @@ -65,15 +446,15 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() - await expect(reader.read()).rejects.toThrow('Invalid raw frame streamId') + await expect(reader.read()).rejects.toThrow('Invalid frame') }) it('should reject JSON frames with non-zero streamId', async () => { const badJson = encodeFrame( - FrameType.JSON, + FRAME_TYPE_JSON, 1, new TextEncoder().encode('{}\n'), ) @@ -84,17 +465,17 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() - await expect(reader.read()).rejects.toThrow('Invalid JSON frame streamId') + await expect(reader.read()).rejects.toThrow('Invalid frame') }) it('should reject oversized frame payloads', async () => { // Declare a payload length > MAX_FRAME_PAYLOAD_SIZE with no payload. const headerOnly = new Uint8Array(FRAME_HEADER_SIZE) const view = new DataView(headerOnly.buffer) - view.setUint8(0, FrameType.JSON) + view.setUint8(0, FRAME_TYPE_JSON) view.setUint32(1, 0, false) view.setUint32(5, 16 * 1024 * 1024 + 1, false) @@ -105,16 +486,16 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() - await expect(reader.read()).rejects.toThrow('Frame payload too large') + await expect(reader.read()).rejects.toThrow('Invalid frame') }) it('should reject incomplete frames at end-of-stream', async () => { const headerOnly = new Uint8Array(FRAME_HEADER_SIZE) const view = new DataView(headerOnly.buffer) - view.setUint8(0, FrameType.JSON) + view.setUint8(0, FRAME_TYPE_JSON) view.setUint32(1, 0, false) view.setUint32(5, 3, false) @@ -125,12 +506,216 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() await expect(reader.read()).rejects.toThrow('Incomplete frame') }) + it('should reject END frames with a payload', async () => { + const input = new ReadableStream({ + start(controller) { + controller.enqueue( + encodeFrame(FRAME_TYPE_END, 1, new Uint8Array([1])), + ) + }, + }) + + const [chunks] = createFrameDecoder(input) + await expect(chunks.getReader().read()).rejects.toThrow('Invalid frame') + }) + + it('should reject invalid getStream IDs', async () => { + const input = new ReadableStream({ pull() {} }) + const [chunks, getStream] = createFrameDecoder(input) + + for (const id of [0, -1, 1.5, 0x1_0000_0000, NaN, Infinity]) { + expect(() => getStream(id)).toThrow('Invalid raw stream ID') + } + const maxStream = getStream(0xffff_ffff) + expect(getStream(0xffff_ffff)).toBe(maxStream) + await chunks.cancel() + }) + + it('should reject frames after a raw stream has ended', async () => { + const firstEnd = encodeEndFrame(1) + const secondEnd = encodeEndFrame(1) + const input = new ReadableStream({ + start(controller) { + const frames = new Uint8Array(firstEnd.length + secondEnd.length) + frames.set(firstEnd) + frames.set(secondEnd, firstEnd.length) + controller.enqueue(frames) + }, + }) + + const [chunks] = createFrameDecoder(input) + await expect(chunks.getReader().read()).rejects.toThrow( + 'Raw stream already ended', + ) + }) + + it('rejects a header-only CHUNK for an ended stream without pulling its payload', async () => { + const chunkHeader = encodeChunkFrame(1, new Uint8Array()) + new DataView(chunkHeader.buffer).setUint32( + 5, + MAX_FRAME_PAYLOAD_SIZE, + false, + ) + const frames = [encodeEndFrame(1), chunkHeader] + let pulls = 0 + const input = new ReadableStream( + { + pull(controller) { + pulls++ + const frame = frames.shift() + if (!frame) { + throw new Error('Payload was pulled') + } + controller.enqueue(frame) + }, + }, + { highWaterMark: 0 }, + ) + + const [chunks] = createFrameDecoder(input) + await expect(chunks.getReader().read()).rejects.toThrow( + 'Raw stream already ended', + ) + expect(pulls).toBe(2) + }) + + it('returns stable closed streams after completion', async () => { + const input = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + const [chunks, getStream] = createFrameDecoder(input) + + await expect(chunks.getReader().read()).resolves.toEqual({ + done: true, + value: undefined, + }) + const stream = getStream(1) + expect(getStream(1)).toBe(stream) + await expect(stream.getReader().read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('errors active and late streams after a fatal frame', async () => { + const json = encodeJSONFrame('{"ref":1}') + const malformed = encodeFrame(99, 0, new Uint8Array()) + const cancel = vi.fn() + const input = new ReadableStream({ + start(controller) { + const frames = new Uint8Array(json.length + malformed.length) + frames.set(json) + frames.set(malformed, json.length) + controller.enqueue(frames) + }, + cancel, + }) + const [chunks, getStream] = createFrameDecoder(input) + const jsonReader = chunks.getReader() + + await jsonReader.read() + const active = getStream(1) + await expect(jsonReader.read()).rejects.toThrow('Invalid frame') + await expect(active.getReader().read()).rejects.toThrow('Invalid frame') + const late = getStream(2) + expect(getStream(2)).toBe(late) + await expect(late.getReader().read()).rejects.toThrow('Invalid frame') + expect(cancel).toHaveBeenCalledOnce() + expect(input.locked).toBe(false) + }) + + it('errors a raw stream when input ends without END', async () => { + const json = encodeJSONFrame('{"ref":1}') + const chunk = encodeChunkFrame(1, new Uint8Array([1])) + const input = new ReadableStream({ + start(controller) { + const frames = new Uint8Array(json.length + chunk.length) + frames.set(json) + frames.set(chunk, json.length) + controller.enqueue(frames) + controller.close() + }, + }) + const [chunks, getStream] = createFrameDecoder(input) + const jsonReader = chunks.getReader() + await jsonReader.read() + const rawReader = getStream(1).getReader() + + await expect(rawReader.read()).resolves.toEqual({ + done: false, + value: new Uint8Array([1]), + }) + await expect(rawReader.read()).rejects.toThrow('Incomplete raw stream') + await expect(jsonReader.read()).rejects.toThrow('Incomplete raw stream') + }) + + it('rejects input that ends without END for a cancelled raw stream', async () => { + let inputController!: ReadableStreamDefaultController + const input = new ReadableStream({ + start(controller) { + inputController = controller + }, + }) + const [chunks, getStream] = createFrameDecoder(input) + const jsonReader = chunks.getReader() + + inputController.enqueue(encodeJSONFrame('{"ref":1}')) + await jsonReader.read() + await getStream(1).cancel() + inputController.close() + + await expect(jsonReader.read()).rejects.toThrow('Incomplete raw stream') + }) + + it('accepts clean EOF after cancelling an unread stream that received END', async () => { + let inputController!: ReadableStreamDefaultController + let pulls = 0 + const frames = [ + encodeJSONFrame('{"ref":1}'), + encodeChunkFrame(1, new Uint8Array([1])), + encodeEndFrame(1), + ] + const input = new ReadableStream( + { + start(controller) { + inputController = controller + }, + pull(controller) { + pulls++ + const frame = frames.shift() + if (frame) { + controller.enqueue(frame) + } + }, + }, + { highWaterMark: 0 }, + ) + const [chunks, getStream] = createFrameDecoder(input) + const jsonReader = chunks.getReader() + + await expect(jsonReader.read()).resolves.toEqual({ + done: false, + value: '{"ref":1}', + }) + const rawStream = getStream(1) + await vi.waitFor(() => expect(pulls).toBe(4)) + await rawStream.cancel('unused') + inputController.close() + + await expect(jsonReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + it('should cancel input when jsonChunks cancelled', async () => { let cancelled = false const input = new ReadableStream({ @@ -140,15 +725,286 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() await reader.cancel() expect(cancelled).toBe(true) }) + it('does not decode a payload after JSON cancellation wins its read', async () => { + const first = encodeJSONFrame('{"first":true}') + const second = encodeJSONFrame('{"second":true}') + const firstInput = new Uint8Array(first.length + FRAME_HEADER_SIZE) + firstInput.set(first) + firstInput.set(second.subarray(0, FRAME_HEADER_SIZE), first.length) + + let pulls = 0 + let jsonReader!: ReadableStreamDefaultReader + const cancel = vi.fn() + const decode = vi.spyOn(TextDecoder.prototype, 'decode') + const input = new ReadableStream( + { + pull(controller) { + if (pulls++ === 0) { + controller.enqueue(firstInput) + } else { + queueMicrotask(() => void jsonReader.cancel('stop')) + controller.enqueue(second.subarray(FRAME_HEADER_SIZE)) + } + }, + cancel, + }, + { highWaterMark: 0 }, + ) + + try { + const [chunks] = createFrameDecoder(input) + jsonReader = chunks.getReader() + await expect(jsonReader.read()).resolves.toEqual({ + done: false, + value: '{"first":true}', + }) + decode.mockClear() + + await vi.waitFor(() => expect(input.locked).toBe(false)) + expect(decode).not.toHaveBeenCalled() + expect(cancel).toHaveBeenCalledWith('stop') + } finally { + decode.mockRestore() + } + }) + + it('continues decoding while a raw chunk is unread', async () => { + const frames = [ + encodeJSONFrame('{"ref":1}'), + encodeChunkFrame(1, new Uint8Array([1])), + encodeJSONFrame('{"after":true}'), + encodeEndFrame(1), + ] + let pulls = 0 + const input = new ReadableStream( + { + pull(controller) { + const frame = frames[pulls++] + if (frame) { + controller.enqueue(frame) + } else { + controller.close() + } + }, + }, + { highWaterMark: 0 }, + ) + + const [chunks, getStream] = createFrameDecoder(input) + const jsonReader = chunks.getReader() + await expect(jsonReader.read()).resolves.toEqual({ + done: false, + value: '{"ref":1}', + }) + const rawStream = getStream(1) + await expect(jsonReader.read()).resolves.toEqual({ + done: false, + value: '{"after":true}', + }) + + const rawReader = rawStream.getReader() + await expect(rawReader.read()).resolves.toEqual({ + done: false, + value: new Uint8Array([1]), + }) + await expect(rawReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + expect(getStream(1)).toBe(rawStream) + }) + + it('keeps the multiplexed response active after one raw stream is cancelled', async () => { + const frames = [ + encodeJSONFrame('{"refs":[1,2]}'), + encodeChunkFrame(1, new Uint8Array([1])), + encodeChunkFrame(1, new Uint8Array([2])), + encodeEndFrame(1), + encodeChunkFrame(2, new Uint8Array([3])), + encodeEndFrame(2), + encodeJSONFrame('{"after":true}'), + ] + let pulls = 0 + const input = new ReadableStream( + { + pull(controller) { + const frame = frames[pulls++] + if (frame) { + controller.enqueue(frame) + } else { + controller.close() + } + }, + }, + { highWaterMark: 0 }, + ) + + const [chunks, getStream] = createFrameDecoder(input) + const jsonReader = chunks.getReader() + await jsonReader.read() + const blockingStream = getStream(1) + const otherReader = getStream(2).getReader() + const otherRead = otherReader.read() + await vi.waitFor(() => expect(pulls).toBe(frames.length)) + + await blockingStream.cancel('unused') + await expect(otherRead).resolves.toEqual({ + done: false, + value: new Uint8Array([3]), + }) + await expect(otherReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + await expect(jsonReader.read()).resolves.toEqual({ + done: false, + value: '{"after":true}', + }) + await expect(jsonReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + expect(getStream(1)).toBe(blockingStream) + await expect(blockingStream.getReader().read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('streams more than the old 16 MiB queue limit when consumed', async () => { + const payload = new Uint8Array(1024 * 1024) + let frame = 0 + const input = new ReadableStream( + { + pull(controller) { + if (frame++ === 0) { + controller.enqueue(encodeJSONFrame('{"ref":1}')) + } else if (frame <= 18) { + controller.enqueue(encodeChunkFrame(1, payload)) + } else if (frame === 19) { + controller.enqueue(encodeEndFrame(1)) + } else { + controller.close() + } + }, + }, + { highWaterMark: 0 }, + ) + + const [chunks, getStream] = createFrameDecoder(input) + const jsonReader = chunks.getReader() + await jsonReader.read() + const rawReader = getStream(1).getReader() + let bytes = 0 + while (true) { + const next = await rawReader.read() + if (next.done) { + break + } + bytes += next.value.byteLength + } + + expect(bytes).toBe(17 * 1024 * 1024) + await expect(jsonReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('cancelling JSON aborts the response while a raw chunk is unread', async () => { + let pulls = 0 + let inputCancelReason: unknown + const frames = [ + encodeJSONFrame('{"ref":1}'), + encodeChunkFrame(1, new Uint8Array([1])), + ] + const input = new ReadableStream( + { + pull(controller) { + const frame = frames[pulls++] + if (frame) { + controller.enqueue(frame) + } + }, + cancel(reason) { + inputCancelReason = reason + }, + }, + { highWaterMark: 0 }, + ) + + const [chunks, getStream] = createFrameDecoder(input) + const jsonReader = chunks.getReader() + await jsonReader.read() + const rawStream = getStream(1) + await vi.waitFor(() => expect(pulls).toBe(frames.length + 1)) + + const reason = 'stop' + await jsonReader.cancel(reason) + await expect(rawStream.getReader().read()).rejects.toBe(reason) + expect(inputCancelReason).toBe(reason) + await vi.waitFor(() => expect(input.locked).toBe(false)) + // A stream requested after cancellation fails the same way. + await expect(getStream(2).getReader().read()).rejects.toBe(reason) + }) + + it('fails the response when one raw stream holds too many unread bytes', async () => { + // One 16 MiB frame, enqueued repeatedly; the decoder never copies it. + const frame = encodeChunkFrame(1, new Uint8Array(MAX_FRAME_PAYLOAD_SIZE)) + const frameCount = + MAX_UNREAD_RAW_STREAM_BYTES / MAX_FRAME_PAYLOAD_SIZE + 2 + const input = new ReadableStream({ + start(controller) { + controller.enqueue(encodeJSONFrame('{}')) + for (let i = 0; i < frameCount; i++) { + controller.enqueue(frame) + } + controller.close() + }, + }) + + const [chunks, getStream] = createFrameDecoder(input) + const rawReader = getStream(1).getReader() + const jsonReader = chunks.getReader() + await expect(jsonReader.read()).resolves.toMatchObject({ done: false }) + await expect(jsonReader.read()).rejects.toThrow( + 'Raw stream 1 has too many unread bytes', + ) + await expect(rawReader.read()).rejects.toThrow( + 'Raw stream 1 has too many unread bytes', + ) + }) + + it('copies a small chunk instead of pinning its network buffer', async () => { + const network = new Uint8Array(4096) + const frame = encodeChunkFrame(1, Uint8Array.of(1, 2, 3)) + network.set(frame) + const input = new ReadableStream({ + start(controller) { + controller.enqueue(encodeJSONFrame('{}')) + controller.enqueue(network.subarray(0, frame.byteLength)) + controller.enqueue(encodeEndFrame(1)) + controller.close() + }, + }) + + const [chunks, getStream] = createFrameDecoder(input) + const raw = getStream(1).getReader() + await chunks.getReader().read() + const { value } = await raw.read() + expect(Array.from(value!)).toEqual([1, 2, 3]) + expect(value!.buffer.byteLength).toBe(3) + }) + it('should reject too many raw streams', async () => { - // END frames create streams via ensureController, even with no CHUNKs. + // END frames create streams, even with no CHUNKs. const frames: Array = [] for (let i = 1; i <= 1025; i++) { frames.push(encodeEndFrame(i)) @@ -169,27 +1025,59 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() await expect(reader.read()).rejects.toThrow('Too many raw streams') }) - it('should reject when buffered bytes exceed limit', async () => { - // No valid frame can be parsed from this; we just want to exceed MAX_BUFFERED_BYTES. - const tooLarge = new Uint8Array(32 * 1024 * 1024 + 1) - - const input = new ReadableStream({ - start(controller) { - controller.enqueue(tooLarge) - controller.close() - }, + it('rejects a header-only CHUNK for a 1025th stream without pulling its payload', async () => { + const chunkHeader = encodeChunkFrame(1025, new Uint8Array()) + new DataView(chunkHeader.buffer).setUint32( + 5, + MAX_FRAME_PAYLOAD_SIZE, + false, + ) + let releaseHeader!: () => void + const headerReady = new Promise((resolve) => { + releaseHeader = resolve }) + let pulls = 0 + const input = new ReadableStream( + { + async pull(controller) { + pulls++ + if (pulls > 1) { + throw new Error('Payload was pulled') + } + await headerReady + controller.enqueue(chunkHeader) + }, + }, + { highWaterMark: 0 }, + ) + const [chunks, getStream] = createFrameDecoder(input) + for (let id = 1; id <= 1024; id++) { + getStream(id) + } - const { chunks: jsonChunks } = createFrameDecoder(input) - const reader = jsonChunks.getReader() + releaseHeader() + await expect(chunks.getReader().read()).rejects.toThrow( + 'Too many raw streams', + ) + expect(pulls).toBe(1) + }) + + it('should count cancelled raw streams toward the stream limit', async () => { + const input = new ReadableStream({ pull() {} }) + const [chunks, getStream] = createFrameDecoder(input) + + for (let id = 1; id <= 1024; id++) { + await getStream(id).cancel() + } - await expect(reader.read()).rejects.toThrow('buffer exceeded') + expect(() => getStream(1025)).toThrow('Too many raw streams') + await chunks.cancel() }) it('should decode JSON frames', async () => { @@ -207,7 +1095,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const chunks: Array = [] @@ -240,8 +1128,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks, getStream: getOrCreateStream } = - createFrameDecoder(input) + const [jsonChunks, getOrCreateStream] = createFrameDecoder(input) // Pre-create the stream before consuming const stream5 = getOrCreateStream(5) @@ -277,7 +1164,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const chunks: Array = [] @@ -291,8 +1178,7 @@ describe('frame-decoder', () => { expect(chunks).toEqual(['{"test":"data"}']) }) - it('should use fast path when header fits in first chunk', async () => { - // Single chunk contains entire frame - exercises fast path + it('should decode a frame from one input chunk', async () => { const frame = encodeJSONFrame('{"fast":"path"}') const input = new ReadableStream({ @@ -302,15 +1188,14 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const { value } = await reader.read() expect(value).toBe('{"fast":"path"}') }) - it('should use slow path when header spans multiple chunks', async () => { - // Split header itself across multiple chunks - exercises slow path + it('should decode a header split across input chunks', async () => { const frame = encodeJSONFrame('{"slow":"path"}') // Split at byte 3, then byte 6, then rest - header is 9 bytes @@ -327,7 +1212,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const { value } = await reader.read() @@ -354,7 +1239,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const { value } = await reader.read() @@ -388,8 +1273,7 @@ describe('frame-decoder', () => { }, }) - const { getStream: getOrCreateStream, chunks: jsonChunks } = - createFrameDecoder(input) + const [jsonChunks, getOrCreateStream] = createFrameDecoder(input) // Pre-create streams before consuming const stream1 = getOrCreateStream(1) @@ -403,11 +1287,19 @@ describe('frame-decoder', () => { const reader1 = stream1.getReader() const { value: val1 } = await reader1.read() expect(val1).toEqual(new Uint8Array([10])) + await expect(reader1.read()).resolves.toEqual({ + done: true, + value: undefined, + }) // Read stream 2 const reader2 = stream2.getReader() const { value: val2 } = await reader2.read() expect(val2).toEqual(new Uint8Array([20])) + await expect(reader2.read()).resolves.toEqual({ + done: true, + value: undefined, + }) }) it('should handle error frames for existing streams', async () => { @@ -430,8 +1322,7 @@ describe('frame-decoder', () => { }, }) - const { getStream: getOrCreateStream, chunks: jsonChunks } = - createFrameDecoder(input) + const [jsonChunks, getOrCreateStream] = createFrameDecoder(input) // Pre-create stream 3 const stream3 = getOrCreateStream(3) @@ -441,30 +1332,19 @@ describe('frame-decoder', () => { await jsonReader.read() const reader = stream3.getReader() - - // Stream was created but then errored - // Reading should throw the error - let errorCaught = false - let chunkReceived = false - try { - while (true) { - const { value, done } = await reader.read() - if (done) break - if (value) chunkReceived = true - } - } catch (error) { - errorCaught = true - expect((error as Error).message).toBe('Stream failed') - } - - // Either we got the chunk before error, or we got error immediately - // The important thing is that the error was thrown - expect(errorCaught).toBe(true) + await expect(reader.read()).resolves.toEqual({ + done: false, + value: new Uint8Array([1]), + }) + await expect(reader.read()).rejects.toThrow('Stream failed') + expect(getOrCreateStream(3)).toBe(stream3) + await expect(jsonReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) }) it('should preserve stream after END frame for late consumers', async () => { - // This tests a race condition fix: stream should still be available - // even if END frame is processed before getOrCreateStream is called const jsonFrame = encodeJSONFrame('{"streamRef":7}') const chunkFrame = encodeChunkFrame(7, new Uint8Array([42, 43, 44])) const endFrame = encodeEndFrame(7) @@ -483,21 +1363,15 @@ describe('frame-decoder', () => { }, }) - const { getStream: getOrCreateStream, chunks: jsonChunks } = - createFrameDecoder(input) + const [jsonChunks, getOrCreateStream] = createFrameDecoder(input) - // First, fully consume JSON stream (this processes all frames) + // The reference is admitted before its raw data, so the deserializer can + // obtain the stream without requiring the decoder to buffer it eagerly. const jsonReader = jsonChunks.getReader() - const chunks: Array = [] - while (true) { - const { done, value } = await jsonReader.read() - if (done) break - chunks.push(value) - } - expect(chunks).toEqual(['{"streamRef":7}']) - - // Now call getOrCreateStream AFTER all frames processed (including END) - // This simulates deserializer calling getOrCreateStream late + await expect(jsonReader.read()).resolves.toEqual({ + done: false, + value: '{"streamRef":7}', + }) const stream7 = getOrCreateStream(7) // The stream should still have the data that was enqueued @@ -510,25 +1384,26 @@ describe('frame-decoder', () => { // Next read should be done (stream was closed by END frame) const { done: finalDone } = await rawReader.read() expect(finalDone).toBe(true) + await expect(jsonReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) }) - it('should handle CHUNK creating stream before getOrCreateStream called', async () => { - // CHUNK frame arrives first and creates stream internally, - // then getOrCreateStream returns the same stream with data + it('should preserve chunks when the stream is obtained after its JSON reference', async () => { + const jsonFrame = encodeJSONFrame('{"ref":9}') const chunkFrame1 = encodeChunkFrame(9, new Uint8Array([1, 2])) const chunkFrame2 = encodeChunkFrame(9, new Uint8Array([3, 4])) const endFrame = encodeEndFrame(9) - const jsonFrame = encodeJSONFrame('{"ref":9}') - // Order: CHUNK, CHUNK, END, then JSON (unusual but valid) const combined = new Uint8Array( - chunkFrame1.length + + jsonFrame.length + + chunkFrame1.length + chunkFrame2.length + - endFrame.length + - jsonFrame.length, + endFrame.length, ) let offset = 0 - for (const frame of [chunkFrame1, chunkFrame2, endFrame, jsonFrame]) { + for (const frame of [jsonFrame, chunkFrame1, chunkFrame2, endFrame]) { combined.set(frame, offset) offset += frame.length } @@ -540,17 +1415,14 @@ describe('frame-decoder', () => { }, }) - const { getStream: getOrCreateStream, chunks: jsonChunks } = - createFrameDecoder(input) + const [jsonChunks, getOrCreateStream] = createFrameDecoder(input) - // Drain JSON (processes all frames) const jsonReader = jsonChunks.getReader() - while (true) { - const { done } = await jsonReader.read() - if (done) break - } + await expect(jsonReader.read()).resolves.toEqual({ + done: false, + value: '{"ref":9}', + }) - // Now get the stream - should have all the data const stream9 = getOrCreateStream(9) const reader = stream9.getReader() @@ -562,12 +1434,15 @@ describe('frame-decoder', () => { const { done: finalDone } = await reader.read() expect(finalDone).toBe(true) + await expect(jsonReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) }) it('should reassemble a chunk payload that spans many small reads', async () => { - // A large binary payload delivered in tiny network reads forces the - // multi-chunk (copy) path; the contiguous fast path must not change the - // reassembled bytes. + // A large binary payload delivered in tiny network reads exercises the + // cursor's exact-length assembly path. const payload = new Uint8Array(300) for (let i = 0; i < payload.length; i++) payload[i] = i % 256 @@ -585,7 +1460,7 @@ describe('frame-decoder', () => { const input = new ReadableStream({ start(controller) { // 7-byte reads: smaller than the 9-byte header and the payload, so - // both header and payload span multiple buffered chunks. + // both header and payload span multiple input chunks. for (let i = 0; i < combined.length; i += 7) { controller.enqueue(combined.subarray(i, i + 7)) } @@ -593,8 +1468,7 @@ describe('frame-decoder', () => { }, }) - const { getStream: getOrCreateStream, chunks: jsonChunks } = - createFrameDecoder(input) + const [jsonChunks, getOrCreateStream] = createFrameDecoder(input) const stream11 = getOrCreateStream(11) const jsonReader = jsonChunks.getReader() @@ -612,9 +1486,8 @@ describe('frame-decoder', () => { }) it('reassembles a large chunk payload delivered one byte at a time', async () => { - // Forces the header slow path AND many whole-chunk consumptions within a - // single extract, exercising the head-pointer advance + fully-drained - // reset. With the previous bufferList.shift() this path was O(n^2). + // One-byte reads exercise repeated cursor advancement without repeated + // concatenation or rescanning. const payload = new Uint8Array(200) for (let i = 0; i < payload.length; i++) { payload[i] = (i * 7) % 256 @@ -640,8 +1513,7 @@ describe('frame-decoder', () => { }, }) - const { getStream: getOrCreateStream, chunks: jsonChunks } = - createFrameDecoder(input) + const [jsonChunks, getOrCreateStream] = createFrameDecoder(input) const stream21 = getOrCreateStream(21) const jsonReader = jsonChunks.getReader() @@ -663,9 +1535,7 @@ describe('frame-decoder', () => { }) it('decodes many frames when reads never align with frame boundaries', async () => { - // 100-byte frames fed in 7-byte reads never align until the very end, so - // consumed chunks accumulate and the head pointer climbs past the - // compaction threshold repeatedly, exercising the splice() prefix drop. + // These 100-byte frames never align with the 7-byte input reads. const FRAME_COUNT = 7 const expected: Array = [] const frames: Array = [] @@ -692,7 +1562,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const received: Array = [] while (true) { diff --git a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts index 591625ce1c0..36a34278305 100644 --- a/packages/start-plugin-core/src/rsbuild/virtual-modules.ts +++ b/packages/start-plugin-core/src/rsbuild/virtual-modules.ts @@ -297,6 +297,7 @@ export function registerVirtualModules( serverFnsById: opts.serverFnsById, includeClientReferencedCheck: !isProviderEnvironment(environmentName), useStaticImports: Boolean(opts.rscEnabled && isDev), + memoizeModules: !isDev, }) } diff --git a/packages/start-plugin-core/src/start-compiler/server-fn-resolver-module.ts b/packages/start-plugin-core/src/start-compiler/server-fn-resolver-module.ts index 8f872a48c17..d03bec33413 100644 --- a/packages/start-plugin-core/src/start-compiler/server-fn-resolver-module.ts +++ b/packages/start-plugin-core/src/start-compiler/server-fn-resolver-module.ts @@ -1,59 +1,69 @@ import type { ServerFn } from './types' -interface ResolverManifestEntry { - id: string - functionName: string - extractedFilename: string - isClientReferenced: boolean -} - interface GenerateServerFnResolverModuleOptions { serverFnsById: Record includeClientReferencedCheck: boolean useStaticImports?: boolean + /** + * Keep each imported module on its manifest entry after the first call. + * Only safe when modules cannot be hot-replaced (production builds). + */ + memoizeModules?: boolean } -function getResolverManifestEntries( - serverFnsById: Record, -): Array { - return ( - Object.entries(serverFnsById) - // Sort entries by ID so that the generated manifest has a stable, deterministic order. - // Non-deterministic ordering causes the compiled hash of the same source file to change - // between builds, breaking content-addressed caching and reproducible deployments. - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([id, fn]) => ({ - id, - functionName: fn.functionName, - extractedFilename: fn.extractedFilename, - isClientReferenced: fn.isClientReferenced ?? true, - })) - ) -} - -function getClientReferencedCheck( - includeClientReferencedCheck: boolean, +export function generateServerFnResolverModule( + opts: GenerateServerFnResolverModuleOptions, ): string { - if (!includeClientReferencedCheck) { - return '' - } + const staticImports: Array = [] + const manifest = Object.entries(opts.serverFnsById) + // Keep generated source stable for content-addressed caches. + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([id, fn], index) => { + const moduleRef = `serverFnModule${index}` + const filename = JSON.stringify(fn.extractedFilename) + if (opts.useStaticImports) { + staticImports.push(`import * as ${moduleRef} from ${filename}`) + } + const moduleAccess = opts.useStaticImports + ? `module: ${moduleRef}` + : `importer: () => import(${filename})` + const clientReferenced = opts.includeClientReferencedCheck + ? `,\n isClientReferenced: ${fn.isClientReferenced ?? true}` + : '' + + return `'${id}': { + functionName: '${fn.functionName}', + ${moduleAccess}${clientReferenced} + }` + }) + .join(',\n ') - return ` + const clientReferencedCheck = opts.includeClientReferencedCheck + ? ` if (access.origin === 'client' && !serverFnInfo.isClientReferenced) { throw new Error('Server function not accessible from client: ' + id) } ` -} + : '' + // Every import() goes through the ESM loader; a resolved production module + // can be kept on the manifest entry instead. + const resolveModule = opts.memoizeModules + ? 'serverFnInfo.module ??= await serverFnInfo.importer()' + : 'serverFnInfo.module ?? (await serverFnInfo.importer())' -function getResolverBody(): string { return ` +${staticImports.join('\n')} +const manifest = { + ${manifest} +} + export async function getServerFnById(id, access) { const serverFnInfo = manifest[id] if (!serverFnInfo) { throw new Error('Server function info not found for ' + id) } -__CLIENT_REFERENCED_CHECK__ - const fnModule = serverFnInfo.module ?? (await serverFnInfo.importer()) +${clientReferencedCheck} + const fnModule = ${resolveModule} if (!fnModule) { throw new Error('Server function module not resolved for ' + id) } @@ -63,74 +73,6 @@ __CLIENT_REFERENCED_CHECK__ } return action } -` -} - -function getResolverManifestModuleAccess(opts: { - useStaticImports?: boolean - extractedFilename: string - moduleRef: string -}): string { - if (opts.useStaticImports) { - return `module: ${opts.moduleRef}` - } - - return `importer: () => import(${JSON.stringify(opts.extractedFilename)})` -} - -function getResolverManifestEntry(opts: { - entry: ResolverManifestEntry - moduleAccess: string - includeClientReferencedCheck: boolean -}): string { - const clientReferenced = opts.includeClientReferencedCheck - ? `,\n isClientReferenced: ${opts.entry.isClientReferenced}` - : '' - - return `'${opts.entry.id}': { - functionName: '${opts.entry.functionName}', - ${opts.moduleAccess}${clientReferenced} - }` -} -export function generateServerFnResolverModule( - opts: GenerateServerFnResolverModuleOptions, -): string { - const manifestEntries = getResolverManifestEntries(opts.serverFnsById) - const staticImports: Array = [] - - const manifest = manifestEntries - .map((entry, index) => { - const moduleRef = `serverFnModule${index}` - - if (opts.useStaticImports) { - staticImports.push( - `import * as ${moduleRef} from ${JSON.stringify(entry.extractedFilename)}`, - ) - } - - return getResolverManifestEntry({ - entry, - moduleAccess: getResolverManifestModuleAccess({ - useStaticImports: opts.useStaticImports, - extractedFilename: entry.extractedFilename, - moduleRef, - }), - includeClientReferencedCheck: opts.includeClientReferencedCheck, - }) - }) - .join(',\n ') - - const body = getResolverBody().replace( - '__CLIENT_REFERENCED_CHECK__', - getClientReferencedCheck(opts.includeClientReferencedCheck), - ) - - return ` -${staticImports.join('\n')} -const manifest = { - ${manifest} -} -${body} ` } diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 6814740074f..1dabf64db88 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -646,6 +646,8 @@ export function startCompilerPlugin( return generateServerFnResolverModule({ serverFnsById, includeClientReferencedCheck: !ssrIsProvider, + // Only production builds reach this branch. + memoizeModules: true, }) }, }), diff --git a/packages/start-plugin-core/tests/server-fn-resolver-module.test.ts b/packages/start-plugin-core/tests/server-fn-resolver-module.test.ts new file mode 100644 index 00000000000..89a24b08449 --- /dev/null +++ b/packages/start-plugin-core/tests/server-fn-resolver-module.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest' +import { generateServerFnResolverModule } from '../src/start-compiler/server-fn-resolver-module' + +const serverFnsById = { + fn_a: { + functionName: 'fnA', + functionId: 'fn_a', + extractedFilename: '/app/fn-a.ts', + filename: '/app/fn-a.ts', + isClientReferenced: true, + }, +} + +describe('generateServerFnResolverModule', () => { + test.each([false, true])( + 'resolves public exports and checks client access with static imports: %s', + async (useStaticImports) => { + const entry = { + ...serverFnsById.fn_a, + extractedFilename: `data:text/javascript,${encodeURIComponent( + 'export const fnA = () => "resolved"', + )}`, + } + const source = generateServerFnResolverModule({ + serverFnsById: { + fn_a: entry, + fn_private: { ...entry, isClientReferenced: false }, + }, + includeClientReferencedCheck: true, + memoizeModules: true, + useStaticImports, + }) + const { getServerFnById } = await import( + /* @vite-ignore */ `data:text/javascript,${encodeURIComponent(source)}` + ) + const action = await getServerFnById('fn_a', { origin: 'client' }) + expect(action()).toBe('resolved') + await expect( + getServerFnById('fn_private', { origin: 'client' }), + ).rejects.toThrow('Server function not accessible from client') + await expect( + getServerFnById('fn_private', { origin: 'server' }), + ).resolves.toBe(action) + await expect( + getServerFnById('missing', { origin: 'server' }), + ).rejects.toThrow('Server function info not found') + }, + ) + + test('imports on every call by default', () => { + const source = generateServerFnResolverModule({ + serverFnsById, + includeClientReferencedCheck: false, + }) + expect(source).toContain( + 'serverFnInfo.module ?? (await serverFnInfo.importer())', + ) + expect(source).not.toContain('??=') + }) + + test('keeps the imported module when memoizeModules is set', () => { + const source = generateServerFnResolverModule({ + serverFnsById, + includeClientReferencedCheck: false, + memoizeModules: true, + }) + expect(source).toContain( + 'serverFnInfo.module ??= await serverFnInfo.importer()', + ) + }) +}) diff --git a/packages/start-server-core/INTERNALS.md b/packages/start-server-core/INTERNALS.md new file mode 100644 index 00000000000..b759b33e5a7 --- /dev/null +++ b/packages/start-server-core/INTERNALS.md @@ -0,0 +1,89 @@ +# Start server response ownership + +This document describes how Start tracks response bodies while server middleware runs. + +## Two ownership layers + +Response body ownership tells Start which body belongs to the current middleware result. + +Router SSR ownership tells Router when it can release request data, serialization state, readers, timers, and renderer work. + +An internal `SsrResponse` value carries Router SSR cleanup data beside a `Response`. Start stores that data while middleware sees the ordinary `Response`. + +Each request must use a separate router instance. Sharing one router between requests is unsupported. + +## Middleware transfers + +Start keeps one ownership record for the current response body. A response without a body does not need an ownership record. + +Ownership transfers automatically in these cases: + +- Middleware returns the same `Response` object. +- Middleware returns another `Response` with the same body object. + +Web Streams do not expose the source of a different body. TypeScript also cannot describe this runtime relationship. + +Middleware must mark a response that has a different derived body: + +```ts +return transferResponseBodyOwnership( + response, + new Response(response.body!.pipeThrough(transform), response), +) +``` + +The first argument must be the current response. The second argument must contain a body derived directly from that response. + +The marker records one direct relationship. It does not connect the streams or manage their lifecycles. + +The derived body must propagate reads, completion, errors, and cancellation to the source body. A normal `pipeThrough()` chain has this behavior. + +Start stores markers in a `WeakMap`. An entry lives exactly as long as its derived response; a stale marker cannot cause a false transfer because ownership never returns to an older source. + +Normal responses do not create marker entries. The map does not keep an unreferenced derived response alive. + +## Replacement and disposal + +An unrelated response replaces the current ownership record. Start disposes the old record before it stores the replacement. + +For a Router SSR response, disposal releases Router SSR state and cancels the original SSR body. + +If middleware returned a different derived body, disposal also cancels that final body. This cancellation propagates through a correct derived stream. + +For a plain response, disposal cancels the current body. + +Body cancellation is best effort. Start does not await it, and a cancellation failure does not block Router cleanup. + +Cleanup paths can request disposal more than once. Router SSR cleanup and native stream cancellation have idempotent effects. + +Every internal `SsrResponse` disposer must also have idempotent effects. + +A result that settles after request cancellation is no longer usable. Start disposes that late result when it arrives. + +## Locks, clones, and branches + +A locked body only proves that code holds a reader or pipe. A lock does not prove ownership or stream ancestry. + +Start cannot cancel a locked body through `body.cancel()`. The code that acquired the reader owns that reader and must cancel or release it. + +`Response.clone()` creates two body branches. It can also change the body object exposed by the original `Response`. + +Start reads the current response body during disposal. It keeps only the original SSR body for Router cleanup. + +Middleware owns every clone or `tee()` branch that it does not return. Middleware must consume or cancel each unused branch. + +## Final handoff + +Redirect conversion and `HEAD` handling dispose any body that they replace or remove. + +Before returning a Router SSR stream, Start checks request cancellation. Start disposes the stream immediately if the request is already aborted. + +Start binds later request cancellation to the disposer. While Router SSR state is live, SSR cleanup removes that listener. Otherwise the one-time listener ends with the request signal. + +The Router stream transform releases Router SSR state after natural completion, stream errors, or consumer cancellation. + +After handoff, the HTTP runtime owns consumption or cancellation of the returned body. + +If Start does not hand off Router SSR ownership, the request cleanup block releases any remaining Router SSR state. + +An abort can race with final handoff. Cleanup is idempotent. Either path can safely request cleanup. diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index c7b427bfb1f..9da8a1526ae 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -10,15 +10,15 @@ import { import { _getRenderedMatches, executeRewriteInput, + isPromise, isRedirect, isResolvedRedirect, } from '@tanstack/router-core' import { attachRouterServerSsrUtils, bindSsrResponseToRequest, - disposeSsrResponseDetached, + disposeSsrResponse, getNormalizedURL, - getOrigin, isSsrResponse, normalizeSsrResponse, replaceSsrResponse, @@ -45,7 +45,6 @@ import type { AnyRequestMiddleware, AnyStartInstanceOptions, RouteMethod, - RouteMethodHandlerFn, RouterEntry, StartEntry, } from '@tanstack/start-client-core' @@ -195,127 +194,172 @@ function throwIfMayNotDefer(): never { throw new Error(ERR_NO_DEFER) } -/** - * Check if a value is a special response (Response or Redirect) - */ -function isSpecialResponse(value: unknown): value is Response { - return value instanceof Response || isRedirect(value) +function getResponseFromResult(result: TODO): TODO { + return isSsrResponse(result) || result instanceof Response + ? result + : result?.response +} + +type StreamSsrResponse = Extract + +type ResponseBody = NonNullable +type ResponseWithBody = Response & { readonly body: ResponseBody } + +/** The context object threaded through one middleware pipeline. */ +interface PipelineContext { + request: Request + pathname: string + handlerType: 'serverFn' | 'router' + context: Record + params?: Record + response?: Response + // Middleware may add arbitrary own properties through `next(ctx)`. + [key: string]: unknown +} + +interface MiddlewareResponseOwnership { + response: ResponseWithBody + sourceBody: ResponseBody + streamResponse?: StreamSsrResponse +} + +// Entries are released with their response; the map is never cleared by hand. +const responseBodySources = new WeakMap() + +function disposeResponseResult(result: TODO, reason: unknown): void { + const response = getResponseFromResult(result) + if (isSsrResponse(response) || response instanceof Response) { + disposeSsrResponse(response, reason) + } } /** - * Normalize middleware result to context shape + * Marks `response` as directly derived from the current `source`. + * Middleware must consume or cancel any other `clone()` or `tee()` branches. */ -function handleCtxResult(result: TODO) { - if (isSsrResponse(result) || isSpecialResponse(result)) { - return { response: result } +export function transferResponseBodyOwnership( + source: Response, + response: TResponse, +): TResponse { + if (!source.body || !response.body) { + throw new Error('Response body ownership requires two response bodies') } - return result + responseBodySources.set(response, source) + return response +} + +function hasResponseBody(value: unknown): value is ResponseWithBody { + return value instanceof Response && value.body !== null +} + +function inheritsResponseOwnership( + ownership: MiddlewareResponseOwnership, + candidate: unknown, +): candidate is ResponseWithBody { + return ( + hasResponseBody(candidate) && + (candidate.body === ownership.response.body || + responseBodySources.get(candidate) === ownership.response) + ) } -function disposeLateResponse(result: TODO, signal: AbortSignal): void { - const response = handleCtxResult(result)?.response - if (isSsrResponse(response) || isSpecialResponse(response)) { - disposeSsrResponseDetached(response, signal.reason) +function disposeResponseOwnership( + ownership: MiddlewareResponseOwnership, + reason: unknown, +): void { + const { response, sourceBody, streamResponse } = ownership + streamResponse?.dispose(reason) + if (!streamResponse || response.body !== sourceBody) { + void response.body.cancel(reason).catch(() => {}) } } -function isSignalAborted(signal: AbortSignal): boolean { - return signal.aborted +function getOwnedResponse( + ownership: MiddlewareResponseOwnership, +): HandlerCallbackResult { + const { response, sourceBody, streamResponse } = ownership + if (!streamResponse) { + return response + } + if (streamResponse.response === response && response.body === sourceBody) { + return streamResponse + } + if (response.body === sourceBody) { + return { ...streamResponse, response } + } + return { + ...streamResponse, + response, + dispose(reason): undefined { + disposeResponseOwnership(ownership, reason) + }, + } +} + +function createLateResponseDisposer(signal: AbortSignal) { + return (result: TODO) => disposeResponseResult(result, signal.reason) } /** - * Execute a middleware chain + * Compose middleware around a terminal response handler. With no middleware + * the terminal runs directly. */ async function executeMiddleware( middlewares: Array, - ctx: TODO, + terminal: TODO, + ctx: PipelineContext, signal: AbortSignal, -): Promise<{ ctx: TODO; response: HandlerCallbackResult }> { + terminalNext?: TODO, +): Promise { let index = -1 - let streamResponse: - | Extract - | undefined - let retiredStreamIdentities: WeakSet | undefined - - const isResponseAlias = (candidate: unknown, response: Response) => - candidate === response || - (candidate instanceof Response && - response.body !== null && - candidate.body === response.body) + let responseOwnership: MiddlewareResponseOwnership | undefined + // Once the pipeline returned, the HTTP runtime owns the response. A result + // that settles later (for example the loser of a `Promise.race`) must not + // replace or cancel it. + let settled = false + const disposeAbandonedResult = createLateResponseDisposer(signal) const setResponse = (response: TODO) => { - if (isSsrResponse(response)) { - if (response.serverSsrCleanup === 'stream') { - streamResponse = response + const ssrResponse = isSsrResponse(response) ? response : undefined + const streamResponse = + ssrResponse?.serverSsrCleanup === 'stream' ? ssrResponse : undefined + const exposed: Response | undefined = ssrResponse + ? ssrResponse.response + : response + const current = responseOwnership + + if (settled) { + if (exposed !== ctx.response) { + disposeResponseResult(response, 'late middleware response') } - ctx.response = response.response - return - } - - ctx.response = response - } - - const disposeStreamResponse = async (reason: unknown) => { - const response = streamResponse - if (!response) { return } - - streamResponse = undefined - retiredStreamIdentities ??= new WeakSet() - retiredStreamIdentities.add(response.response) - if (response.response.body) { - retiredStreamIdentities.add(response.response.body) - } - const currentResponse = ctx.response - if (isResponseAlias(currentResponse, response.response)) { - ctx.response = undefined - } - await response.dispose(reason) - } - - const disposeAbandonedResult = (result: TODO) => { - const exposed = handleCtxResult(result)?.response - const response = isSsrResponse(exposed) ? exposed.response : exposed - if (streamResponse && isResponseAlias(response, streamResponse.response)) { - void disposeStreamResponse(signal.reason).catch(console.error) - return - } - if ( - response instanceof Response && - retiredStreamIdentities && - (retiredStreamIdentities.has(response) || - (response.body !== null && retiredStreamIdentities.has(response.body))) - ) { - return + if (current && current.response === exposed) { + current.streamResponse ??= streamResponse + } else if (current && inheritsResponseOwnership(current, exposed)) { + current.response = exposed + current.streamResponse ??= streamResponse + } else { + if (current) { + disposeResponseOwnership(current, 'middleware response replaced') + } + if (hasResponseBody(exposed)) { + responseOwnership = { + response: exposed, + sourceBody: exposed.body, + streamResponse, + } + } else { + responseOwnership = undefined + } } - - disposeLateResponse(result, signal) + ctx.response = exposed } - const getFinalResponse = async (): Promise => { - const response = ctx.response - if (!response) { - throwRouteHandlerError() - } - - if (!streamResponse) { - return response - } - - if (response === streamResponse.response) { - return streamResponse - } - - if ( - streamResponse.response.body !== null && - response.body === streamResponse.response.body - ) { - return { ...streamResponse, response } + const reconcileCtxResponse = () => { + if (ctx.response !== responseOwnership?.response) { + setResponse(ctx.response) } - - await disposeStreamResponse('middleware response replaced') - return response } let nextPromise: Promise | undefined @@ -327,9 +371,7 @@ async function executeMiddleware( } async function runNext(nextCtx?: TODO): Promise { - if (signal.aborted) { - throw signal.reason - } + signal.throwIfAborted() // Merge context if provided using safeObjectMerge for prototype pollution prevention if (nextCtx) { @@ -347,41 +389,74 @@ async function executeMiddleware( } index++ - const middleware = middlewares[index] - if (!middleware) return ctx + const isTerminal = index === middlewares.length + const middleware = + index < middlewares.length + ? middlewares[index] + : isTerminal + ? terminal + : undefined + const middlewareNext = isTerminal && terminalNext ? terminalNext : next + if (!middleware) { + return ctx + } let result: TODO try { - const pending = middleware({ ...ctx, next }) + const pending = middleware({ ...ctx, next: middlewareNext }) // A directly returned next() promise already propagates request aborts. - if (pending === nextPromise) { + if (nextPromise && pending === nextPromise) { nextPromise = undefined - result = await pending - if (isSignalAborted(signal)) { - disposeAbandonedResult(result) + await pending + if (signal.aborted) { throw signal.reason } + return ctx + } else if (!isPromise(pending)) { + result = pending + signal.throwIfAborted() } else { - result = await waitForRequest(pending, signal, disposeAbandonedResult) + result = await waitForRequest( + pending, + signal, + disposeAbandonedResult, + disposeAbandonedResult, + ) } } catch (err) { - if (isSignalAborted(signal)) { + reconcileCtxResponse() + if (signal.aborted) { + if (result !== undefined) { + disposeAbandonedResult(result) + } + if (err !== signal.reason) { + disposeAbandonedResult(err) + } throw signal.reason } - if (isSpecialResponse(err)) { + if (err instanceof Response) { setResponse(err) return ctx } throw err } - const normalized = handleCtxResult(result) - if (normalized) { - if (normalized.response !== undefined) { - setResponse(normalized.response) + if (isTerminal && terminalNext && !result) { + throwRouteHandlerError() + } + + reconcileCtxResponse() + if (result && result !== ctx) { + const response = getResponseFromResult(result) + if (response !== undefined && response !== ctx.response) { + setResponse(response) } - if (normalized.context) { - ctx.context = safeObjectMerge(ctx.context, normalized.context) + if ( + response !== result && + result.context && + result.context !== ctx.context + ) { + ctx.context = safeObjectMerge(ctx.context, result.context) } } @@ -390,46 +465,28 @@ async function executeMiddleware( try { await runNext() - const response = await waitForRequest( - getFinalResponse(), - signal, - disposeAbandonedResult, - ) + const response = ctx.response + if (!response) { + throwRouteHandlerError() + } + reconcileCtxResponse() if (signal.aborted) { - disposeAbandonedResult(response) throw signal.reason } - return { ctx, response } + settled = true + return responseOwnership ? getOwnedResponse(responseOwnership) : response } catch (err) { - const disposal = disposeStreamResponse(signal.aborted ? signal.reason : err) - if (signal.aborted) { - void disposal.catch(console.error) - } else { - await disposal + settled = true + if (responseOwnership) { + disposeResponseOwnership( + responseOwnership, + signal.aborted ? signal.reason : err, + ) } throw err } } -/** - * Wrap a route handler as middleware - */ -function handlerToMiddleware( - handler: RouteMethodHandlerFn, - mayDefer: boolean = false, -): TODO { - if (mayDefer) { - return handler - } - return async (ctx: TODO) => { - const response = await handler({ ...ctx, next: throwIfMayNotDefer }) - if (!response) { - throwRouteHandlerError() - } - return response - } -} - /** * Creates the TanStack Start request handler. * @@ -486,29 +543,38 @@ export function createStartHandler( request, requestOpts, ) => { - let router: AnyRouter | null = null as AnyRouter | null - let responseOwnsCleanup = false as boolean + const signal = request.signal + let router: AnyRouter | undefined + let routerPromise: Promise | undefined + let responseOwnsCleanup = false try { - request.signal.throwIfAborted() + signal.throwIfAborted() // normalizing and sanitizing the pathname here for server, so we always deal with the same format during SSR. // during normalization paths like '//posts' are flattened to '/posts'. // in these cases we would prefer to redirect to the new path const { url, handledProtocolRelativeURL } = getNormalizedURL(request.url) const href = url.pathname + url.search + url.hash - const origin = getOrigin(request) + const origin = url.origin if (handledProtocolRelativeURL) { return Response.redirect(url, 308) } - const entries = await waitForRequest(getEntries(), request.signal) - const hasStartInstance = !!entries.startEntry.startInstance - const startOptions: AnyStartInstanceOptions = - (await waitForRequest( - entries.startEntry.startInstance?.getOptions(), - request.signal, - )) || ({} as AnyStartInstanceOptions) + const entries = await waitForRequest(getEntries(), signal) + const isServerFnRequest = + !!SERVER_FN_BASE && url.pathname.startsWith(SERVER_FN_BASE) + const startInstance = entries.startEntry.startInstance + let startOptions: AnyStartInstanceOptions + if (startInstance) { + const pendingStartOptions = startInstance.getOptions() + startOptions = isPromise(pendingStartOptions) + ? await waitForRequest(pendingStartOptions, signal) + : pendingStartOptions + signal.throwIfAborted() + } else { + startOptions = {} as AnyStartInstanceOptions + } const { hasPluginAdapters, pluginSerializationAdapters } = entries.pluginAdapters @@ -521,9 +587,11 @@ export function createStartHandler( const requestStartOptions = { ...startOptions, - requestMiddleware: hasStartInstance + requestMiddleware: startInstance ? startOptions.requestMiddleware - : [defaultCsrfMiddleware], + : isServerFnRequest + ? [defaultCsrfMiddleware] + : undefined, serializationAdapters, } @@ -538,43 +606,59 @@ export function createStartHandler( ) // Memoized router getter - const getRouter = async (): Promise => { - if (router) return router + const getRouter = (): Promise => { + routerPromise ??= (async () => { + signal.throwIfAborted() + const requestRouter = await waitForRequest( + entries.routerEntry.getRouter(), + signal, + ) - router = await waitForRequest( - entries.routerEntry.getRouter(), - request.signal, - ) + let isShell = IS_SHELL_ENV + if (IS_PRERENDERING && !isShell) { + isShell = request.headers.get(HEADERS.TSS_SHELL) === 'true' + } - let isShell = IS_SHELL_ENV - if (IS_PRERENDERING && !isShell) { - isShell = request.headers.get(HEADERS.TSS_SHELL) === 'true' - } + const history = createMemoryHistory({ + initialEntries: [href], + }) + + requestRouter.update({ + history, + isShell, + isPrerendering: IS_PRERENDERING, + origin: requestRouter.options.origin ?? origin, + // Start-owned options that RouterConstructorOptions omits. + ...{ + defaultSsr: requestStartOptions.defaultSsr, + serializationAdapters: [ + ...requestStartOptions.serializationAdapters, + ...(requestRouter.options.serializationAdapters || []), + ], + }, + basepath: ROUTER_BASEPATH, + }) + + router = requestRouter + return requestRouter + })() + + return routerPromise + } - const history = createMemoryHistory({ - initialEntries: [href], - }) - - router.update({ - history, - isShell, - isPrerendering: IS_PRERENDERING, - origin: router.options.origin ?? origin, - ...{ - defaultSsr: requestStartOptions.defaultSsr, - serializationAdapters: [ - ...requestStartOptions.serializationAdapters, - ...(router.options.serializationAdapters || []), - ], - }, - basepath: ROUTER_BASEPATH, - }) - - return router + const handlerType = isServerFnRequest + ? ('serverFn' as const) + : ('router' as const) + const startContext = { + getRouter, + startOptions: requestStartOptions, + request, + executedRequestMiddlewares, + handlerType, } + let terminal: (ctx: PipelineContext) => unknown - // Check for server function requests first (early exit) - if (SERVER_FN_BASE && url.pathname.startsWith(SERVER_FN_BASE)) { + if (isServerFnRequest) { if ( process.env.NODE_ENV !== 'production' && process.env.TSS_DISABLE_CSRF_MIDDLEWARE_WARNING !== 'true' && @@ -591,16 +675,9 @@ export function createStartHandler( throw new Error('Invalid server action param for serverFnId') } - const serverFnHandler = async ({ context }: TODO) => { - return runWithStartContext( - { - getRouter, - startOptions: requestStartOptions, - contextAfterGlobalMiddlewares: context, - request, - executedRequestMiddlewares, - handlerType: 'serverFn', - }, + terminal = ({ context }) => + runWithStartContext( + { ...startContext, contextAfterGlobalMiddlewares: context }, () => handleServerAction({ request, @@ -608,177 +685,138 @@ export function createStartHandler( serverFnId, }), ) - } - - const middlewares = flattenedRequestMiddlewares.map( - (d) => d.options.server, - ) - const { response: middlewareResponse } = await executeMiddleware( - [...middlewares, serverFnHandler], - { - request, - pathname: url.pathname, - handlerType: 'serverFn', - context: createNullProtoObject(requestOpts?.context), - }, - request.signal, - ) - - const result = await handleRedirectResponse( - middlewareResponse, - request, - getRouter, - request.signal, - ) - bindSsrResponseToRequest(router ?? undefined, result, request.signal) - request.signal.throwIfAborted() - responseOwnsCleanup = result.serverSsrCleanup === 'stream' - return result.response - } - - // Router execution function - const executeRouter = async ( - serverContext: TODO, - matchedRoutes?: ReadonlyArray, - ): Promise => { - const acceptHeader = request.headers.get('Accept') || '*/*' - const acceptParts = acceptHeader.split(',') - const supportedMimeTypes = ['*/*', 'text/html'] - - const isSupported = supportedMimeTypes.some((mimeType) => - acceptParts.some((part) => part.trim().startsWith(mimeType)), - ) + } else { + const executeRouter = async ( + serverContext: TODO, + matchedRoutes?: ReadonlyArray, + ): Promise => { + if ( + !/(^|,)\s*(\*\/\*|text\/html)/.test( + request.headers.get('Accept') || '*/*', + ) + ) { + return normalizeSsrResponse( + Response.json( + { error: 'Only HTML requests are supported here' }, + { status: 406 }, + ), + ) + } - if (!isSupported) { - return normalizeSsrResponse( - Response.json( - { error: 'Only HTML requests are supported here' }, - { status: 500 }, - ), + const manifest = await waitForRequest( + resolveManifestForRequest({ + request, + requestInlineCss: requestOpts?.inlineCss, + getBaseManifest: () => getBaseManifest(matchedRoutes), + }), + signal, ) - } - const manifest = await waitForRequest( - resolveManifestForRequest({ - request, - requestInlineCss: requestOpts?.inlineCss, - getBaseManifest: () => getBaseManifest(matchedRoutes), - }), - request.signal, - ) - - const earlyHints = createEarlyHintsForRequest({ - onEarlyHints: requestOpts?.onEarlyHints, - responseLinkHeader: requestOpts?.responseLinkHeader, - }) - - earlyHints?.collectStatic({ manifest, matchedRoutes }) + const earlyHints = createEarlyHintsForRequest({ + onEarlyHints: requestOpts?.onEarlyHints, + responseLinkHeader: requestOpts?.responseLinkHeader, + }) - const routerInstance = await getRouter() + earlyHints?.collectStatic({ manifest, matchedRoutes }) - attachRouterServerSsrUtils({ - router: routerInstance, - manifest, - getRequestAssets: () => - getStartContext({ throwIfNotFound: false })?.requestAssets, - }) + const routerInstance = await getRouter() - // `additionalContext` is request-scoped and only read from router.options - // during load; avoid a full router.update() and redundant location parse. - routerInstance.options.additionalContext = { serverContext } - await routerInstance.load({ _signal: request.signal }) - request.signal.throwIfAborted() - - if (routerInstance._serverResult?.type === 'redirect') { - return normalizeSsrResponse(routerInstance._serverResult.redirect) - } + attachRouterServerSsrUtils({ + router: routerInstance, + manifest, + getRequestAssets: () => + getStartContext({ throwIfNotFound: false })?.requestAssets, + }) + + // `additionalContext` is request-scoped and only read from router.options + // during load; avoid a full router.update() and redundant location parse. + routerInstance.options.additionalContext = { serverContext } + await routerInstance.load({ _signal: signal }) + signal.throwIfAborted() + + if (routerInstance._serverResult?.type === 'redirect') { + return normalizeSsrResponse(routerInstance._serverResult.redirect) + } - earlyHints?.collectDynamic( - _getRenderedMatches(routerInstance.stores.matches.get()), - ) + earlyHints?.collectDynamic( + _getRenderedMatches(routerInstance.stores.matches.get()), + ) - // Pass request-scoped assets to dehydrate for manifest injection - const ctx = getStartContext({ throwIfNotFound: false }) - await waitForRequest( - routerInstance.serverSsr!.dehydrate({ + // Pass request-scoped assets to dehydrate for manifest injection + const ctx = getStartContext({ throwIfNotFound: false }) + await routerInstance.serverSsr!.dehydrate({ requestAssets: ctx?.requestAssets, - }), - request.signal, - ) - request.signal.throwIfAborted() - - const responseHeaders = getStartResponseHeaders({ - router: routerInstance, - }) - earlyHints?.appendResponseHeaders(responseHeaders) - request.signal.throwIfAborted() - const response = await waitForRequest( - cb({ - request, + signal, + }) + signal.throwIfAborted() + + const responseHeaders = getStartResponseHeaders({ router: routerInstance, - responseHeaders, - }), - request.signal, - (late) => disposeLateResponse(late, request.signal), - ) - return normalizeSsrResponse(response) - } + }) + earlyHints?.appendResponseHeaders(responseHeaders) + signal.throwIfAborted() + const disposeLate = createLateResponseDisposer(signal) + const response = await waitForRequest( + cb({ + request, + router: routerInstance, + responseHeaders, + }), + signal, + disposeLate, + disposeLate, + ) + return normalizeSsrResponse(response) + } - // Main request handler - const requestHandlerMiddleware = async ({ context }: TODO) => { - return runWithStartContext( - { - getRouter, - startOptions: requestStartOptions, - contextAfterGlobalMiddlewares: context, - request, - executedRequestMiddlewares, - handlerType: 'router', - }, - async () => { - try { - return await handleServerRoutes({ + terminal = ({ context }) => + runWithStartContext( + { ...startContext, contextAfterGlobalMiddlewares: context }, + () => + handleServerRoutes({ getRouter, request, url, executeRouter, context, executedRequestMiddlewares, - }) - } catch (err) { - if (err instanceof Response) { - return err - } - throw err - } - }, - ) + }), + ) } - const middlewares = flattenedRequestMiddlewares.map( - (d) => d.options.server, - ) - const { response: middlewareResponse } = await executeMiddleware( - [...middlewares, requestHandlerMiddleware], + const middlewareResponse = await executeMiddleware( + flattenedRequestMiddlewares.map((d) => d.options.server), + terminal, { request, pathname: url.pathname, - handlerType: 'router', + handlerType, context: createNullProtoObject(requestOpts?.context), }, - request.signal, + signal, ) - const response = await handleRedirectResponse( - middlewareResponse, - request, - getRouter, - request.signal, - ) - bindSsrResponseToRequest(router ?? undefined, response, request.signal) - request.signal.throwIfAborted() - responseOwnsCleanup = response.serverSsrCleanup === 'stream' - return response.response + let result: SsrResponse + try { + result = await handleRedirectResponse( + middlewareResponse, + request, + getRouter, + ) + if (request.method === 'HEAD') { + result = stripSsrResponseBody(result, 'HEAD body stripped') + } + } catch (error) { + disposeResponseResult( + middlewareResponse, + signal.aborted ? signal.reason : error, + ) + throw error + } + bindSsrResponseToRequest(router, result, signal) + signal.throwIfAborted() + responseOwnsCleanup = result.serverSsrCleanup === 'stream' + return result.response } finally { if (router?.serverSsr && !responseOwnsCleanup) { // Clean up router SSR state if it was set up but won't be cleaned up by the callback @@ -786,7 +824,8 @@ export function createStartHandler( // Transformed streaming response bodies clean up when consumed/cancelled. router.serverSsr.cleanup() } - router = null + // `routerPromise` stays memoized: a streamed Suspense boundary or a late + // server function may still ask for this request's router. } } @@ -797,76 +836,57 @@ async function handleRedirectResponse( response: HandlerCallbackResult, request: Request, getRouter: () => Promise, - signal: AbortSignal, ): Promise { + const signal = request.signal signal.throwIfAborted() const ssrResponse = normalizeSsrResponse(response) - if (!isRedirect(ssrResponse.response)) { + const redirect = ssrResponse.response + if (!isRedirect(redirect)) { return ssrResponse } - if (isResolvedRedirect(ssrResponse.response)) { - if (request.headers.get('x-tsr-serverFn') === 'true') { - return waitForRequest( - replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, - ), - 'redirect response replaced', - ), - signal, + if (!isResolvedRedirect(redirect)) { + const opts = redirect.options + if (opts.to && typeof opts.to === 'string' && !opts.to.startsWith('/')) { + throw new Error( + `Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(opts)}`, ) } - return ssrResponse - } - - const opts = ssrResponse.response.options - if (opts.to && typeof opts.to === 'string' && !opts.to.startsWith('/')) { - throw new Error( - `Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(opts)}`, - ) - } - if ( - ['params', 'search', 'hash'].some( - (d) => typeof (opts as TODO)[d] === 'function', - ) - ) { - throw new Error( - `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys( - opts, + if ( + ['params', 'search', 'hash'].some( + (d) => typeof (opts as TODO)[d] === 'function', ) - .filter((d) => typeof (opts as TODO)[d] === 'function') - .map((d) => `"${d}"`) - .join(', ')}`, - ) - } + ) { + throw new Error( + `Server side redirects must use static search, params, and hash values and do not support functional values. Received functional values for: ${Object.keys( + opts, + ) + .filter((d) => typeof (opts as TODO)[d] === 'function') + .map((d) => `"${d}"`) + .join(', ')}`, + ) + } - signal.throwIfAborted() - const router = await waitForRequest(getRouter(), signal) - signal.throwIfAborted() - const redirect = router.resolveRedirect(ssrResponse.response) + signal.throwIfAborted() + const router = await getRouter() + signal.throwIfAborted() + // Resolves `redirect` in place. + router.resolveRedirect(redirect) + } if (request.headers.get('x-tsr-serverFn') === 'true') { - return waitForRequest( - replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, - ), - 'redirect response replaced', + return replaceSsrResponse( + ssrResponse, + Response.json( + { ...redirect.options, isSerializedRedirect: true }, + { headers: redirect.headers }, ), - signal, + 'redirect response replaced', ) } - return waitForRequest( - replaceSsrResponse(ssrResponse, redirect, 'redirect response replaced'), - signal, - ) + return ssrResponse } async function handleServerRoutes({ @@ -900,6 +920,9 @@ async function handleServerRoutes({ // Collect and dedupe route middlewares const routeMiddlewares: Array = [] + let terminalHandler: TODO = (ctx: TODO) => + executeRouter(ctx.context, matchedRoutes) + let terminalNext: TODO // Collect middleware from matched routes, filtering out those already executed // in the request phase @@ -919,7 +942,6 @@ async function handleServerRoutes({ // Add handler middleware if exact match const server = foundRoute?.options.server - let isHeadFallback = false if (server?.handlers && isExactMatch) { const handlers = typeof server.handlers === 'function' @@ -933,14 +955,16 @@ async function handleServerRoutes({ requestMethod === 'HEAD' ? (handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY']) : (handlers[requestMethod] ?? handlers['ANY']) - isHeadFallback = - requestMethod === 'HEAD' && handler !== undefined && !handlers['HEAD'] - if (handler) { const mayDefer = !!foundRoute.options.component if (typeof handler === 'function') { - routeMiddlewares.push(handlerToMiddleware(handler, mayDefer)) + if (!mayDefer) { + terminalHandler = handler + terminalNext = throwIfMayNotDefer + } else { + routeMiddlewares.push(handler) + } } else { if (handler.middleware?.length) { const handlerMiddlewares = flattenMiddlewares(handler.middleware) @@ -949,18 +973,20 @@ async function handleServerRoutes({ } } if (handler.handler) { - routeMiddlewares.push(handlerToMiddleware(handler.handler, mayDefer)) + if (!mayDefer) { + terminalHandler = handler.handler + terminalNext = throwIfMayNotDefer + } else { + routeMiddlewares.push(handler.handler) + } } } } } - // Final middleware: execute router with matched routes for dev styles - routeMiddlewares.push(((ctx: TODO) => - executeRouter(ctx.context, matchedRoutes)) as TODO) - - const { ctx, response } = await executeMiddleware( + const response = await executeMiddleware( routeMiddlewares, + terminalHandler, { request, context, @@ -969,26 +995,8 @@ async function handleServerRoutes({ handlerType: 'router', }, request.signal, + terminalNext, ) - // RFC 9110 §9.3.2: HEAD must carry the same header fields as GET but no body. - // Resolve any redirect before stripping so the Location header survives. - if (isHeadFallback) { - if (!ctx.response) { - throwRouteHandlerError() - } - - const resolved = await handleRedirectResponse( - response, - request, - getRouter, - request.signal, - ) - return waitForRequest( - stripSsrResponseBody(resolved, 'HEAD body stripped'), - request.signal, - ) - } - return normalizeSsrResponse(response) } diff --git a/packages/start-server-core/src/frame-protocol.ts b/packages/start-server-core/src/frame-protocol.ts index e96275c0bb6..d359ce6511d 100644 --- a/packages/start-server-core/src/frame-protocol.ts +++ b/packages/start-server-core/src/frame-protocol.ts @@ -8,22 +8,27 @@ * - payload: variable length bytes */ -// Re-export constants from shared location -import { FRAME_HEADER_SIZE, FrameType } from '@tanstack/start-client-core' - -export { +import { FRAME_HEADER_SIZE, - FrameType, - TSS_CONTENT_TYPE_FRAMED, - TSS_CONTENT_TYPE_FRAMED_VERSIONED, - TSS_FRAMED_PROTOCOL_VERSION, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + FRAME_TYPE_ERROR, + FRAME_TYPE_JSON, + MAX_FRAME_PAYLOAD_SIZE, } from '@tanstack/start-client-core' +type FrameType = + | typeof FRAME_TYPE_JSON + | typeof FRAME_TYPE_CHUNK + | typeof FRAME_TYPE_END + | typeof FRAME_TYPE_ERROR + /** Cached TextEncoder for frame encoding */ const textEncoder = new TextEncoder() /** Shared empty payload for END frames - avoids allocation per call */ const EMPTY_PAYLOAD = new Uint8Array(0) +const MAX_ERROR_MESSAGE_CODE_UNITS = 4096 /** * Encodes a single frame with header and payload. @@ -33,6 +38,11 @@ export function encodeFrame( streamId: number, payload: Uint8Array, ): Uint8Array { + if (payload.byteLength > MAX_FRAME_PAYLOAD_SIZE) { + throw new RangeError( + `Frame payload exceeds ${MAX_FRAME_PAYLOAD_SIZE} bytes`, + ) + } const frame = new Uint8Array(FRAME_HEADER_SIZE + payload.length) // Write header bytes directly to avoid DataView allocation per frame // Frame format: [type:1][streamId:4 BE][length:4 BE] @@ -49,37 +59,15 @@ export function encodeFrame( return frame } -/** - * Encodes a JSON frame (type 0, streamId 0). - */ -export function encodeJSONFrame(json: string): Uint8Array { - return encodeFrame(FrameType.JSON, 0, textEncoder.encode(json)) -} - -/** - * Encodes a raw stream chunk frame. - */ -export function encodeChunkFrame( - streamId: number, - chunk: Uint8Array, -): Uint8Array { - return encodeFrame(FrameType.CHUNK, streamId, chunk) -} - -/** - * Encodes a raw stream end frame. - */ -export function encodeEndFrame(streamId: number): Uint8Array { - return encodeFrame(FrameType.END, streamId, EMPTY_PAYLOAD) -} - -/** - * Encodes a raw stream error frame. - */ -export function encodeErrorFrame(streamId: number, error: unknown): Uint8Array { - const message = +/** Encodes an error message payload, truncated to a bounded length. */ +export function encodeErrorPayload(error: unknown): Uint8Array { + const originalMessage = error instanceof Error ? error.message : String(error ?? 'Unknown error') - return encodeFrame(FrameType.ERROR, streamId, textEncoder.encode(message)) + const message = + originalMessage.length > MAX_ERROR_MESSAGE_CODE_UNITS + ? `${originalMessage.slice(0, MAX_ERROR_MESSAGE_CODE_UNITS)}…` + : originalMessage + return textEncoder.encode(message) } /** @@ -91,168 +79,280 @@ export interface LateStreamRegistration { stream: ReadableStream } +/** One serialized JSON patch and the raw streams referenced by that patch. */ +export interface MultiplexedStreamRecord { + json: Uint8Array + rawStreams: Array +} + +export interface MultiplexedStreamOptions { + onCancel?: (reason?: unknown) => void + signal?: AbortSignal +} + +type CancellableReader = Pick< + ReadableStreamDefaultReader, + 'cancel' | 'releaseLock' +> + /** - * Creates a multiplexed ReadableStream from JSON stream and raw streams. + * Creates a multiplexed ReadableStream from serialized response records. * - * The JSON stream emits NDJSON lines (from seroval's toCrossJSONStream). - * Raw streams are pumped concurrently, interleaved with JSON frames. - * - * Supports late stream registration for RawStreams discovered after initial - * serialization (e.g., from resolved Promises). - * - * @param jsonStream Stream of JSON strings (each string is one NDJSON line) - * @param rawStreams Map of stream IDs to raw binary streams (known at start) - * @param lateStreamSource Optional stream of late registrations for streams discovered later + * A record's JSON frame is admitted before any raw stream referenced by that + * record starts. Raw streams from admitted records are pumped concurrently. + * The caller bounds the stream count before records reach this function. */ export function createMultiplexedStream( - jsonStream: ReadableStream, - rawStreams: Map>, - lateStreamSource?: ReadableStream, + recordStream: ReadableStream, + options: MultiplexedStreamOptions = {}, ): ReadableStream { - // Shared state for the multiplexed stream let controller: ReadableStreamDefaultController - let cancelled = false - const readers: Array> = [] + let stopped: false | [unknown] = false + let activePumps = 0 + let wakeDemand: (() => void) | undefined + let admission: Promise | undefined + const readers = new Set() + const pendingRawStreams = new Set>() + const abortOutput = () => errorOutput(options.signal?.reason) - // Helper to enqueue a frame, ignoring errors if stream is closed/cancelled - const enqueue = (frame: Uint8Array): boolean => { - if (cancelled) return false - try { - controller.enqueue(frame) - return true - } catch { + const wakeAdmission = () => { + const wake = wakeDemand + wakeDemand = undefined + wake?.() + } + + const cancelReader = (reader: CancellableReader, reason?: unknown) => { + void reader.cancel(reason).catch(() => {}) + } + + const cancelStream = (stream: ReadableStream, reason?: unknown) => { + void stream.cancel(reason).catch(() => {}) + } + + const stop = (reason?: unknown) => { + if (stopped) { return false } + stopped = [reason] + options.signal?.removeEventListener('abort', abortOutput) + wakeAdmission() + for (const reader of readers) { + cancelReader(reader, reason) + } + for (const stream of pendingRawStreams) { + cancelStream(stream, reason) + } + pendingRawStreams.clear() + return true } - // Helper to error the output stream (for fatal errors like JSON stream failure) - const errorOutput = (error: unknown): void => { - if (cancelled) return - cancelled = true + const errorOutput = (error: unknown) => { + if (!stop(error)) { + return + } try { controller.error(error) } catch { - // Already errored + // The output was already closed by its consumer. } - // Cancel all readers to stop other pumps - for (const reader of readers) { - reader.cancel().catch(() => {}) + } + + const waitForDemand = async () => { + while (!stopped && (controller.desiredSize ?? 0) <= 0) { + await new Promise((resolve) => { + wakeDemand = resolve + }) } + return !stopped + } + + // Only the pump at the head of this chain may inspect desiredSize and + // enqueue. Each other pump can retain at most one unencoded source chunk. + // The frame is encoded only once the output has demand for it. + const admitFrame = ( + type: FrameType, + streamId: number, + payload: Uint8Array, + ): boolean | Promise => { + if (stopped) { + return false + } + if (!admission && (controller.desiredSize ?? 0) > 0) { + controller.enqueue(encodeFrame(type, streamId, payload)) + return true + } + + const runAdmission = async () => { + if (!(await waitForDemand())) { + return false + } + controller.enqueue(encodeFrame(type, streamId, payload)) + return true + } + const result = admission ? admission.then(runAdmission) : runAdmission() + const clearAdmission = () => { + if (admission === tail) { + admission = undefined + } + } + const tail = result.then(clearAdmission, clearAdmission) + admission = tail + return result + } + + const maybeClose = () => { + if (activePumps !== 0 || !stop()) { + return + } + try { + controller.close() + } catch { + // The output was already closed by its consumer. + } + } + + const startPump = (pump: () => Promise) => { + activePumps++ + void pump().then( + () => { + activePumps-- + maybeClose() + }, + (error) => { + activePumps-- + errorOutput(error) + }, + ) } - // Pumps a raw stream, sending CHUNK frames and END/ERROR on completion async function pumpRawStream( streamId: number, stream: ReadableStream, - ): Promise { + ) { const reader = stream.getReader() - readers.push(reader) + readers.add(reader) try { - while (!cancelled) { + while (!stopped) { const { done, value } = await reader.read() + // Cancellation can run while the read is suspended. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (stopped) { + return + } if (done) { - enqueue(encodeEndFrame(streamId)) + const frameAdmission = admitFrame( + FRAME_TYPE_END, + streamId, + EMPTY_PAYLOAD, + ) + if (frameAdmission !== true) { + await frameAdmission + } return } - if (!enqueue(encodeChunkFrame(streamId, value))) return + if (!(value instanceof Uint8Array)) { + throw new TypeError('RawStream chunks must be Uint8Array') + } + // One frame per read, split only when the chunk exceeds a frame. + let offset = 0 + do { + const chunk = + value.byteLength <= MAX_FRAME_PAYLOAD_SIZE + ? value + : value.subarray(offset, offset + MAX_FRAME_PAYLOAD_SIZE) + const frameAdmission = admitFrame(FRAME_TYPE_CHUNK, streamId, chunk) + if ( + frameAdmission !== true && + (frameAdmission === false || !(await frameAdmission)) + ) { + return + } + offset += MAX_FRAME_PAYLOAD_SIZE + } while (offset < value.byteLength) } } catch (error) { - // Raw stream error - send ERROR frame, don't fail entire response - enqueue(encodeErrorFrame(streamId, error)) + if (!stopped) { + // A raw-stream failure is isolated to that stream. + const frameAdmission = admitFrame( + FRAME_TYPE_ERROR, + streamId, + encodeErrorPayload(error), + ) + if (frameAdmission !== true) { + await frameAdmission + } + } } finally { + readers.delete(reader) reader.releaseLock() } } - // Pumps the JSON stream, sending JSON frames - // JSON stream errors are fatal - they error the entire output - async function pumpJSON(): Promise { - const reader = jsonStream.getReader() - readers.push(reader) + async function pumpRecords() { + const reader = recordStream.getReader() + readers.add(reader) try { - while (!cancelled) { + while (!stopped) { const { done, value } = await reader.read() - if (done) return - if (!enqueue(encodeJSONFrame(value))) return + // Cancellation can run while the read is suspended. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (stopped) { + if (!done) { + for (const registration of value.rawStreams) { + cancelStream(registration.stream, stopped[0]) + } + } + return + } + if (done) { + return + } + for (const registration of value.rawStreams) { + pendingRawStreams.add(registration.stream) + } + const frameAdmission = admitFrame(FRAME_TYPE_JSON, 0, value.json) + if ( + frameAdmission !== true && + (frameAdmission === false || !(await frameAdmission)) + ) { + return + } + for (const registration of value.rawStreams) { + pendingRawStreams.delete(registration.stream) + startPump( + pumpRawStream.bind(undefined, registration.id, registration.stream), + ) + } } } catch (error) { - // JSON stream error is fatal - error the entire output - errorOutput(error) - throw error // Re-throw to signal failure to Promise.all - } finally { - reader.releaseLock() - } - } - - // Pumps late stream registrations, spawning raw stream pumps as they arrive - async function pumpLateStreams(): Promise>> { - if (!lateStreamSource) return [] - - const lateStreamPumps: Array> = [] - const reader = lateStreamSource.getReader() - readers.push(reader) - try { - while (!cancelled) { - const { done, value } = await reader.read() - if (done) break - // Start pumping this late stream and track it - lateStreamPumps.push(pumpRawStream(value.id, value.stream)) + if (!stopped) { + // JSON records describe the response graph, so losing one is fatal. + errorOutput(error) } } finally { + readers.delete(reader) reader.releaseLock() } - return lateStreamPumps } return new ReadableStream({ - async start(ctrl) { + start(ctrl) { controller = ctrl - - // Collect all pump promises - const pumps: Array>>> = [pumpJSON()] - - for (const [streamId, stream] of rawStreams) { - pumps.push(pumpRawStream(streamId, stream)) - } - - // Add late stream pump (returns array of spawned pump promises) - if (lateStreamSource) { - pumps.push(pumpLateStreams()) - } - - try { - // Wait for initial pumps to complete - const results = await Promise.all(pumps) - - // Wait for any late stream pumps that were spawned - const latePumps = results.find(Array.isArray) as - | Array> - | undefined - if (latePumps && latePumps.length > 0) { - await Promise.all(latePumps) - } - - // All pumps done - close the output stream - if (!cancelled) { - try { - controller.close() - } catch { - // Already closed - } - } - } catch { - // Error already handled by errorOutput in pumpJSON - // or was a raw stream error (non-fatal, already sent ERROR frame) + if (options.signal?.aborted) { + cancelStream(recordStream, options.signal.reason) + errorOutput(options.signal.reason) + return } + options.signal?.addEventListener('abort', abortOutput, { once: true }) + startPump(pumpRecords) }, - - cancel() { - cancelled = true - // Cancel all readers to stop pumps quickly - for (const reader of readers) { - reader.cancel().catch(() => {}) + pull() { + wakeAdmission() + }, + cancel(reason) { + if (stop(reason)) { + options.onCancel?.(reason) } - readers.length = 0 }, }) } diff --git a/packages/start-server-core/src/index.tsx b/packages/start-server-core/src/index.tsx index ee818e0cf1f..92937ff0392 100644 --- a/packages/start-server-core/src/index.tsx +++ b/packages/start-server-core/src/index.tsx @@ -1,4 +1,7 @@ -export { createStartHandler } from './createStartHandler' +export { + createStartHandler, + transferResponseBodyOwnership, +} from './createStartHandler' export type { CreateStartHandlerOptions } from './createStartHandler' export type { diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index e7d961decf1..6245f6a809b 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -1,24 +1,27 @@ +import { invariant, isNotFound, isRedirect } from '@tanstack/router-core' import { createRawStreamRPCPlugin, - invariant, - isNotFound, - isRedirect, -} from '@tanstack/router-core' + defaultSerovalDeserializerPlugins as routerDefaultSerovalPlugins, +} from '@tanstack/router-core/ssr/server' import { + MAX_FRAMED_STREAMS, + MAX_FRAME_PAYLOAD_SIZE, + TSS_CONTENT_TYPE_FRAMED_VERSIONED, TSS_FORMDATA_CONTEXT, X_TSS_RAW_RESPONSE, X_TSS_SERIALIZED, - getDefaultSerovalPlugins, + getSerovalPlugins, safeObjectMerge, } from '@tanstack/start-client-core' import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval' import { getResponse } from './request-response' import { getServerFnById } from './getServerFnById' -import { - TSS_CONTENT_TYPE_FRAMED_VERSIONED, - createMultiplexedStream, +import { createMultiplexedStream } from './frame-protocol' +import type { + LateStreamRegistration, + MultiplexedStreamOptions, + MultiplexedStreamRecord, } from './frame-protocol' -import type { LateStreamRegistration } from './frame-protocol' import type { Plugin as SerovalPlugin } from 'seroval' // Cache serovalPlugins at module level to avoid repeated calls @@ -32,6 +35,34 @@ const FORM_DATA_CONTENT_TYPES = [ // Maximum payload size for GET requests (1MB) const MAX_PAYLOAD_SIZE = 1_000_000 +const MAX_PENDING_SERIALIZATION_RECORDS = 1024 +const MAX_PENDING_SERIALIZATION_BYTES = 32 * 1024 * 1024 +const textEncoder = new TextEncoder() + +function encodeSerializationRecord(value: unknown) { + return textEncoder.encode(JSON.stringify(value)) +} + +function exceedsPendingSerializationLimit( + record: Uint8Array, + recordCount: number, + pendingBytes: number, +) { + return ( + recordCount >= MAX_PENDING_SERIALIZATION_RECORDS || + pendingBytes + record.byteLength > MAX_PENDING_SERIALIZATION_BYTES + ) +} + +function runSerializationCleanup(dispose: () => void) { + try { + dispose() + } catch {} +} + +function cancelRawStream(stream: ReadableStream, reason?: unknown) { + void stream.cancel(reason).catch(() => {}) +} export const handleServerAction = async ({ request, @@ -42,8 +73,7 @@ export const handleServerAction = async ({ context: any serverFnId: string }) => { - const method = request.method - const methodUpper = method.toUpperCase() + const methodUpper = request.method.toUpperCase() const url = new URL(request.url) const action = await getServerFnById(serverFnId, { origin: 'client' }) @@ -63,359 +93,424 @@ export const handleServerAction = async ({ } const isServerFn = request.headers.get('x-tsr-serverFn') === 'true' - // Initialize serovalPlugins lazily (cached at module level) - if (!serovalPlugins) { - serovalPlugins = getDefaultSerovalPlugins() - } - + serovalPlugins ??= getSerovalPlugins(routerDefaultSerovalPlugins) const contentType = request.headers.get('Content-Type') - function parsePayload(payload: any) { - const parsedPayload = fromJSON(payload, { plugins: serovalPlugins }) - return parsedPayload as any - } - - const response = await (async () => { - try { - let res = await (async () => { - // FormData - if ( - FORM_DATA_CONTENT_TYPES.some( - (type) => contentType && contentType.includes(type), + try { + let res: any + if ( + FORM_DATA_CONTENT_TYPES.some( + (type) => contentType && contentType.includes(type), + ) + ) { + // We don't support GET requests with FormData payloads... that seems impossible + if (methodUpper === 'GET') { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'Invariant failed: GET requests with FormData payloads are not supported', ) - ) { - // We don't support GET requests with FormData payloads... that seems impossible - if (methodUpper === 'GET') { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: GET requests with FormData payloads are not supported', - ) - } - - invariant() - } - const formData = await request.formData() - const serializedContext = formData.get(TSS_FORMDATA_CONTEXT) - formData.delete(TSS_FORMDATA_CONTEXT) - - const params = { - context, - data: formData, - method: methodUpper, - } - if (typeof serializedContext === 'string') { - try { - const parsedContext = JSON.parse(serializedContext) - const deserializedContext = fromJSON(parsedContext, { - plugins: serovalPlugins, - }) - if ( - typeof deserializedContext === 'object' && - deserializedContext - ) { - params.context = safeObjectMerge( - deserializedContext as Record, - context, - ) - } - } catch (e) { - // Log warning for debugging but don't expose to client - if (process.env.NODE_ENV === 'development') { - console.warn('Failed to parse FormData context:', e) - } - } - } - - return await action(params) } - // Get requests use the query string - if (methodUpper === 'GET') { - // Get payload directly from searchParams - const payloadParam = url.searchParams.get('payload') - // Reject oversized payloads to prevent DoS - if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) { - throw new Error('Payload too large') + invariant() + } + const formData = await request.formData() + const serializedContext = formData.get(TSS_FORMDATA_CONTEXT) + formData.delete(TSS_FORMDATA_CONTEXT) + + const params = { + context, + data: formData, + method: methodUpper, + } + if (typeof serializedContext === 'string') { + try { + const parsedContext = JSON.parse(serializedContext) + const deserializedContext = fromJSON(parsedContext, { + plugins: serovalPlugins, + }) + if (typeof deserializedContext === 'object' && deserializedContext) { + params.context = safeObjectMerge( + deserializedContext as Record, + context, + ) + } + } catch (e) { + // Log warning for debugging but don't expose to client + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to parse FormData context:', e) } - // If there's a payload, we should try to parse it - const payload: any = payloadParam - ? parsePayload(JSON.parse(payloadParam)) - : {} - payload.context = safeObjectMerge(payload.context, context) - payload.method = methodUpper - // Send it through! - return await action(payload) } + } - let jsonPayload - if (contentType?.includes('application/json')) { - jsonPayload = await request.json() - } + res = await action(params) + } else if (methodUpper === 'GET') { + // Get payload directly from searchParams + const payloadParam = url.searchParams.get('payload') + // Reject oversized payloads to prevent DoS + if (payloadParam && payloadParam.length > MAX_PAYLOAD_SIZE) { + throw new Error('Payload too large') + } + const payload: any = payloadParam + ? fromJSON(JSON.parse(payloadParam), { plugins: serovalPlugins }) + : {} + payload.context = safeObjectMerge(payload.context, context) + payload.method = methodUpper + res = await action(payload) + } else { + const payload: any = contentType?.includes('application/json') + ? fromJSON(await request.json(), { plugins: serovalPlugins }) + : {} + payload.context = safeObjectMerge(payload.context, context) + payload.method = methodUpper + res = await action(payload) + } - const payload = jsonPayload ? parsePayload(jsonPayload) : {} - payload.context = safeObjectMerge(payload.context, context) - payload.method = methodUpper - return await action(payload) - })() + const unwrapped = res.result !== undefined ? res.result : res.error - const unwrapped = res.result || res.error + if (isNotFound(res)) { + res = isNotFoundResponse(res) + } - if (isNotFound(res)) { - res = isNotFoundResponse(res) - } + if (!isServerFn) { + return unwrapped + } - if (!isServerFn) { + if (unwrapped instanceof Response) { + if (isRedirect(unwrapped)) { return unwrapped } + unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true') + return unwrapped + } - if (unwrapped instanceof Response) { - if (isRedirect(unwrapped)) { - return unwrapped - } - unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true') - return unwrapped + return serializeResult(res, request.signal, serovalPlugins) + } catch (error: any) { + if (error instanceof Response) { + return error + } + + // Currently this server-side context has no idea how to + // build final URLs, so we need to defer that to the client. + // The client will check for __redirect and __notFound keys, + // and if they exist, it will handle them appropriately. + + if (isNotFound(error)) { + return isNotFoundResponse(error) + } + + console.error('Server Fn Error!', error) + + const serializedError = JSON.stringify( + await toCrossJSONAsync(error, { + refs: new Map(), + plugins: serovalPlugins, + }), + ) + const response = getResponse() + const headers = { + 'Content-Type': 'application/json', + [X_TSS_SERIALIZED]: 'true', + } + try { + return new Response(serializedError, { + status: response.status ?? 500, + statusText: response.statusText, + headers, + }) + } catch { + return new Response(serializedError, { + status: 500, + statusText: '', + headers, + }) + } + } +} + +/** + * Serializes a server-function result. A result that Seroval completes + * synchronously without RawStreams becomes plain JSON; everything else is a + * framed response whose records and raw streams are multiplexed in order. + */ +function serializeResult( + res: unknown, + signal: AbortSignal, + plugins: Array>, +): Response { + const alsResponse = getResponse() + const initialRecords: Array = [] + let initialBytes = 0 + const pendingRawStreams: Array = [] + + // Seroval replays synchronously discovered work before returning. Collect + // that first pass so a complete result can skip framing entirely. + let done = false as boolean + let initialParsed = false + let serializationFailure: [unknown] | undefined + let disposeSerialization: (() => void) | undefined + let onParse = (value: any, initial: boolean) => { + if (serializationFailure) { + return + } + initialParsed ||= initial + const record = encodeSerializationRecord(value) + if ( + exceedsPendingSerializationLimit( + record, + initialRecords.length, + initialBytes, + ) + ) { + serializationFailure = [ + new Error( + 'Server function serialization exceeded its pending output limit', + ), + ] + return + } + initialRecords.push(record) + initialBytes += record.byteLength + } + let onDone = () => { + if (initialParsed) { + done = true + } + } + let onError = (error: any) => { + serializationFailure ??= [error] + } + const rawStreamPlugin = createRawStreamRPCPlugin( + (id: number, stream: ReadableStream) => { + if (serializationFailure) { + cancelRawStream(stream, serializationFailure[0]) + return + } + if (id > MAX_FRAMED_STREAMS) { + const error = new Error( + `Too many raw streams in framed response (max ${MAX_FRAMED_STREAMS})`, + ) + cancelRawStream(stream, error) + onError(error) + return } + pendingRawStreams.push({ id, stream }) + }, + ) - return serializeResult(res) - - function serializeResult(res: unknown): Response { - let nonStreamingBody: any = undefined - - const alsResponse = getResponse() - if (res !== undefined) { - // Collect raw streams encountered during initial synchronous serialization - const rawStreams = new Map>() - - // Track whether we're still in the initial synchronous phase - // After initial phase, new RawStreams go to lateStreamWriter - let initialPhase = true - - // Late stream registration for RawStreams discovered after initial pass - // (e.g., from resolved Promises) - let lateStreamWriter: - | WritableStreamDefaultWriter - | undefined - let lateStreamReadable: - | ReadableStream - | undefined = undefined - const pendingLateStreams: Array = [] - - const rawStreamPlugin = createRawStreamRPCPlugin( - (id: number, stream: ReadableStream) => { - if (initialPhase) { - rawStreams.set(id, stream) - return - } - - if (lateStreamWriter) { - // Late stream - write to the late stream channel - lateStreamWriter.write({ id, stream }).catch(() => { - // Ignore write errors - stream may be closed - }) - return - } - - // Discovered after initial phase but before writer exists. - pendingLateStreams.push({ id, stream }) - }, - ) + const dispose = toCrossJSONStream(res, { + refs: new Map(), + plugins: [rawStreamPlugin, ...plugins], + onParse(value, initial) { + onParse(value, initial) + }, + onDone() { + onDone() + }, + onError: (error) => { + onError(error) + }, + }) + if (serializationFailure) { + runSerializationCleanup(dispose) + for (const registration of pendingRawStreams) { + cancelRawStream(registration.stream, serializationFailure[0]) + } + throw serializationFailure[0] + } + if (!done) { + disposeSerialization = dispose + } - // Build plugins with RawStreamRPCPlugin first (before default SSR plugin) - const plugins = [rawStreamPlugin, ...(serovalPlugins || [])] - - // first run without the stream in case `result` does not need streaming - let done = false as boolean - const callbacks: { - onParse: (value: any) => void - onDone: () => void - onError: (error: any) => void - } = { - onParse: (value) => { - nonStreamingBody = value - }, - onDone: () => { - done = true - }, - onError: (error) => { - throw error - }, - } - toCrossJSONStream(res, { - refs: new Map(), - plugins, - onParse(value) { - callbacks.onParse(value) - }, - onDone() { - callbacks.onDone() - }, - onError: (error) => { - callbacks.onError(error) - }, - }) + if (done && pendingRawStreams.length === 0 && initialRecords.length === 1) { + // TextEncoder always creates an ArrayBuffer-backed Uint8Array. + return new Response(initialRecords[0]! as BodyInit, { + status: alsResponse.status, + statusText: alsResponse.statusText, + headers: { + 'Content-Type': 'application/json', + [X_TSS_SERIALIZED]: 'true', + }, + }) + } - // End of initial synchronous phase - any new RawStreams are "late" - initialPhase = false - - // If any RawStreams are discovered after this point but before the - // late-stream writer exists, we buffer them and flush once the writer - // is ready. This avoids an occasional missed-stream race. - - // If no raw streams and done synchronously, return simple JSON - if (done && rawStreams.size === 0) { - return new Response( - nonStreamingBody ? JSON.stringify(nonStreamingBody) : undefined, - { - status: alsResponse.status, - statusText: alsResponse.statusText, - headers: { - 'Content-Type': 'application/json', - [X_TSS_SERIALIZED]: 'true', - }, - }, - ) - } + if (done && initialRecords.length === 1) { + const json = initialRecords[0]! + if (json.byteLength > MAX_FRAME_PAYLOAD_SIZE) { + const error = new Error( + 'Server function serialization exceeded its pending output limit', + ) + for (const registration of pendingRawStreams) { + cancelRawStream(registration.stream, error) + } + throw error + } - // Not done synchronously or has raw streams - use framed protocol - // This supports late RawStreams from resolved Promises - const { readable, writable } = - new TransformStream() - lateStreamReadable = readable - lateStreamWriter = writable.getWriter() - - // Flush any late streams that were discovered in the small window - // between end of initial serialization and writer setup. - for (const registration of pendingLateStreams) { - lateStreamWriter.write(registration).catch(() => { - // Ignore write errors - stream may be closed - }) + // Serialization is complete, so this one bounded record needs no writer + // or pending-serialization lifecycle. The mux still controls raw demand. + const rawStreams = pendingRawStreams.splice(0) + initialRecords.length = 0 + return createFramedResponse( + new ReadableStream({ + start(controller) { + controller.enqueue({ json, rawStreams }) + controller.close() + }, + cancel(reason) { + for (const registration of rawStreams) { + cancelRawStream(registration.stream, reason) } - pendingLateStreams.length = 0 - - // Create a stream of JSON chunks - const jsonStream = new ReadableStream({ - start(controller) { - callbacks.onParse = (value) => { - controller.enqueue(JSON.stringify(value) + '\n') - } - callbacks.onDone = () => { - try { - controller.close() - } catch { - // Already closed - } - // Close late stream writer when JSON serialization is done - // Any RawStreams not yet discovered won't be sent - lateStreamWriter - ?.close() - .catch(() => { - // Ignore close errors - }) - .finally(() => { - lateStreamWriter = undefined - }) - } - - callbacks.onError = (error) => { - controller.error(error) - lateStreamWriter - ?.abort(error) - .catch(() => { - // Ignore abort errors - }) - .finally(() => { - lateStreamWriter = undefined - }) - } - - // Emit initial body if we have one - if (nonStreamingBody !== undefined) { - callbacks.onParse(nonStreamingBody) - } - // If serialization already completed synchronously, close now - // This handles the case where onDone was called during toCrossJSONStream - // before we overwrote callbacks.onDone - if (done) { - callbacks.onDone() - } - }, - cancel() { - lateStreamWriter?.abort().catch(() => {}) - lateStreamWriter = undefined - }, - }) - - // Create multiplexed stream with JSON, initial raw streams, and late streams - const multiplexedStream = createMultiplexedStream( - jsonStream, - rawStreams, - lateStreamReadable, - ) + }, + }), + { signal }, + ) + } - return new Response(multiplexedStream, { - status: alsResponse.status, - statusText: alsResponse.statusText, - headers: { - 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, - [X_TSS_SERIALIZED]: 'true', - }, - }) - } + // Couple every JSON patch to the RawStreams it introduces. The mux admits + // each JSON reference before it starts that stream's chunks. + const { readable, writable } = new TransformStream() + const writer = writable.getWriter() + const recordAbortController = new AbortController() + let pendingBytes = 0 + const pendingRecords = new Set() + + const abortRecordStream = (error: unknown) => { + if (serializationFailure) { + return + } + serializationFailure = [error] + const disposeCurrentSerialization = disposeSerialization + disposeSerialization = undefined + for (const registration of pendingRawStreams.splice(0)) { + cancelRawStream(registration.stream, error) + } + for (const record of pendingRecords) { + for (const registration of record.rawStreams) { + cancelRawStream(registration.stream, error) + } + } + pendingRecords.clear() + recordAbortController.abort(error) + void writer.abort(error).catch(() => {}) + if (disposeCurrentSerialization) { + runSerializationCleanup(disposeCurrentSerialization) + } + } - return new Response(undefined, { - status: alsResponse.status, - statusText: alsResponse.statusText, - }) + const writeRecord = ( + json: Uint8Array, + rawStreams: Array, + ) => { + if (serializationFailure) { + for (const registration of rawStreams) { + cancelRawStream(registration.stream, serializationFailure[0]) } - } catch (error: any) { - if (error instanceof Response) { - return error + return false + } + + if ( + json.byteLength > MAX_FRAME_PAYLOAD_SIZE || + exceedsPendingSerializationLimit(json, pendingRecords.size, pendingBytes) + ) { + const error = new Error( + 'Server function serialization exceeded its pending output limit', + ) + for (const registration of rawStreams) { + cancelRawStream(registration.stream, error) } - // else if ( - // isPlainObject(error) && - // 'result' in error && - // error.result instanceof Response - // ) { - // return error.result - // } - - // Currently this server-side context has no idea how to - // build final URLs, so we need to defer that to the client. - // The client will check for __redirect and __notFound keys, - // and if they exist, it will handle them appropriately. - - if (isNotFound(error)) { - return isNotFoundResponse(error) + onError(error) + return false + } + + pendingBytes += json.byteLength + const record = { json, rawStreams } + pendingRecords.add(record) + void writer.write(record).then( + () => { + pendingRecords.delete(record) + pendingBytes -= json.byteLength + }, + (error) => { + const stillOwned = pendingRecords.delete(record) + pendingBytes -= json.byteLength + if (stillOwned) { + for (const registration of rawStreams) { + cancelRawStream(registration.stream, error) + } + } + }, + ) + return true + } + + onParse = (value) => { + if (serializationFailure) { + return + } + writeRecord(encodeSerializationRecord(value), pendingRawStreams.splice(0)) + } + onDone = () => { + if (serializationFailure) { + return + } + disposeSerialization = undefined + void writer.close().catch(() => {}) + } + onError = (error) => { + abortRecordStream(error) + } + + // Seroval buffers nested patches during its initial traversal. Their + // RawStream callbacks may precede the root callback, so start every + // synchronously discovered stream only after all initial records. + const initialRawStreams = pendingRawStreams.splice(0) + for (let index = 0; index < initialRecords.length; index++) { + const isLast = index === initialRecords.length - 1 + if (!writeRecord(initialRecords[index]!, isLast ? initialRawStreams : [])) { + // `writeRecord` recorded the failure. Nothing was handed to the client + // yet, so fail the whole call. + if (!isLast) { + for (const registration of initialRawStreams) { + cancelRawStream(registration.stream, serializationFailure![0]) + } } + initialRecords.length = 0 + throw serializationFailure![0] + } + } + initialRecords.length = 0 + if (done) { + onDone() + } - console.info() - console.info('Server Fn Error!') - console.info() - console.error(error) - console.info() + void writer.closed.catch((error) => { + abortRecordStream(error) + }) - const serializedError = JSON.stringify( - await Promise.resolve( - toCrossJSONAsync(error, { - refs: new Map(), - plugins: serovalPlugins, - }), - ), - ) - const response = getResponse() - return new Response(serializedError, { - status: response.status ?? 500, - statusText: response.statusText, + return createFramedResponse(readable, { + signal: AbortSignal.any([recordAbortController.signal, signal]), + onCancel: abortRecordStream, + }) + + function createFramedResponse( + records: ReadableStream, + options: MultiplexedStreamOptions, + ) { + const multiplexedStream = createMultiplexedStream(records, options) + try { + return new Response(multiplexedStream, { + status: alsResponse.status, + statusText: alsResponse.statusText, headers: { - 'Content-Type': 'application/json', + 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, [X_TSS_SERIALIZED]: 'true', }, }) + } catch (error) { + cancelRawStream(multiplexedStream, error) + throw error } - })() - - return response + } } function isNotFoundResponse(error: any) { diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index dbcc169cb59..313e676288e 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -1,16 +1,11 @@ // @vitest-environment node -import { - afterAll, - afterEach, - describe, - expect, - it, - onTestFinished, - vi, -} from 'vitest' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { createMiddleware } from '@tanstack/start-client-core' +import { + createMiddleware, + getRouterInstance, +} from '@tanstack/start-client-core' import { BaseRootRoute, BaseRoute, @@ -21,11 +16,16 @@ import { createNonReactiveMutableStore, createNonReactiveReadonlyStore, } from '@tanstack/router-core' +import { ReadableStream as NodeReadableStream } from 'node:stream/web' import { attachRouterServerSsrUtils, createSsrStreamResponse, + transformReadableStreamWithRouter, } from '@tanstack/router-core/ssr/server' -import { createStartHandler } from '../src/createStartHandler' +import { + createStartHandler, + transferResponseBodyOwnership, +} from '../src/createStartHandler' import { getStaticHandlerInlineCssDefault, resolveInlineCssForRequest, @@ -38,7 +38,9 @@ const startMocks = vi.hoisted(() => { previousServerFnBase, requestMiddleware: [] as Array, serverFnResult: undefined as undefined | Response | object, + serverFnHandler: undefined as undefined | (() => unknown), router: undefined as undefined | AnyRouter, + routerFactory: undefined as undefined | (() => AnyRouter), } }) @@ -52,11 +54,14 @@ vi.mock('#tanstack-start-entry', () => ({ })) vi.mock('#tanstack-router-entry', () => ({ - getRouter: () => startMocks.router, + getRouter: () => startMocks.routerFactory?.() ?? startMocks.router, })) vi.mock('../src/server-functions-handler', () => ({ - handleServerAction: () => startMocks.serverFnResult, + handleServerAction: () => + startMocks.serverFnHandler + ? startMocks.serverFnHandler() + : startMocks.serverFnResult, })) const getStoreConfig = () => ({ @@ -65,12 +70,13 @@ const getStoreConfig = () => ({ batch: (fn: () => void) => fn(), }) -function makeRouter() { +function makeRouter(routeOptions: Record = {}) { const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/', component: () => null, + ...routeOptions, }) const router = new RouterCore( { @@ -115,23 +121,45 @@ function waitForAbortOrRelease(signal: AbortSignal) { }) } -function makeStreamResponse(router: ReturnType) { +function makeStreamResponse( + router: ReturnType, + onCancel?: (reason?: unknown) => void, +) { attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('stream')) }, - cancel() { + cancel(reason) { + onCancel?.(reason) router.serverSsr?.cleanup() }, }) return createSsrStreamResponse(router as any, new Response(stream)) } +function makeCompletingStreamResponse(router: ReturnType) { + attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) + router.serverSsr!.disableHydration() + const source = new NodeReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('stream')) + controller.close() + }, + }) + const stream = transformReadableStreamWithRouter(router as any, source) + return createSsrStreamResponse( + router as any, + new Response(stream as unknown as BodyInit), + ) +} + afterEach(() => { startMocks.requestMiddleware = [] startMocks.serverFnResult = undefined + startMocks.serverFnHandler = undefined startMocks.router = undefined + startMocks.routerFactory = undefined vi.unstubAllEnvs() }) @@ -168,6 +196,54 @@ describe('createStartHandler SSR cleanup ownership', () => { expect(router.serverSsr).toBeUndefined() }) + it('does not cancel the returned response when an inner result settles late', async () => { + let resolveInner!: (response: Response) => void + startMocks.serverFnHandler = () => + new Promise((resolve) => { + resolveInner = resolve + }) + const timeoutCancel = vi.fn() + const timeoutResponse = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('timeout')) + controller.close() + }, + cancel: timeoutCancel, + }), + { status: 504 }, + ) + startMocks.requestMiddleware = [ + createMiddleware().server(({ next }) => + Promise.race([ + next(), + new Promise((resolve) => + setTimeout(() => resolve(timeoutResponse), 5), + ), + ]), + ), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + expect(response.status).toBe(504) + + const innerCancel = vi.fn() + resolveInner(new Response(new ReadableStream({ cancel: innerCancel }))) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(timeoutCancel).not.toHaveBeenCalled() + expect(innerCancel).toHaveBeenCalledExactlyOnceWith( + 'late middleware response', + ) + expect(await response.text()).toBe('timeout') + }) + it('disposes stream response replaced by middleware result', async () => { const router = makeRouter() startMocks.router = router @@ -256,6 +332,77 @@ describe('createStartHandler SSR cleanup ownership', () => { expect(router.serverSsr).toBeUndefined() }) + it.each(['return', 'mutate', 'throw'] as const)( + 'cancels a plain stream that outer middleware replaces via %s', + async (mode) => { + const router = makeRouter() + startMocks.router = router + const cancel = vi.fn(() => new Promise(() => {})) + startMocks.serverFnResult = new Response(new ReadableStream({ cancel })) + const replacement = new Response('replacement') + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + if (mode === 'return') { + return replacement + } + if (mode === 'throw') { + throw replacement + } + result.response = replacement + return result + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response).toBe(replacement) + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith('middleware response replaced') + }, + ) + + it('preserves a plain stream when middleware pipes the body', async () => { + const router = makeRouter() + startMocks.router = router + const cancel = vi.fn() + startMocks.serverFnResult = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('stream')) + controller.close() + }, + cancel, + }), + ) + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + return new Response( + result.response.body!.pipeThrough(new TransformStream()), + result.response, + ) + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + await expect(response.text()).resolves.toBe('stream') + expect(cancel).not.toHaveBeenCalled() + }) + it('preserves stream ownership when middleware wraps same body', async () => { const router = makeRouter() startMocks.router = router @@ -290,15 +437,301 @@ describe('createStartHandler SSR cleanup ownership', () => { expect(router.serverSsr).toBeUndefined() }) - it('disposes stream response on middleware error after next', async () => { + it('preserves stream ownership when middleware pipes the body', async () => { const router = makeRouter() startMocks.router = router - const ssrResponse = makeStreamResponse(router) + const ssrResponse = makeCompletingStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + let wrappedResponse: Response | undefined + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + wrappedResponse = transferResponseBodyOwnership( + result.response, + new Response( + result.response.body!.pipeThrough(new TransformStream()), + result.response, + ), + ) + return wrappedResponse + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response).toBe(wrappedResponse) + expect(dispose).not.toHaveBeenCalled() + await expect(response.text()).resolves.toBe('stream') + expect(router.serverSsr).toBeUndefined() + }) + + it.each([false, true])( + 'cancels a derived body after handoff with a router transform: %s', + async (useRouterTransform) => { + const router = makeRouter() + startMocks.router = router + const sourceCancel = vi.fn() + const source = new ReadableStream({ cancel: sourceCancel }) + let dispose: ReturnType + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + return transferResponseBodyOwnership( + result.response, + new Response( + result.response.body!.pipeThrough(new TransformStream()), + result.response, + ), + ) + }), + ] + const requestController = new AbortController() + const handler = createStartHandler( + ({ router: requestRouter, request }) => { + const responseBody = useRouterTransform + ? transformReadableStreamWithRouter(requestRouter, source, { + signal: request.signal, + }) + : source + const ssrResponse = createSsrStreamResponse( + requestRouter, + new Response(responseBody), + ) + dispose = vi.spyOn(ssrResponse, 'dispose') + return ssrResponse + }, + ) + const response = await handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + const derivedCancel = vi.spyOn(response.body!, 'cancel') + const reason = new Error('request disconnected') + + expect(source.locked).toBe(true) + requestController.abort(reason) + + await vi.waitFor(() => { + expect(dispose).toHaveBeenCalledExactlyOnceWith(reason) + expect(derivedCancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(sourceCancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(source.locked).toBe(false) + expect(router.serverSsr).toBeUndefined() + }) + }, + ) + + it('preserves both Response.clone() branches when the clone is assigned', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeCompletingStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + let siblingResponse!: Response + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + siblingResponse = result.response + result.response = transferResponseBodyOwnership( + siblingResponse, + siblingResponse.clone(), + ) + return result + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(dispose).not.toHaveBeenCalled() + await expect( + Promise.all([response.text(), siblingResponse.text()]), + ).resolves.toEqual(['stream', 'stream']) + expect(router.serverSsr).toBeUndefined() + }) + + it('disposes a piped stream that outer middleware replaces', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeCompletingStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + const replacement = new Response('replacement') + let derivedCancel!: ReturnType + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + await next() + expect(dispose).not.toHaveBeenCalled() + return replacement + }), + createMiddleware().server(async ({ next }) => { + const result = await next() + const response = transferResponseBodyOwnership( + result.response, + new Response( + result.response.body!.pipeThrough(new TransformStream()), + result.response, + ), + ) + derivedCancel = vi.spyOn(response.body!, 'cancel') + return response + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response).toBe(replacement) + expect(dispose).toHaveBeenCalledOnce() + expect(derivedCancel).toHaveBeenCalledWith('middleware response replaced') + expect(router.serverSsr).toBeUndefined() + }) + + it('refreshes a cloned response before an outer replacement', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeCompletingStreamResponse(router) startMocks.serverFnResult = ssrResponse const dispose = vi.spyOn(ssrResponse as any, 'dispose') + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const replacement = new Response('replacement') + let ownerBody!: ReadableStream + let cloneCancellation!: Promise startMocks.requestMiddleware = [ createMiddleware().server(async ({ next }) => { await next() + return replacement + }), + createMiddleware().server(async ({ next }) => { + const result = await next() + cloneCancellation = result.response.clone().body!.cancel('not used') + ownerBody = result.response.body! + vi.spyOn(ownerBody, 'cancel') + return result + }), + ] + + try { + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response).toBe(replacement) + await expect(response.text()).resolves.toBe('replacement') + expect(dispose).toHaveBeenCalledOnce() + expect(ownerBody.cancel).toHaveBeenCalledOnce() + await cloneCancellation + expect(router.serverSsr).toBeUndefined() + await Promise.resolve() + await Promise.resolve() + expect(consoleError).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + + it('disposes a locked stream that middleware replaces with no body', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeCompletingStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + let reader: ReadableStreamDefaultReader | undefined + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + reader = result.response.body!.getReader() + return new Response(null, { status: 204 }) + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response.status).toBe(204) + expect(response.body).toBeNull() + expect(dispose).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + reader?.releaseLock() + }) + + it('disposes a locked stream replaced by an unrelated body', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + const replacement = new Response('replacement') + let reader: ReadableStreamDefaultReader | undefined + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + reader = result.response.body!.getReader() + return replacement + }), + ] + + try { + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response).toBe(replacement) + await expect(response.text()).resolves.toBe('replacement') + expect(dispose).toHaveBeenCalledOnce() + expect(dispose).toHaveBeenCalledWith('middleware response replaced') + expect(router.serverSsr).toBeUndefined() + } finally { + await reader?.cancel('test cleanup') + reader?.releaseLock() + } + }) + + it('disposes an in-place replacement on middleware error', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + const cancel = vi.fn() + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + result.response = new Response(new ReadableStream({ cancel })) throw new Error('middleware failed') }), ] @@ -313,6 +746,7 @@ describe('createStartHandler SSR cleanup ownership', () => { expect(response.status).toBe(500) expect(dispose).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledOnce() expect(router.serverSsr).toBeUndefined() }) @@ -325,50 +759,594 @@ describe('createStartHandler SSR cleanup ownership', () => { const replacement = new Response('handled', { status: 418 }) startMocks.requestMiddleware = [ createMiddleware().server(async ({ next }) => { - await next() + const result = await next() + result.response = result.response.clone() throw replacement }), ] - const handler = createStartHandler(() => new Response('unused')) + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response).toBe(replacement) + expect(dispose).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) + + it('disposes a side-cloned stream before an unrelated thrown response', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeCompletingStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + const replacement = new Response('handled', { status: 418 }) + let cloneCancellation!: Promise + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + cloneCancellation = result.response.clone().body!.cancel('not used') + throw replacement + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response).toBe(replacement) + expect(dispose).toHaveBeenCalledOnce() + await cloneCancellation + expect(router.serverSsr).toBeUndefined() + }) + + it('honors in-place response assignment on returned context', async () => { + const router = makeRouter() + startMocks.router = router + const ssrResponse = makeStreamResponse(router) + startMocks.serverFnResult = ssrResponse + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + const replacement = new Response('replacement') + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + result.response = replacement + return result + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response).toBe(replacement) + expect(dispose).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) +}) + +describe('createStartHandler router initialization', () => { + it('shares one router between concurrent request-context reads', async () => { + let factoryCalls = 0 + let instances: Array = [] + startMocks.routerFactory = () => { + factoryCalls++ + return makeRouter() + } + startMocks.serverFnHandler = async () => { + instances = await Promise.all([getRouterInstance(), getRouterInstance()]) + return new Response('ok') + } + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + await expect(response.text()).resolves.toBe('ok') + expect(factoryCalls).toBe(1) + expect(instances[0]).toBe(instances[1]) + }) + + it('shares one router failure between sequential request-context reads', async () => { + const factoryError = new Error('router factory failed') + const errors: Array = [] + let factoryCalls = 0 + startMocks.routerFactory = () => { + factoryCalls++ + throw factoryError + } + startMocks.serverFnHandler = async () => { + for (let attempt = 0; attempt < 2; attempt++) { + try { + await getRouterInstance() + } catch (error) { + errors.push(error) + } + } + return new Response('ok') + } + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + await expect(response.text()).resolves.toBe('ok') + expect(factoryCalls).toBe(1) + expect(errors).toEqual([factoryError, factoryError]) + }) + + it('does not start the router factory from a continuation after request abort', async () => { + const requestController = new AbortController() + const reason = new Error('request aborted') + let factoryCalls = 0 + let lateError: unknown + let continueServerFn!: () => void + const serverFnCanContinue = new Promise((resolve) => { + continueServerFn = resolve + }) + let notifyServerFnStarted!: () => void + const serverFnStarted = new Promise((resolve) => { + notifyServerFnStarted = resolve + }) + let notifyLateReadFinished!: () => void + const lateReadFinished = new Promise((resolve) => { + notifyLateReadFinished = resolve + }) + + startMocks.routerFactory = () => { + factoryCalls++ + return makeRouter() + } + startMocks.serverFnHandler = async () => { + notifyServerFnStarted() + await serverFnCanContinue + try { + await getRouterInstance() + } catch (error) { + lateError = error + } + notifyLateReadFinished() + return new Response('late') + } + + const handler = createStartHandler(() => new Response('unused')) + const response = handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + signal: requestController.signal, + }), + {}, + ) + + await serverFnStarted + requestController.abort(reason) + expect((await response).status).toBe(500) + + continueServerFn() + await lateReadFinished + expect(factoryCalls).toBe(0) + expect(lateError).toBe(reason) + }) +}) + +describe('createStartHandler direct server routes', () => { + it('disposes a thrown sole-terminal response when the request aborts after the fast-path check', async () => { + const requestController = new AbortController() + const cancellation = new Error('request disconnected') + const cancel = vi.fn() + const thrownResponse = new Response( + new ReadableStream({ cancel }), + ) + // Abort during the `instanceof Response` check, immediately after the + // fast path's first abort check has passed. + const responsePrototype = new Proxy(Response.prototype, { + getPrototypeOf() { + requestController.abort(cancellation) + return Response.prototype + }, + }) + Object.setPrototypeOf(thrownResponse, responsePrototype) + startMocks.serverFnHandler = () => { + throw thrownResponse + } + const handler = createStartHandler(() => new Response('unused')) + + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + signal: requestController.signal, + }), + {}, + ) + + expect(response.status).toBe(500) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + }) + + it.each([ + ['Response', 'sole-terminal'], + ['SsrResponse', 'sole-terminal'], + ['Response', 'middleware-chain'], + ['SsrResponse', 'middleware-chain'], + ] as const)( + 'does not read context from a direct %s result in the %s path', + async (resultType, executionPath) => { + if (executionPath === 'middleware-chain') { + startMocks.requestMiddleware = [ + createMiddleware().server(({ next }) => next()), + ] + } + const directResponse = new Response(resultType, { status: 202 }) + const directResult = + resultType === 'Response' + ? directResponse + : { response: directResponse, serverSsrCleanup: 'none' as const } + const contextGetter = vi.fn(() => { + throw new Error('direct response context must not be read') + }) + Object.defineProperty(directResult, 'context', { + get: contextGetter, + }) + startMocks.serverFnHandler = () => directResult + const handler = createStartHandler(() => new Response('unused')) + + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response.status).toBe(202) + await expect(response.text()).resolves.toBe(resultType) + expect(contextGetter).not.toHaveBeenCalled() + }, + ) + + it('does not invoke the sole terminal after abort while copying request context', async () => { + const requestController = new AbortController() + const cancellation = new Error('request disconnected') + const serverFnHandler = vi.fn(() => new Response('must not run')) + const requestContext = {} as { abort: boolean } + Object.defineProperty(requestContext, 'abort', { + enumerable: true, + get() { + requestController.abort(cancellation) + return true + }, + }) + startMocks.serverFnHandler = serverFnHandler + const handler = createStartHandler<{ + server: { requestContext: { abort: boolean } } + }>(() => new Response('unused')) + + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + signal: requestController.signal, + }), + { context: requestContext }, + ) + + expect(response.status).toBe(500) + expect(serverFnHandler).not.toHaveBeenCalled() + }) + + it('does not invoke a direct handler when its handler factory aborts', async () => { + const requestController = new AbortController() + const cancellation = new Error('request disconnected') + const routeHandler = vi.fn(() => new Response('must not run')) + const handlers = vi.fn(() => { + requestController.abort(cancellation) + return { GET: routeHandler } + }) + const router = makeRouter({ + component: undefined, + server: { handlers }, + }) + startMocks.router = router + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + + const response = await handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + expect(response.status).toBe(500) + expect(handlers).toHaveBeenCalledOnce() + expect(routeHandler).not.toHaveBeenCalled() + expect(render).not.toHaveBeenCalled() + }) + + it('returns an exact non-component handler response without rendering a document', async () => { + const routeHandler = vi.fn(() => new Response('direct', { status: 201 })) + const router = makeRouter({ + component: undefined, + server: { + handlers: { + GET: routeHandler, + }, + }, + }) + startMocks.router = router + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(201) + await expect(response.text()).resolves.toBe('direct') + expect(routeHandler).toHaveBeenCalledOnce() + expect(render).not.toHaveBeenCalled() + expect(router.serverSsr).toBeUndefined() + }) + + it('preserves request context, params, and pathname for a direct handler', async () => { + const routeHandler = vi.fn( + ({ context, params, pathname, handlerType, request }: any) => + Response.json({ + context, + params, + pathname, + handlerType, + requestUrl: request.url, + }), + ) + const router = makeRouter({ + path: '/items/$itemId', + component: undefined, + server: { + handlers: { + GET: routeHandler, + }, + }, + }) + startMocks.router = router + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler<{ + server: { requestContext: { requestValue: string } } + }>(render) + + const response = await handler( + new Request('http://localhost/items/42?source=test'), + { context: { requestValue: 'preserved' } }, + ) + + await expect(response.json()).resolves.toMatchObject({ + context: { requestValue: 'preserved' }, + params: { itemId: '42' }, + pathname: '/items/42', + handlerType: 'router', + requestUrl: 'http://localhost/items/42?source=test', + }) + expect(render).not.toHaveBeenCalled() + }) + + it.each(['route', 'handler'] as const)( + 'runs %s middleware instead of incorrectly taking the direct path', + async (placement) => { + const events: Array = [] + const middleware = createMiddleware().server(async ({ next }) => { + events.push('middleware before') + const result = await next({ + context: { middlewarePlacement: placement }, + }) + events.push('middleware after') + result.response.headers.set('x-middleware', placement) + return result + }) + const routeHandler = vi.fn(({ context }: any) => { + events.push('handler') + return Response.json(context) + }) + const handlers = { + GET: + placement === 'handler' + ? { middleware: [middleware], handler: routeHandler } + : routeHandler, + } + const router = makeRouter({ + component: undefined, + server: { + ...(placement === 'route' ? { middleware: [middleware] } : {}), + handlers, + }, + }) + startMocks.router = router + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.headers.get('x-middleware')).toBe(placement) + await expect(response.json()).resolves.toMatchObject({ + middlewarePlacement: placement, + }) + expect(events).toEqual([ + 'middleware before', + 'handler', + 'middleware after', + ]) + expect(render).not.toHaveBeenCalled() + }, + ) + + it('rejects a missing direct response before middleware resumes', async () => { + const afterNext = vi.fn() + const middleware = createMiddleware().server(async ({ next }) => { + await next() + afterNext() + return new Response('must not return') + }) + const router = makeRouter({ + component: undefined, + server: { + middleware: [middleware], + handlers: { GET: () => undefined }, + }, + }) + startMocks.router = router + const handler = createStartHandler(() => new Response('must not render')) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(500) + expect(afterNext).not.toHaveBeenCalled() + }) + + it('lets a component route handler defer to document rendering with next', async () => { + const routeHandler = vi.fn(({ next }: any) => next()) + const router = makeRouter({ + server: { + handlers: { + GET: routeHandler, + }, + }, + }) + startMocks.router = router + const render = vi.fn(() => new Response('rendered document')) + const handler = createStartHandler(render) + + const response = await handler(new Request('http://localhost/'), {}) + + await expect(response.text()).resolves.toBe('rendered document') + expect(routeHandler).toHaveBeenCalledOnce() + expect(render).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) + + it('cancels a direct handler body that resolves after request abort', async () => { + const requestController = new AbortController() + const cancellation = new Error('request disconnected') + let notifyHandlerStarted!: () => void + const handlerStarted = new Promise((resolve) => { + notifyHandlerStarted = resolve + }) + let resolveHandler!: (response: Response) => void + const handlerResult = new Promise((resolve) => { + resolveHandler = resolve + }) + const routeHandler = vi.fn(() => { + notifyHandlerStarted() + return handlerResult + }) + const router = makeRouter({ + component: undefined, + server: { + handlers: { + GET: routeHandler, + }, + }, + }) + startMocks.router = router + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await handlerStarted + requestController.abort(cancellation) + expect((await response).status).toBe(500) + + const cancel = vi.fn((_reason: unknown) => new Promise(() => {})) + resolveHandler(new Response(new ReadableStream({ cancel }))) + + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + expect(render).not.toHaveBeenCalled() + }) +}) + +describe('createStartHandler HEAD fallback', () => { + it('strips and disposes the rendered document body', async () => { + const router = makeRouter() + startMocks.router = router + const cancel = vi.fn() + let cleanupEffects = 0 + + const handler = createStartHandler(({ router: requestRouter }) => { + requestRouter.serverSsr!.onCleanup(() => { + cleanupEffects++ + }) + return createSsrStreamResponse( + requestRouter, + new Response(new ReadableStream({ cancel }), { + headers: { 'x-rendered': 'true' }, + status: 201, + }), + ) + }) const response = await handler( - new Request('http://localhost/_serverFn/test', { - headers: { 'x-tsr-serverFn': 'true' }, - }), + new Request('http://localhost/', { method: 'HEAD' }), {}, ) - expect(response).toBe(replacement) - expect(dispose).toHaveBeenCalledOnce() + expect(response.status).toBe(201) + expect(response.headers.get('x-rendered')).toBe('true') + expect(response.body).toBeNull() + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith('HEAD body stripped') + expect(cleanupEffects).toBe(1) expect(router.serverSsr).toBeUndefined() }) - it('honors in-place response assignment on returned context', async () => { - const router = makeRouter() + it('cancels a plain streaming GET body before stripping it', async () => { + const cancel = vi.fn() + const router = makeRouter({ + server: { + handlers: { + GET: () => + new Response( + new ReadableStream({ + cancel, + }), + ), + }, + }, + }) startMocks.router = router - const ssrResponse = makeStreamResponse(router) - startMocks.serverFnResult = ssrResponse - const dispose = vi.spyOn(ssrResponse as any, 'dispose') - const replacement = new Response('replacement') - startMocks.requestMiddleware = [ - createMiddleware().server(async ({ next }) => { - const result = await next() - result.response = replacement - return result - }), - ] - const handler = createStartHandler(() => new Response('unused')) + const handler = createStartHandler(() => new Response('must not render')) const response = await handler( - new Request('http://localhost/_serverFn/test', { - headers: { 'x-tsr-serverFn': 'true' }, - }), + new Request('http://localhost/', { method: 'HEAD' }), {}, ) - expect(response).toBe(replacement) - expect(dispose).toHaveBeenCalledOnce() - expect(router.serverSsr).toBeUndefined() + expect(response.body).toBeNull() + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith('HEAD body stripped') }) }) @@ -429,16 +1407,14 @@ describe('createStartHandler request cancellation', () => { >((resolve) => { resolveRender = resolve }) - let cleanupCalls = 0 + let cleanupEffects = 0 let cancelCalls = 0 let lateStreamResponse!: ReturnType const handler = createStartHandler(({ router: requestRouter }) => { const serverSsr = requestRouter.serverSsr! - const cleanup = serverSsr.cleanup - serverSsr.cleanup = () => { - cleanupCalls++ - cleanup() - } + serverSsr.onCleanup(() => { + cleanupEffects++ + }) lateStreamResponse = createSsrStreamResponse( requestRouter, new Response( @@ -464,13 +1440,13 @@ describe('createStartHandler request cancellation', () => { requestController.abort(new Error('request disconnected')) expect((await response).status).toBe(500) - expect(cleanupCalls).toBe(1) + expect(cleanupEffects).toBe(1) expect(router.serverSsr).toBeUndefined() resolveRender(lateStreamResponse) await Promise.resolve() await Promise.resolve() - expect(cleanupCalls).toBe(1) + expect(cleanupEffects).toBe(1) expect(cancelCalls).toBe(1) expect(router.serverSsr).toBeUndefined() }) @@ -511,165 +1487,107 @@ describe('createStartHandler request cancellation', () => { }) }) - it('cancels a plain response resolved by request middleware later', async () => { - const router = makeRouter() - startMocks.router = router - const requestController = new AbortController() - let notifyMiddlewareStarted!: () => void - const middlewareStarted = new Promise((resolve) => { - notifyMiddlewareStarted = resolve - }) - let resolveMiddleware!: (value: Response) => void - const middlewareResult = new Promise((resolve) => { - resolveMiddleware = resolve - }) - const cancel = vi.fn((_reason: unknown) => new Promise(() => {})) - startMocks.requestMiddleware = [ - createMiddleware().server(() => { - notifyMiddlewareStarted() - return middlewareResult - }), - ] - const handler = createStartHandler(() => new Response('must not render')) - const response = handler( - new Request('http://localhost/', { - signal: requestController.signal, - }), - {}, - ) - - await middlewareStarted - const cancellation = new Error('request disconnected') - requestController.abort(cancellation) - - expect((await response).status).toBe(500) - resolveMiddleware(new Response(new ReadableStream({ cancel }))) - await vi.waitFor(() => { - expect(cancel).toHaveBeenCalledTimes(1) - expect(cancel).toHaveBeenCalledWith(cancellation) - }) - }) - - it.each(['throw', 'reject'] as const)( - 'reports a %s from disposal of a late render response', - async (failureMode) => { - const router = makeRouter() - startMocks.router = router - const requestController = new AbortController() - const cleanupError = new Error('late stream cleanup failed') - const dispose = vi.fn(() => { - if (failureMode === 'throw') { - throw cleanupError - } - return Promise.reject(cleanupError) - }) - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined) - let notifyRenderStarted!: () => void - const renderStarted = new Promise((resolve) => { - notifyRenderStarted = resolve - }) - let resolveRender!: (value: any) => void - const renderResult = new Promise((resolve) => { - resolveRender = resolve - }) - onTestFinished(() => { - consoleError.mockRestore() - }) - onTestFinished(() => { - router.serverSsr?.cleanup() - }) - - const handler = createStartHandler(() => { - notifyRenderStarted() - return renderResult - }) - const response = handler( - new Request('http://localhost/', { - signal: requestController.signal, - }), - {}, - ) - - await renderStarted - requestController.abort(new Error('request disconnected')) - expect((await response).status).toBe(500) - - resolveRender({ - response: new Response('stream'), - serverSsrCleanup: 'stream', - dispose, - }) - await vi.waitFor(() => { - expect(consoleError).toHaveBeenCalledWith(cleanupError) - }) - expect(dispose).toHaveBeenCalledOnce() - }, - ) - - it.each(['throw', 'reject'] as const)( - 'reports a stream disposal %s when middleware is aborted', - async (failureMode) => { + it.each(['resolves', 'rejects'] as const)( + 'cancels a plain response when request middleware %s later', + async (settlement) => { const router = makeRouter() startMocks.router = router const requestController = new AbortController() - const cleanupError = new Error('custom stream cleanup failed') - const consoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined) - const ssrResponse = makeStreamResponse(router) - const dispose = vi.fn(() => { - if (failureMode === 'throw') { - throw cleanupError - } - return Promise.reject(cleanupError) - }) - ;(ssrResponse as any).dispose = dispose - startMocks.serverFnResult = ssrResponse let notifyMiddlewareStarted!: () => void const middlewareStarted = new Promise((resolve) => { notifyMiddlewareStarted = resolve }) + let settleMiddleware!: (value: Response) => void + const middlewareResult = new Promise((resolve, reject) => { + settleMiddleware = settlement === 'resolves' ? resolve : reject + }) + const cancel = vi.fn((_reason: unknown) => new Promise(() => {})) startMocks.requestMiddleware = [ - createMiddleware().server(async ({ next }) => { - await next() + createMiddleware().server(() => { notifyMiddlewareStarted() - return new Promise(() => {}) + return middlewareResult }), ] - onTestFinished(() => { - consoleError.mockRestore() - }) - onTestFinished(() => { - router.serverSsr?.cleanup() - }) - - const handler = createStartHandler(() => new Response('unused')) + const handler = createStartHandler(() => new Response('must not render')) const response = handler( - new Request('http://localhost/_serverFn/test', { - headers: { 'x-tsr-serverFn': 'true' }, + new Request('http://localhost/', { signal: requestController.signal, }), {}, ) await middlewareStarted - requestController.abort(new Error('request disconnected')) + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) expect((await response).status).toBe(500) + settleMiddleware(new Response(new ReadableStream({ cancel }))) await vi.waitFor(() => { - expect(consoleError).toHaveBeenCalledWith(cleanupError) + expect(cancel).toHaveBeenCalledTimes(1) + expect(cancel).toHaveBeenCalledWith(cancellation) }) - expect(dispose).toHaveBeenCalledOnce() }, ) - it('disposes a stream when the request aborts after response handoff', async () => { + it('cancels a stream resolved by the render callback later', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + let notifyRenderStarted!: () => void + const renderStarted = new Promise((resolve) => { + notifyRenderStarted = resolve + }) + let resolveRender!: ( + value: ReturnType, + ) => void + const renderResult = new Promise< + ReturnType + >((resolve) => { + resolveRender = resolve + }) + const cancel = vi.fn((_reason: unknown) => new Promise(() => {})) + let streamResponse!: ReturnType + + const handler = createStartHandler(({ router: requestRouter }) => { + streamResponse = createSsrStreamResponse( + requestRouter, + new Response(new ReadableStream({ cancel })), + ) + notifyRenderStarted() + return renderResult + }) + const response = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await renderStarted + const cancellation = new Error('request disconnected') + requestController.abort(cancellation) + expect((await response).status).toBe(500) + + resolveRender(streamResponse) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledTimes(1) + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + }) + + it('disposes a side-cloned stream when the request aborts after handoff', async () => { const router = makeRouter() startMocks.router = router const requestController = new AbortController() let cancelCalls = 0 + let siblingResponse!: Response + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + siblingResponse = result.response.clone() + return result + }), + ] const handler = createStartHandler(({ router: requestRouter }) => createSsrStreamResponse( requestRouter, @@ -692,55 +1610,17 @@ describe('createStartHandler request cancellation', () => { ) expect(response.body).not.toBeNull() expect(router.serverSsr).toBeDefined() + const cancel = vi.spyOn(response.body!, 'cancel') + const reason = new Error('request disconnected') - requestController.abort(new Error('request disconnected')) - await Promise.resolve() - - expect(cancelCalls).toBe(1) - expect(router.serverSsr).toBeUndefined() - }) - - it('settles when request middleware ignores cancellation', async () => { - const router = makeRouter() - startMocks.router = router - const requestController = new AbortController() - let notifyMiddlewareStarted!: () => void - const middlewareStarted = new Promise((resolve) => { - notifyMiddlewareStarted = resolve - }) - const dispose = vi.fn(() => Promise.resolve()) - let releaseMiddleware!: (response: any) => void - const middlewareResult = new Promise((resolve) => { - releaseMiddleware = resolve - }) - startMocks.requestMiddleware = [ - createMiddleware().server(() => { - notifyMiddlewareStarted() - return middlewareResult - }), - ] - const render = vi.fn(() => new Response('must not render')) - const handler = createStartHandler(render) - const response = handler( - new Request('http://localhost/', { - signal: requestController.signal, - }), - {}, - ) - - await middlewareStarted - requestController.abort(new Error('request disconnected')) - - expect((await response).status).toBe(500) - expect(render).not.toHaveBeenCalled() + requestController.abort(reason) + void siblingResponse.body!.cancel(reason) - releaseMiddleware({ - response: new Response('late'), - serverSsrCleanup: 'stream', - dispose, + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith(reason) + expect(cancelCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() }) - await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce()) - expect(render).not.toHaveBeenCalled() }) it('unwinds nested middleware when an inner operation ignores cancellation', async () => { @@ -821,12 +1701,17 @@ describe('createStartHandler request cancellation', () => { startMocks.router = router const requestController = new AbortController() const reason = new Error('request disconnected') + const cancel = vi.fn() + const ssrResponse = makeStreamResponse(router, cancel) + const dispose = vi.spyOn(ssrResponse as any, 'dispose') startMocks.requestMiddleware = [ createMiddleware().server(({ next }) => next()), - createMiddleware().server(() => { + createMiddleware().server(async ({ next }) => { + const result = await next() requestController.abort(reason) - throw new Response('must not escape', { status: 418 }) + throw result.response }), + createMiddleware().server(() => ssrResponse as any), ] const render = vi.fn(() => new Response('must not render')) const handler = createStartHandler(render) @@ -839,6 +1724,11 @@ describe('createStartHandler request cancellation', () => { ) expect(response.status).toBe(500) + await vi.waitFor(() => { + expect(dispose).toHaveBeenCalledWith(reason) + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(reason) + }) expect(render).not.toHaveBeenCalled() }) @@ -849,9 +1739,9 @@ describe('createStartHandler request cancellation', () => { const reason = new Error('request disconnected') const observedErrors: Array = [] const afterNext = vi.fn() - const ssrResponse = makeStreamResponse(router) + const cancel = vi.fn() + const ssrResponse = makeStreamResponse(router, cancel) const dispose = vi.spyOn(ssrResponse as any, 'dispose') - const cancel = vi.spyOn(ssrResponse.response.body!, 'cancel') startMocks.requestMiddleware = [ createMiddleware().server(async ({ next }) => { try { @@ -885,7 +1775,6 @@ describe('createStartHandler request cancellation', () => { expect(response.status).toBe(500) await vi.waitFor(() => expect(observedErrors).toEqual([reason])) await vi.waitFor(() => { - expect(dispose).toHaveBeenCalledOnce() expect(dispose).toHaveBeenCalledWith(reason) expect(cancel).toHaveBeenCalledOnce() expect(cancel).toHaveBeenCalledWith(reason) @@ -894,14 +1783,14 @@ describe('createStartHandler request cancellation', () => { expect(render).not.toHaveBeenCalled() }) - it('disposes a tagged final response once when abort wins handoff', async () => { + it('disposes a tagged final response when abort wins handoff', async () => { const router = makeRouter() startMocks.router = router const requestController = new AbortController() const reason = new Error('request disconnected') - const ssrResponse = makeStreamResponse(router) + const cancel = vi.fn() + const ssrResponse = makeStreamResponse(router, cancel) const dispose = vi.spyOn(ssrResponse as any, 'dispose') - const cancel = vi.spyOn(ssrResponse.response.body!, 'cancel') startMocks.requestMiddleware = [ createMiddleware().server(() => { queueMicrotask(() => { @@ -922,7 +1811,6 @@ describe('createStartHandler request cancellation', () => { expect(response.status).toBe(500) await vi.waitFor(() => { - expect(dispose).toHaveBeenCalledOnce() expect(dispose).toHaveBeenCalledWith(reason) expect(cancel).toHaveBeenCalledOnce() expect(cancel).toHaveBeenCalledWith(reason) @@ -931,7 +1819,7 @@ describe('createStartHandler request cancellation', () => { expect(render).not.toHaveBeenCalled() }) - it('ignores a late same-body alias after catch disposes its owner', async () => { + it('keeps late same-body disposal idempotent after abort', async () => { const router = makeRouter() startMocks.router = router const requestController = new AbortController() @@ -960,14 +1848,15 @@ describe('createStartHandler request cancellation', () => { return wrapped }), ] + const sourceCancel = vi.fn() const response = new Response( new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('stream')) }, + cancel: sourceCancel, }), ) - const cancel = vi.spyOn(response.body!, 'cancel') let ssrResponse!: ReturnType const render = vi.fn(({ router: requestRouter }) => { ssrResponse = createSsrStreamResponse(requestRouter, response) @@ -989,10 +1878,68 @@ describe('createStartHandler request cancellation', () => { releaseMiddleware() await lateResultDelivered await vi.waitFor(() => { - expect(dispose).toHaveBeenCalledOnce() expect(dispose).toHaveBeenCalledWith(reason) - expect(cancel).toHaveBeenCalledOnce() - expect(cancel).toHaveBeenCalledWith(reason) + expect(sourceCancel).toHaveBeenCalledOnce() + expect(sourceCancel).toHaveBeenCalledWith(reason) + }) + expect(router.serverSsr).toBeUndefined() + }) + + it('cancels a transferred body that middleware returns after abort', async () => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const reason = new Error('request disconnected') + let notifyResponseCaptured!: () => void + const responseCaptured = new Promise((resolve) => { + notifyResponseCaptured = resolve + }) + let releaseMiddleware!: () => void + const middlewareRelease = new Promise((resolve) => { + releaseMiddleware = resolve + }) + let derivedBody!: ReadableStream + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + const derived = transferResponseBodyOwnership( + result.response, + new Response( + result.response.body!.pipeThrough(new TransformStream()), + result.response, + ), + ) + derivedBody = derived.body! + notifyResponseCaptured() + await middlewareRelease + return derived + }), + ] + const sourceCancel = vi.fn() + const response = new Response( + new ReadableStream({ cancel: sourceCancel }), + ) + const handler = createStartHandler(({ router: requestRouter }) => + createSsrStreamResponse(requestRouter, response), + ) + const result = handler( + new Request('http://localhost/', { + signal: requestController.signal, + }), + {}, + ) + + await responseCaptured + const derivedCancel = vi.spyOn(derivedBody, 'cancel') + requestController.abort(reason) + + expect((await result).status).toBe(500) + expect(sourceCancel).not.toHaveBeenCalled() + releaseMiddleware() + await vi.waitFor(() => { + expect(derivedCancel).toHaveBeenCalledWith(reason) + expect(sourceCancel).toHaveBeenCalledOnce() + expect(sourceCancel).toHaveBeenCalledWith(reason) }) expect(router.serverSsr).toBeUndefined() }) diff --git a/packages/start-server-core/tests/frame-protocol.test.ts b/packages/start-server-core/tests/frame-protocol.test.ts index 380f3a8351e..0e09251185a 100644 --- a/packages/start-server-core/tests/frame-protocol.test.ts +++ b/packages/start-server-core/tests/frame-protocol.test.ts @@ -1,26 +1,53 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { FRAME_HEADER_SIZE, - FrameType, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + FRAME_TYPE_ERROR, + FRAME_TYPE_JSON, + MAX_FRAME_PAYLOAD_SIZE, +} from '@tanstack/start-client-core' +import { createMultiplexedStream, - encodeChunkFrame, - encodeEndFrame, - encodeErrorFrame, + encodeErrorPayload, encodeFrame, - encodeJSONFrame, } from '../src/frame-protocol' +import type { + LateStreamRegistration, + MultiplexedStreamRecord, +} from '../src/frame-protocol' + +function createRecord( + json: string, + rawStreams: Array = [], +): MultiplexedStreamRecord { + return { json: new TextEncoder().encode(json), rawStreams } +} + +function createRecordStream( + records: Array, +): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const record of records) { + controller.enqueue(record) + } + controller.close() + }, + }) +} describe('frame-protocol', () => { describe('encodeFrame', () => { it('should encode frame with header and payload', () => { const payload = new Uint8Array([1, 2, 3, 4]) - const frame = encodeFrame(FrameType.CHUNK, 42, payload) + const frame = encodeFrame(FRAME_TYPE_CHUNK, 42, payload) expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length) // Check header const view = new DataView(frame.buffer) - expect(view.getUint8(0)).toBe(FrameType.CHUNK) + expect(view.getUint8(0)).toBe(FRAME_TYPE_CHUNK) expect(view.getUint32(1, false)).toBe(42) // streamId big-endian expect(view.getUint32(5, false)).toBe(4) // length big-endian @@ -29,41 +56,23 @@ describe('frame-protocol', () => { }) it('should handle empty payload', () => { - const frame = encodeFrame(FrameType.END, 1, new Uint8Array(0)) + const frame = encodeFrame(FRAME_TYPE_END, 1, new Uint8Array(0)) expect(frame.length).toBe(FRAME_HEADER_SIZE) const view = new DataView(frame.buffer) - expect(view.getUint8(0)).toBe(FrameType.END) + expect(view.getUint8(0)).toBe(FRAME_TYPE_END) expect(view.getUint32(5, false)).toBe(0) // length is 0 }) }) - describe('encodeJSONFrame', () => { - it('should encode JSON string as frame type 0 with streamId 0', () => { - const json = '{"hello":"world"}' - const frame = encodeJSONFrame(json) - - const view = new DataView(frame.buffer) - expect(view.getUint8(0)).toBe(FrameType.JSON) - expect(view.getUint32(1, false)).toBe(0) // streamId always 0 for JSON - - const encoder = new TextEncoder() - const expectedPayload = encoder.encode(json) - expect(view.getUint32(5, false)).toBe(expectedPayload.length) - - const payload = frame.slice(FRAME_HEADER_SIZE) - expect(new TextDecoder().decode(payload)).toBe(json) - }) - }) - - describe('encodeChunkFrame', () => { + describe('chunk frames', () => { it('should encode binary chunk with frame type CHUNK', () => { const chunk = new Uint8Array([0xff, 0xfe, 0xfd]) - const frame = encodeChunkFrame(123, chunk) + const frame = encodeFrame(FRAME_TYPE_CHUNK, 123, chunk) const view = new DataView(frame.buffer) - expect(view.getUint8(0)).toBe(FrameType.CHUNK) + expect(view.getUint8(0)).toBe(FRAME_TYPE_CHUNK) expect(view.getUint32(1, false)).toBe(123) expect(view.getUint32(5, false)).toBe(3) @@ -71,25 +80,29 @@ describe('frame-protocol', () => { }) }) - describe('encodeEndFrame', () => { + describe('end frames', () => { it('should encode end frame with empty payload', () => { - const frame = encodeEndFrame(456) + const frame = encodeFrame(FRAME_TYPE_END, 456, new Uint8Array(0)) expect(frame.length).toBe(FRAME_HEADER_SIZE) const view = new DataView(frame.buffer) - expect(view.getUint8(0)).toBe(FrameType.END) + expect(view.getUint8(0)).toBe(FRAME_TYPE_END) expect(view.getUint32(1, false)).toBe(456) expect(view.getUint32(5, false)).toBe(0) }) }) - describe('encodeErrorFrame', () => { + describe('encodeErrorPayload', () => { it('should encode Error message', () => { - const frame = encodeErrorFrame(789, new Error('Something went wrong')) + const frame = encodeFrame( + FRAME_TYPE_ERROR, + 789, + encodeErrorPayload(new Error('Something went wrong')), + ) const view = new DataView(frame.buffer) - expect(view.getUint8(0)).toBe(FrameType.ERROR) + expect(view.getUint8(0)).toBe(FRAME_TYPE_ERROR) expect(view.getUint32(1, false)).toBe(789) const payload = frame.slice(FRAME_HEADER_SIZE) @@ -97,33 +110,34 @@ describe('frame-protocol', () => { }) it('should handle non-Error values', () => { - const frame = encodeErrorFrame(1, 'string error') - - const payload = frame.slice(FRAME_HEADER_SIZE) - expect(new TextDecoder().decode(payload)).toBe('string error') + expect(new TextDecoder().decode(encodeErrorPayload('string error'))).toBe( + 'string error', + ) }) it('should handle undefined error', () => { - const frame = encodeErrorFrame(1, undefined) + expect(new TextDecoder().decode(encodeErrorPayload(undefined))).toBe( + 'Unknown error', + ) + }) - const payload = frame.slice(FRAME_HEADER_SIZE) - expect(new TextDecoder().decode(payload)).toBe('Unknown error') + it('should bound oversized raw-stream error messages', () => { + const payload = encodeErrorPayload( + new Error('x'.repeat(MAX_FRAME_PAYLOAD_SIZE + 1)), + ) + + expect(payload.byteLength).toBeLessThan(MAX_FRAME_PAYLOAD_SIZE) + expect(new TextDecoder().decode(payload)).toMatch(/…$/) }) }) describe('createMultiplexedStream', () => { it('should multiplex JSON stream only', async () => { - const jsonStream = new ReadableStream({ - start(controller) { - controller.enqueue('{"data":1}') - controller.enqueue('{"data":2}') - controller.close() - }, - }) - const multiplexed = createMultiplexedStream( - jsonStream, - new Map(), // no raw streams + createRecordStream([ + createRecord('{"data":1}'), + createRecord('{"data":2}'), + ]), ) const reader = multiplexed.getReader() @@ -140,18 +154,11 @@ describe('frame-protocol', () => { // Both should be JSON frames for (const chunk of chunks) { const view = new DataView(chunk.buffer, chunk.byteOffset) - expect(view.getUint8(0)).toBe(FrameType.JSON) + expect(view.getUint8(0)).toBe(FRAME_TYPE_JSON) } }) it('should multiplex JSON and raw streams', async () => { - const jsonStream = new ReadableStream({ - start(controller) { - controller.enqueue('{"result":"ok"}') - controller.close() - }, - }) - const rawStream = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1, 2, 3])) @@ -159,10 +166,11 @@ describe('frame-protocol', () => { }, }) - const rawStreams = new Map>() - rawStreams.set(5, rawStream) - - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{"result":"ok"}', [{ id: 5, stream: rawStream }]), + ]), + ) const reader = multiplexed.getReader() const chunks: Array = [] @@ -181,51 +189,349 @@ describe('frame-protocol', () => { return view.getUint8(0) }) - expect(types).toContain(FrameType.JSON) - expect(types).toContain(FrameType.CHUNK) - expect(types).toContain(FrameType.END) + expect(types).toEqual([FRAME_TYPE_JSON, FRAME_TYPE_CHUNK, FRAME_TYPE_END]) }) - it('should handle cancel without errors', async () => { - // Create slow streams that won't complete before cancel - let jsonCancelled = false - let rawCancelled = false + it('splits raw chunks at the wire payload limit', async () => { + const payload = new Uint8Array(MAX_FRAME_PAYLOAD_SIZE + 1) + const rawStream = new ReadableStream({ + start(controller) { + controller.enqueue(payload) + controller.close() + }, + }) + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{"ref":1}', [{ id: 1, stream: rawStream }]), + ]), + ) + const reader = multiplexed.getReader() + const frames: Array = [] + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + frames.push(value) + } - const jsonStream = new ReadableStream({ - async start(controller) { - await new Promise((r) => setTimeout(r, 100)) - controller.enqueue('{}\n') + expect(frames.map((frame) => frame[0])).toEqual([ + FRAME_TYPE_JSON, + FRAME_TYPE_CHUNK, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + ]) + expect(frames[1]!.byteLength - FRAME_HEADER_SIZE).toBe( + MAX_FRAME_PAYLOAD_SIZE, + ) + expect(frames[2]!.byteLength - FRAME_HEADER_SIZE).toBe(1) + }) + + it('bounds upstream reads and copies chunks only when output has demand', async () => { + const first = new Uint8Array([1]) + const second = new Uint8Array([2]) + const chunks = [first, second] + let pullCount = 0 + let chunkIndex = 0 + const rawStream = new ReadableStream( + { + pull(controller) { + pullCount++ + const chunk = chunks[chunkIndex++] + if (chunk) { + controller.enqueue(chunk) + } else { + controller.close() + } + }, + }, + { highWaterMark: 0 }, + ) + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{"ref":1}', [{ id: 1, stream: rawStream }]), + ]), + ) + + // Let every currently runnable pump microtask settle without consuming + // the output. One chunk is queued and one is held unencoded by its pump. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(pullCount).toBe(1) + + // The second frame must copy its payload only after the first frame is + // consumed and output capacity becomes available. + second[0] = 9 + const reader = multiplexed.getReader() + const jsonFrame = (await reader.read()).value! + expect(new DataView(jsonFrame.buffer).getUint8(0)).toBe(FRAME_TYPE_JSON) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(pullCount).toBe(2) + const firstFrame = (await reader.read()).value! + const secondFrame = (await reader.read()).value! + expect(firstFrame.slice(FRAME_HEADER_SIZE)).toEqual(new Uint8Array([1])) + expect(secondFrame.slice(FRAME_HEADER_SIZE)).toEqual(new Uint8Array([9])) + + const endFrame = (await reader.read()).value! + expect(new DataView(endFrame.buffer).getUint8(0)).toBe(FRAME_TYPE_END) + await expect(reader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('bounds concurrent pumps while a late record waits for output demand', async () => { + const sources = Array.from({ length: 5 }, (_, index) => { + const chunks: Array = [] + const cancel = vi.fn() + const stream = new ReadableStream( + { + pull(controller) { + const chunk = new Uint8Array([index + 1, 1]) + chunks.push(chunk) + controller.enqueue(chunk) + }, + cancel, + }, + { highWaterMark: 0 }, + ) + return { id: index + 1, stream, chunks, cancel } + }) + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{"refs":[1,2,3,4]}', sources.slice(0, 4)), + createRecord('{"ref":5}', [sources[4]!]), + ]), + ) + const reader = multiplexed.getReader() + + // The first JSON frame fills the output. Each active pump holds one + // unencoded chunk; the late stream waits for its own JSON admission. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(sources.map(({ chunks }) => chunks.length)).toEqual([ + 1, 1, 1, 1, 0, + ]) + expect((await reader.read()).value![0]).toBe(FRAME_TYPE_JSON) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(sources.map(({ chunks }) => chunks.length)).toEqual([ + 2, 1, 1, 1, 0, + ]) + + // Only the first raw frame has been copied into the output queue. + for (const source of sources.slice(0, 4)) { + source.chunks[0]![1] = 9 + } + for (let streamId = 1; streamId <= 4; streamId++) { + const frame = (await reader.read()).value! + expect(frame[0]).toBe(FRAME_TYPE_CHUNK) + expect(frame.slice(FRAME_HEADER_SIZE)).toEqual( + new Uint8Array([streamId, streamId === 1 ? 1 : 9]), + ) + } + expect((await reader.read()).value![0]).toBe(FRAME_TYPE_JSON) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(sources[4]!.chunks).toHaveLength(1) + + const reason = new Error('client disconnected') + await reader.cancel(reason) + await new Promise((resolve) => setTimeout(resolve, 0)) + for (const { stream, cancel } of sources) { + expect(cancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(stream.locked).toBe(false) + } + }) + + it.each(['cancel', 'abort'] as const)( + 'releases active and unadmitted raw streams on %s while output is full', + async (mode) => { + const reason = new Error('request ended') + const abortController = new AbortController() + const sources = Array.from({ length: 3 }, (_, index) => { + const cancel = vi.fn(() => new Promise(() => {})) + const pull = vi.fn( + (controller: ReadableStreamDefaultController) => { + controller.enqueue(new Uint8Array([index])) + }, + ) + const stream = new ReadableStream( + { pull, cancel }, + { highWaterMark: 0 }, + ) + return { id: index + 1, stream, pull, cancel } + }) + const recordCancel = vi.fn(() => new Promise(() => {})) + const records = new ReadableStream({ + start(controller) { + controller.enqueue( + createRecord('{"refs":[1,2]}', sources.slice(0, 2)), + ) + controller.enqueue(createRecord('{"ref":3}', [sources[2]!])) + }, + cancel: recordCancel, + }) + const reader = createMultiplexedStream(records, { + signal: abortController.signal, + }).getReader() + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(sources.map(({ pull }) => pull.mock.calls.length)).toEqual([ + 1, 1, 0, + ]) + if (mode === 'abort') { + abortController.abort(reason) + await expect(reader.read()).rejects.toBe(reason) + } else { + await reader.cancel(reason) + } + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(recordCancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(records.locked).toBe(false) + for (const { stream, cancel } of sources) { + expect(cancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(stream.locked).toBe(false) + } + expect(sources.map(({ pull }) => pull.mock.calls.length)).toEqual([ + 1, 1, 0, + ]) + }, + ) + + it('does not let a ready frame overtake one waiting for demand', async () => { + let secondController: ReadableStreamDefaultController + const firstRawStream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])) controller.close() }, - cancel() { - jsonCancelled = true + }) + const secondRawStream = new ReadableStream({ + start(controller) { + secondController = controller }, }) + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{"refs":[1,2]}', [ + { id: 1, stream: firstRawStream }, + { id: 2, stream: secondRawStream }, + ]), + ]), + ) + const reader = multiplexed.getReader() + + // The JSON frame fills the output queue. The first raw frame then waits + // for demand while the second raw-stream read remains pending. + await new Promise((resolve) => setTimeout(resolve, 0)) + secondController!.enqueue(new Uint8Array([2])) + secondController!.close() + + // Fulfilling the second read is already queued when consuming the JSON + // creates demand. It must not bypass the first frame's admission. + const jsonFrame = (await reader.read()).value! + expect(jsonFrame[0]).toBe(FRAME_TYPE_JSON) + const firstFrame = (await reader.read()).value! + const secondFrame = (await reader.read()).value! + expect(firstFrame[0]).toBe(FRAME_TYPE_CHUNK) + expect(secondFrame[0]).toBe(FRAME_TYPE_CHUNK) + expect( + new DataView(firstFrame.buffer, firstFrame.byteOffset).getUint32( + 1, + false, + ), + ).toBe(1) + expect( + new DataView(secondFrame.buffer, secondFrame.byteOffset).getUint32( + 1, + false, + ), + ).toBe(2) + + await reader.cancel() + }) + + it('should handle cancel without errors', async () => { + let recordStreamCancelled = false + let rawCancelled = false const rawStream = new ReadableStream({ - async start(controller) { - await new Promise((r) => setTimeout(r, 100)) - controller.enqueue(new Uint8Array([1, 2, 3])) - controller.close() + pull() { + return new Promise(() => {}) }, cancel() { rawCancelled = true + return new Promise(() => {}) }, }) - const rawStreams = new Map>() - rawStreams.set(1, rawStream) + const recordStream = new ReadableStream({ + start(controller) { + controller.enqueue(createRecord('{}', [{ id: 1, stream: rawStream }])) + }, + cancel() { + recordStreamCancelled = true + return new Promise(() => {}) + }, + }) - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const multiplexed = createMultiplexedStream(recordStream) const reader = multiplexed.getReader() - // Cancel immediately before streams complete - // Should not throw ERR_INVALID_STATE + await reader.read() + + // Output cancellation must not adopt a user cancellation promise that + // is allowed to remain pending forever. await reader.cancel() - // Underlying reader.cancel should propagate to sources - expect(jsonCancelled).toBe(true) + expect(recordStreamCancelled).toBe(true) expect(rawCancelled).toBe(true) + expect(recordStream.locked).toBe(false) + expect(rawStream.locked).toBe(false) + }) + + it('cancels raw streams from a record fulfilled as the request aborts', async () => { + const reason = new Error('request aborted') + let cancelReason: unknown + let cancelCount = 0 + const rawStream = new ReadableStream({ + cancel(value) { + cancelCount++ + cancelReason = value + }, + }) + const abortController = new AbortController() + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{}', [{ id: 1, stream: rawStream }]), + ]), + { signal: abortController.signal }, + ) + + // The record read is already fulfilled, but its pump has not resumed. + abortController.abort(reason) + + await expect(multiplexed.getReader().read()).rejects.toBe(reason) + expect(cancelCount).toBe(1) + expect(cancelReason).toBe(reason) + expect(rawStream.locked).toBe(false) + }) + + it('cancels a stream source for an already-aborted request', async () => { + const reason = new Error('request already aborted') + const cancel = vi.fn() + const recordStream = new ReadableStream({ + cancel, + }) + const abortController = new AbortController() + abortController.abort(reason) + + const multiplexed = createMultiplexedStream(recordStream, { + signal: abortController.signal, + }) + + await expect(multiplexed.getReader().read()).rejects.toBe(reason) + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(reason) + expect(recordStream.locked).toBe(false) }) it('should interleave multiple raw streams correctly', async () => { @@ -235,13 +541,6 @@ describe('frame-protocol', () => { const gate1 = new Promise((r) => (resolve1 = r)) const gate2 = new Promise((r) => (resolve2 = r)) - const jsonStream = new ReadableStream({ - start(controller) { - controller.enqueue('{"streams":[1,2]}') - controller.close() - }, - }) - const rawStream1 = new ReadableStream({ async start(controller) { controller.enqueue(new Uint8Array([0x11])) @@ -260,11 +559,14 @@ describe('frame-protocol', () => { }, }) - const rawStreams = new Map>() - rawStreams.set(1, rawStream1) - rawStreams.set(2, rawStream2) - - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{"streams":[1,2]}', [ + { id: 1, stream: rawStream1 }, + { id: 2, stream: rawStream2 }, + ]), + ]), + ) const reader = multiplexed.getReader() const chunks: Array = [] @@ -295,56 +597,41 @@ describe('frame-protocol', () => { return view.getUint8(0) }) - expect(types.filter((t) => t === FrameType.JSON).length).toBe(1) - expect(types.filter((t) => t === FrameType.CHUNK).length).toBe(4) - expect(types.filter((t) => t === FrameType.END).length).toBe(2) + expect(types.filter((t) => t === FRAME_TYPE_JSON).length).toBe(1) + expect(types.filter((t) => t === FRAME_TYPE_CHUNK).length).toBe(4) + expect(types.filter((t) => t === FRAME_TYPE_END).length).toBe(2) }) it('should handle late stream registration', async () => { - const jsonStream = new ReadableStream({ - start(controller) { - controller.enqueue('{"ref":99}') - controller.close() - }, - }) - - // Late stream source that emits a registration after a delay - // (ensures framed protocol doesn't miss late-stream messages) + // The record containing the late reference is emitted after a delay. let resolveGate: () => void const gate = new Promise((r) => (resolveGate = r)) - const lateStreamSource = new ReadableStream<{ - id: number - stream: ReadableStream - }>({ + const recordStream = new ReadableStream({ async start(controller) { await gate - controller.enqueue({ - id: 99, - stream: new ReadableStream({ - start(c) { - c.enqueue(new Uint8Array([0xaa, 0xbb])) - c.close() + controller.enqueue( + createRecord('{"ref":99}', [ + { + id: 99, + stream: new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array([0xaa, 0xbb])) + c.close() + }, + }), }, - }), - }) + ]), + ) controller.close() }, }) - const multiplexed = createMultiplexedStream( - jsonStream, - new Map(), - lateStreamSource, - ) + const multiplexed = createMultiplexedStream(recordStream) const reader = multiplexed.getReader() const chunks: Array = [] - // Read JSON frame first - const { value: jsonChunk } = await reader.read() - if (jsonChunk) chunks.push(jsonChunk) - // Release gate to let late stream arrive resolveGate!() @@ -363,7 +650,7 @@ describe('frame-protocol', () => { return view.getUint8(0) }) - expect(types).toEqual([FrameType.JSON, FrameType.CHUNK, FrameType.END]) + expect(types).toEqual([FRAME_TYPE_JSON, FRAME_TYPE_CHUNK, FRAME_TYPE_END]) // Verify late stream data const chunkFrame = chunks[1]! @@ -378,43 +665,33 @@ describe('frame-protocol', () => { let startJson: () => void const jsonGate = new Promise((r) => (startJson = r)) - const jsonStream = new ReadableStream({ + const recordStream = new ReadableStream({ async start(controller) { await jsonGate - controller.enqueue('{"ref":1}') - controller.close() - }, - }) - - // Late stream registers immediately (before JSON starts) - const lateStreamSource = new ReadableStream<{ - id: number - stream: ReadableStream - }>({ - start(controller) { - controller.enqueue({ - id: 1, - stream: new ReadableStream({ - start(c) { - c.enqueue(new Uint8Array([0x01])) - c.close() + controller.enqueue( + createRecord('{"ref":1}', [ + { + id: 1, + stream: new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array([0x01])) + c.close() + }, + }), }, - }), - }) + ]), + ) controller.close() }, }) - const multiplexed = createMultiplexedStream( - jsonStream, - new Map(), - lateStreamSource, - ) + const multiplexed = createMultiplexedStream(recordStream) const reader = multiplexed.getReader() const chunks: Array = [] - // Allow late stream to run before JSON + // The stream exists before its delayed JSON record, but the mux must not + // start it until after that record is admitted. await Promise.resolve() startJson!() @@ -429,27 +706,13 @@ describe('frame-protocol', () => { return view.getUint8(0) }) - // Order is not strictly guaranteed, but all frames must be present. - expect(types.filter((t) => t === FrameType.JSON).length).toBe(1) - expect(types.filter((t) => t === FrameType.CHUNK).length).toBe(1) - expect(types.filter((t) => t === FrameType.END).length).toBe(1) + expect(types).toEqual([FRAME_TYPE_JSON, FRAME_TYPE_CHUNK, FRAME_TYPE_END]) }) it('should handle multiple late stream registrations', async () => { - const jsonStream = new ReadableStream({ - start(controller) { - controller.enqueue('{}') - controller.close() - }, - }) - - const lateStreamSource = new ReadableStream<{ - id: number - stream: ReadableStream - }>({ - start(controller) { - // Register two streams - controller.enqueue({ + const recordStream = createRecordStream([ + createRecord('{}', [ + { id: 10, stream: new ReadableStream({ start(c) { @@ -457,8 +720,8 @@ describe('frame-protocol', () => { c.close() }, }), - }) - controller.enqueue({ + }, + { id: 20, stream: new ReadableStream({ start(c) { @@ -466,16 +729,11 @@ describe('frame-protocol', () => { c.close() }, }), - }) - controller.close() - }, - }) + }, + ]), + ]) - const multiplexed = createMultiplexedStream( - jsonStream, - new Map(), - lateStreamSource, - ) + const multiplexed = createMultiplexedStream(recordStream) const reader = multiplexed.getReader() const chunks: Array = [] @@ -495,24 +753,15 @@ describe('frame-protocol', () => { return view.getUint8(0) }) - expect(types.filter((t) => t === FrameType.JSON).length).toBe(1) - expect(types.filter((t) => t === FrameType.CHUNK).length).toBe(2) - expect(types.filter((t) => t === FrameType.END).length).toBe(2) + expect(types.filter((t) => t === FRAME_TYPE_JSON).length).toBe(1) + expect(types.filter((t) => t === FRAME_TYPE_CHUNK).length).toBe(2) + expect(types.filter((t) => t === FRAME_TYPE_END).length).toBe(2) }) it('should interleave initial and late streams', async () => { let resolveJson: () => void const jsonGate = new Promise((r) => (resolveJson = r)) - const jsonStream = new ReadableStream({ - async start(controller) { - controller.enqueue('{"first":true}') - await jsonGate - controller.enqueue('{"second":true}') - controller.close() - }, - }) - // Initial raw stream const initialRaw = new ReadableStream({ start(controller) { @@ -521,39 +770,36 @@ describe('frame-protocol', () => { }, }) - const rawStreams = new Map>() - rawStreams.set(1, initialRaw) - - // Late stream arrives after first JSON - const lateStreamSource = new ReadableStream<{ - id: number - stream: ReadableStream - }>({ - start(controller) { - controller.enqueue({ - id: 2, - stream: new ReadableStream({ - start(c) { - c.enqueue(new Uint8Array([2])) - c.close() + const recordStream = new ReadableStream({ + async start(controller) { + controller.enqueue( + createRecord('{"first":true}', [{ id: 1, stream: initialRaw }]), + ) + await jsonGate + controller.enqueue( + createRecord('{"second":true}', [ + { + id: 2, + stream: new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array([2])) + c.close() + }, + }), }, - }), - }) + ]), + ) controller.close() }, }) - const multiplexed = createMultiplexedStream( - jsonStream, - rawStreams, - lateStreamSource, - ) + const multiplexed = createMultiplexedStream(recordStream) const reader = multiplexed.getReader() const chunks: Array = [] - // Read a few frames - for (let i = 0; i < 4; i++) { + // Read the first record and its stream. + for (let i = 0; i < 3; i++) { const { value, done } = await reader.read() if (done) break if (value) chunks.push(value) @@ -577,29 +823,23 @@ describe('frame-protocol', () => { return view.getUint8(0) }) - expect(types.filter((t) => t === FrameType.JSON).length).toBe(2) - expect(types.filter((t) => t === FrameType.CHUNK).length).toBe(2) - expect(types.filter((t) => t === FrameType.END).length).toBe(2) + expect(types.filter((t) => t === FRAME_TYPE_JSON).length).toBe(2) + expect(types.filter((t) => t === FRAME_TYPE_CHUNK).length).toBe(2) + expect(types.filter((t) => t === FRAME_TYPE_END).length).toBe(2) }) it('should handle raw stream error', async () => { - const jsonStream = new ReadableStream({ - start(controller) { - controller.enqueue('{}') - controller.close() - }, - }) - const errorStream = new ReadableStream({ start(controller) { controller.error(new Error('Stream failed')) }, }) - const rawStreams = new Map>() - rawStreams.set(10, errorStream) - - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{}', [{ id: 10, stream: errorStream }]), + ]), + ) const reader = multiplexed.getReader() const chunks: Array = [] @@ -616,13 +856,13 @@ describe('frame-protocol', () => { return view.getUint8(0) }) - expect(types).toContain(FrameType.JSON) - expect(types).toContain(FrameType.ERROR) + expect(types).toContain(FRAME_TYPE_JSON) + expect(types).toContain(FRAME_TYPE_ERROR) // Find ERROR frame and check content const errorFrame = chunks.find((chunk) => { const view = new DataView(chunk.buffer, chunk.byteOffset) - return view.getUint8(0) === FrameType.ERROR + return view.getUint8(0) === FRAME_TYPE_ERROR }) expect(errorFrame).toBeDefined() @@ -631,11 +871,10 @@ describe('frame-protocol', () => { }) it('should propagate JSON stream error to output (fatal)', async () => { - let errorController: ReadableStreamDefaultController - const jsonStream = new ReadableStream({ + let errorController: ReadableStreamDefaultController + const recordStream = new ReadableStream({ start(controller) { errorController = controller - controller.enqueue('{"first":true}') }, }) @@ -648,17 +887,18 @@ describe('frame-protocol', () => { }, }) - const rawStreams = new Map>() - rawStreams.set(1, rawStream) + errorController!.enqueue( + createRecord('{"first":true}', [{ id: 1, stream: rawStream }]), + ) - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const multiplexed = createMultiplexedStream(recordStream) const reader = multiplexed.getReader() // Should be able to read first JSON frame const { value: firstChunk } = await reader.read() expect(firstChunk).toBeDefined() const view = new DataView(firstChunk!.buffer, firstChunk!.byteOffset) - expect(view.getUint8(0)).toBe(FrameType.JSON) + expect(view.getUint8(0)).toBe(FRAME_TYPE_JSON) // Now error the JSON stream errorController!.error(new Error('JSON serialization failed')) @@ -669,13 +909,6 @@ describe('frame-protocol', () => { it('should not hang when raw stream never ends', async () => { // This tests the fix for hanging requests - const jsonStream = new ReadableStream({ - start(controller) { - controller.enqueue('{}') - controller.close() - }, - }) - let rawStreamCancelled = false const neverEndingStream = new ReadableStream({ start(controller) { @@ -687,10 +920,11 @@ describe('frame-protocol', () => { }, }) - const rawStreams = new Map>() - rawStreams.set(1, neverEndingStream) - - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const multiplexed = createMultiplexedStream( + createRecordStream([ + createRecord('{}', [{ id: 1, stream: neverEndingStream }]), + ]), + ) const reader = multiplexed.getReader() // Read first two frames (JSON and CHUNK) diff --git a/packages/start-server-core/tests/server-functions-handler.test.ts b/packages/start-server-core/tests/server-functions-handler.test.ts new file mode 100644 index 00000000000..d540e229b46 --- /dev/null +++ b/packages/start-server-core/tests/server-functions-handler.test.ts @@ -0,0 +1,553 @@ +// @vitest-environment node + +import { beforeEach, expect, test, vi } from 'vitest' +import { createStream, fromCrossJSON } from 'seroval' +import { + FRAME_HEADER_SIZE, + FRAME_TYPE_CHUNK, + FRAME_TYPE_JSON, + MAX_FRAMED_STREAMS, + TSS_CONTENT_TYPE_FRAMED_VERSIONED, + X_TSS_SERIALIZED, +} from '@tanstack/start-client-core' +import { RawStream } from '@tanstack/router-core' +import { defaultSerovalDeserializerPlugins } from '@tanstack/router-core/ssr/server' +import { handleServerAction } from '../src/server-functions-handler' + +const mocks = vi.hoisted(() => ({ + action: vi.fn(), + response: { status: 200, statusText: 'OK' }, +})) + +vi.mock('../src/getServerFnById', () => ({ + getServerFnById: () => mocks.action, +})) + +vi.mock('../src/request-response', () => ({ + getResponse: () => mocks.response, +})) + +vi.mock('@tanstack/start-client-core', async (importOriginal) => { + const actual = + await importOriginal() + const { defaultSerovalDeserializerPlugins } = + await import('@tanstack/router-core/ssr/server') + return { + ...actual, + getSerovalPlugins: () => defaultSerovalDeserializerPlugins, + } +}) + +beforeEach(() => { + mocks.action.mockReset() + mocks.response.status = 200 + mocks.response.statusText = 'OK' +}) + +async function readFrames(response: Response) { + const frames: Array<{ type: number; payload: Uint8Array }> = [] + const reader = response.body!.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + return frames + } + frames.push({ + type: value[0]!, + payload: value.slice(FRAME_HEADER_SIZE), + }) + } +} + +function expectRawReferenceBeforeChunk( + frames: Array<{ type: number; payload: Uint8Array }>, +) { + const chunkIndex = frames.findIndex( + (frame) => frame.type === FRAME_TYPE_CHUNK, + ) + const referenceIndex = frames.findIndex( + (frame) => + frame.type === FRAME_TYPE_JSON && + new TextDecoder().decode(frame.payload).includes('tss/RawStream'), + ) + expect(referenceIndex).toBeGreaterThanOrEqual(0) + expect(chunkIndex).toBeGreaterThan(referenceIndex) +} + +test.each([false, 0, '', null])( + 'preserves the direct server-function result %j', + async (result) => { + mocks.action.mockResolvedValue({ result }) + + await expect( + handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + }), + context: {}, + serverFnId: 'test', + }), + ).resolves.toBe(result) + }, +) + +test('serializes a complete direct JSON record without LF', async () => { + mocks.action.mockResolvedValue({ result: { value: 'complete' } }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + const encoded = await response.text() + const parsed = JSON.parse(encoded) + expect(encoded).toBe(JSON.stringify(parsed)) + expect(encoded).not.toContain('\n') +}) + +test('cancelling a framed response disposes its serializer', async () => { + const cancel = vi.fn() + const source = new ReadableStream({ + cancel, + }) + mocks.action.mockResolvedValue({ result: source }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expect(source.locked).toBe(true) + await response.body!.cancel('client disconnected') + expect(cancel).toHaveBeenCalledOnce() + expect(source.locked).toBe(false) +}) + +test('aborting the request disposes a handed-off framed response', async () => { + const abortController = new AbortController() + const reason = new Error('request aborted') + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + mocks.action.mockResolvedValue({ result: new RawStream(source) }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + signal: abortController.signal, + }), + context: {}, + serverFnId: 'test', + }) + + await vi.waitFor(() => { + expect(source.locked).toBe(true) + }) + abortController.abort(reason) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(reason) + expect(source.locked).toBe(false) + }) + await expect(response.body!.getReader().read()).rejects.toBe(reason) +}) + +test('cancels synchronous RawStreams without pulling when the request is already aborted', async () => { + const abortController = new AbortController() + const reason = new Error('request already aborted') + abortController.abort(reason) + const sources = Array.from({ length: 3 }, () => { + const cancel = vi.fn(() => new Promise(() => {})) + const pull = vi.fn() + const stream = new ReadableStream( + { pull, cancel }, + { highWaterMark: 0 }, + ) + return { stream, pull, cancel } + }) + mocks.action.mockResolvedValue({ + result: sources.map(({ stream }) => new RawStream(stream)), + }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + signal: abortController.signal, + }), + context: {}, + serverFnId: 'test', + }) + + await expect(response.body!.getReader().read()).rejects.toBe(reason) + await new Promise((resolve) => setTimeout(resolve, 0)) + for (const { stream, pull, cancel } of sources) { + expect(pull).not.toHaveBeenCalled() + expect(cancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(stream.locked).toBe(false) + } +}) + +test('bounds and cancels synchronous RawStreams when their response remains unread', async () => { + const sources = Array.from({ length: 3 }, () => { + const cancel = vi.fn() + const pull = vi.fn( + (controller: ReadableStreamDefaultController) => { + controller.enqueue(new Uint8Array(64 * 1024)) + }, + ) + const stream = new ReadableStream( + { pull, cancel }, + { highWaterMark: 0 }, + ) + return { stream, pull, cancel } + }) + mocks.action.mockResolvedValue({ + result: sources.map(({ stream }) => new RawStream(stream)), + }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(sources.map(({ pull }) => pull.mock.calls.length)).toEqual([1, 1, 1]) + const reason = new Error('client disconnected') + await response.body!.cancel(reason) + await new Promise((resolve) => setTimeout(resolve, 0)) + for (const { stream, pull, cancel } of sources) { + expect(pull).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledExactlyOnceWith(reason) + expect(stream.locked).toBe(false) + } +}) + +test('a synchronous serialization failure cancels registered raw streams', async () => { + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + mocks.action.mockResolvedValue({ + result: { + raw: new RawStream(source), + unsupported: () => {}, + }, + }) + const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(expect.any(Error)) + expect(source.locked).toBe(false) + expect(response.headers.get(X_TSS_SERIALIZED)).toBe('true') + expect(response.headers.get('content-type')).toBe('application/json') + expect( + fromCrossJSON(await response.json(), { + plugins: defaultSerovalDeserializerPlugins, + }), + ).toBeInstanceOf(Error) + } finally { + consoleInfo.mockRestore() + consoleError.mockRestore() + } +}) + +test('admits replayed nested RawStream references before their chunks', async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])) + controller.close() + }, + }) + const replayed = createStream() + replayed.next(new RawStream(source)) + replayed.return(undefined) + mocks.action.mockResolvedValue({ result: { replayed } }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expectRawReferenceBeforeChunk(await readFrames(response)) +}) + +test('frames every record from a synchronously replayed Seroval stream', async () => { + const replayed = createStream() + replayed.next(1) + replayed.return(undefined) + mocks.action.mockResolvedValue({ result: { replayed } }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expect(response.headers.get('content-type')).toBe( + TSS_CONTENT_TYPE_FRAMED_VERSIONED, + ) + const frames = await readFrames(response) + expect(frames.every((frame) => frame.type === FRAME_TYPE_JSON)).toBe(true) + expect(frames.length).toBeGreaterThan(1) + + const refs = new Map() + let result: any + for (let index = 0; index < frames.length; index++) { + const encoded = new TextDecoder().decode(frames[index]!.payload) + const parsed = JSON.parse(encoded) + expect(encoded).toBe(JSON.stringify(parsed)) + expect(encoded).not.toContain('\n') + const value = fromCrossJSON(parsed, { + refs, + plugins: defaultSerovalDeserializerPlugins, + }) + if (index === 0) { + result = value + } + } + + const events: Array<[string, unknown]> = [] + result.result.replayed.on({ + next(value: unknown) { + events.push(['next', value]) + }, + throw(error: unknown) { + events.push(['throw', error]) + }, + return(value: unknown) { + events.push(['return', value]) + }, + }) + expect(events).toEqual([ + ['next', 1], + ['return', undefined], + ]) +}) + +test('keeps work discovered after a transient synchronous completion', async () => { + const replayed = createStream() + replayed.return(undefined) + let resolveLate!: (value: RawStream) => void + const late = new Promise((resolve) => { + resolveLate = resolve + }) + mocks.action.mockResolvedValue({ result: { replayed, late } }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + resolveLate( + new RawStream( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])) + controller.close() + }, + }), + ), + ) + + expectRawReferenceBeforeChunk(await readFrames(response)) +}) + +test('bounds synchronously replayed Seroval records before framing', async () => { + const replayed = createStream() + for (let index = 0; index < 1025; index++) { + replayed.next(index) + } + replayed.return(undefined) + mocks.action.mockResolvedValue({ result: { replayed } }) + const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + const error = fromCrossJSON(await response.json(), { + plugins: defaultSerovalDeserializerPlugins, + }) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain('pending output limit') + } finally { + consoleInfo.mockRestore() + consoleError.mockRestore() + } +}) + +test('admits late RawStream references before their chunks', async () => { + let resolveRawStream!: (value: RawStream) => void + const lateRawStream = new Promise((resolve) => { + resolveRawStream = resolve + }) + mocks.action.mockResolvedValue({ result: { lateRawStream } }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + resolveRawStream( + new RawStream( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([2])) + controller.close() + }, + }), + ), + ) + + expectRawReferenceBeforeChunk(await readFrames(response)) +}) + +test('rejects a non-ASCII JSON record larger than the wire limit', async () => { + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + // h3 leaves the status unset until a handler sets one. + mocks.response.status = undefined as unknown as number + mocks.action.mockResolvedValue({ + result: { + // UTF-16 length is within the old limit, but UTF-8 is over 16 MiB. + value: 'é'.repeat(8 * 1024 * 1024), + raw: new RawStream(source), + }, + }) + + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expect(cancel).toHaveBeenCalledOnce() + expect(source.locked).toBe(false) + // Nothing reached the client yet, so the whole call fails. + expect(response.status).toBe(500) + expect(response.headers.get('content-type')).toBe('application/json') + expect(await response.text()).toContain('pending output limit') +}) + +test.each(['RawStream', 'ReadableStream'])( + 'cancels %s serialization when Response construction rejects it', + async (kind) => { + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + mocks.response.status = 204 + mocks.response.statusText = 'No Content' + mocks.action.mockResolvedValue({ + result: kind === 'RawStream' ? new RawStream(source) : source, + }) + const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expect(response.status).toBe(500) + expect(response.statusText).toBe('') + expect(cancel).toHaveBeenCalledOnce() + expect(source.locked).toBe(false) + } finally { + consoleInfo.mockRestore() + consoleError.mockRestore() + } + }, +) + +test('rejects excess RawStreams before starting their readers', async () => { + let pullCount = 0 + let cancelCount = 0 + const sources = Array.from( + { length: MAX_FRAMED_STREAMS + 1 }, + () => + new ReadableStream( + { + pull() { + pullCount++ + }, + cancel() { + cancelCount++ + }, + }, + { highWaterMark: 0 }, + ), + ) + mocks.action.mockResolvedValue({ + result: sources.map((source) => new RawStream(source)), + }) + const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expect(response.headers.get('content-type')).toBe('application/json') + expect(pullCount).toBe(0) + expect(cancelCount).toBe(MAX_FRAMED_STREAMS + 1) + expect(sources.every((source) => !source.locked)).toBe(true) + } finally { + consoleInfo.mockRestore() + consoleError.mockRestore() + } +}) diff --git a/packages/start-static-server-functions/src/staticFunctionMiddleware.ts b/packages/start-static-server-functions/src/staticFunctionMiddleware.ts index e6681fce1d7..ff20ab1ba23 100644 --- a/packages/start-static-server-functions/src/staticFunctionMiddleware.ts +++ b/packages/start-static-server-functions/src/staticFunctionMiddleware.ts @@ -2,7 +2,9 @@ import fs from 'node:fs/promises' import path from 'node:path' import { createMiddleware, + defaultSerovalDeserializerPlugins, getDefaultSerovalPlugins, + getSerovalPlugins, } from '@tanstack/start-client-core' import { fromJSON, toJSONAsync } from 'seroval' @@ -69,9 +71,6 @@ const jsonToFilenameSafeString = (json: any) => { .replace(/\s+/g, '_') // Optionally replace whitespace with underscores } -const staticClientCache = - typeof document !== 'undefined' ? new Map() : null - async function addItemToCache({ functionId, data, @@ -81,27 +80,25 @@ async function addItemToCache({ data: any response: StaticCachedResult }): Promise { - { - const hash = jsonToFilenameSafeString(data) - const url = await getStaticCacheUrl({ functionId, hash }) - const clientUrl = process.env.TSS_CLIENT_OUTPUT_DIR! - const filePath = path.join(clientUrl, url) - - // Ensure the directory exists - await fs.mkdir(path.dirname(filePath), { recursive: true }) - - // Store the result with fs - const stringifiedResult = JSON.stringify( - await toJSONAsync( - { - result: response.result, - context: response.context.sendContext, - }, - { plugins: getDefaultSerovalPlugins() }, - ), - ) - await fs.writeFile(filePath, stringifiedResult, 'utf-8') - } + const hash = jsonToFilenameSafeString(data) + const url = await getStaticCacheUrl({ functionId, hash }) + const clientUrl = process.env.TSS_CLIENT_OUTPUT_DIR! + const filePath = path.join(clientUrl, url) + + // Ensure the directory exists + await fs.mkdir(path.dirname(filePath), { recursive: true }) + + // Store the result with fs + const stringifiedResult = JSON.stringify( + await toJSONAsync( + { + result: response.result, + context: response.context.sendContext, + }, + { plugins: getDefaultSerovalPlugins() }, + ), + ) + await fs.writeFile(filePath, stringifiedResult, 'utf-8') } const fetchItem = async ({ @@ -114,15 +111,16 @@ const fetchItem = async ({ const hash = jsonToFilenameSafeString(data) const url = await getStaticCacheUrl({ functionId, hash }) - let result: any = staticClientCache?.get(url) - - result = await fetch(url, { + return fetch(url, { method: 'GET', }) .then((r) => r.json()) - .then((d) => fromJSON(d, { plugins: getDefaultSerovalPlugins() })) - - return result + .then((d) => + fromJSON(d, { + // Cached responses may carry RawStream nodes. + plugins: getSerovalPlugins(defaultSerovalDeserializerPlugins), + }), + ) } export const staticFunctionMiddleware = createMiddleware({ type: 'function' }) diff --git a/packages/vue-router-ssr-query/package.json b/packages/vue-router-ssr-query/package.json index 9724981eb73..b0f535dc785 100644 --- a/packages/vue-router-ssr-query/package.json +++ b/packages/vue-router-ssr-query/package.json @@ -75,7 +75,7 @@ "peerDependencies": { "@tanstack/query-core": ">=5.102.0", "@tanstack/vue-query": ">=5.102.0", - "@tanstack/vue-router": ">=1.127.0", + "@tanstack/vue-router": ">=1.170.30", "vue": "^3.3.0" } } diff --git a/packages/vue-router-ssr-query/src/index.tsx b/packages/vue-router-ssr-query/src/index.tsx index c9b666acad9..42a97b7df1d 100644 --- a/packages/vue-router-ssr-query/src/index.tsx +++ b/packages/vue-router-ssr-query/src/index.tsx @@ -31,6 +31,13 @@ export function setupRouterSsrQueryIntegration( if (OGWrap) { return Vue.h(OGWrap, null, () => children) } + + // Returning the slot array creates an implicit Vue Fragment around the + // document. Preserve its sole VNode so `` remains the outer root. + if (children?.length === 1) { + return children[0] + } + return children } }, diff --git a/packages/vue-router/src/Scripts.tsx b/packages/vue-router/src/Scripts.tsx index 4c0d8feed06..32e5d295a65 100644 --- a/packages/vue-router/src/Scripts.tsx +++ b/packages/vue-router/src/Scripts.tsx @@ -6,6 +6,10 @@ import { Asset } from './Asset' import { useRouter } from './useRouter' import type { RouterManagedTag } from '@tanstack/router-core' +/** + * During streaming SSR, `` marks where late hydration scripts may + * begin to be inserted. + */ export const Scripts = Vue.defineComponent({ name: 'Scripts', setup() { @@ -61,11 +65,14 @@ function renderScripts( nonce?: string, ) { const allScripts: Array = [] + let streamBoundary: RouterManagedTag | undefined if ((isServer ?? router.isServer) && router.serverSsr) { - const serverBufferedScript = router.serverSsr.takeBufferedScripts() - if (serverBufferedScript) { - allScripts.push(serverBufferedScript) + const initialHydrationScripts = + router.serverSsr.takeInitialHydrationScriptTags() + if (initialHydrationScripts) { + allScripts.push(...initialHydrationScripts.before) + streamBoundary = initialHydrationScripts.boundary } } else if (router.ssr && !mounted) { allScripts.push({ @@ -74,15 +81,14 @@ function renderScripts( children: '', } satisfies RouterManagedTag) - allScripts.push({ + streamBoundary = { tag: 'script', attrs: { nonce, - id: '$tsr-stream-barrier', 'data-allow-mismatch': true, }, children: '', - } satisfies RouterManagedTag) + } satisfies RouterManagedTag for (const asset of assetScripts) { allScripts.push({ @@ -102,6 +108,10 @@ function renderScripts( allScripts.push(...assetScripts) } + if (streamBoundary) { + allScripts.push(streamBoundary) + } + return ( <> {allScripts.map((asset, i) => ( diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 2e14e349f06..988f1630907 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -199,7 +199,6 @@ export type { RegisteredRouter, RouterContextOptions, ControllablePromise, - InjectedHtmlEntry, RouterOptions, RouterState, ListenerFn, diff --git a/packages/vue-router/src/ssr/defaultStreamHandler.tsx b/packages/vue-router/src/ssr/defaultStreamHandler.tsx index 5c663c8f5be..574a1edb663 100644 --- a/packages/vue-router/src/ssr/defaultStreamHandler.tsx +++ b/packages/vue-router/src/ssr/defaultStreamHandler.tsx @@ -3,8 +3,8 @@ import { renderRouterToStream } from './renderRouterToStream' import { RouterServer } from './RouterServer' export const defaultStreamHandler = defineHandlerCallback( - async ({ request, router, responseHeaders }) => - await renderRouterToStream({ + ({ request, router, responseHeaders }) => + renderRouterToStream({ request, router, responseHeaders, diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index d352c524972..c48354546e9 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -1,66 +1,25 @@ -import { ReadableStream as NodeReadableStream } from 'node:stream/web' import * as Vue from 'vue' -import { pipeToWebWritable, renderToString } from 'vue/server-renderer' +import { renderToString, renderToWebStream } from 'vue/server-renderer' import { isbot } from 'isbot' import { createSsrStreamResponse, + getSsrStatus, + transformHtmlStringWithRouter, transformReadableStreamWithRouter, + waitForRequest, } from '@tanstack/router-core/ssr/server' import type { AnyRouter } from '@tanstack/router-core' import type { Component } from 'vue' -const isAbortError = (request: Request, error: unknown) => - (request.signal.aborted && error === request.signal.reason) || - (error instanceof Error && error.name === 'AbortError') || - (error as any)?.code === 'ABORT_ERR' +const DOCTYPE = new TextEncoder().encode('') -function prependDoctype( - readable: globalThis.ReadableStream, -): NodeReadableStream { - const encoder = new TextEncoder() - let sentDoctype = false - let reader: ReadableStreamDefaultReader | undefined - const releaseReader = () => { - try { - reader?.releaseLock() - } catch { - // ignore - } - reader = undefined +/** The router transport needs `` as the outer root, not a fragment. */ +export function warnUnlessHtmlRoot(html: string) { + if (process.env.NODE_ENV !== 'production' && !html.startsWith(' as its outer element. A fragment or array root breaks streamed hydration scripts.', + ) } - - return new NodeReadableStream({ - start() { - reader = readable.getReader() - }, - async pull(controller) { - if (!sentDoctype) { - sentDoctype = true - controller.enqueue(encoder.encode('')) - return - } - try { - const { done, value } = await reader!.read() - if (done) { - controller.close() - releaseReader() - return - } - controller.enqueue(value) - } catch (err) { - controller.error(err) - releaseReader() - } - }, - async cancel(reason) { - try { - await reader?.cancel(reason) - } catch { - // ignore - } - releaseReader() - }, - }) } export const renderRouterToStream = async ({ @@ -74,157 +33,47 @@ export const renderRouterToStream = async ({ responseHeaders: Headers App: Component }) => { - const app = Vue.createSSRApp(App, { router }) - - if (isbot(request.headers.get('User-Agent'))) { - try { - let cleanupAbortListener: (() => void) | undefined - const abortPromise = new Promise((_, reject) => { - if (request.signal.aborted) { - reject(request.signal.reason) - return - } - const onRequestAbort = () => reject(request.signal.reason) - request.signal.addEventListener('abort', onRequestAbort, { once: true }) - cleanupAbortListener = () => { - request.signal.removeEventListener('abort', onRequestAbort) - } - }) - - let fullHtml = await Promise.race([ - renderToString(app), - abortPromise, - ]).finally(() => cleanupAbortListener?.()) - - router.serverSsr!.setRenderFinished() - const injectedHtml = router.serverSsr!.takeBufferedHtml() - if (injectedHtml) { - fullHtml = fullHtml.replace(``, () => `${injectedHtml}`) - } - - const htmlOpenIndex = fullHtml.indexOf('') + const signal = request.signal + if (signal.aborted) { + router.serverSsr?.cleanup() + throw signal.reason + } - if (htmlOpenIndex !== -1 && htmlCloseIndex !== -1) { - fullHtml = fullHtml.slice(htmlOpenIndex, htmlCloseIndex + 7) - } else if (htmlOpenIndex !== -1) { - fullHtml = fullHtml.slice(htmlOpenIndex) - } + try { + const app = Vue.createSSRApp(App, { router }) - return new Response(`${fullHtml}`, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, + if (isbot(request.headers.get('User-Agent'))) { + let fullHtml = await waitForRequest(renderToString(app), signal) + warnUnlessHtmlRoot(fullHtml) + fullHtml = await transformHtmlStringWithRouter(router, fullHtml, { + signal, + }) + return new Response(fullHtml, { + status: getSsrStatus(router), headers: responseHeaders, }) - } finally { - router.serverSsr?.cleanup() - } - } - - const { writable, readable } = new TransformStream() - const innerWriter = writable.getWriter() - let writerDone = false - const releaseWriter = () => { - try { - innerWriter.releaseLock() - } catch { - // already released / errored - } - } - const abortVuePipe = (reason?: unknown) => { - if (writerDone) { - return - } - - writerDone = true - void innerWriter - .abort(reason) - .catch(() => {}) - .finally(releaseWriter) - } - const handleWriterError = (err: unknown) => { - if (isAbortError(request, err)) { - return } - throw err - } - const handleWriteError = (err: unknown) => { - if (writerDone || isAbortError(request, err)) { - return - } - - throw err - } - - const vueWritable = new WritableStream({ - write(chunk) { - if (writerDone) { - return - } - - return innerWriter.write(chunk).catch(handleWriteError) - }, - close() { - if (writerDone) { - return - } - - writerDone = true - return innerWriter.close().catch(handleWriterError).finally(releaseWriter) - }, - abort(reason) { - if (writerDone) { - return - } - - writerDone = true - return innerWriter - .abort(reason) - .catch(handleWriterError) - .finally(releaseWriter) - }, - }) - - // `pipeToWebWritable` returns void (see @vue/server-renderer). Pass a - // proxy writable so request aborts can abort the real TransformStream - // writer even while Vue holds a lock on the proxy writable. - try { - pipeToWebWritable(app, {}, vueWritable) - } catch (err) { - console.error('Error in Vue pipeToWebWritable:', err) - // Setup failed before any pipe was wired; abort writable so the - // readable side errors instead of hanging until the lifetime timeout. - abortVuePipe(err) - } - - if (request.signal.aborted) { - abortVuePipe(request.signal.reason) - } else { - const onRequestAbort = () => abortVuePipe(request.signal.reason) - request.signal.addEventListener('abort', onRequestAbort, { once: true }) - router.serverSsr?.onCleanup(() => { - request.signal.removeEventListener('abort', onRequestAbort) + const readable = renderToWebStream(app).pipeThrough( + new TransformStream({ + start(controller) { + controller.enqueue(DOCTYPE) + }, + }), + ) + const responseStream = transformReadableStreamWithRouter(router, readable, { + signal, }) - } - - const doctypedStream = prependDoctype(readable) - const responseStream = transformReadableStreamWithRouter( - router, - doctypedStream, - { signal: request.signal, onAbort: abortVuePipe }, - ) - return createSsrStreamResponse( - router, - new Response(responseStream as any, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, - headers: responseHeaders, - }), - ) + return createSsrStreamResponse( + router, + new Response(responseStream, { + status: getSsrStatus(router), + headers: responseHeaders, + }), + ) + } catch (error) { + router.serverSsr?.cleanup() + throw error + } } diff --git a/packages/vue-router/src/ssr/renderRouterToString.tsx b/packages/vue-router/src/ssr/renderRouterToString.tsx index 723aa0b91e9..fdb076bb31d 100644 --- a/packages/vue-router/src/ssr/renderRouterToString.tsx +++ b/packages/vue-router/src/ssr/renderRouterToString.tsx @@ -1,5 +1,10 @@ import * as Vue from 'vue' import { renderToString as vueRenderToString } from 'vue/server-renderer' +import { + getSsrStatus, + transformHtmlStringWithRouter, +} from '@tanstack/router-core/ssr/server' +import { warnUnlessHtmlRoot } from './renderRouterToStream' import type { AnyRouter } from '@tanstack/router-core' import type { Component } from 'vue' @@ -14,20 +19,13 @@ export const renderRouterToString = async ({ }) => { try { const app = Vue.createSSRApp(App, { router }) + const rendered = await vueRenderToString(app) + warnUnlessHtmlRoot(rendered) - let html = await vueRenderToString(app) - router.serverSsr!.setRenderFinished() + const html = await transformHtmlStringWithRouter(router, rendered) - const injectedHtml = router.serverSsr!.takeBufferedHtml() - if (injectedHtml) { - html = html.replace(``, () => `${injectedHtml}`) - } - - return new Response(`${html}`, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, + return new Response(html, { + status: getSsrStatus(router), headers: responseHeaders, }) } catch (error) { diff --git a/packages/vue-router/tests/renderRouterToStream.test.tsx b/packages/vue-router/tests/renderRouterToStream.test.tsx index 60bfa9a8af6..656e8a54b5e 100644 --- a/packages/vue-router/tests/renderRouterToStream.test.tsx +++ b/packages/vue-router/tests/renderRouterToStream.test.tsx @@ -4,12 +4,30 @@ import { normalizeSsrResponse, } from '@tanstack/router-core/ssr/server' import { createMemoryHistory, createRootRoute, createRouter } from '../src' +import type { RouterManagedTag } from '@tanstack/router-core' +import type * as RouterSsrServer from '@tanstack/router-core/ssr/server' import type * as VueServerRenderer from 'vue/server-renderer' const rendererMocks = vi.hoisted(() => ({ - pipeToWebWritable: vi.fn(), + renderToWebStream: vi.fn(), renderToString: vi.fn(), })) +const routerTransformMocks = vi.hoisted(() => ({ + readableOptions: vi.fn(), +})) + +vi.mock('@tanstack/router-core/ssr/server', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + transformReadableStreamWithRouter: ( + ...args: Parameters + ) => { + routerTransformMocks.readableOptions(args[2]) + return actual.transformReadableStreamWithRouter(...args) + }, + } +}) vi.mock('vue/server-renderer', async () => { const actual = await vi.importActual( @@ -17,11 +35,15 @@ vi.mock('vue/server-renderer', async () => { ) return { ...actual, - pipeToWebWritable: rendererMocks.pipeToWebWritable, + renderToWebStream: rendererMocks.renderToWebStream, renderToString: rendererMocks.renderToString, } }) +const actualVueServerRenderer = await vi.importActual( + 'vue/server-renderer', +) + // Imported after mock so the wrapper picks up the mocked binding. const { renderRouterToStream } = await import('../src/ssr/renderRouterToStream') @@ -32,8 +54,9 @@ function unwrapResponse( } afterEach(() => { - rendererMocks.pipeToWebWritable.mockReset() + rendererMocks.renderToWebStream.mockReset() rendererMocks.renderToString.mockReset() + routerTransformMocks.readableOptions.mockReset() vi.restoreAllMocks() }) @@ -61,14 +84,40 @@ function drainBody(response: Response) { })().catch(() => true) } +function createManualVueStream() { + let controller!: ReadableStreamDefaultController + const cancel = vi.fn() + const stream = new ReadableStream({ + start(nextController) { + controller = nextController + }, + cancel, + }) + return { stream, controller, cancel } +} + +function renderScript(tag: RouterManagedTag) { + const attrs = tag.attrs ?? {} + const id = attrs.id ? ` id="${attrs.id}"` : '' + const nonce = attrs.nonce ? ` nonce="${attrs.nonce}"` : '' + const streamPart = + 'data-tsr-stream-part' in attrs ? ' data-tsr-stream-part=""' : '' + return `${tag.children ?? ''}` +} + describe('renderRouterToStream - sync setup failures', () => { - test('bot string response injects final scripts and cleans up', async () => { - rendererMocks.renderToString.mockResolvedValueOnce( - '
bot
', - ) + test('bot string response preserves hydration scripts and cleans up', async () => { const router = await buildRouter() await router.serverSsr!.dehydrate() const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags()! + expect(initialScripts.boundary.attrs).not.toHaveProperty('id') + const renderedScripts = [...initialScripts.before, initialScripts.boundary] + .map(renderScript) + .join('') + rendererMocks.renderToString.mockResolvedValueOnce( + `
bot
${renderedScripts}`, + ) const response = unwrapResponse( await renderRouterToStream({ @@ -83,83 +132,279 @@ describe('renderRouterToStream - sync setup failures', () => { const html = await response.text() expect(html).toContain('$_TSR.e()') + expect(html).toContain(initialScripts.boundary.children) expect(html.indexOf('$_TSR.e()')).toBeLessThan(html.indexOf('')) expect(cleanup).toHaveBeenCalledOnce() expect(router.serverSsr).toBeUndefined() }) - test('pipeToWebWritable sync throw terminates response stream', async () => { - rendererMocks.pipeToWebWritable.mockImplementationOnce(() => { - throw new Error('forced-sync-setup-throw') + test('request abort stops a bot wait and removes its temporary listener', async () => { + rendererMocks.renderToString.mockImplementationOnce( + () => new Promise(() => {}), + ) + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const abortController = new AbortController() + const request = new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + signal: abortController.signal, + }) + const addEventListener = vi.spyOn(request.signal, 'addEventListener') + const reason = new Error('bot-request-gone') + + const responsePromise = renderRouterToStream({ + request, + router, + responseHeaders: new Headers(), + App: { template: '
' } as any, }) + await Promise.resolve() + abortController.abort(reason) + + await expect(responsePromise).rejects.toBe(reason) + // The shared abort waiter registers once; `{ once: true }` retires it. + expect( + addEventListener.mock.calls.filter(([type]) => type === 'abort'), + ).toHaveLength(1) + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) + + test('an already-aborted bot request does not start string rendering', async () => { + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const abortController = new AbortController() + const reason = new Error('bot-already-gone') + abortController.abort(reason) + + await expect( + renderRouterToStream({ + request: new Request('http://localhost/', { + headers: { 'User-Agent': 'Googlebot' }, + signal: abortController.signal, + }), + router, + responseHeaders: new Headers(), + App: { template: '
' } as any, + }), + ).rejects.toBe(reason) + + expect(rendererMocks.renderToString).not.toHaveBeenCalled() + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) + + test('prepends one doctype without wrapping the renderer stream', async () => { + const router = await buildRouter() + await router.serverSsr!.dehydrate() + const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags()! + const renderedScripts = [...initialScripts.before, initialScripts.boundary] + .map(renderScript) + .join('') + const appHtml = `
app
${renderedScripts}` + rendererMocks.renderToWebStream.mockReturnValueOnce( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(appHtml)) + controller.close() + }, + }), + ) + + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + App: { template: '
' } as any, + }), + ) + const html = await response.text() + + expect(html).toBe(`${appHtml}`) + expect(html.match(//g)).toHaveLength(1) + expect(router.serverSsr).toBeUndefined() + }) + + test('already-aborted requests do not start the Vue producer', async () => { + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const abortController = new AbortController() + const reason = new Error('already-gone') + abortController.abort(reason) + + await expect( + renderRouterToStream({ + request: new Request('http://localhost/', { + signal: abortController.signal, + }), + router, + responseHeaders: new Headers(), + App: { template: '
' } as any, + }), + ).rejects.toBe(reason) + + expect(rendererMocks.renderToWebStream).not.toHaveBeenCalled() + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) + + test('an abort during producer setup rejects before a response', async () => { + const abortController = new AbortController() + const reason = new Error('reentrant-abort') + rendererMocks.renderToWebStream.mockImplementationOnce( + actualVueServerRenderer.renderToWebStream, + ) + const router = await buildRouter() + + await expect( + renderRouterToStream({ + request: new Request('http://localhost/', { + signal: abortController.signal, + }), + router, + responseHeaders: new Headers(), + App: { + setup() { + abortController.abort(reason) + return () => null + }, + }, + }), + ).rejects.toBe(reason) + + expect(router.serverSsr).toBeUndefined() + }) + + test('real synchronous renderer failure rejects before creating a response', async () => { + const setupError = new Error('real-vue-setup-failure') + rendererMocks.renderToWebStream.mockImplementationOnce( + actualVueServerRenderer.renderToWebStream, + ) const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const originalServerSsr = router.serverSsr! const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { - const response = unwrapResponse( - await renderRouterToStream({ + await expect( + renderRouterToStream({ request: new Request('http://localhost/'), router, responseHeaders: new Headers(), - App: { template: '
' } as any, + App: { + setup() { + throw setupError + }, + }, }), - ) + ).rejects.toBe(setupError) - const terminated = await Promise.race([ - drainBody(response), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - - expect(terminated).toBe(true) + expect(routerTransformMocks.readableOptions).not.toHaveBeenCalled() + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() } finally { errorSpy.mockRestore() - router.serverSsr?.cleanup() + warnSpy.mockRestore() + originalServerSsr.cleanup() } }) test('request abort drops later Vue writes and terminates the response', async () => { - let vueWriter: WritableStreamDefaultWriter | undefined - rendererMocks.pipeToWebWritable.mockImplementationOnce( - ( - _app: unknown, - _context: unknown, - writable: WritableStream, - ) => { - vueWriter = writable.getWriter() - }, - ) + const vue = createManualVueStream() + rendererMocks.renderToWebStream.mockReturnValueOnce(vue.stream) const router = await buildRouter() const abortController = new AbortController() + const request = new Request('http://localhost/', { + signal: abortController.signal, + }) + const addEventListener = vi.spyOn(request.signal, 'addEventListener') const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const response = unwrapResponse( await renderRouterToStream({ - request: new Request('http://localhost/', { - signal: abortController.signal, - }), + request, router, responseHeaders: new Headers(), App: { template: '
' } as any, }), ) - expect(vueWriter).toBeDefined() - abortController.abort(new Error('client-gone')) - - await expect( - vueWriter!.write(new TextEncoder().encode('
')), - ).resolves.toBeUndefined() + expect( + addEventListener.mock.calls.filter(([type]) => type === 'abort'), + ).toHaveLength(1) + const reason = new Error('client-gone') + abortController.abort(reason) expect(response.body).not.toBeNull() - const terminated = await Promise.race([ - drainBody(response), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) + expect(await drainBody(response)).toBe(true) + expect(vue.cancel).toHaveBeenCalledWith(reason) + expect(() => + vue.controller.enqueue(new TextEncoder().encode('
')), + ).toThrow() + } finally { + errorSpy.mockRestore() + router.serverSsr?.cleanup() + } + }) + + test('downstream cancellation stops Vue writes and cleans up once', async () => { + const vue = createManualVueStream() + rendererMocks.renderToWebStream.mockReturnValueOnce(vue.stream) + + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + App: { template: '
' } as any, + }), + ) + + await response.body!.cancel('consumer-gone') + expect(vue.cancel).toHaveBeenCalledWith('consumer-gone') + expect(() => + vue.controller.enqueue(new TextEncoder().encode('
')), + ).toThrow() + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + }) + + test('a renderer buffer failure errors the response body', async () => { + const renderError = new Error('vue-buffer-failed') + rendererMocks.renderToWebStream.mockImplementationOnce( + actualVueServerRenderer.renderToWebStream, + ) + const router = await buildRouter() + const cleanup = vi.spyOn(router.serverSsr!, 'cleanup') + router.serverSsr!.disableHydration() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/'), + router, + responseHeaders: new Headers(), + App: { + ssrRender(_context: unknown, push: (value: unknown) => void) { + push('partial') + push(Promise.reject(renderError)) + }, + } as any, + }), + ) - expect(terminated).toBe(true) + await expect(response.text()).rejects.toBe(renderError) + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() } finally { errorSpy.mockRestore() + warnSpy.mockRestore() router.serverSsr?.cleanup() } }) diff --git a/packages/vue-router/tests/ssr-test-utils.ts b/packages/vue-router/tests/ssr-test-utils.ts index 7d8c5c80f3b..91770af4dce 100644 --- a/packages/vue-router/tests/ssr-test-utils.ts +++ b/packages/vue-router/tests/ssr-test-utils.ts @@ -11,8 +11,8 @@ export async function dehydrateToBootstrap( await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - if (typeof script?.children !== 'string') { + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + if (!scripts?.before.length) { throw new Error( 'Expected server dehydration to produce a bootstrap script', ) @@ -30,7 +30,9 @@ export async function dehydrateToBootstrap( }, } context.self = context - runInNewContext(script.children, context) + for (const script of scripts.before) { + runInNewContext(script.children!, context) + } if (!context.$_TSR) { throw new Error('Expected bootstrap script to initialize $_TSR') diff --git a/packages/vue-start-server/src/defaultStreamHandler.tsx b/packages/vue-start-server/src/defaultStreamHandler.tsx index 2eb8c09f822..c02aeb8c6c6 100644 --- a/packages/vue-start-server/src/defaultStreamHandler.tsx +++ b/packages/vue-start-server/src/defaultStreamHandler.tsx @@ -5,8 +5,8 @@ import { import { StartServer } from './StartServer' export const defaultStreamHandler = defineHandlerCallback( - async ({ request, router, responseHeaders }) => - await renderRouterToStream({ + ({ request, router, responseHeaders }) => + renderRouterToStream({ request, router, responseHeaders, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4058009971d..22dc8b0271e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3852,6 +3852,9 @@ importers: '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0(supports-color@10.2.2))(rolldown@1.0.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) srvx: specifier: ^0.11.9 version: 0.11.12 @@ -5679,6 +5682,49 @@ importers: specifier: ^2.11.11 version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + e2e/solid-start/streaming-ssr: + dependencies: + '@tanstack/solid-query': + specifier: ^5.102.0 + version: 5.102.0(solid-js@1.9.12) + '@tanstack/solid-router': + specifier: workspace:^ + version: link:../../../packages/solid-router + '@tanstack/solid-router-ssr-query': + specifier: workspace:* + version: link:../../../packages/solid-router-ssr-query + '@tanstack/solid-start': + specifier: workspace:* + version: link:../../../packages/solid-start + solid-js: + specifier: 1.9.12 + version: 1.9.12 + devDependencies: + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.11 + version: 2.11.11(@testing-library/jest-dom@6.6.3)(solid-js@1.9.12)(supports-color@10.2.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)) + e2e/solid-start/virtual-routes: dependencies: '@tanstack/solid-router': @@ -7546,6 +7592,49 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/vue-start/streaming-ssr: + dependencies: + '@tanstack/vue-query': + specifier: ^5.102.0 + version: 5.102.0(vue@3.5.25(@typescript/typescript6@6.0.2)) + '@tanstack/vue-router': + specifier: workspace:* + version: link:../../../packages/vue-router + '@tanstack/vue-router-ssr-query': + specifier: workspace:* + version: link:../../../packages/vue-router-ssr-query + '@tanstack/vue-start': + specifier: workspace:* + version: link:../../../packages/vue-start + vue: + specifier: ^3.5.16 + version: 3.5.25(@typescript/typescript6@6.0.2) + devDependencies: + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 25.0.9 + version: 25.0.9 + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + '@vitejs/plugin-vue-jsx': + specifier: ^5.1.5 + version: 5.1.5(supports-color@10.2.2)(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))(vue@3.5.25(@typescript/typescript6@6.0.2)) + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vite: + specifier: ^8.0.14 + version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/vue-start/virtual-routes: dependencies: '@tanstack/virtual-file-routes': diff --git a/tsconfig.json b/tsconfig.json index f3765b8ed3c..7228771323f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2022"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true,