diff --git a/.changeset/steady-streams-flow.md b/.changeset/steady-streams-flow.md new file mode 100644 index 00000000000..c2ad94a15e4 --- /dev/null +++ b/.changeset/steady-streams-flow.md @@ -0,0 +1,22 @@ +--- +'@tanstack/router-core': patch +'@tanstack/router-ssr-query-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/solid-start-server': patch +'@tanstack/start-client-core': patch +'@tanstack/start-server-core': patch +'@tanstack/vue-router': patch +'@tanstack/vue-router-ssr-query': patch +'@tanstack/vue-start-server': patch +--- + +Stream large deferred SSR hydration payloads through a backpressure-aware router transport, fail known setup errors before response creation, and close cancelled or expired transforms safely. + +Start now cancels discarded middleware and HEAD response bodies, including plain streams and derived branches. + +Server-function raw streams now use bounded transport backpressure instead of buffering unread raw data on the client. + +Solid SSR now emits one document type, preserves blocking Await values, and renders late lazy errors through route boundaries. + +SSR Query integrations now keep request cleanup and stream ownership aligned with the router lifecycle. 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/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..ae94404482c 100644 --- a/e2e/react-start/streaming-ssr/package.json +++ b/e2e/react-start/streaming-ssr/package.json @@ -26,9 +26,37 @@ "@types/node": "^22.10.2", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", "srvx": "^0.11.9", "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2", "vite": "^8.0.14" + }, + "nx": { + "targets": { + "build": { + "inputs": [ + "buildProduction", + "^buildProduction", + "dependentTaskOutputs", + "streamingSsrTests" + ] + }, + "test:e2e": { + "inputs": [ + "default", + "^production", + "dependentTaskOutputs", + "streamingSsrTests" + ] + }, + "test:e2e:preview": { + "inputs": [ + "default", + "^production", + "streamingSsrTests" + ] + } + } } } 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..e0dffd024d1 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('.next(') && 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..3c256f42d31 --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/router-html-buffer.spec.ts @@ -0,0 +1,33 @@ +import { + 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', + ) + + 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('[data-tsr-stream-part]')).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..36009b6c868 --- /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 useSuspenseQuery calls)

+

Tests multiple useSuspenseQuery 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..3d484984613 --- /dev/null +++ b/e2e/streaming-ssr-assertions.ts @@ -0,0 +1,176 @@ +import { expect, test as base } from '@playwright/test' +import type { Page } from '@playwright/test' + +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 reader = response.body!.getReader() + const decoder = new TextDecoder() + let html = '' + + for (;;) { + const { done, value } = await reader.read() + + if (done) { + break + } + + html += decoder.decode(value, { stream: true }) + } + + html += decoder.decode() + + 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('/*$tsr-stream-boundary*/') + const routerEndIndex = html.indexOf('$_TSR.e()') + + expect(boundaryIndex).toBeGreaterThan(-1) + expect(boundaryIndex).toBeLessThan(routerEndIndex) + expect(renderedDeferredIndex).toBeLessThan(routerEndIndex) + + expectCompleteRouterStreamBeforeDocumentCloses(html, renderedDeferredMarker, { + expectReturn: true, + }) +} + +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"', { + expectReturn: true, + }) +} + +function expectCompleteRouterStreamBeforeDocumentCloses( + html: string, + payloadMarker: string, + options: { expectReturn?: boolean } = {}, +) { + const endIndex = html.indexOf('$_TSR.e()') + 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) + if (options.expectReturn) { + expect(html.slice(scriptOpenIndex, endIndex)).toContain('.return(void 0)') + } + expect(html.slice(endIndex, scriptCloseIndex)).toContain( + 'document.currentScript.remove()', + ) + 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('[data-tsr-stream-part]')).toHaveCount(0) + await expect( + page.locator('script').filter({ hasText: '$tsr-stream-boundary' }), + ).toHaveCount(0) +} + +type StreamingSsrOptions = { + whitelistErrors: Array +} + +export const test = base.extend({ + whitelistErrors: [[], { option: true }], + page: async ({ page, whitelistErrors }, use) => { + const errorMessages: Array = [] + + page.on('console', (message) => { + if (message.type() !== 'error') { + return + } + const text = message.text() + const whitelisted = whitelistErrors.some((value) => + typeof value === 'string' ? text.includes(value) : value.test(text), + ) + if (!whitelisted) { + errorMessages.push(text) + } + }) + + await use(page) + expect(errorMessages).toEqual([]) + }, +}) + +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..1043baeb50f --- /dev/null +++ b/e2e/streaming-ssr-specs/fast-serial.ts @@ -0,0 +1,72 @@ +import { expect, 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() + + expect(responseHtml).toContain('$_TSR') + expect(responseHtml).toContain('$_TSR.router') + expect(responseHtml).toContain('$_TSR.e()') + expect(responseHtml).toContain('$tsr-stream-boundary') + }) + + 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..eeb2e8032a6 --- /dev/null +++ b/e2e/streaming-ssr-specs/preview-streaming.ts @@ -0,0 +1,10 @@ +import { + expectDeferredRouteResponseOrder, + test, +} from '../streaming-ssr-assertions' + +test.skip(process.env.MODE !== 'preview', 'Only runs against vite 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..1ec4d1c8fdf --- /dev/null +++ b/e2e/streaming-ssr-specs/sync-only.ts @@ -0,0 +1,83 @@ +import { expect, 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() + + // 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 boundary marker rendered by . + // The stream merger scans for this exact marker. + expect(responseHtml).toContain('$tsr-stream-boundary') +}) + +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..08aea3bcea4 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/deferred-rejection.tsx @@ -0,0 +1,45 @@ +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.message}
+ ), + 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..fb9ee3980aa --- /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 useSuspenseQuery calls)

+

Tests multiple useSuspenseQuery 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..a8b04edc8f8 --- /dev/null +++ b/e2e/vue-start/streaming-ssr/src/routes/stream.tsx @@ -0,0 +1,161 @@ +import { Await, createFileRoute } 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 streamData = ref>([]) + const streamComplete = ref(false) + 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 + + 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 + } + } + + 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..a51614f09af --- /dev/null +++ b/e2e/vue-start/streaming-ssr/tests/stream.spec.ts @@ -0,0 +1 @@ +import '../../../streaming-ssr-specs/stream' 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 764112a2f63..dca7106df46 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/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..3c702373ae4 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, transformReadableStreamWithRouter, + waitForReason, } 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,37 +21,61 @@ export const renderRouterToStream = async ({ responseHeaders: Headers children: ReactNode }) => { + const signal = request.signal + if (signal.aborted) { + router.serverSsr?.cleanup() + throw signal.reason + } + let rendererTeardown = false + const status = + router._serverResult?.type === 'render' ? router._serverResult.status : 200 + 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) - } - }, - }) + let stream: Awaited< + ReturnType + > + try { + stream = await ReactDOMServer.renderToReadableStream(children, { + signal, + nonce: router.options.ssr?.nonce, + progressiveChunkSize: Number.POSITIVE_INFINITY, + onError: (error, info) => { + if (!rendererTeardown && !signal.aborted) { + console.error('Error in renderToReadableStream:', error, info) + } + }, + }) + } catch (error) { + router.serverSsr?.cleanup() + throw error + } if (isbot(request.headers.get('User-Agent'))) { - await waitForReadyOrAbort(stream.allReady, request.signal) + try { + await waitForReason(stream.allReady, signal) + } catch (error) { + rendererTeardown = true + router.serverSsr?.cleanup() + await stream.cancel(error).catch(() => {}) + throw error + } } const responseStream = transformReadableStreamWithRouter( router, stream as unknown as ReadableStream, { - signal: request.signal, - onAbort: () => stream.cancel().catch(() => {}), + rendererSafePoint: 'script-close', + signal, + onAbort: () => { + rendererTeardown = true + }, }, ) return createSsrStreamResponse( router, new Response(responseStream as any, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, + status, headers: responseHeaders, }), ) @@ -94,127 +87,70 @@ export const renderRouterToStream = async ({ 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. + let resolveReady!: () => void + let rejectReady!: (reason?: unknown) => void + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + const rendererAbort = new AbortController() + const readySignal = AbortSignal.any([signal, rendererAbort.signal]) + const abortPipeable = (reason?: unknown) => { + if (rendererTeardown) { + return } - } - const abortPipeable = ( - reason?: unknown, - opts?: { defaultError?: boolean }, - ) => { - if (aborted) return - aborted = true - pendingAbortReason = reason - const err = toError(reason) + rendererTeardown = true + rendererAbort.abort(reason) try { - pipeable?.abort(err) + pipeable?.abort(reason) } catch { // ignore — React may throw if already aborted/finished } - 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, { nonce: router.options.ssr?.nonce, progressiveChunkSize: Number.POSITIVE_INFINITY, ...(isbot(request.headers.get('User-Agent')) - ? { - onAllReady() { - pipeable!.pipe(reactAppPassthrough) - }, - } - : { - onShellReady() { - pipeable!.pipe(reactAppPassthrough) - }, - }), + ? { onAllReady: resolveReady } + : { onShellReady: resolveReady }), onError: (error, info) => { - if (!isAbortError(request, error)) { + if (!rendererTeardown && !signal.aborted) { console.error('Error in renderToPipeableStream:', error, info) } - abortPipeable(error, { defaultError: true }) }, + onShellError: rejectReady, }) - } catch (e) { - console.error('Error in renderToPipeableStream:', e) - router.serverSsr?.cleanup() - throw e - } - - const responseStream = transformPipeableStreamWithRouter( - router, - reactAppPassthrough, - { signal: request.signal, onAbort: abortPipeable }, - ) - responseAttached = true + const responseStream = transformReadableStreamWithRouter( + router, + Readable.toWeb(reactAppPassthrough), + { + rendererSafePoint: 'script-close', + signal, + onAbort: abortPipeable, + }, + ) - if (endedBeforeAttach) { - reactAppPassthrough.destroy(pendingDestroyError()) - } + await waitForReason(ready, readySignal) + 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 as any, { + status, + headers: responseHeaders, + }), + ) + } catch (error) { + abortPipeable(error) + router.serverSsr?.cleanup() + throw error } - - return createSsrStreamResponse( - router, - new Response(responseStream as any, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, - headers: responseHeaders, - }), - ) } - throw new Error( + const error = new Error( 'No renderToReadableStream or renderToPipeableStream found in react-dom/server. Ensure you are using a version of react-dom that supports streaming.', ) + router.serverSsr?.cleanup() + throw error } diff --git a/packages/react-router/src/ssr/renderRouterToString.tsx b/packages/react-router/src/ssr/renderRouterToString.tsx index 5e299cc2159..f16210c7833 100644 --- a/packages/react-router/src/ssr/renderRouterToString.tsx +++ b/packages/react-router/src/ssr/renderRouterToString.tsx @@ -1,4 +1,5 @@ import ReactDOMServer from 'react-dom/server' +import { transformHtmlStringWithRouter } from '@tanstack/router-core/ssr/server' import type { ReactNode } from 'react' import type { AnyRouter } from '@tanstack/router-core' @@ -12,15 +13,12 @@ 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}`, { + return new Response(html, { status: router._serverResult?.type === 'render' ? router._serverResult.status diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index b5ee4b6709c..96fe95b2ab6 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -6,6 +6,7 @@ import { Link, Outlet, RouterProvider, + Scripts, createBrowserHistory, createControlledPromise, createLazyRoute, @@ -464,6 +465,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 }, @@ -653,7 +664,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', @@ -718,7 +741,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..e66289d3e09 --- /dev/null +++ b/packages/react-router/tests/renderRouterToStream.react19.test.tsx @@ -0,0 +1,187 @@ +import { Suspense } from 'react' +import { afterEach, describe, expect, test } from 'vitest' +import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' +import { HYDRATION_SCRIPT_BOUNDARY_SOURCE } from '../../router-core/src/ssr/hydrationScripts' +import { + RouterContextProvider, + Scripts, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' +import { renderRouterToStream } from '../src/ssr/renderRouterToStream' +import type { AnyRouter } from '@tanstack/router-core' + +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('renderRouterToStream - real React 19 renderer', () => { + test('emits router data after a real Suspense patch while React is still rendering', async () => { + 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 { + await readUntil(reader, decoder, state, (html) => + html.includes(HYDRATION_SCRIPT_BOUNDARY_SOURCE), + ) + expect(state.html).toContain('first-fallback') + expect(state.html).toContain('second-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() + } 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..95ded469f38 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 }) @@ -39,7 +72,9 @@ async function expectBodyRejects(response: Response, message: string) { (async () => { for (;;) { const { done } = await reader.read() - if (done) return + if (done) { + return + } } })(), ).rejects.toThrow(message) @@ -51,76 +86,327 @@ 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: '' } - expect(response.body).not.toBeNull() - controller.abort(new Error('request-gone')) - await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()) + 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') + + // The router value arrived at React's completed patch even + // though React had not closed its renderer stream yet. + expect(rendererClosed).toBe(false) + + 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 errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const requestController = new AbortController() try { const response = unwrapResponse( await renderRouterToStream({ - request: new Request('http://localhost/'), + request: new Request('http://localhost/', { + signal: requestController.signal, + }), router, responseHeaders: new Headers(), children: null, }), ) - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'sync-react-error') + 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 { - 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('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({ @@ -131,54 +417,160 @@ describe('renderRouterToStream - pipeable sync errors', () => { }), ) - expect(abort).toHaveBeenCalledOnce() - await expectBodyRejects(response, 'string-react-error') + await response.body!.cancel(reason) + expect(cancelReadable).toHaveBeenCalledWith(reason) + expect(errorSpy).not.toHaveBeenCalled() } finally { errorSpy.mockRestore() router.serverSsr?.cleanup() } }) - test('sync undefined onError before pipeable assignment still errors body', async () => { - const abort = vi.fn() - reactDomServerMocks.renderToPipeableStream.mockImplementationOnce( - (_children, opts) => { - opts.onError(undefined, { 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, 'SSR aborted') + controller.abort(new Error('request-gone')) + await expectBodyRejects(response, 'request-gone') + expect(errorSpy).not.toHaveBeenCalled() } finally { errorSpy.mockRestore() router.serverSsr?.cleanup() } }) - test('undefined onError after response attach 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() + 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(new Error('readable-bot-gone')) + + await cancelStarted + expect(cleanup).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + resolveCancel() + await expect(responsePromise).rejects.toThrow('readable-bot-gone') + expect(cancelReadable).toHaveBeenCalledOnce() + } finally { + resolveCancel?.() + originalServerSsr.cleanup() + } + }) + + 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('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 +582,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 +646,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 +662,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/benchmarks/ssr-streaming/.gitignore b/packages/router-core/benchmarks/ssr-streaming/.gitignore new file mode 100644 index 00000000000..264c73bcfce --- /dev/null +++ b/packages/router-core/benchmarks/ssr-streaming/.gitignore @@ -0,0 +1,2 @@ +/RESULTS.md +/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..4913638269b --- /dev/null +++ b/packages/router-core/benchmarks/ssr-streaming/README.md @@ -0,0 +1,152 @@ +# 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 | +| `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 `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..70e2d8aa6ba --- /dev/null +++ b/packages/router-core/benchmarks/ssr-streaming/run.mjs @@ -0,0 +1,841 @@ +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 '../../node_modules/esbuild/lib/main.js' + +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', + '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 === '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 === '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, + ) + 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' + ? ['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' + 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..d062684df98 --- /dev/null +++ b/packages/router-core/benchmarks/ssr-streaming/worker.ts @@ -0,0 +1,2181 @@ +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 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: 'application-string' + bytes: number + shape: Shape + routerRecordBytes: number + framework: 'react' + rendererSafePoint: 'script-close' + }) + +function parseScenario(name: string): Scenario { + 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 === '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 === '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* 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 modes: Array = ( + scenario.kind === 'application-string' + ? ['merge'] + : scenario.kind !== 'renderer-records' && scenario.framework === 'react' + ? ['fast', 'merge'] + : ['merge'] + ).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 = [] + const wallElapsedRatios = [] + 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..a9979cac437 100644 --- a/packages/router-core/src/await-signal.ts +++ b/packages/router-core/src/await-signal.ts @@ -2,26 +2,38 @@ export function waitForReason( value: T | PromiseLike, signal: AbortSignal, onLate?: (value: T) => void, + onLateError?: (reason: unknown) => void, ): 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(onLate, onLateError).catch(() => {}) return Promise.reject(signal.reason) } + return new Promise((resolve, reject) => { - const abort = () => reject(signal.reason) + const abort = () => { + signal.removeEventListener('abort', abort) + reject(signal.reason) + } signal.addEventListener('abort', abort, { once: true }) - promise - .then((result) => { - if (signal.aborted) { - onLate?.(result) - } else { - resolve(result) - } - }, reject) + void promise + .then( + (result) => { + if (signal.aborted) { + return onLate?.(result) + } else { + resolve(result) + } + }, + (error) => { + if (signal.aborted) { + return onLateError?.(error) + } else { + reject(error) + } + }, + ) .finally(() => signal.removeEventListener('abort', abort)) + .catch(() => {}) }) } diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index a296629ffa3..7e9b609527a 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -271,7 +271,6 @@ export type { ParseLocationFn, InvalidateFn, ControllablePromise, - InjectedHtmlEntry, EmitFn, LoadFn, SubscribeFn, @@ -469,19 +468,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 8a4c85c0aa6..67a614ac3c0 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -5,7 +5,6 @@ import { isRedirect } from './redirect' import { getLocationChangeInfo, runRouteLifecycle } 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' @@ -2156,9 +2155,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/router.ts b/packages/router-core/src/router.ts index 13e7ad329c8..a5ec6b8bb02 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -106,17 +106,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 & { @@ -124,8 +124,6 @@ export type ControllablePromise = Promise & { reject: (value?: any) => void } -export type InjectedHtmlEntry = Promise - export interface Register { // Lots of things on here like... // router @@ -797,17 +795,22 @@ 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: { + reserveFastPath: (output?: HydrationScriptOutput) => boolean + claimOutput: () => HydrationScriptOutput + liftBarrier: () => void + isInitialTaken: () => boolean + startSerializationTimeout: (timeoutMs: number) => void + } /** Framework-only. */ onRenderFinished: (listener: () => void) => void /** Framework-only. */ @@ -821,18 +824,23 @@ export interface ServerSsr { * cache subscriptions, gcTime timers, abort controllers). * * 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 - /** 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..2963093449d --- /dev/null +++ b/packages/router-core/src/ssr/STREAMING.md @@ -0,0 +1,604 @@ +# 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. + +Hydration-enabled merge mode fails if application EOF arrives before the boundary. +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 has additional rules 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 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 changes these rules: + +- With `Waiting`, the transform waits. +- With `Ready`, the transform fails because the document close is incomplete. +- If normal serialization reaches `Done`, the transform forwards the partial bytes and completes. + +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. + +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 three cases: + +- If the request is already aborted, handoff disposes the stream immediately. +- With live router SSR state, abort disposes the stream and SSR cleanup removes the listener. +- Without live router SSR state, the code adds no listener for a later abort. + +### 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. + +Solid passes a writer-abort function to the transform. +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. + +Bot-readiness waits use temporary abort listeners. +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..436cb6b5878 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, +} from './serializer/seroval-plugins.client' +export { defaultSerovalDeserializerPlugins } from './serializer/seroval-plugins.client-deserialize' +export { makeSerovalPlugin } from './serializer/makeSerovalPlugin' +export type { AnySerializationAdapter } from './serializer/transformer' +export { createRawStreamDeserializePlugin } from './serializer/RawStreamDeserializePlugin' 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..ab431ccb332 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 } 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,12 @@ export type RequestHandler = ( cb: HandlerCallback, ) => Promise -type RequestWaiter = ((reason: unknown) => void) | undefined - -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 +39,17 @@ export function createRequestHandler({ getRouterManifest?: () => ServerManifest | Promise }): RequestHandler { return async (cb) => { - request.signal.throwIfAborted() + const signal = request.signal + signal.throwIfAborted() + const manifest = await waitForReason(getRouterManifest?.(), signal) + 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 +69,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() - const response = await waitForRequest( + signal.throwIfAborted() + const disposeLate = createLateResponseDisposer(signal) + const response = await waitForReason( 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..15ea010442a 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,44 @@ 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 - } - - if (ssrResponse.response.body) { - try { - void ssrResponse.response.body.cancel(reason).catch(onError) - } catch (error) { - onError(error) - } +): undefined { + const response = normalizeSsrResponse(result) + if (response.serverSsrCleanup === 'stream') { + response.dispose(reason) + } else { + cancelResponseBody(response.response, reason) } } -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,17 +80,13 @@ 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() @@ -121,28 +94,32 @@ export function bindSsrResponseToRequest( } signal.addEventListener('abort', abort, { once: true }) - router?.serverSsr?.onCleanup(() => { + const serverSsr = router?.serverSsr + 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..27f4e05451f --- /dev/null +++ b/packages/router-core/src/ssr/hydrationScripts.ts @@ -0,0 +1,623 @@ +/* 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, + } +} + +/** Create the hydration-script owner for one server request. */ +export function createHydrationScripts( + nonce: string | undefined, + initialSources?: ReadonlyArray, +) { + const seedSources = initialSources ?? DEFAULT_INITIAL_SOURCES + let queuedSources: Array = [] + let queuedSourceHead = 0 + let initialTaken = false + let barrierLifted = false + let producerDone = false + let consumer: ConsumerOwnership + let listener: (() => void) | undefined + + let active: Array | undefined + let retainedSources = 0 + let regularCodeUnits = 0 + let hasOversizedSource = false + + let segmentIndex = 0 + let closingSegmentIndex = 0 + let source = '' + let sourceOffset = 0 + let outputCapacity = MIN_OUTPUT_BYTES + + let state: HydrationScriptOutputState = HydrationScriptOutputState.Waiting + let error: unknown + let timeout: ReturnType | undefined + + let opening: string | undefined + + function notify() { + try { + listener?.() + } catch (listenerError) { + console.error('Hydration script output listener error:', listenerError) + } + } + + function refresh(notifyChange = true) { + const next = + state === HydrationScriptOutputState.Failed + ? HydrationScriptOutputState.Failed + : active + ? HydrationScriptOutputState.Active + : typeof consumer === 'object' && + initialTaken && + barrierLifted && + !queueIsEmpty() + ? HydrationScriptOutputState.Ready + : producerDone && initialTaken && queueIsEmpty() + ? HydrationScriptOutputState.Done + : HydrationScriptOutputState.Waiting + if (state !== next) { + state = next + if (notifyChange) { + notify() + } + } + } + + function clearTimeoutIfSet() { + if (timeout !== undefined) { + clearTimeout(timeout) + timeout = undefined + } + } + + function queueIsEmpty() { + return queuedSourceHead === queuedSources.length + } + + function clearQueue() { + queuedSources = [] + queuedSourceHead = 0 + } + + function dropBufferedOutput() { + clearQueue() + active = undefined + retainedSources = 0 + regularCodeUnits = 0 + hasOversizedSource = false + segmentIndex = 0 + closingSegmentIndex = 0 + source = '' + sourceOffset = 0 + outputCapacity = MIN_OUTPUT_BYTES + opening = undefined + } + + function fail(reason: unknown) { + if (consumer === 'cleaned' || state === HydrationScriptOutputState.Failed) { + return + } + error = reason + clearTimeoutIfSet() + dropBufferedOutput() + state = HydrationScriptOutputState.Failed + notify() + } + + function rejectBacklog(kind: 'source-part' | 'code-unit') { + fail(new Error(`SSR hydration backlog exceeded maximum ${kind} count`)) + return false + } + + function account(nextSource: string) { + if (retainedSources === MAX_BACKLOG_SOURCES) { + return rejectBacklog('source-part') + } + if (nextSource.length > MAX_BACKLOG_CODE_UNITS) { + if (hasOversizedSource) { + return rejectBacklog('code-unit') + } + hasOversizedSource = true + } else if (regularCodeUnits + nextSource.length > MAX_BACKLOG_CODE_UNITS) { + return rejectBacklog('code-unit') + } else { + regularCodeUnits += nextSource.length + } + retainedSources++ + return true + } + + function releaseSource(part: string) { + retainedSources-- + if (part.length > MAX_BACKLOG_CODE_UNITS) { + hasOversizedSource = false + } else { + regularCodeUnits -= part.length + } + } + + function releaseAccounting(batch: ReadonlyArray) { + for (const part of batch) { + if (part !== undefined) { + releaseSource(part) + } + } + } + + function liftBarrier() { + if (consumer !== 'cleaned' && !barrierLifted) { + barrierLifted = true + refresh() + } + } + + function producerCanWrite() { + return ( + consumer !== 'cleaned' && + state !== HydrationScriptOutputState.Failed && + !producerDone + ) + } + + function pushSource(nextSource: string) { + if (!producerCanWrite()) { + return false + } + if (account(nextSource)) { + queuedSources.push(nextSource) + if (initialTaken) { + refresh() + } + } else { + return false + } + // A notification can synchronously fail or clean up this owner. + return producerCanWrite() + } + + function takeQueuedBatch(batchLength: number) { + if (queuedSourceHead === 0 && batchLength === queuedSources.length) { + const batch = queuedSources + clearQueue() + return batch + } + const end = queuedSourceHead + batchLength + const batch = queuedSources.slice(queuedSourceHead, end) + for (let index = queuedSourceHead; index < end; index++) { + queuedSources[index] = undefined + } + queuedSourceHead = end + if (queueIsEmpty()) { + clearQueue() + } else if ( + queuedSourceHead >= 1024 && + queuedSourceHead >= queuedSources.length - queuedSourceHead + ) { + queuedSources = queuedSources.slice(queuedSourceHead) + queuedSourceHead = 0 + } + return batch + } + + function release(batch: Array) { + releaseAccounting(batch) + active = undefined + source = '' + sourceOffset = 0 + refresh(false) + } + + function advanceSource() { + const batch = active! + if (segmentIndex > 0 && segmentIndex < closingSegmentIndex) { + const partIndex = (segmentIndex - 1) >> 1 + if (segmentIndex % 2 === 1) { + const part = batch[partIndex] + if (part !== undefined) { + releaseSource(part) + batch[partIndex] = undefined + } + } + } + segmentIndex++ + // Segment 0 is the opening tag. Sources and separators alternate until + // the closing segment. The next advance releases the complete record. + if (segmentIndex < closingSegmentIndex) { + const partIndex = (segmentIndex - 1) >> 1 + source = segmentIndex % 2 === 1 ? batch[partIndex]! : SOURCE_SEPARATOR + } else if (segmentIndex === closingSegmentIndex) { + source = DYNAMIC_CLOSE_SOURCE + } else { + release(batch) + } + sourceOffset = 0 + } + + function pullActive() { + const bytes = new Uint8Array(outputCapacity) + let offset = 0 + while (active) { + if (sourceOffset === source.length) { + advanceSource() + } else if (offset === bytes.length) { + break + } else { + const target = offset === 0 ? bytes : bytes.subarray(offset) + const result = encoder.encodeInto(source.slice(sourceOffset), target) + if (result.read === 0) { + break + } + 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) + } + + function pullReady() { + const scriptOpening = (opening ??= 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 +441,183 @@ 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 dehydratedDataValue = router.options.dehydrate?.() + const dehydratedData = opts?.signal + ? await waitForReason(dehydratedDataValue, opts.signal) + : await dehydratedDataValue + 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) + ? [ + ...serializationAdapters.map((adapter) => + makeSsrSerovalPlugin(adapter, trackPlugins), + ), + ...defaultSerovalPlugins, + ] : defaultSerovalPlugins let serializationCompleteSignaled = false - const signalSerializationComplete = () => { - if (serializationCompleteSignaled || cleanupStarted) return + const completeScriptSerialization = (failure?: { 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 (failure) { + hydrationScripts.fail(failure.error) + } else { + hydrationScripts.finish() + } + 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 + } + if ( + !hydrationScripts.pushSerializedSource( + data, + initial, + trackPlugins.didRun, + ) + ) { + completeScriptSerialization() } - scriptBuffer.enqueue(serialized) }, onError: (err: unknown) => { + if (serializationCompleteSignaled || cleanupStarted) { + return + } console.error('Serialization error:', err) if (err && (err as any).stack) { console.error((err as any).stack) } - finishScriptSerialization() + synchronousFailure = { error: err } + completeScriptSerialization({ error: err }) }, - scopeId: SCOPE_ID, + scopeId: SSR_SERIALIZATION_SCOPE_ID, onDone: () => { - finishScriptSerialization() + completeScriptSerialization() }, }) - }, - 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() + } 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:', + ) }, - 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:', + ) renderFinishedListeners.length = 0 - injectedHtmlListeners.length = 0 - serializationFinishedListeners.length = 0 - injectedHtmlBuffer = '' - scriptBuffer.cleanup() + router.ssr = undefined router.serverSsr = undefined }, } @@ -762,7 +661,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..561f767d256 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -1,32 +1,51 @@ 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 { + ReadableStreamDefaultReader as NodeReadableStreamDefaultReader, + ReadableStreamReadResult as NodeReadableStreamReadResult, + ReadableStreamReadValueResult as NodeReadableStreamReadValueResult, +} 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 export function transformPipeableStreamWithRouter( router: AnyRouter, @@ -34,880 +53,995 @@ export function transformPipeableStreamWithRouter( opts?: TransformStreamWithRouterOptions, ) { return Readable.fromWeb( - transformStreamWithRouter(router, Readable.toWeb(routerStream), opts), + transformReadableStreamWithRouter( + router, + Readable.toWeb(routerStream) as ReadableStream, + opts, + ), ) } -// 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() + opts?.signal?.throwIfAborted() + 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 - } + let sourceIndex = 0 + const sources = ['', html] + let sourceOffset = 0 + const appStream = new ReadableStream({ + pull(controller) { + while (sourceIndex < sources.length) { + const source = sources[sourceIndex]! + if (sourceOffset >= source.length) { + sourceIndex++ + sourceOffset = 0 + continue } - - if (str.charCodeAt(i) === 62) { - lastClosingTagEnd = i + 1 + const encoded = encodeStringSource(source, sourceOffset) + sourceOffset += encoded.read + if (encoded.bytes.length > 0) { + controller.enqueue(encoded.bytes) + return } } - } + controller.close() + }, + }) + const output = transformReadableStreamWithRouter(router, appStream, opts) + return readUtf8Stream(output) +} - searchFrom = openSlash - 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 }) + } + 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 + +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 = NodeReadableStreamDefaultReader + +const textEncoder = new TextEncoder() -function safeReleaseReader(reader: ReaderOps) { +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 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 - } - } - - const cleanup = (reason?: unknown, cancelReader = true) => { - if (cleanedUp) return resolvedPromise - cleanedUp = true - + let stopAbortListener: (() => void) | undefined + const disarm = () => { + stopAbortListener?.() + stopAbortListener = undefined 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(opts?.signal, (reason) => { + terminate('failure', reason) + }) + // 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) + // Keep a valid UTF-16 surrogate pair in one slice so chunking cannot change + // how the original string is encoded. + let sliceEnd = Math.min(value.length, offset + output.length) + if ( + sliceEnd < value.length && + value.charCodeAt(sliceEnd - 1) >= 0xd800 && + value.charCodeAt(sliceEnd - 1) <= 0xdbff && + value.charCodeAt(sliceEnd) >= 0xdc00 && + value.charCodeAt(sliceEnd) <= 0xdfff + ) { + sliceEnd-- + } + const { read, written } = textEncoder.encodeInto( + value.slice(offset, sliceEnd), + output, + ) + if (read === 0 && remaining > 0) { + throw new Error('SSR string encoder made no progress') } + 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: ReadableStream, + 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 + + 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) - }) + const 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: + | NodeReadableStreamReadValueResult + | undefined + + let appBytes: Uint8Array | undefined + let appOffset = 0 + 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 + + 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 - } - pendingWrites.push(chunk) - pendingWriteChars += chunk.length - drainPending() + function wakePump() { + const resolve = wakeResolve + wakeResolve = undefined + resolve?.() } - function safeClose() { - if (isDone()) return - state = MergeState.Done - try { - controller?.close() - } catch { - // ignore + // 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 } + controller.enqueue(value) + return true } - function safeError(error: unknown) { - if (isDone()) return - state = MergeState.Done - try { - controller?.error(error) - } catch { - // ignore + function finishAppChunk() { + appBytes = undefined + if ( + appString === undefined && + useRecordEndSafePoints && + closeCarry === undefined + ) { + insertionBoundary = true } } - /** - * Cleanup with guards; must be idempotent. - */ - function cleanup(reason?: unknown, cancelReader = true) { - if (cleanedUp) return resolvedPromise - cleanedUp = true - - try { - stopListeningToInjectedHtml?.() - stopListeningToSerializationFinished?.() - } 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 } - stopListeningToInjectedHtml = undefined - stopListeningToSerializationFinished = undefined - stopListeningToAbort?.() - stopListeningToAbort = undefined + } - if (serializationTimeoutHandle !== undefined) { - clearTimeout(serializationTimeoutHandle) - serializationTimeoutHandle = undefined + 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) } - if (lifetimeTimeoutHandle !== undefined) { - clearTimeout(lifetimeTimeoutHandle) - lifetimeTimeoutHandle = undefined + const matchEnd = advanceByteMatcher(barrierMatcher, value, appOffset) + if (matchEnd === undefined) { + const remainder = appOffset === 0 ? value : value.subarray(appOffset) + finishAppChunk() + return enqueueAppBytes(remainder) } - clearPendingRouterHtml() - leftover = '' - pendingTail = '' - clearPending() + 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() + } + return emitted + } - if (cancelReader) { - // Notify the producer immediately. Reader cancellation may take time to - // settle, and upstream renderers must tolerate abort + cancel overlap. - notifyAbort(reason) + function holdDocumentClose(matchStart: number) { + const value = appBytes! + const prefix = + matchStart === appOffset + ? undefined + : value.subarray(appOffset, matchStart) + appOffset = matchStart + DOCUMENT_CLOSE_BYTES.length + applicationPhase = ApplicationPhase.HeldClose + if (safePointMatcher) { + // The removed close breaks byte continuity with later renderer bytes. + safePointMatcher.matched = 0 } - const readerDone = cancelReader - ? readerState.cancel(reason) - : (readerState.release(), resolvedPromise) - signalDrain() - try { - serverSsr.cleanup() - } catch (error) { - console.error('Error in SSR cleanup:', error) + insertionBoundary = true + if (appOffset === value.length) { + finishAppChunk() } - return readerDone + return prefix ? enqueueAppBytes(prefix) : false } - const textDecoder = new TextDecoder() + function processUntilSafePoint(endIndex: number) { + const value = appBytes! + const matchEnd = findSafePointEnd(value, appOffset, endIndex) + if (matchEnd === undefined) { + return false + } - // 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 + const throughSafePoint = + appOffset === 0 && matchEnd === value.length + ? value + : value.subarray(appOffset, matchEnd) + appOffset = matchEnd + insertionBoundary = true + if (appOffset === value.length) { + finishAppChunk() + } + return enqueueAppBytes(throughSafePoint) + } - // between-chunk text buffer; keep bounded to avoid unbounded memory - let leftover = '' + function findSafePointEnd( + value: Uint8Array, + startIndex: number, + endIndex: number, + ) { + const hydrationState = hydrationOutput.state + if ( + endIndex === startIndex || + hydrationState === HydrationScriptOutputState.Done + ) { + return undefined + } - // captured bytes from onward; must stay behind router scripts. - let pendingTail = '' + 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 + } - let streamBarrierLifted = false - let streamBarrierMarkerSeen = false - let serializationFinished = false + 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, + ) - function noteBarrierMarker(chunk: string) { - if (streamBarrierMarkerSeen) return - if (chunk.includes(TSR_SCRIPT_BARRIER_ID)) { - streamBarrierMarkerSeen = true + 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)) + } + } + + if (matchStart >= 0) { + const consumedFromValue = + matchStart + DOCUMENT_CLOSE_BYTES.length - carry.length + appOffset += consumedFromValue + closeCarry = undefined + applicationPhase = ApplicationPhase.HeldClose + if (safePointMatcher) { + // The removed close breaks byte continuity with later renderer bytes. + safePointMatcher.matched = 0 + } + insertionBoundary = true + if (appOffset === value.length) { + finishAppChunk() + } + return matchStart === 0 + ? false + : enqueueAppBytes(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 + + const matchStart = 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 + 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: NodeReadableStreamReadResult) { + 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 - } - - 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 = '' + 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 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 = '' - 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 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) + if (appDone) { + if (applicationPhase === ApplicationPhase.BeforeBoundary) { + throw new Error( + 'SSR router scripts require a rendered boundary. ' + + 'Render in , or opt the request out of hydration with ' + + 'serverSsr.disableHydration().', + ) + } + if (closeCarry) { + if (hydrationState === HydrationScriptOutputState.Waiting) { + await waitForWake() + continue } - } 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 (hydrationState === HydrationScriptOutputState.Ready) { + throw new Error( + 'SSR app HTML ended with an incomplete document close', + ) } + controller.enqueue(closeCarry) + closeCarry = undefined + terminate('complete') + return } + 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) + }, + }) + + const stopOutputListener = 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() }) + stopHydrationOutputListener = stopOutputListener + const 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..5de5fbdcf21 100644 --- a/packages/router-core/tests/RawStream.test.ts +++ b/packages/router-core/tests/RawStream.test.ts @@ -1,10 +1,95 @@ -import { describe, expect, it } from 'vitest' -import { toCrossJSONAsync, fromCrossJSON } from 'seroval' +import { describe, expect, it, vi } from 'vitest' import { - RawStream, - createRawStreamRPCPlugin, - createRawStreamDeserializePlugin, -} from '../src/ssr/serializer/RawStream' + createStream, + crossSerializeStream, + toCrossJSONAsync, + toCrossJSONStream, + fromCrossJSON, +} from 'seroval' +import type { SerovalNode } from 'seroval' +import { RawStream } from '../src/ssr/serializer/RawStream' +import { + RawStreamJSONPlugin as RawStreamClientPlugin, + createRawStreamJSONPlugin, +} from '../src/ssr/serializer/RawStreamJSONPlugin.client' +import { RawStreamJSONPlugin as RawStreamServerPlugin } from '../src/ssr/serializer/RawStreamJSONPlugin.server' +import { createRawStreamRPCPlugin } from '../src/ssr/serializer/RawStreamRPCPlugin' +import { createRawStreamDeserializePlugin } from '../src/ssr/serializer/RawStreamDeserializePlugin' +import { RawStreamSSRPlugin } from '../src/ssr/serializer/RawStreamSSRPlugin' +import { defaultSerovalDeserializerPlugins } from '../src/ssr/serializer/seroval-plugins.client-deserialize' + +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 (RawStreamServerPlugin.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 +130,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 +146,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 +159,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 +180,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 +195,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 +207,164 @@ 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 = RawStreamClientPlugin.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: [RawStreamClientPlugin], + }, + ) + const output = fromCrossJSON(serialized, { + refs: new Map(), + plugins: [RawStreamServerPlugin], + }) 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([ + ['browser', 'binary', defaultSerovalDeserializerPlugins], + ['browser', 'text', defaultSerovalDeserializerPlugins], + ['server', 'binary', [RawStreamServerPlugin]], + ['server', 'text', [RawStreamServerPlugin]], + ] as const)( + 'preserves every %s-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: [RawStreamClientPlugin], + }, + ) + 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) - }) - }) - - describe('round-trip serialization', () => { it('should serialize and deserialize RawStream correctly', async () => { // Collect streams during serialization const collectedStreams = new Map>() @@ -193,9 +386,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 +413,7 @@ describe('RawStream', () => { createRawStreamDeserializePlugin(getOrCreateStream) // Deserialize - const deserialized = fromCrossJSON(serialized, { + const deserialized = fromCrossJSON(serialized!, { refs: new Map(), plugins: [deserializePlugin], }) as any @@ -225,4 +422,141 @@ 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() + + await reader.cancel() + expect(unsubscribe).not.toHaveBeenCalled() + }, + ) + + 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 bcd75475951..77b9d21611f 100644 --- a/packages/router-core/tests/client-lane-adversarial.test.ts +++ b/packages/router-core/tests/client-lane-adversarial.test.ts @@ -673,21 +673,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 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..cfceee588e5 100644 --- a/packages/router-core/tests/hydrate.test.ts +++ b/packages/router-core/tests/hydrate.test.ts @@ -10,6 +10,7 @@ import { notFound, } from '../src' import { hydrate } from '../src/ssr/client' +import { HYDRATION_SCRIPT_BOUNDARY_SOURCE } from '../src/ssr/hydrationScripts' import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' import { createTestRouter } from './routerTestUtils' @@ -44,8 +45,9 @@ async function dehydrateToBootstrap( await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - 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') const context: Record = { document: { @@ -55,7 +57,11 @@ async function dehydrateToBootstrap( }, } context.self = context - runInNewContext(script!.children!, 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 @@ -699,13 +705,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 +779,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..6de2ec1a4ac 100644 --- a/packages/router-core/tests/hydrated-stay-match-data.test.ts +++ b/packages/router-core/tests/hydrated-stay-match-data.test.ts @@ -17,14 +17,16 @@ async function dehydrateToBootstrap(router: AnyRouter): Promise { await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.before.length).toBeGreaterThan(0) const context: Record = { document: { currentScript: { remove() {} } }, } context.self = context - runInNewContext(script!.children!, context) + for (const script of scripts!.before) { + runInNewContext(script.children!, context) + } expect(context.$_TSR).toBeDefined() return context.$_TSR 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..15e78bd6bc8 100644 --- a/packages/router-core/tests/hydration-asset-context-order.test.ts +++ b/packages/router-core/tests/hydration-asset-context-order.test.ts @@ -17,14 +17,16 @@ async function dehydrateToBootstrap(router: AnyRouter): Promise { await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.before.length).toBeGreaterThan(0) const context: Record = { document: { currentScript: { remove() {} } }, } context.self = context - runInNewContext(script!.children!, context) + for (const script of scripts!.before) { + runInNewContext(script.children!, context) + } expect(context.$_TSR).toBeDefined() return context.$_TSR diff --git a/packages/router-core/tests/hydration-boundary-chunks.test.ts b/packages/router-core/tests/hydration-boundary-chunks.test.ts index fe760e074a1..ad6d0a7e43f 100644 --- a/packages/router-core/tests/hydration-boundary-chunks.test.ts +++ b/packages/router-core/tests/hydration-boundary-chunks.test.ts @@ -17,14 +17,16 @@ async function dehydrateToBootstrap(router: AnyRouter): Promise { await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.before.length).toBeGreaterThan(0) const context: Record = { document: { currentScript: { remove() {} } }, } context.self = context - runInNewContext(script!.children!, context) + for (const script of scripts!.before) { + runInNewContext(script.children!, context) + } expect(context.$_TSR).toBeDefined() return context.$_TSR diff --git a/packages/router-core/tests/hydration-currentness.test.ts b/packages/router-core/tests/hydration-currentness.test.ts index a9d52f0c97b..d5b5089cfe9 100644 --- a/packages/router-core/tests/hydration-currentness.test.ts +++ b/packages/router-core/tests/hydration-currentness.test.ts @@ -31,14 +31,16 @@ async function dehydrateToBootstrap(router: AnyRouter): Promise { await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.before.length).toBeGreaterThan(0) const context: Record = { document: { currentScript: { remove() {} } }, } context.self = context - runInNewContext(script!.children!, context) + for (const script of scripts!.before) { + runInNewContext(script.children!, context) + } expect(context.$_TSR).toBeDefined() return context.$_TSR 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 0e607f7a1c6..73d946c295f 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 @@ -17,14 +17,16 @@ async function dehydrateToBootstrap(router: AnyRouter): Promise { await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.before.length).toBeGreaterThan(0) const context: Record = { document: { currentScript: { remove() {} } }, } context.self = context - runInNewContext(script!.children!, context) + for (const script of scripts!.before) { + runInNewContext(script.children!, context) + } expect(context.$_TSR).toBeDefined() return context.$_TSR 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..1920479c962 --- /dev/null +++ b/packages/router-core/tests/hydrationScripts.test.ts @@ -0,0 +1,756 @@ +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('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..419c2f8ece0 --- /dev/null +++ b/packages/router-core/tests/issue-7942-repro.test.ts @@ -0,0 +1,276 @@ +import { ReadableStream } from 'node:stream/web' +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 5ef9febb598..07de8cedfbb 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,6 @@ 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' describe('waitFor', () => { test('observes a rejected value when the signal is already aborted', async () => { @@ -33,44 +32,28 @@ describe('waitFor', () => { }) }) -describe('waitForRequest', () => { - test('shares one abort listener across concurrent and sequential waits', async () => { +describe('waitForReason', () => { + test('removes its abort listener when a wait settles', async () => { const controller = new AbortController() const addEventListener = vi.spyOn(controller.signal, 'addEventListener') - let resolveFirst!: (value: string) => void - let resolveSecond!: (value: string) => void - let resolveThird!: (value: string) => void - const first = new Promise((resolve) => { - resolveFirst = resolve - }) - const second = new Promise((resolve) => { - resolveSecond = resolve - }) - const third = new Promise((resolve) => { - resolveThird = resolve - }) - - const firstResult = waitForRequest(first, controller.signal) - const secondResult = waitForRequest(second, controller.signal) - - expect(addEventListener).toHaveBeenCalledTimes(1) - resolveFirst('first') - resolveSecond('second') - await expect(firstResult).resolves.toBe('first') - await expect(secondResult).resolves.toBe('second') + const removeEventListener = vi.spyOn( + controller.signal, + 'removeEventListener', + ) - const thirdResult = waitForRequest(third, controller.signal) + await expect( + waitForReason(Promise.resolve('value'), controller.signal), + ).resolves.toBe('value') expect(addEventListener).toHaveBeenCalledTimes(1) - - const reason = new Error('request canceled') - controller.abort(reason) - await expect(thirdResult).rejects.toBe(reason) - resolveThird('third') - await Promise.resolve() + expect(removeEventListener).toHaveBeenCalledTimes(1) }) test('rejects all active waits and observes their late values', async () => { const controller = new AbortController() + const removeEventListener = vi.spyOn( + controller.signal, + 'removeEventListener', + ) const reason = new Error('request canceled') const onFirstLate = vi.fn() const onSecondLate = vi.fn() @@ -82,12 +65,13 @@ 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) await expect(secondResult).rejects.toBe(reason) + expect(removeEventListener).toHaveBeenCalledTimes(2) resolveFirst('first') resolveSecond('second') @@ -97,113 +81,99 @@ describe('waitForRequest', () => { }) }) - test('aborts remaining waits after an out-of-order rejection', async () => { - const controller = new AbortController() - const reason = new Error('request canceled') - const failure = new Error('wait failed') - const onLate = vi.fn() - let resolveFirst!: (value: string) => void - let rejectSecond!: (error: Error) => void - let rejectThird!: (error: Error) => void - const first = new Promise((resolve) => { - resolveFirst = resolve - }) - const second = new Promise((_, reject) => { - rejectSecond = reject - }) - 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) - - rejectSecond(failure) - await expect(secondResult).rejects.toBe(failure) - - controller.abort(reason) - await expect(firstResult).rejects.toBe(reason) - await expect(thirdResult).rejects.toBe(reason) - - resolveFirst('late') - rejectThird(new Error('late failure')) - await vi.waitFor(() => expect(onLate).toHaveBeenCalledWith('late')) - }) - - test('isolates waits for different request signals', async () => { - const firstController = new AbortController() - const secondController = new AbortController() - const firstReason = new Error('first request canceled') - const firstAddEventListener = vi.spyOn( - firstController.signal, - 'addEventListener', - ) - const secondAddEventListener = vi.spyOn( - secondController.signal, - 'addEventListener', - ) - let resolveFirst!: (value: string) => void - let resolveSecond!: (value: string) => void - const first = new Promise((resolve) => { - resolveFirst = resolve - }) - const second = new Promise((resolve) => { - resolveSecond = resolve - }) - const firstResult = waitForRequest(first, firstController.signal) - const secondResult = waitForRequest(second, secondController.signal) - - expect(firstAddEventListener).toHaveBeenCalledOnce() - expect(secondAddEventListener).toHaveBeenCalledOnce() - - firstController.abort(firstReason) - await expect(firstResult).rejects.toBe(firstReason) - - resolveSecond('second result') - await expect(secondResult).resolves.toBe('second result') - resolveFirst('late first result') - await Promise.resolve() - }) - test('observes fulfilled and rejected values when already aborted', async () => { const controller = new AbortController() 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')) - }) - - test('handles an abort during listener registration', async () => { - const controller = new AbortController() - const reason = new Error('request canceled') - const onLate = vi.fn() - const addEventListener = controller.signal.addEventListener.bind( - controller.signal, - ) - vi.spyOn(controller.signal, 'addEventListener').mockImplementation( - (type, listener, options) => { - addEventListener(type, listener, options) - controller.abort(reason) - }, - ) - let resolve!: (value: string) => void - const value = new Promise((resolveValue) => { - resolve = resolveValue + await vi.waitFor(() => { + expect(onLate).toHaveBeenCalledWith('late') + expect(onLateError).toHaveBeenCalledWith(lateError) }) + }) - const result = waitForRequest(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..43d4f6b3005 100644 --- a/packages/router-core/tests/public-hydration-contract.test.ts +++ b/packages/router-core/tests/public-hydration-contract.test.ts @@ -32,14 +32,16 @@ async function dehydrateToBootstrap(router: AnyRouter): Promise { await router.load() await router.serverSsr!.dehydrate() - const script = router.serverSsr!.takeBufferedScripts() - expect(script?.children).toBeTruthy() + const scripts = router.serverSsr!.takeInitialHydrationScriptTags() + expect(scripts?.before.length).toBeGreaterThan(0) const context: Record = { document: { currentScript: { remove() {} } }, } context.self = context - runInNewContext(script!.children!, context) + for (const script of scripts!.before) { + runInNewContext(script.children!, context) + } return context.$_TSR } finally { 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..88735b018fa 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 @@ -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..a45aba292be 100644 --- a/packages/router-core/tests/ssr-server-cleanup.test.ts +++ b/packages/router-core/tests/ssr-server-cleanup.test.ts @@ -1,14 +1,30 @@ +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 { 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 +61,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 +162,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 +317,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() @@ -147,36 +340,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 +400,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 +531,158 @@ 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() + } + }) + + 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, + }) - router.serverSsr!.injectHtml('') + 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 }) + + try { + attachRouterServerSsrUtils({ router, manifest: undefined }) + 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() + } finally { + router.serverSsr?.cleanup() + } + }) + + 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 +702,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 +712,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 +783,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 +795,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 +818,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 +859,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 +901,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 +918,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 +941,193 @@ 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('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..bf9f51f93bc 100644 --- a/packages/router-core/tests/transformStreamBackpressure.perf.test.ts +++ b/packages/router-core/tests/transformStreamBackpressure.perf.test.ts @@ -1,19 +1,25 @@ -// 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 +55,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..6874a7def72 100644 --- a/packages/router-core/tests/transformStreamWithRouter.test.ts +++ b/packages/router-core/tests/transformStreamWithRouter.test.ts @@ -1,111 +1,247 @@ -// 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 +250,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 +269,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 +364,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 +400,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 +439,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 +476,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,7 +504,7 @@ describe('transformStreamWithRouter — real SSR scripts', () => { expect(endIndex).toBeLessThan(html.indexOf('')) }) - test('flushes stream-end scripts even when no barrier marker was emitted', async () => { + test('rejects router scripts when no Scripts barrier was rendered', async () => { const streamed = createDeferred() const router = createRealSsrRouter({ streamed: streamed.promise }) attachRouterServerSsrUtils({ router, manifest: undefined }) @@ -291,81 +512,42 @@ describe('transformStreamWithRouter — real SSR scripts', () => { await router.load() await router.serverSsr!.dehydrate() - 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
') upstream.close() - const html = 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(''), - ) - }) - - test('keeps stream scripts before uppercase body close', 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()! - - const upstream = makeManualUpstream() - const output = transformStreamWithRouter( - 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.close() - - const html = await readAll(output as any) - expect(html).toContain(`${GLOBAL_TSR}.e()`) - expect(html.indexOf(`${GLOBAL_TSR}.e()`)).toBeLessThan( - html.indexOf(''), + await expect(readAll(output as any)).rejects.toThrow( + 'SSR router scripts require a rendered boundary', ) }) 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 +557,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, + ) - await flush() - expect(cancelSettled).toBe(false) - expect(cleanupCalls.count).toBe(1) + upstream.controller.enqueue(rendererBytes) + upstream.controller.close() - resolveCancel() - await cancelPromise - expect(cancelSettled).toBe(true) + await expect(readAllBytes(output)).resolves.toEqual( + Buffer.from(rendererBytes), + ) + expect(claimCalls.count).toBe(0) + expect(cleanupCalls.count).toBe(1) }) - test('natural close calls cleanup exactly once', async () => { + 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({ - isSerializationFinished: () => true, - reserveStreamFastPath: () => true, - takeBufferedHtml: () => undefined, + reserveFastPath: () => { + throw new Error('setup failed') + }, }) const upstream = makeManualUpstream() + const onAbort = vi.fn() - const out = transformStreamWithRouter(router as any, upstream.stream as any) + expect(() => + transformReadableStreamWithRouter(router as any, upstream.stream as any, { + onAbort, + }), + ).toThrow('setup failed') + await flush() - upstream.push('done') - upstream.close() + expect(upstream.cancelled.value).toBe(true) + expect(onAbort).toHaveBeenCalledOnce() + expect(cleanupCalls.count).toBe(1) + }) - const text = await readAll(out as any) - expect(text).toContain('done') + 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('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({ + reserveFastPath: () => true, + }) + const upstream = makeManualUpstream() + + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = ( + out as any + ).getReader() as ReadableStreamDefaultReader + + // Cancel before any data flows. Should still trigger upstream cancel + // and exactly one cleanup invocation. + await reader.cancel('consumer-gone') + await flush() + + 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 +1245,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 +1266,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 +1554,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 +1564,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 +1588,562 @@ 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(internalSplitOffsets(DOCUMENT_CLOSE))( + 'relocates the structural document close split at byte %s', + async (splitAt) => { + const { router, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() + const output = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const beforeClose = `${SCRIPT_BARRIER_HTML}
app
` + const afterClose = '' + + upstream.push(beforeClose + DOCUMENT_CLOSE.slice(0, splitAt)) + upstream.push(DOCUMENT_CLOSE.slice(splitAt) + afterClose) + upstream.close() + finishSerialization() + + await expect(readAll(output)).resolves.toBe( + beforeClose + 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() + + 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(readAll(out as any)).rejects.toThrow( - 'SSR stream pending output exceeded maximum buffer', + 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('rejects pending router scripts after an incomplete document close', async () => { + const { router, emitScriptBatch, finishSerialization, cleanupCalls } = + makeRouter() const upstream = makeManualUpstream() + const out = transformReadableStreamWithRouter( + router as any, + upstream.stream as any, + ) + const reader = out.getReader() + + upstream.push(`${SCRIPT_BARRIER_HTML}${SCRIPT_BARRIER_HTML}`, + ) - const out = transformStreamWithRouter(router as any, upstream.stream as any) + const pendingRead = reader.read() + await flush() + emitScriptBatch(['pending()']) + finishSerialization() + upstream.close() - await expect(readAll(out as any)).rejects.toThrow('take-buffered-html-boom') + await expect(pendingRead).rejects.toThrow( + 'SSR app HTML ended with an incomplete document close', + ) 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 +2153,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 +2292,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 +2318,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 +2335,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 +2350,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 +2619,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 +2643,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 +2683,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 +2712,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 +2723,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-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/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx index a69c4859238..4359ee43867 100644 --- a/packages/solid-router/src/CatchBoundary.tsx +++ b/packages/solid-router/src/CatchBoundary.tsx @@ -20,23 +20,30 @@ export function CatchBoundary( Solid.on(props.getResetKey, () => reset(), { defer: true }), ) - return process.env.NODE_ENV !== 'production' ? ( - renderInNonRouteComponentContext( - () => ( + return ( + // 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. + + {process.env.NODE_ENV !== 'production' ? ( + renderInNonRouteComponentContext( + () => ( + + ), + 'errorComponent', + ) + ) : ( - ), - 'errorComponent', - ) - ) : ( - + )} + ) }} > 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..402c820a206 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,7 @@ export function lazyRouteComponent< return loadPromise } - const lazyComp = function Lazy(props: any) { + function Lazy(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 +67,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 } } } @@ -80,17 +78,18 @@ export function lazyRouteComponent< } if (!comp) { - const [compResource] = createResource(load, { - initialValue: comp, - ssrLoadFrom: 'initial', - }) - return + return } return } - ;(lazyComp as any).preload = load + const Loadable = lazy(async () => { + await load() + return { default: Lazy } + }) - return lazyComp as any + ;(Lazy as any).preload = load + + 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/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 7432f195105..ac47fce4031 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, + makeSsrSerovalPlugin, transformReadableStreamWithRouter, + waitForReason, } from '@tanstack/router-core/ssr/server' -import { makeSsrSerovalPlugin } from '@tanstack/router-core' 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,30 +21,53 @@ export const renderRouterToStream = async ({ responseHeaders: Headers children: () => JSXElement }) => { - const { writable, readable } = new TransformStream() + const signal = request.signal + if (signal.aborted) { + router.serverSsr?.cleanup() + throw signal.reason + } - const docType = Solid.ssr('') + const bot = isbot(request.headers.get('User-Agent')) + let stream: ReturnType + try { + const docType = Solid.ssr('') + const serializationAdapters = router.options.serializationAdapters + const serovalPlugins = serializationAdapters?.map((adapter) => + makeSsrSerovalPlugin(adapter, { didRun: false }), + ) - 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 - }) + stream = Solid.renderToStream( + () => ( + <> + {docType} + {children()} + + ), + { + nonce: router.options.ssr?.nonce, + plugins: serovalPlugins, + } as any, + ) + } catch (error) { + router.serverSsr?.cleanup() + throw error + } - const stream = Solid.renderToStream( - () => ( - <> - {docType} - {children()} - - ), - { - nonce: router.options.ssr?.nonce, - plugins: serovalPlugins, - } as any, - ) + if (signal.aborted) { + router.serverSsr?.cleanup() + throw signal.reason + } + + if (bot) { + try { + await waitForReason(stream as unknown, signal) + } catch (error) { + router.serverSsr?.cleanup() + throw error + } + } + + const { writable, readable } = new TransformStream() // 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 @@ -76,18 +76,8 @@ export const renderRouterToStream = async ({ // 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. + // Solid does not expose a disposal handle for unresolved renderer work. + // See router-core's `STREAMING.md` for this accepted limitation. const innerWriter = writable.getWriter() let writerDone = false const releaseWriter = () => { @@ -98,7 +88,9 @@ export const renderRouterToStream = async ({ } } const abortSolidPipe = (reason?: unknown) => { - if (writerDone) return + if (writerDone) { + return + } writerDone = true void innerWriter .abort(reason) @@ -106,28 +98,6 @@ export const renderRouterToStream = async ({ .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) - }) - } - - if (isbot(request.headers.get('User-Agent'))) { - await waitForReadyOrAbort( - Promise.resolve(stream as unknown), - request.signal, - ) - } - const solidWritable = new WritableStream({ write(chunk) { return innerWriter.write(chunk) @@ -142,32 +112,24 @@ export const renderRouterToStream = async ({ }, }) - 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) + // 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(solidWritable)).catch((error: unknown) => { + if (!writerDone) { + console.error('Error in Solid render stream:', error) + abortSolidPipe(error) } - } + router.serverSsr?.cleanup() + }) const responseStream = transformReadableStreamWithRouter( router, readable as unknown as ReadableStream, - { signal: request.signal, onAbort: abortSolidPipe }, + { + rendererSafePoint: 'record-end', + signal, + onAbort: abortSolidPipe, + }, ) return createSsrStreamResponse( router, diff --git a/packages/solid-router/src/ssr/renderRouterToString.tsx b/packages/solid-router/src/ssr/renderRouterToString.tsx index 421ac234bc8..86d55577a3a 100644 --- a/packages/solid-router/src/ssr/renderRouterToString.tsx +++ b/packages/solid-router/src/ssr/renderRouterToString.tsx @@ -1,9 +1,36 @@ import * as Solid from 'solid-js/web' -import { makeSsrSerovalPlugin } from '@tanstack/router-core' +import { + makeSsrSerovalPlugin, + transformHtmlStringWithRouter, +} from '@tanstack/router-core/ssr/server' import type { AnyRouter } from '@tanstack/router-core' import type { JSXElement } from 'solid-js' -export const renderRouterToString = ({ +async function renderToCompleteString( + children: () => JSXElement, + options: Parameters[1], +): Promise { + // Solid creates its timeout before it starts rendering and does not clear it + // when rendering throws synchronously. + const stream = Solid.renderToStream(children, options) + let timeoutHandle: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject('renderToString timed out'), 30_000) + }) + + try { + // The server runtime is thenable, although its public type exposes only the + // streaming methods. + return await Promise.race([ + stream as unknown as PromiseLike, + timeout, + ]) + } finally { + clearTimeout(timeoutHandle) + } +} + +export const renderRouterToString = async ({ router, responseHeaders, children, @@ -13,25 +40,19 @@ 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 serializationAdapters = router.options.serializationAdapters + const serovalPlugins = serializationAdapters?.map((adapter) => + makeSsrSerovalPlugin(adapter, { didRun: false }), + ) - const injectedHtml = router.serverSsr!.takeBufferedHtml() - if (injectedHtml) { - html = html.replace(``, () => `${injectedHtml}`) - } - return new Response(`${html}`, { + const html = await transformHtmlStringWithRouter( + router, + await renderToCompleteString(children, { + nonce: router.options.ssr?.nonce, + plugins: serovalPlugins, + } as any), + ) + return new Response(html, { status: router._serverResult?.type === 'render' ? router._serverResult.status 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/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/renderRouterToStream.test.tsx b/packages/solid-router/tests/renderRouterToStream.test.tsx index 6351d83ebd0..3b4e210a2f3 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,6 @@ describe('renderRouterToStream - bot abort', () => { const router = await buildRouter() const abortController = new AbortController() - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const responsePromise = renderRouterToStream({ request: new Request('http://localhost/', { @@ -91,57 +122,240 @@ describe('renderRouterToStream - bot abort', () => { await Promise.resolve() abortController.abort(new Error('client-gone')) - const result = await Promise.race([ - responsePromise, - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]) - - expect(result).not.toBe(false) + await expect(responsePromise).rejects.toThrow('client-gone') 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('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 + }) - expect(terminated).toBe(true) + 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')) + + await expect( + outboundWriter.write(new TextEncoder().encode('late Solid chunk')), + ).rejects.toBeDefined() - expect(terminated).toBe(true) - expect(errorSpy).toHaveBeenCalled() + 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 4ab6e796585..bad090594a2 100644 --- a/packages/solid-router/tests/server/errorComponent.test.tsx +++ b/packages/solid-router/tests/server/errorComponent.test.tsx @@ -1,12 +1,68 @@ -import { describe, expect, it } from 'vitest' -import { createRootRoute, createRoute, createRouter } from '../../src' +import { Suspense } from 'solid-js' +import { describe, expect, it, vi } from 'vitest' +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('clears string-render state after a synchronous 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 response = await handler(({ router, responseHeaders }) => { + requestRouter = router + return renderRouterToString({ + router, + responseHeaders, + children: () => { + throw new Error('render boom') + }, + }) + }) + + expect(response.status).toBe(500) + expect(requestRouter?.serverSsr).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + } finally { + consoleError.mockRestore() + vi.clearAllTimers() + vi.useRealTimers() + } + }) + it('renders the route error component when a loader throws during SSR', async () => { const rootRoute = createRootRoute() @@ -42,4 +98,79 @@ describe('errorComponent (server)', () => { expect(html).toContain('loader boom') expect(html).not.toContain('Index route') }) + + it('waits for a lazy error component after a render error', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + throw new Error('render boom') + }, + errorComponent: createLazyErrorComponent(), + }) + 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(html).toContain('data-testid="error-component"') + expect(html).toContain('render boom') + }) + + 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/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..a2d8c44825b 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, +} from '../constants' + +const decoder = new TextDecoder() +const empty: Uint8Array = new Uint8Array() + +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) { + state = 1 + wake() + void reader.cancel(reason).catch(() => {}) + const failed: Failed = [ + reason === undefined ? new Error('Framed response cancelled') : reason, + ] + 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 (streams.size >= MAX_STREAMS) { - throw new Error( - `Too many raw streams in framed response (max ${MAX_STREAMS})`, - ) + if (rawStreams.size >= MAX_FRAMED_STREAMS) { + throw new Error('Too many raw streams') } + let controller!: ReadableStreamDefaultController const stream = new ReadableStream({ - start(ctrl) { - streamControllers.set(id, ctrl) + start(value) { + controller = value }, + pull: wake, cancel() { - cancelledStreamIds.add(id) - streamControllers.delete(id) - streams.delete(id) + entry[1] = null + wake() }, }) - streams.set(id, stream) - return stream + 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 + async function wait( + controller: ReadableStreamDefaultController, + entry?: RawEntry, + ) { + while ( + state === 0 && + (!entry || entry[1] === controller) && + controller.desiredSize! <= 0 + ) { + await new Promise((resolve) => { + resume = resolve + }) + } + } - // 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 frameSize = FRAME_HEADER_SIZE + length - if (totalLength < frameSize) break // Wait for more data + 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') + } - 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 (type === FRAME_TYPE_JSON) { + const value = decoder.decode(payload) + payload = empty + jsonController.enqueue(value) + if (jsonController.desiredSize! <= 0) { + await wait(jsonController) } + 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 + const entry = getRaw(streamId) + if (entry[1] === false) { + throw new Error('Raw stream already ended') + } + if (type === FRAME_TYPE_CHUNK) { + const controller = entry[1] + if (controller) { + const chunk = + payload.byteLength === payload.buffer.byteLength + ? payload + : payload.slice() + payload = empty + controller.enqueue(chunk) + if (controller.desiredSize! <= 0) { + await wait(controller, entry) } } + } 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..b705afa13c0 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -1,10 +1,10 @@ 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 { @@ -12,13 +12,12 @@ import { TSS_FORMDATA_CONTEXT, 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 +29,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 +50,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,9 +78,17 @@ 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 +} + /** * Checks if an object has at least one own enumerable property. * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property. @@ -115,22 +120,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() 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 +142,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 +166,7 @@ export async function serverFnFetcher( } } - return await getResponse(async () => + return getResponse(() => fetchImpl(url, { method: first.method, headers, @@ -176,29 +179,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,7 +212,7 @@ 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) @@ -266,39 +270,28 @@ 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) - 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 +333,58 @@ 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() + const options = { refs: new Map(), plugins } - // 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') - } + let result: any + const initialPostProcessPromises: Array> = [] + try { + const first = await reader.read() + if (first.done) { + throw new Error('Stream ended before first object') + } - // Each frame is a complete JSON string - const firstObject = JSON.parse(firstValue) + result = deserialize( + JSON.parse(first.value), + options, + initialPostProcessPromises, + ) + } catch (error) { + void reader.cancel(error).catch(() => {}) + reader.releaseLock() + throw error + } - // 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 () => { + // 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 { - // 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) - } + 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 (err) { - if (!drainCancelled) { - onError?.('Stream processing error:', err) - } + } catch (error) { + void reader.cancel(error).catch(() => {}) + console.error('Stream processing error:', error) + } finally { + reader.releaseLock() } })() - // 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) - // 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(() => {}) - }) - - // Detach reader once draining completes. - drain.finally(() => { - try { - reader.releaseLock() - } catch { - // Ignore - } - }) - return result } diff --git a/packages/start-client-core/src/constants.ts b/packages/start-client-core/src/constants.ts index 3df983dfe78..4ff333a3f24 100644 --- a/packages/start-client-core/src/constants.ts +++ b/packages/start-client-core/src/constants.ts @@ -14,59 +14,26 @@ 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 +/** Largest payload accepted by one framed-protocol record. */ +export const MAX_FRAME_PAYLOAD_SIZE = 16 * 1024 * 1024 + +/** Largest number of raw streams accepted in one framed response. */ +export const MAX_FRAMED_STREAMS = 1024 + /** 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}` -/** - * Parses the version parameter from a framed Content-Type header. - * Returns undefined if no version parameter is present. - */ -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 -} - -/** - * 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.`, - ) - } -} - /** * Minimal metadata about a server function, available to client middleware. * Only contains the function ID since name/filename may expose server internals. diff --git a/packages/start-client-core/src/getDefaultSerovalPlugins.ts b/packages/start-client-core/src/getDefaultSerovalPlugins.ts index 8070137c205..aeccb05b962 100644 --- a/packages/start-client-core/src/getDefaultSerovalPlugins.ts +++ b/packages/start-client-core/src/getDefaultSerovalPlugins.ts @@ -1,18 +1,28 @@ import { + createDefaultSerovalPlugins as createRouterDefaultSerovalPlugins, 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> { +export function getSerovalPlugins( + routerPlugins: Array>, +): Array> { const start = getStartOptions() const adapters = start?.serializationAdapters as | Array | undefined - return [ - ...(adapters?.map(makeSerovalPlugin) ?? []), - ...routerDefaultSerovalPlugins, - ] + return [...(adapters?.map(makeSerovalPlugin) ?? []), ...routerPlugins] +} + +export function getDefaultSerovalPlugins( + signal?: AbortSignal, +): Array> { + return getSerovalPlugins( + signal + ? createRouterDefaultSerovalPlugins(signal) + : routerDefaultSerovalPlugins, + ) } 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..e203df7efe3 100644 --- a/packages/start-client-core/tests/frame-decoder.test.ts +++ b/packages/start-client-core/tests/frame-decoder.test.ts @@ -1,6 +1,30 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { RawStream } from '@tanstack/router-core' +import { runWithStartContext } from '@tanstack/start-storage-context' 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, + 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 +44,248 @@ 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)) + }) + 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 +293,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 +312,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 +333,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 +353,145 @@ 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('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('should cancel input when jsonChunks cancelled', async () => { let cancelled = false const input = new ReadableStream({ @@ -140,15 +501,243 @@ 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('backpressures the input until an unread raw chunk is read', 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 vi.waitFor(() => expect(pulls).toBe(2)) + await Promise.resolve() + expect(pulls).toBe(2) + + const rawReader = rawStream.getReader() + await expect(rawReader.read()).resolves.toEqual({ + done: false, + value: new Uint8Array([1]), + }) + await expect(jsonReader.read()).resolves.toEqual({ + done: false, + value: '{"after":true}', + }) + await expect(rawReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + expect(getStream(1)).toBe(rawStream) + }) + + it('resumes after the blocking 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(2)) + + 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 wakes a decoder blocked by an unread raw chunk', 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(2)) + + 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)) + await expect(getStream(2).getReader().read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + 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 +758,22 @@ 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) + it('should count cancelled raw streams toward the stream limit', async () => { + const input = new ReadableStream({ pull() {} }) + const [chunks, getStream] = createFrameDecoder(input) - const input = new ReadableStream({ - start(controller) { - controller.enqueue(tooLarge) - controller.close() - }, - }) - - const { chunks: jsonChunks } = createFrameDecoder(input) - const reader = jsonChunks.getReader() + 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 +791,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const chunks: Array = [] @@ -240,8 +824,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 +860,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const chunks: Array = [] @@ -291,8 +874,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 +884,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 +908,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const { value } = await reader.read() @@ -354,7 +935,7 @@ describe('frame-decoder', () => { }, }) - const { chunks: jsonChunks } = createFrameDecoder(input) + const [jsonChunks] = createFrameDecoder(input) const reader = jsonChunks.getReader() const { value } = await reader.read() @@ -388,8 +969,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) @@ -430,8 +1010,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 +1020,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 +1051,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 +1072,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 +1103,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 +1122,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 +1148,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 +1156,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 +1174,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 +1201,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 +1223,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 +1250,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-server-core/INTERNALS.md b/packages/start-server-core/INTERNALS.md new file mode 100644 index 00000000000..b2a9e7d868f --- /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`. Start removes a marker after normal transfer or late disposal. + +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. + +While Router SSR state is live, Start binds later request cancellation to the disposer. Start adds no listener after Router SSR state is released. + +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..caef5e1beb8 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -16,14 +16,14 @@ import { import { attachRouterServerSsrUtils, bindSsrResponseToRequest, - disposeSsrResponseDetached, + disposeSsrResponse, getNormalizedURL, getOrigin, isSsrResponse, normalizeSsrResponse, replaceSsrResponse, stripSsrResponseBody, - waitForRequest, + waitForReason, } from '@tanstack/router-core/ssr/server' import { getStartContext, @@ -195,32 +195,106 @@ 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) -} - /** * Normalize middleware result to context shape */ function handleCtxResult(result: TODO) { - if (isSsrResponse(result) || isSpecialResponse(result)) { + if (isSsrResponse(result) || result instanceof Response) { return { response: result } } return result } +type StreamSsrResponse = Extract + +type ResponseBody = NonNullable +type ResponseWithBody = Response & { readonly body: ResponseBody } + +interface MiddlewareResponseOwnership { + response: ResponseWithBody + sourceBody: ResponseBody + streamResponse?: StreamSsrResponse +} + +const responseBodySources = new WeakMap() + function disposeLateResponse(result: TODO, signal: AbortSignal): void { const response = handleCtxResult(result)?.response - if (isSsrResponse(response) || isSpecialResponse(response)) { - disposeSsrResponseDetached(response, signal.reason) + if (isSsrResponse(response)) { + responseBodySources.delete(response.response) + disposeSsrResponse(response, signal.reason) + } else if (response instanceof Response) { + responseBodySources.delete(response) + disposeSsrResponse(response, signal.reason) + } +} + +/** + * Marks `response` as directly derived from the current `source`. + * Middleware must consume or cancel any other `clone()` or `tee()` branches. + */ +export function transferResponseBodyOwnership( + source: Response, + response: TResponse, +): TResponse { + if (!source.body || !response.body) { + throw new Error('Response body ownership requires two response bodies') + } + 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 ( + candidate === ownership.response || + (hasResponseBody(candidate) && + (candidate.body === ownership.response.body || + responseBodySources.get(candidate) === ownership.response)) + ) +} + +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 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 isSignalAborted(signal: AbortSignal): boolean { - return signal.aborted +function createLateResponseDisposer(signal: AbortSignal) { + return (result: TODO) => disposeLateResponse(result, signal) } /** @@ -230,92 +304,55 @@ async function executeMiddleware( middlewares: Array, ctx: TODO, signal: AbortSignal, -): Promise<{ ctx: TODO; response: HandlerCallbackResult }> { +): 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 + const disposeAbandonedResult = createLateResponseDisposer(signal) const setResponse = (response: TODO) => { - if (isSsrResponse(response)) { - if (response.serverSsrCleanup === 'stream') { - streamResponse = response + const streamResponse = + isSsrResponse(response) && response.serverSsrCleanup === 'stream' + ? response + : undefined + const exposed = isSsrResponse(response) ? response.response : response + const current = responseOwnership + + if (current && inheritsResponseOwnership(current, exposed)) { + responseBodySources.delete(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 } - 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) + ctx.response = exposed } - 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 + const reconcileCtxResponse = () => { + if (ctx.response !== responseOwnership?.response) { + setResponse(ctx.response) } - - disposeLateResponse(result, signal) } - const getFinalResponse = async (): Promise => { + const getFinalResponse = (): HandlerCallbackResult => { const response = ctx.response if (!response) { throwRouteHandlerError() } - if (!streamResponse) { - return response - } + reconcileCtxResponse() - if (response === streamResponse.response) { - return streamResponse - } - - if ( - streamResponse.response.body !== null && - response.body === streamResponse.response.body - ) { - return { ...streamResponse, response } - } - - await disposeStreamResponse('middleware response replaced') - return response + return responseOwnership ? getOwnedResponse(responseOwnership) : response } let nextPromise: Promise | undefined @@ -327,9 +364,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) { @@ -357,18 +392,26 @@ async function executeMiddleware( if (pending === nextPromise) { nextPromise = undefined result = await pending - if (isSignalAborted(signal)) { - disposeAbandonedResult(result) + if (signal.aborted) { throw signal.reason } } else { - result = await waitForRequest(pending, signal, disposeAbandonedResult) + result = await waitForReason( + pending, + signal, + disposeAbandonedResult, + disposeAbandonedResult, + ) } } catch (err) { - if (isSignalAborted(signal)) { + reconcileCtxResponse() + if (signal.aborted) { + if (err !== signal.reason) { + disposeAbandonedResult(err) + } throw signal.reason } - if (isSpecialResponse(err)) { + if (err instanceof Response) { setResponse(err) return ctx } @@ -376,6 +419,7 @@ async function executeMiddleware( } const normalized = handleCtxResult(result) + reconcileCtxResponse() if (normalized) { if (normalized.response !== undefined) { setResponse(normalized.response) @@ -390,22 +434,17 @@ async function executeMiddleware( try { await runNext() - const response = await waitForRequest( - getFinalResponse(), - signal, - disposeAbandonedResult, - ) + const response = getFinalResponse() if (signal.aborted) { - disposeAbandonedResult(response) throw signal.reason } - return { ctx, response } + return response } catch (err) { - const disposal = disposeStreamResponse(signal.aborted ? signal.reason : err) - if (signal.aborted) { - void disposal.catch(console.error) - } else { - await disposal + if (responseOwnership) { + disposeResponseOwnership( + responseOwnership, + signal.aborted ? signal.reason : err, + ) } throw err } @@ -416,7 +455,7 @@ async function executeMiddleware( */ function handlerToMiddleware( handler: RouteMethodHandlerFn, - mayDefer: boolean = false, + mayDefer: boolean, ): TODO { if (mayDefer) { return handler @@ -486,11 +525,13 @@ 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 @@ -502,12 +543,12 @@ export function createStartHandler( return Response.redirect(url, 308) } - const entries = await waitForRequest(getEntries(), request.signal) + const entries = await waitForReason(getEntries(), signal) const hasStartInstance = !!entries.startEntry.startInstance const startOptions: AnyStartInstanceOptions = - (await waitForRequest( + (await waitForReason( entries.startEntry.startInstance?.getOptions(), - request.signal, + signal, )) || ({} as AnyStartInstanceOptions) const { hasPluginAdapters, pluginSerializationAdapters } = @@ -538,39 +579,43 @@ export function createStartHandler( ) // Memoized router getter - const getRouter = async (): Promise => { - if (router) return router - - router = await waitForRequest( - entries.routerEntry.getRouter(), - request.signal, - ) + const getRouter = (): Promise => { + routerPromise ??= (async () => { + signal.throwIfAborted() + const requestRouter = await waitForReason( + entries.routerEntry.getRouter(), + 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], - }) + const history = createMemoryHistory({ + initialEntries: [href], + }) + + requestRouter.update({ + history, + isShell, + isPrerendering: IS_PRERENDERING, + origin: requestRouter.options.origin ?? origin, + ...{ + defaultSsr: requestStartOptions.defaultSsr, + serializationAdapters: [ + ...requestStartOptions.serializationAdapters, + ...(requestRouter.options.serializationAdapters || []), + ], + }, + basepath: ROUTER_BASEPATH, + }) - router.update({ - history, - isShell, - isPrerendering: IS_PRERENDERING, - origin: router.options.origin ?? origin, - ...{ - defaultSsr: requestStartOptions.defaultSsr, - serializationAdapters: [ - ...requestStartOptions.serializationAdapters, - ...(router.options.serializationAdapters || []), - ], - }, - basepath: ROUTER_BASEPATH, - }) + router = requestRouter + return requestRouter + })() - return router + return routerPromise } // Check for server function requests first (early exit) @@ -613,7 +658,7 @@ export function createStartHandler( const middlewares = flattenedRequestMiddlewares.map( (d) => d.options.server, ) - const { response: middlewareResponse } = await executeMiddleware( + const middlewareResponse = await executeMiddleware( [...middlewares, serverFnHandler], { request, @@ -621,17 +666,15 @@ export function createStartHandler( handlerType: 'serverFn', context: createNullProtoObject(requestOpts?.context), }, - request.signal, + signal, ) - const result = await handleRedirectResponse( - middlewareResponse, + const result = finalizeResponseForRequest( + await handleRedirectResponse(middlewareResponse, request, getRouter), request, - getRouter, - request.signal, ) - bindSsrResponseToRequest(router ?? undefined, result, request.signal) - request.signal.throwIfAborted() + bindSsrResponseToRequest(router, result, signal) + signal.throwIfAborted() responseOwnsCleanup = result.serverSsrCleanup === 'stream' return result.response } @@ -658,13 +701,13 @@ export function createStartHandler( ) } - const manifest = await waitForRequest( + const manifest = await waitForReason( resolveManifestForRequest({ request, requestInlineCss: requestOpts?.inlineCss, getBaseManifest: () => getBaseManifest(matchedRoutes), }), - request.signal, + signal, ) const earlyHints = createEarlyHintsForRequest({ @@ -686,8 +729,8 @@ export function createStartHandler( // `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() + await routerInstance.load({ _signal: signal }) + signal.throwIfAborted() if (routerInstance._serverResult?.type === 'redirect') { return normalizeSsrResponse(routerInstance._serverResult.redirect) @@ -699,33 +742,33 @@ export function createStartHandler( // Pass request-scoped assets to dehydrate for manifest injection const ctx = getStartContext({ throwIfNotFound: false }) - await waitForRequest( - routerInstance.serverSsr!.dehydrate({ - requestAssets: ctx?.requestAssets, - }), - request.signal, - ) - request.signal.throwIfAborted() + await routerInstance.serverSsr!.dehydrate({ + requestAssets: ctx?.requestAssets, + signal, + }) + signal.throwIfAborted() const responseHeaders = getStartResponseHeaders({ router: routerInstance, }) earlyHints?.appendResponseHeaders(responseHeaders) - request.signal.throwIfAborted() - const response = await waitForRequest( + signal.throwIfAborted() + const disposeLate = createLateResponseDisposer(signal) + const response = await waitForReason( cb({ request, router: routerInstance, responseHeaders, }), - request.signal, - (late) => disposeLateResponse(late, request.signal), + signal, + disposeLate, + disposeLate, ) return normalizeSsrResponse(response) } // Main request handler - const requestHandlerMiddleware = async ({ context }: TODO) => { + const requestHandlerMiddleware = ({ context }: TODO) => { return runWithStartContext( { getRouter, @@ -735,30 +778,22 @@ export function createStartHandler( executedRequestMiddlewares, handlerType: 'router', }, - async () => { - try { - return await handleServerRoutes({ - getRouter, - request, - url, - executeRouter, - context, - executedRequestMiddlewares, - }) - } catch (err) { - if (err instanceof Response) { - return err - } - throw err - } - }, + () => + handleServerRoutes({ + getRouter, + request, + url, + executeRouter, + context, + executedRequestMiddlewares, + }), ) } const middlewares = flattenedRequestMiddlewares.map( (d) => d.options.server, ) - const { response: middlewareResponse } = await executeMiddleware( + const middlewareResponse = await executeMiddleware( [...middlewares, requestHandlerMiddleware], { request, @@ -766,17 +801,15 @@ export function createStartHandler( handlerType: 'router', context: createNullProtoObject(requestOpts?.context), }, - request.signal, + signal, ) - const response = await handleRedirectResponse( - middlewareResponse, + const response = finalizeResponseForRequest( + await handleRedirectResponse(middlewareResponse, request, getRouter), request, - getRouter, - request.signal, ) - bindSsrResponseToRequest(router ?? undefined, response, request.signal) - request.signal.throwIfAborted() + bindSsrResponseToRequest(router, response, signal) + signal.throwIfAborted() responseOwnsCleanup = response.serverSsrCleanup === 'stream' return response.response } finally { @@ -786,19 +819,29 @@ export function createStartHandler( // Transformed streaming response bodies clean up when consumed/cancelled. router.serverSsr.cleanup() } - router = null + router = undefined + routerPromise = undefined } } return requestHandler(startRequestResolver) } +function finalizeResponseForRequest( + response: SsrResponse, + request: Request, +): SsrResponse { + return request.method === 'HEAD' + ? stripSsrResponseBody(response, 'HEAD body stripped') + : response +} + 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)) { @@ -807,16 +850,13 @@ async function handleRedirectResponse( 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', + return replaceSsrResponse( + ssrResponse, + Response.json( + { ...ssrResponse.response.options, isSerializedRedirect: true }, + { headers: ssrResponse.response.headers }, ), - signal, + 'redirect response replaced', ) } return ssrResponse @@ -845,28 +885,22 @@ async function handleRedirectResponse( } signal.throwIfAborted() - const router = await waitForRequest(getRouter(), signal) + const router = await getRouter() signal.throwIfAborted() const redirect = router.resolveRedirect(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', + return replaceSsrResponse( + ssrResponse, + Response.json( + { ...ssrResponse.response.options, isSerializedRedirect: true }, + { headers: ssrResponse.response.headers }, ), - signal, + 'redirect response replaced', ) } - return waitForRequest( - replaceSsrResponse(ssrResponse, redirect, 'redirect response replaced'), - signal, - ) + return replaceSsrResponse(ssrResponse, redirect, 'redirect response replaced') } async function handleServerRoutes({ @@ -919,7 +953,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,9 +966,6 @@ 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 @@ -959,7 +989,7 @@ async function handleServerRoutes({ routeMiddlewares.push(((ctx: TODO) => executeRouter(ctx.context, matchedRoutes)) as TODO) - const { ctx, response } = await executeMiddleware( + const response = await executeMiddleware( routeMiddlewares, { request, @@ -971,24 +1001,5 @@ async function handleServerRoutes({ request.signal, ) - // 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..27124a7c62f 100644 --- a/packages/start-server-core/src/frame-protocol.ts +++ b/packages/start-server-core/src/frame-protocol.ts @@ -9,21 +9,41 @@ */ // Re-export constants from shared location -import { FRAME_HEADER_SIZE, FrameType } from '@tanstack/start-client-core' +import { + FRAME_HEADER_SIZE, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + FRAME_TYPE_ERROR, + FRAME_TYPE_JSON, + MAX_FRAMED_STREAMS, + MAX_FRAME_PAYLOAD_SIZE, +} from '@tanstack/start-client-core' export { FRAME_HEADER_SIZE, - FrameType, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + FRAME_TYPE_ERROR, + FRAME_TYPE_JSON, + MAX_FRAME_PAYLOAD_SIZE, + MAX_FRAMED_STREAMS, TSS_CONTENT_TYPE_FRAMED, TSS_CONTENT_TYPE_FRAMED_VERSIONED, TSS_FRAMED_PROTOCOL_VERSION, } 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 +53,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,13 +74,6 @@ 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. */ @@ -63,23 +81,27 @@ export function encodeChunkFrame( streamId: number, chunk: Uint8Array, ): Uint8Array { - return encodeFrame(FrameType.CHUNK, streamId, chunk) + return encodeFrame(FRAME_TYPE_CHUNK, streamId, chunk) } /** * Encodes a raw stream end frame. */ export function encodeEndFrame(streamId: number): Uint8Array { - return encodeFrame(FrameType.END, streamId, EMPTY_PAYLOAD) + return encodeFrame(FRAME_TYPE_END, streamId, EMPTY_PAYLOAD) } /** * Encodes a raw stream error frame. */ export function encodeErrorFrame(streamId: number, error: unknown): Uint8Array { - const message = + 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 encodeFrame(FRAME_TYPE_ERROR, streamId, textEncoder.encode(message)) } /** @@ -91,168 +113,259 @@ 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. - * - * 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). + * Creates a multiplexed ReadableStream from serialized response records. * - * @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. */ 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 streamCount = 0 + let wakeDemand: (() => void) | undefined + let admission = Promise.resolve() + 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 cancelReaders = (reason?: unknown) => { + for (const reader of readers) { + cancelReader(reader, reason) + } + } + + const stop = (reason?: unknown) => { + if (stopped) { return false } + stopped = [reason] + options.signal?.removeEventListener('abort', abortOutput) + wakeAdmission() + cancelReaders(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. + const admitFrame = (createFrame: () => Uint8Array) => { + const result = admission.then(async () => { + if (!(await waitForDemand())) { + return false + } + controller.enqueue(createFrame()) + return true + }) + admission = result.then( + () => undefined, + () => undefined, + ) + return result + } + + const maybeClose = () => { + if (activePumps !== 0 || !stop()) { + return + } + try { + controller.close() + } catch { + // The output was already closed by its consumer. } } - // Pumps a raw stream, sending CHUNK frames and END/ERROR on completion + const startPump = (pump: () => Promise) => { + activePumps++ + void pump().then( + () => { + activePumps-- + maybeClose() + }, + (error) => { + activePumps-- + errorOutput(error) + }, + ) + } + 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)) + await admitFrame(() => encodeEndFrame(streamId)) return } - if (!enqueue(encodeChunkFrame(streamId, value))) return + if (value.byteLength === 0) { + if (!(await admitFrame(() => encodeChunkFrame(streamId, value)))) { + return + } + } else { + for ( + let offset = 0; + offset < value.byteLength; + offset += MAX_FRAME_PAYLOAD_SIZE + ) { + const chunk = value.subarray( + offset, + offset + MAX_FRAME_PAYLOAD_SIZE, + ) + if (!(await admitFrame(() => encodeChunkFrame(streamId, chunk)))) { + return + } + } + } } } 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. + await admitFrame(() => encodeErrorFrame(streamId, error)) + } } 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 + } + if (streamCount + value.rawStreams.length > MAX_FRAMED_STREAMS) { + for (const registration of value.rawStreams) { + cancelStream(registration.stream) + } + throw new Error( + `Too many raw streams in framed response (max ${MAX_FRAMED_STREAMS})`, + ) + } + streamCount += value.rawStreams.length + for (const registration of value.rawStreams) { + pendingRawStreams.add(registration.stream) + } + if ( + !(await admitFrame(() => encodeFrame(FRAME_TYPE_JSON, 0, value.json))) + ) { + 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) + startPump(pumpRecords) + if (options.signal?.aborted) { + errorOutput(options.signal.reason) + return } + options.signal?.addEventListener('abort', abortOutput, { once: true }) }, - - 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..0c8d311d35a 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -1,24 +1,28 @@ +import { invariant, isNotFound, isRedirect } from '@tanstack/router-core' import { createRawStreamRPCPlugin, - invariant, - isNotFound, - isRedirect, -} from '@tanstack/router-core' + defaultSerovalPlugins as routerDefaultSerovalPlugins, +} from '@tanstack/router-core/ssr/server' import { 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 { + MAX_FRAMED_STREAMS, + MAX_FRAME_PAYLOAD_SIZE, TSS_CONTENT_TYPE_FRAMED_VERSIONED, createMultiplexedStream, } from './frame-protocol' -import type { LateStreamRegistration } from './frame-protocol' +import type { + LateStreamRegistration, + MultiplexedStreamRecord, +} from './frame-protocol' import type { Plugin as SerovalPlugin } from 'seroval' // Cache serovalPlugins at module level to avoid repeated calls @@ -32,6 +36,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) + '\n') +} + +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, @@ -66,7 +98,7 @@ export const handleServerAction = async ({ // Initialize serovalPlugins lazily (cached at module level) if (!serovalPlugins) { - serovalPlugins = getDefaultSerovalPlugins() + serovalPlugins = getSerovalPlugins(routerDefaultSerovalPlugins) } const contentType = request.headers.get('Content-Type') @@ -159,7 +191,7 @@ export const handleServerAction = async ({ return await action(payload) })() - const unwrapped = res.result || res.error + const unwrapped = res.result !== undefined ? res.result : res.error if (isNotFound(res)) { res = isNotFoundResponse(res) @@ -180,187 +212,257 @@ export const handleServerAction = async ({ 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 initialRecords: Array = [] + let initialBytes = 0 + const pendingRawStreams: Array = [] + // first run without the stream in case `result` does not need streaming + let done = false as boolean + let serializationFailure: [unknown] | undefined + let disposeSerialization: (() => void) | undefined + let onParse = (value: any) => { + if (serializationFailure) { + return + } + 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 = () => { + done = true + } + let onError = (error: any) => { + serializationFailure ??= [error] + } const rawStreamPlugin = createRawStreamRPCPlugin( (id: number, stream: ReadableStream) => { - if (initialPhase) { - rawStreams.set(id, stream) + if (serializationFailure) { + cancelRawStream(stream, serializationFailure[0]) return } - - if (lateStreamWriter) { - // Late stream - write to the late stream channel - lateStreamWriter.write({ id, stream }).catch(() => { - // Ignore write errors - stream may be closed - }) + 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 } - - // Discovered after initial phase but before writer exists. - pendingLateStreams.push({ id, stream }) + pendingRawStreams.push({ id, stream }) }, ) - // 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, { + const plugins = [rawStreamPlugin, ...serovalPlugins!] + const dispose = toCrossJSONStream(res, { refs: new Map(), plugins, onParse(value) { - callbacks.onParse(value) + onParse(value) }, onDone() { - callbacks.onDone() + onDone() }, onError: (error) => { - callbacks.onError(error) + onError(error) }, }) - - // 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 (serializationFailure) { + runSerializationCleanup(dispose) + for (const registration of pendingRawStreams) { + cancelRawStream(registration.stream, serializationFailure[0]) + } + throw serializationFailure[0] + } + if (!done) { + disposeSerialization = dispose + } // 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 && + 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', }, - ) + }) } - // Not done synchronously or has raw streams - use framed protocol - // This supports late RawStreams from resolved Promises + // Couple every JSON patch to the RawStreams it introduces. The mux + // admits the JSON before it starts those streams, so a bounded + // client can never block on a chunk before receiving its reference. 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 - }) + 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) + } } - 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 - }) + const writeRecord = ( + json: Uint8Array, + rawStreams: Array, + ) => { + if (serializationFailure) { + for (const registration of rawStreams) { + cancelRawStream(registration.stream, serializationFailure[0]) } + return false + } - callbacks.onError = (error) => { - controller.error(error) - lateStreamWriter - ?.abort(error) - .catch(() => { - // Ignore abort errors - }) - .finally(() => { - lateStreamWriter = undefined - }) + 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) } + onError(error) + return false + } - // 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() + 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 : [], + ) + ) { + if (!isLast) { + for (const registration of initialRawStreams) { + cancelRawStream(registration.stream, serializationFailure![0]) + } } - }, - cancel() { - lateStreamWriter?.abort().catch(() => {}) - lateStreamWriter = undefined - }, - }) + break + } + } + initialRecords.length = 0 + if (done) { + onDone() + } - // Create multiplexed stream with JSON, initial raw streams, and late streams - const multiplexedStream = createMultiplexedStream( - jsonStream, - rawStreams, - lateStreamReadable, - ) + void writer.closed.catch((error) => { + abortRecordStream(error) + }) - return new Response(multiplexedStream, { - status: alsResponse.status, - statusText: alsResponse.statusText, - headers: { - 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, - [X_TSS_SERIALIZED]: 'true', - }, + const multiplexedStream = createMultiplexedStream(readable, { + signal: AbortSignal.any([ + recordAbortController.signal, + request.signal, + ]), + onCancel: abortRecordStream, }) + + try { + return new Response(multiplexedStream, { + status: alsResponse.status, + statusText: alsResponse.statusText, + headers: { + 'Content-Type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }) + } catch (error) { + void multiplexedStream.cancel(error).catch(() => {}) + throw error + } } return new Response(undefined, { @@ -396,22 +498,29 @@ export const handleServerAction = async ({ console.info() const serializedError = JSON.stringify( - await Promise.resolve( - toCrossJSONAsync(error, { - refs: new Map(), - plugins: serovalPlugins, - }), - ), + await toCrossJSONAsync(error, { + refs: new Map(), + plugins: serovalPlugins, + }), ) const response = getResponse() - return new Response(serializedError, { - status: response.status ?? 500, - statusText: response.statusText, - headers: { - 'Content-Type': 'application/json', - [X_TSS_SERIALIZED]: 'true', - }, - }) + 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, + }) + } } })() diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index dbcc169cb59..47258e48afb 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() }) @@ -256,6 +284,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 +389,291 @@ 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('cancels a derived body when the request aborts after handoff', async () => { + 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 }) => { + const ssrResponse = createSsrStreamResponse( + requestRouter, + new Response(source), + ) + 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).toHaveBeenCalledWith(reason) + expect(derivedCancel).toHaveBeenCalledWith(reason) + expect(sourceCancel).toHaveBeenCalledWith(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 +688,7 @@ describe('createStartHandler SSR cleanup ownership', () => { expect(response.status).toBe(500) expect(dispose).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledOnce() expect(router.serverSsr).toBeUndefined() }) @@ -325,7 +701,37 @@ 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 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 }), ] @@ -338,38 +744,212 @@ describe('createStartHandler SSR cleanup ownership', () => { {}, ) - expect(response).toBe(replacement) - expect(dispose).toHaveBeenCalledOnce() - expect(router.serverSsr).toBeUndefined() + 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) }) +}) - it('honors in-place response assignment on returned context', async () => { +describe('createStartHandler HEAD fallback', () => { + it('strips and disposes the rendered document 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') - startMocks.requestMiddleware = [ - createMiddleware().server(async ({ next }) => { - const result = await next() - result.response = replacement - return result - }), - ] + const cancel = vi.fn() + let cleanupEffects = 0 - const handler = createStartHandler(() => new Response('unused')) + 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('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 handler = createStartHandler(() => new Response('must not render')) + const response = await handler( + new Request('http://localhost/', { method: 'HEAD' }), + {}, + ) + + expect(response.body).toBeNull() + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith('HEAD body stripped') + }) }) describe('createStartHandler request cancellation', () => { @@ -429,16 +1009,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 +1042,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 +1089,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 +1212,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 +1303,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 +1326,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 +1341,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 +1377,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 +1385,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 +1413,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 +1421,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 +1450,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 +1480,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..54875cc8526 100644 --- a/packages/start-server-core/tests/frame-protocol.test.ts +++ b/packages/start-server-core/tests/frame-protocol.test.ts @@ -1,26 +1,54 @@ import { describe, expect, it } from 'vitest' import { FRAME_HEADER_SIZE, - FrameType, + FRAME_TYPE_CHUNK, + FRAME_TYPE_END, + FRAME_TYPE_ERROR, + FRAME_TYPE_JSON, + MAX_FRAME_PAYLOAD_SIZE, + MAX_FRAMED_STREAMS, createMultiplexedStream, encodeChunkFrame, encodeEndFrame, encodeErrorFrame, 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 +57,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', () => { it('should encode binary chunk with frame type CHUNK', () => { const chunk = new Uint8Array([0xff, 0xfe, 0xfd]) const frame = encodeChunkFrame(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) @@ -78,7 +88,7 @@ describe('frame-protocol', () => { 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) }) @@ -89,7 +99,7 @@ describe('frame-protocol', () => { const frame = encodeErrorFrame(789, 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) @@ -109,21 +119,26 @@ describe('frame-protocol', () => { const payload = frame.slice(FRAME_HEADER_SIZE) expect(new TextDecoder().decode(payload)).toBe('Unknown error') }) + + it('should bound oversized raw-stream error messages', () => { + const frame = encodeErrorFrame( + 1, + new Error('x'.repeat(MAX_FRAME_PAYLOAD_SIZE + 1)), + ) + const payload = frame.slice(FRAME_HEADER_SIZE) + + 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 +155,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 +167,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 +190,192 @@ 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 - - const jsonStream = new ReadableStream({ - async start(controller) { - await new Promise((r) => setTimeout(r, 100)) - controller.enqueue('{}\n') + 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() }, - cancel() { - jsonCancelled = true + }) + 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) + } + + 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('rejects excess raw streams before starting their readers', async () => { + let pullCount = 0 + let cancelCount = 0 + const rawStreams = Array.from( + { length: MAX_FRAMED_STREAMS + 1 }, + (_, index) => ({ + id: index + 1, + stream: new ReadableStream( + { + pull() { + pullCount++ + }, + cancel() { + cancelCount++ + }, + }, + { highWaterMark: 0 }, + ), + }), + ) + const multiplexed = createMultiplexedStream( + createRecordStream([createRecord('{}', rawStreams)]), + ) + + await expect(multiplexed.getReader().read()).rejects.toThrow( + 'Too many raw streams', + ) + expect(pullCount).toBe(0) + expect(cancelCount).toBe(MAX_FRAMED_STREAMS + 1) + expect(rawStreams.every(({ stream }) => !stream.locked)).toBe(true) + }) + + 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('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('should interleave multiple raw streams correctly', async () => { @@ -235,13 +385,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 +403,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 +441,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 +494,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 +509,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 +550,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 +564,8 @@ describe('frame-protocol', () => { c.close() }, }), - }) - controller.enqueue({ + }, + { id: 20, stream: new ReadableStream({ start(c) { @@ -466,16 +573,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 +597,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 +614,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 +667,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 +700,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 +715,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 +731,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 +753,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 +764,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..37fb6fe357e --- /dev/null +++ b/packages/start-server-core/tests/server-functions-handler.test.ts @@ -0,0 +1,417 @@ +// @vitest-environment node + +import { beforeEach, expect, test, vi } from 'vitest' +import { createStream, fromCrossJSON } from 'seroval' +import { X_TSS_SERIALIZED } from '@tanstack/start-client-core' +import { RawStream } from '@tanstack/router-core' +import { defaultSerovalPlugins } from '@tanstack/router-core/ssr/server' +import { handleServerAction } from '../src/server-functions-handler' +import { + FRAME_HEADER_SIZE, + FRAME_TYPE_CHUNK, + FRAME_TYPE_JSON, + MAX_FRAMED_STREAMS, + TSS_CONTENT_TYPE_FRAMED_VERSIONED, +} from '../src/frame-protocol' + +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 { defaultSerovalPlugins } = + await import('@tanstack/router-core/ssr/server') + return { + ...actual, + getSerovalPlugins: () => defaultSerovalPlugins, + } +}) + +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('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('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: defaultSerovalPlugins, + }), + ).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 value = fromCrossJSON( + JSON.parse(new TextDecoder().decode(frames[index]!.payload)), + { refs, plugins: defaultSerovalPlugins }, + ) + 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('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: defaultSerovalPlugins, + }) + 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 }) + 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) + await expect(response.body!.getReader().read()).rejects.toThrow( + 'pending output limit', + ) +}) + +test('cancels framed serialization when Response construction rejects it', async () => { + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + mocks.response.status = 204 + mocks.response.statusText = 'No Content' + mocks.action.mockResolvedValue({ result: 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.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..a5450a0cd5f 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' @@ -120,7 +122,11 @@ const fetchItem = async ({ method: 'GET', }) .then((r) => r.json()) - .then((d) => fromJSON(d, { plugins: getDefaultSerovalPlugins() })) + .then((d) => + fromJSON(d, { + plugins: getSerovalPlugins(defaultSerovalDeserializerPlugins), + }), + ) return result } diff --git a/packages/vue-router-ssr-query/src/index.tsx b/packages/vue-router-ssr-query/src/index.tsx index c9b666acad9..8989a72ac10 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 (Array.isArray(children) && 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..d63d522bb88 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -1,67 +1,15 @@ -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, + transformHtmlStringWithRouter, transformReadableStreamWithRouter, + waitForReason, } 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' - -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 - } - - 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() - }, - }) -} +import type { ReadableStream } from 'node:stream/web' export const renderRouterToStream = async ({ request, @@ -74,48 +22,32 @@ export const renderRouterToStream = async ({ responseHeaders: Headers App: Component }) => { - const app = Vue.createSSRApp(App, { router }) + const signal = request.signal + if (signal.aborted) { + router.serverSsr?.cleanup() + throw signal.reason + } + + let app: ReturnType + try { + app = Vue.createSSRApp(App, { router }) + } catch (error) { + router.serverSsr?.cleanup() + throw error + } + const status = + router._serverResult?.type === 'render' ? router._serverResult.status : 200 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('') + let fullHtml = await waitForReason(renderToString(app), signal) - if (htmlOpenIndex !== -1 && htmlCloseIndex !== -1) { - fullHtml = fullHtml.slice(htmlOpenIndex, htmlCloseIndex + 7) - } else if (htmlOpenIndex !== -1) { - fullHtml = fullHtml.slice(htmlOpenIndex) - } + fullHtml = await transformHtmlStringWithRouter(router, fullHtml, { + signal, + }) - return new Response(`${fullHtml}`, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, + return new Response(fullHtml, { + status, headers: responseHeaders, }) } finally { @@ -123,107 +55,30 @@ export const renderRouterToStream = async ({ } } - 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. + let readable: ReturnType try { - pipeToWebWritable(app, {}, vueWritable) + readable = renderToWebStream(app).pipeThrough( + new TransformStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('')) + }, + }), + ) } 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) - }) + router.serverSsr?.cleanup() + throw err } - const doctypedStream = prependDoctype(readable) const responseStream = transformReadableStreamWithRouter( router, - doctypedStream, - { signal: request.signal, onAbort: abortVuePipe }, + readable as unknown as ReadableStream, + { signal }, ) return createSsrStreamResponse( router, new Response(responseStream as any, { - status: - router._serverResult?.type === 'render' - ? router._serverResult.status - : 200, + status, headers: responseHeaders, }), ) diff --git a/packages/vue-router/src/ssr/renderRouterToString.tsx b/packages/vue-router/src/ssr/renderRouterToString.tsx index 723aa0b91e9..c097dc10eff 100644 --- a/packages/vue-router/src/ssr/renderRouterToString.tsx +++ b/packages/vue-router/src/ssr/renderRouterToString.tsx @@ -1,5 +1,6 @@ import * as Vue from 'vue' import { renderToString as vueRenderToString } from 'vue/server-renderer' +import { transformHtmlStringWithRouter } from '@tanstack/router-core/ssr/server' import type { AnyRouter } from '@tanstack/router-core' import type { Component } from 'vue' @@ -15,15 +16,12 @@ export const renderRouterToString = async ({ try { const app = Vue.createSSRApp(App, { router }) - let html = await vueRenderToString(app) - router.serverSsr!.setRenderFinished() + const html = await transformHtmlStringWithRouter( + router, + await vueRenderToString(app), + ) - const injectedHtml = router.serverSsr!.takeBufferedHtml() - if (injectedHtml) { - html = html.replace(``, () => `${injectedHtml}`) - } - - return new Response(`${html}`, { + return new Response(html, { status: router._serverResult?.type === 'render' ? router._serverResult.status diff --git a/packages/vue-router/tests/renderRouterToStream.test.tsx b/packages/vue-router/tests/renderRouterToStream.test.tsx index 60bfa9a8af6..14bbc1a9a23 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,282 @@ 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 removeEventListener = vi.spyOn(request.signal, 'removeEventListener') + 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) + expect( + addEventListener.mock.calls.filter(([type]) => type === 'abort'), + ).toHaveLength(1) + expect( + removeEventListener.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':