From bfd58112d1c0ba9b527e34d0148a6a710cba01eb Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 21 Dec 2025 11:12:19 +0100 Subject: [PATCH 1/6] add benchmark for closing tag detection Benchmark Summary | Scenario | Winner | Speed vs Regex | |----------|--------|----------------| | Small Chunk (~70 chars) | optimized (charCodeAt) | 5x faster | | Medium Chunk (~1.5KB) | manual backwards scan | 52x faster | | Large Chunk (~13KB) | optimized (charCodeAt) | 370x faster | | Web Components | optimized (charCodeAt) | 75x faster | | No Closing Tags | regex wins | 1x (regex is actually best here) | | Nested React-like | optimized (charCodeAt) | 28x faster | | Partial Chunk | optimized (charCodeAt) | 2x faster | Key findings: 1. The optimized (charCodeAt) approach is consistently the fastest or close to fastest across all scenarios 2. For realistic HTML chunks with closing tags, the optimized approach is 5x to 370x faster than regex 3. The only case where regex wins is when there are no closing tags at all, which is an edge case for plain text - but even then, all approaches are blazingly fast (millions of ops/sec) --- .../tests/closing-tag-detection.bench.ts | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) create mode 100644 packages/router-core/tests/closing-tag-detection.bench.ts diff --git a/packages/router-core/tests/closing-tag-detection.bench.ts b/packages/router-core/tests/closing-tag-detection.bench.ts new file mode 100644 index 0000000000..a924256dc2 --- /dev/null +++ b/packages/router-core/tests/closing-tag-detection.bench.ts @@ -0,0 +1,441 @@ +import { bench, describe } from 'vitest' + +/** + * Benchmark comparing different approaches for finding the last closing tag in HTML chunks. + * + * The goal is to find the position just after the last closing tag (e.g., , ) + * so we can insert router HTML at that point. + * + * Requirements (from the regex pattern /(<\/[a-zA-Z][\w:.-]*?>)/g): + * - Closing tag starts with + */ + +// ============================================================================ +// Implementation 1: Regex-based (current implementation) +// ============================================================================ +function findLastClosingTagRegex(str: string): number { + const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g + let lastIndex = 0 + let result: RegExpExecArray | null + while ((result = patternClosingTag.exec(str)) !== null) { + lastIndex = result.index + result[0].length + } + return lastIndex > 0 ? lastIndex : -1 +} + +// ============================================================================ +// Implementation 2: Manual backwards scan +// ============================================================================ +function isTagNameStartChar(char: string): boolean { + const code = char.charCodeAt(0) + // a-z: 97-122, A-Z: 65-90 + return (code >= 97 && code <= 122) || (code >= 65 && code <= 90) +} + +function isTagNameChar(char: string): boolean { + const code = char.charCodeAt(0) + // a-z: 97-122, A-Z: 65-90, 0-9: 48-57, _: 95, :: 58, .: 46, -: 45 + return ( + (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 // - + ) +} + +function findLastClosingTagManual(str: string): number { + // Search backwards for pattern + let i = str.length - 1 + + while (i >= 1) { + // Look for > + if (str[i] === '>') { + // Look backwards for = 1 && isTagNameChar(str[j]!)) { + j-- + } + + // Check if the first char after = 1 && str[j] === '/' && str[j - 1] === '<') { + foundValidTag = true + } + } + + if (foundValidTag) { + return i + 1 // Return position after the closing > + } + } + i-- + } + return -1 +} + +// ============================================================================ +// Implementation 3: Optimized manual with charCodeAt (avoid string indexing) +// ============================================================================ +function findLastClosingTagOptimized(str: string): number { + const len = str.length + if (len < 4) return -1 // Minimum: + + let i = len - 1 + + while (i >= 3) { + // Need at least 4 chars: + // Look for > (charCode 62) + if (str.charCodeAt(i) === 62) { + // Look backwards for = 1) { + const code = str.charCodeAt(j) + // Check if it's a valid tag name char + 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 // - + ) { + j-- + } else { + break + } + } + + // Check if the first char after = 97 && startCode <= 122) || + (startCode >= 65 && startCode <= 90) + ) { + // Check for = 1 && + str.charCodeAt(j) === 47 && + str.charCodeAt(j - 1) === 60 + ) { + return i + 1 // Return position after the closing > + } + } + } + } + i-- + } + return -1 +} + +// ============================================================================ +// Implementation 4: Hybrid - use lastIndexOf to find candidates, then validate +// ============================================================================ +function findLastClosingTagHybrid(str: string): number { + let searchFrom = str.length - 1 + + while (searchFrom >= 3) { + // Find the last > starting from searchFrom + const closeIndex = str.lastIndexOf('>', searchFrom) + if (closeIndex < 3) return -1 // Not enough room for + + // Look backwards for = 1) { + const code = str.charCodeAt(j) + 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 // - + ) { + j-- + } else { + break + } + } + + // Check if the first char after = 97 && startCode <= 122) || + (startCode >= 65 && startCode <= 90) + ) { + if ( + j >= 1 && + str.charCodeAt(j) === 47 && + str.charCodeAt(j - 1) === 60 + ) { + return closeIndex + 1 + } + } + } + + // Not a valid tag, continue searching before this > + searchFrom = closeIndex - 1 + } + + return -1 +} + +// ============================================================================ +// Test Data Generation +// ============================================================================ + +// Small chunk - typical streaming chunk +function generateSmallChunk(): string { + return `

Hello World

Some text
` +} + +// Medium chunk - several elements +function generateMediumChunk(): string { + let html = '
' + for (let i = 0; i < 10; i++) { + html += `

Title ${i}

Paragraph with some content ${i}

Custom element content
` + } + html += '
' + return html +} + +// Large chunk - many elements +function generateLargeChunk(): string { + let html = 'Test' + for (let i = 0; i < 100; i++) { + html += `
Label ${i}
` + } + html += '' + return html +} + +// Chunk with custom elements (web components) +function generateWebComponentChunk(): string { + let html = '
' + for (let i = 0; i < 20; i++) { + html += `` + } + html += '
' + return html +} + +// Chunk with no closing tags (edge case) +function generateNoClosingTagChunk(): string { + return 'This is just plain text with no HTML tags at all' +} + +// Chunk ending mid-tag (streaming edge case) +function generatePartialChunk(): string { + return '

Content

More' +} + +// Chunk with nested structure (realistic React output) +function generateNestedChunk(): string { + return `

Article Title

First paragraph with bold and italic text.

Second paragraph with a link.

Footer content

` +} + +// Verify all implementations return the same result +function verifyImplementations() { + const testCases = [ + generateSmallChunk(), + generateMediumChunk(), + generateLargeChunk(), + generateWebComponentChunk(), + generateNoClosingTagChunk(), + generatePartialChunk(), + generateNestedChunk(), + '
', + '
', + '', + 'no tags here', + '', + ] + + for (const testCase of testCases) { + const regexResult = findLastClosingTagRegex(testCase) + const manualResult = findLastClosingTagManual(testCase) + const optimizedResult = findLastClosingTagOptimized(testCase) + const hybridResult = findLastClosingTagHybrid(testCase) + + if ( + regexResult !== manualResult || + regexResult !== optimizedResult || + regexResult !== hybridResult + ) { + console.error('Mismatch for:', testCase.slice(0, 50)) + console.error(' Regex:', regexResult) + console.error(' Manual:', manualResult) + console.error(' Optimized:', optimizedResult) + console.error(' Hybrid:', hybridResult) + throw new Error('Implementation mismatch!') + } + } + console.log('All implementations verified to produce identical results') +} + +// Run verification before benchmarks +verifyImplementations() + +// ============================================================================ +// Benchmarks +// ============================================================================ + +describe('Closing Tag Detection - Small Chunk (~70 chars)', () => { + const chunk = generateSmallChunk() + + bench('regex', () => { + findLastClosingTagRegex(chunk) + }) + + bench('manual backwards scan', () => { + findLastClosingTagManual(chunk) + }) + + bench('optimized (charCodeAt)', () => { + findLastClosingTagOptimized(chunk) + }) + + bench('hybrid (lastIndexOf + validation)', () => { + findLastClosingTagHybrid(chunk) + }) +}) + +describe('Closing Tag Detection - Medium Chunk (~1.5KB)', () => { + const chunk = generateMediumChunk() + + bench('regex', () => { + findLastClosingTagRegex(chunk) + }) + + bench('manual backwards scan', () => { + findLastClosingTagManual(chunk) + }) + + bench('optimized (charCodeAt)', () => { + findLastClosingTagOptimized(chunk) + }) + + bench('hybrid (lastIndexOf + validation)', () => { + findLastClosingTagHybrid(chunk) + }) +}) + +describe('Closing Tag Detection - Large Chunk (~13KB)', () => { + const chunk = generateLargeChunk() + + bench('regex', () => { + findLastClosingTagRegex(chunk) + }) + + bench('manual backwards scan', () => { + findLastClosingTagManual(chunk) + }) + + bench('optimized (charCodeAt)', () => { + findLastClosingTagOptimized(chunk) + }) + + bench('hybrid (lastIndexOf + validation)', () => { + findLastClosingTagHybrid(chunk) + }) +}) + +describe('Closing Tag Detection - Web Components', () => { + const chunk = generateWebComponentChunk() + + bench('regex', () => { + findLastClosingTagRegex(chunk) + }) + + bench('manual backwards scan', () => { + findLastClosingTagManual(chunk) + }) + + bench('optimized (charCodeAt)', () => { + findLastClosingTagOptimized(chunk) + }) + + bench('hybrid (lastIndexOf + validation)', () => { + findLastClosingTagHybrid(chunk) + }) +}) + +describe('Closing Tag Detection - No Closing Tags (worst case for regex)', () => { + const chunk = generateNoClosingTagChunk() + + bench('regex', () => { + findLastClosingTagRegex(chunk) + }) + + bench('manual backwards scan', () => { + findLastClosingTagManual(chunk) + }) + + bench('optimized (charCodeAt)', () => { + findLastClosingTagOptimized(chunk) + }) + + bench('hybrid (lastIndexOf + validation)', () => { + findLastClosingTagHybrid(chunk) + }) +}) + +describe('Closing Tag Detection - Nested React-like Structure', () => { + const chunk = generateNestedChunk() + + bench('regex', () => { + findLastClosingTagRegex(chunk) + }) + + bench('manual backwards scan', () => { + findLastClosingTagManual(chunk) + }) + + bench('optimized (charCodeAt)', () => { + findLastClosingTagOptimized(chunk) + }) + + bench('hybrid (lastIndexOf + validation)', () => { + findLastClosingTagHybrid(chunk) + }) +}) + +describe('Closing Tag Detection - Partial Chunk (streaming edge case)', () => { + const chunk = generatePartialChunk() + + bench('regex', () => { + findLastClosingTagRegex(chunk) + }) + + bench('manual backwards scan', () => { + findLastClosingTagManual(chunk) + }) + + bench('optimized (charCodeAt)', () => { + findLastClosingTagOptimized(chunk) + }) + + bench('hybrid (lastIndexOf + validation)', () => { + findLastClosingTagHybrid(chunk) + }) +}) From e224b6a1fe755b178fa038d31a9c8f7771d7336f Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 21 Dec 2025 23:05:08 +0100 Subject: [PATCH 2/6] fix: streaming --- .../react/api/router/RouterEventsType.md | 1 - e2e/react-start/streaming-ssr/.gitignore | 19 + e2e/react-start/streaming-ssr/package.json | 31 + .../streaming-ssr/playwright.config.ts | 35 ++ .../streaming-ssr/src/routeTree.gen.ts | 261 ++++++++ e2e/react-start/streaming-ssr/src/router.tsx | 18 + .../streaming-ssr/src/routes/__root.tsx | 114 ++++ .../streaming-ssr/src/routes/concurrent.tsx | 121 ++++ .../streaming-ssr/src/routes/deferred.tsx | 108 ++++ .../streaming-ssr/src/routes/fast-serial.tsx | 53 ++ .../streaming-ssr/src/routes/index.tsx | 52 ++ .../src/routes/many-promises.tsx | 162 +++++ .../src/routes/nested-deferred.tsx | 137 +++++ .../streaming-ssr/src/routes/query-heavy.tsx | 285 +++++++++ .../streaming-ssr/src/routes/slow-render.tsx | 85 +++ .../streaming-ssr/src/routes/stream.tsx | 116 ++++ .../streaming-ssr/src/routes/sync-only.tsx | 41 ++ .../tests/client-navigation.spec.ts | 372 ++++++++++++ .../streaming-ssr/tests/concurrent.spec.ts | 100 ++++ .../streaming-ssr/tests/deferred.spec.ts | 120 ++++ .../streaming-ssr/tests/fast-serial.spec.ts | 55 ++ .../streaming-ssr/tests/fixtures.ts | 90 +++ .../streaming-ssr/tests/home.spec.ts | 34 ++ .../streaming-ssr/tests/many-promises.spec.ts | 93 +++ .../tests/nested-deferred.spec.ts | 77 +++ .../streaming-ssr/tests/query-heavy.spec.ts | 131 ++++ .../streaming-ssr/tests/slow-render.spec.ts | 73 +++ .../streaming-ssr/tests/stream.spec.ts | 46 ++ .../streaming-ssr/tests/sync-only.spec.ts | 80 +++ e2e/react-start/streaming-ssr/tsconfig.json | 22 + e2e/react-start/streaming-ssr/vite.config.ts | 9 + .../src/ssr/renderRouterToString.tsx | 10 +- packages/router-core/src/router.ts | 23 +- packages/router-core/src/ssr/constants.ts | 1 + packages/router-core/src/ssr/ssr-server.ts | 150 +++-- .../src/ssr/transformStreamWithRouter.ts | 566 ++++++++++-------- .../src/ssr/renderRouterToString.tsx | 9 +- .../src/ssr/renderRouterToString.tsx | 10 +- pnpm-lock.yaml | 46 ++ 39 files changed, 3440 insertions(+), 316 deletions(-) create mode 100644 e2e/react-start/streaming-ssr/.gitignore create mode 100644 e2e/react-start/streaming-ssr/package.json create mode 100644 e2e/react-start/streaming-ssr/playwright.config.ts create mode 100644 e2e/react-start/streaming-ssr/src/routeTree.gen.ts create mode 100644 e2e/react-start/streaming-ssr/src/router.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/__root.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/concurrent.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/deferred.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/fast-serial.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/index.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/many-promises.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/nested-deferred.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/query-heavy.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/slow-render.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/stream.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/sync-only.tsx create mode 100644 e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/concurrent.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/deferred.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/fixtures.ts create mode 100644 e2e/react-start/streaming-ssr/tests/home.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/many-promises.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/nested-deferred.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/slow-render.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/stream.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tests/sync-only.spec.ts create mode 100644 e2e/react-start/streaming-ssr/tsconfig.json create mode 100644 e2e/react-start/streaming-ssr/vite.config.ts diff --git a/docs/router/framework/react/api/router/RouterEventsType.md b/docs/router/framework/react/api/router/RouterEventsType.md index fd1b6dfb3d..45d6431cdf 100644 --- a/docs/router/framework/react/api/router/RouterEventsType.md +++ b/docs/router/framework/react/api/router/RouterEventsType.md @@ -44,7 +44,6 @@ type RouterEvents = { } onInjectedHtml: { type: 'onInjectedHtml' - promise: Promise } onRendered: { type: 'onRendered' diff --git a/e2e/react-start/streaming-ssr/.gitignore b/e2e/react-start/streaming-ssr/.gitignore new file mode 100644 index 0000000000..7c90462923 --- /dev/null +++ b/e2e/react-start/streaming-ssr/.gitignore @@ -0,0 +1,19 @@ +node_modules +package-lock.json +yarn.lock + +.DS_Store +.cache +.env +.vercel +.output +/build/ +/api/ +/server/build +/public/build +.env.sentry-build-plugin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/dist/ diff --git a/e2e/react-start/streaming-ssr/package.json b/e2e/react-start/streaming-ssr/package.json new file mode 100644 index 0000000000..7f4b38a84a --- /dev/null +++ b/e2e/react-start/streaming-ssr/package.json @@ -0,0 +1,31 @@ +{ + "name": "tanstack-react-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", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-query": "^5.80.7", + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-ssr-query": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^7.1.7" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "srvx": "^0.9.8", + "typescript": "^5.7.2" + } +} diff --git a/e2e/react-start/streaming-ssr/playwright.config.ts b/e2e/react-start/streaming-ssr/playwright.config.ts new file mode 100644 index 0000000000..5f9de5709e --- /dev/null +++ b/e2e/react-start/streaming-ssr/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${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: `VITE_SERVER_PORT=${PORT} pnpm build && PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/streaming-ssr/src/routeTree.gen.ts b/e2e/react-start/streaming-ssr/src/routeTree.gen.ts new file mode 100644 index 0000000000..fb1073a34c --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routeTree.gen.ts @@ -0,0 +1,261 @@ +/* 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 SyncOnlyRouteImport } from './routes/sync-only' +import { Route as StreamRouteImport } from './routes/stream' +import { Route as SlowRenderRouteImport } from './routes/slow-render' +import { Route as QueryHeavyRouteImport } from './routes/query-heavy' +import { Route as NestedDeferredRouteImport } from './routes/nested-deferred' +import { Route as ManyPromisesRouteImport } from './routes/many-promises' +import { Route as FastSerialRouteImport } from './routes/fast-serial' +import { Route as DeferredRouteImport } from './routes/deferred' +import { Route as ConcurrentRouteImport } from './routes/concurrent' +import { Route as IndexRouteImport } from './routes/index' + +const SyncOnlyRoute = SyncOnlyRouteImport.update({ + id: '/sync-only', + path: '/sync-only', + getParentRoute: () => rootRouteImport, +} as any) +const StreamRoute = StreamRouteImport.update({ + id: '/stream', + path: '/stream', + getParentRoute: () => rootRouteImport, +} as any) +const SlowRenderRoute = SlowRenderRouteImport.update({ + id: '/slow-render', + path: '/slow-render', + getParentRoute: () => rootRouteImport, +} as any) +const QueryHeavyRoute = QueryHeavyRouteImport.update({ + id: '/query-heavy', + path: '/query-heavy', + getParentRoute: () => rootRouteImport, +} as any) +const NestedDeferredRoute = NestedDeferredRouteImport.update({ + id: '/nested-deferred', + path: '/nested-deferred', + getParentRoute: () => rootRouteImport, +} as any) +const ManyPromisesRoute = ManyPromisesRouteImport.update({ + id: '/many-promises', + path: '/many-promises', + getParentRoute: () => rootRouteImport, +} as any) +const FastSerialRoute = FastSerialRouteImport.update({ + id: '/fast-serial', + path: '/fast-serial', + getParentRoute: () => rootRouteImport, +} as any) +const DeferredRoute = DeferredRouteImport.update({ + id: '/deferred', + path: '/deferred', + getParentRoute: () => rootRouteImport, +} as any) +const ConcurrentRoute = ConcurrentRouteImport.update({ + id: '/concurrent', + path: '/concurrent', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/concurrent': typeof ConcurrentRoute + '/deferred': typeof DeferredRoute + '/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 + '/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 + '/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' + | '/fast-serial' + | '/many-promises' + | '/nested-deferred' + | '/query-heavy' + | '/slow-render' + | '/stream' + | '/sync-only' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/concurrent' + | '/deferred' + | '/fast-serial' + | '/many-promises' + | '/nested-deferred' + | '/query-heavy' + | '/slow-render' + | '/stream' + | '/sync-only' + id: + | '__root__' + | '/' + | '/concurrent' + | '/deferred' + | '/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 + FastSerialRoute: typeof FastSerialRoute + ManyPromisesRoute: typeof ManyPromisesRoute + NestedDeferredRoute: typeof NestedDeferredRoute + QueryHeavyRoute: typeof QueryHeavyRoute + SlowRenderRoute: typeof SlowRenderRoute + StreamRoute: typeof StreamRoute + SyncOnlyRoute: typeof SyncOnlyRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/sync-only': { + id: '/sync-only' + path: '/sync-only' + fullPath: '/sync-only' + preLoaderRoute: typeof SyncOnlyRouteImport + parentRoute: typeof rootRouteImport + } + '/stream': { + id: '/stream' + path: '/stream' + fullPath: '/stream' + preLoaderRoute: typeof StreamRouteImport + parentRoute: typeof rootRouteImport + } + '/slow-render': { + id: '/slow-render' + path: '/slow-render' + fullPath: '/slow-render' + preLoaderRoute: typeof SlowRenderRouteImport + parentRoute: typeof rootRouteImport + } + '/query-heavy': { + id: '/query-heavy' + path: '/query-heavy' + fullPath: '/query-heavy' + preLoaderRoute: typeof QueryHeavyRouteImport + parentRoute: typeof rootRouteImport + } + '/nested-deferred': { + id: '/nested-deferred' + path: '/nested-deferred' + fullPath: '/nested-deferred' + preLoaderRoute: typeof NestedDeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/many-promises': { + id: '/many-promises' + path: '/many-promises' + fullPath: '/many-promises' + preLoaderRoute: typeof ManyPromisesRouteImport + parentRoute: typeof rootRouteImport + } + '/fast-serial': { + id: '/fast-serial' + path: '/fast-serial' + fullPath: '/fast-serial' + preLoaderRoute: typeof FastSerialRouteImport + parentRoute: typeof rootRouteImport + } + '/deferred': { + id: '/deferred' + path: '/deferred' + fullPath: '/deferred' + preLoaderRoute: typeof DeferredRouteImport + parentRoute: typeof rootRouteImport + } + '/concurrent': { + id: '/concurrent' + path: '/concurrent' + fullPath: '/concurrent' + preLoaderRoute: typeof ConcurrentRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ConcurrentRoute: ConcurrentRoute, + DeferredRoute: DeferredRoute, + 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/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/streaming-ssr/src/router.tsx b/e2e/react-start/streaming-ssr/src/router.tsx new file mode 100644 index 0000000000..238fc5ef25 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/router.tsx @@ -0,0 +1,18 @@ +import { QueryClient } from '@tanstack/react-query' +import { createRouter } from '@tanstack/react-router' +import { setupRouterSsrQueryIntegration } from '@tanstack/react-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/react-start/streaming-ssr/src/routes/__root.tsx b/e2e/react-start/streaming-ssr/src/routes/__root.tsx new file mode 100644 index 0000000000..a208b3bfd2 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/__root.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRouteWithContext, +} from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-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 ( + + + + ) +} + +/** + * Global hydration check component rendered in the root layout. + * Tests can click the button and verify the status changes to confirm + * that React has hydrated and the app is interactive. + */ +function HydrationCheck() { + const [status, setStatus] = useState<'pending' | 'hydrated'>('pending') + + return ( +
+ + + {status} + +
+ ) +} + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + {children} + + + + ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/concurrent.tsx b/e2e/react-start/streaming-ssr/src/routes/concurrent.tsx new file mode 100644 index 0000000000..052bb427b7 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/concurrent.tsx @@ -0,0 +1,121 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { Suspense } from 'react' + +/** + * Tests concurrent promise resolution - multiple promises resolving at the exact same time. + * This can stress the serialization system and reveal race conditions. + */ + +// Shared delay function that resolves multiple promises at the same instant +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}`), + ) +} + +// Create batches of concurrent promises +const batch1 = createConcurrentPromises(5, 100) // 5 promises resolving at 100ms +const batch2 = createConcurrentPromises(5, 200) // 5 promises resolving at 200ms +const batch3 = createConcurrentPromises(5, 300) // 5 promises resolving at 300ms + +export const Route = createFileRoute('/concurrent')({ + loader: async () => { + return { + // Batch 1: 5 promises resolving at exactly the same time (100ms) + concurrent1_1: batch1[0], + concurrent1_2: batch1[1], + concurrent1_3: batch1[2], + concurrent1_4: batch1[3], + concurrent1_5: batch1[4], + + // Batch 2: 5 promises resolving at exactly the same time (200ms) + concurrent2_1: batch2[0], + concurrent2_2: batch2[1], + concurrent2_3: batch2[2], + concurrent2_4: batch2[3], + concurrent2_5: batch2[4], + + // Batch 3: 5 promises resolving at exactly the same time (300ms) + concurrent3_1: batch3[0], + concurrent3_2: batch3[1], + concurrent3_3: batch3[2], + concurrent3_4: batch3[3], + concurrent3_5: batch3[4], + } + }, + component: Concurrent, +}) + +function PromiseItem({ + promise, + testId, +}: { + promise: Promise + testId: string +}) { + return ( + Loading...} + > +
{data}
} + /> +
+ ) +} + +function Concurrent() { + const data = Route.useLoaderData() + + return ( +
+

Concurrent Resolution Test (15 promises in 3 batches)

+

Tests multiple promises resolving at the exact same instant.

+ +
+ {/* Batch 1: 100ms */} +
+

Batch 1 (100ms)

+ + + + + +
+ + {/* Batch 2: 200ms */} +
+

Batch 2 (200ms)

+ + + + + +
+ + {/* Batch 3: 300ms */} +
+

Batch 3 (300ms)

+ + + + + +
+
+
+ ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/deferred.tsx b/e2e/react-start/streaming-ssr/src/routes/deferred.tsx new file mode 100644 index 0000000000..d30450228e --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/deferred.tsx @@ -0,0 +1,108 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { Suspense } from 'react' + +// Server function that returns immediately +const getImmediateData = createServerFn({ method: 'GET' }) + .inputValidator((data: { name: string }) => data) + .handler(({ data }) => { + return { + name: data.name, + timestamp: Date.now(), + // Track where this data came from - should always be 'server' if SSR works + source: 'server' as const, + } + }) + +// Server function that takes time to complete +const getSlowData = createServerFn({ method: 'GET' }) + .inputValidator((data: { name: string; delay: number }) => data) + .handler(async ({ data }) => { + await new Promise((r) => setTimeout(r, data.delay)) + return { + name: data.name, + timestamp: Date.now(), + // Track where this data came from - should always be 'server' if SSR works + source: 'server' as const, + } + }) + +export const Route = createFileRoute('/deferred')({ + loader: async () => { + return { + // Deferred promise that resolves after 1 second + deferredData: new Promise<{ message: string; source: string }>((r) => + setTimeout( + () => + r({ + message: 'Deferred data loaded!', + // Track where this data came from - should always be 'server' if SSR works + source: typeof window === 'undefined' ? 'server' : 'client', + }), + 1000, + ), + ), + // Deferred server function call + deferredServerData: getSlowData({ + data: { name: 'Slow User', delay: 800 }, + }), + // Immediate data (awaited) + immediateData: await getImmediateData({ data: { name: 'Fast User' } }), + // Track where loader ran - should always be 'server' if SSR works + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: Deferred, +}) + +function Deferred() { + const { deferredData, deferredServerData, immediateData, loaderSource } = + Route.useLoaderData() + + return ( +
+

Deferred Data Test

+ + {/* Immediate data should be available right away */} +
+ Immediate: {immediateData.name} @ {immediateData.timestamp} +
+ +
+ Immediate source: {immediateData.source} +
+ +
Loader source: {loaderSource}
+ + {/* Deferred promise */} + Loading deferred...
} + > + ( +
+ {data.message} (source: {data.source}) +
+ )} + /> + + + {/* Deferred server function */} + Loading server data... + } + > + ( +
+ Server: {data.name} @ {data.timestamp} (source: {data.source}) +
+ )} + /> +
+ + ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/fast-serial.tsx b/e2e/react-start/streaming-ssr/src/routes/fast-serial.tsx new file mode 100644 index 0000000000..61a47469f4 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/fast-serial.tsx @@ -0,0 +1,53 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' + +// Server function that returns immediately with minimal data +const getSmallData = createServerFn({ method: 'GET' }).handler(() => { + return { + value: 'small-data', + timestamp: Date.now(), + // Track where this data came from - should always be 'server' if SSR works + source: 'server' as const, + } +}) + +export const Route = createFileRoute('/fast-serial')({ + loader: async () => { + // All data is awaited immediately - serialization should complete quickly + const data = await getSmallData() + return { + serverData: data, + staticData: 'This is static data', + timestamp: Date.now(), + // Track where loader ran - should always be 'server' if SSR works + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: FastSerial, +}) + +function FastSerial() { + const { serverData, staticData, timestamp, loaderSource } = + Route.useLoaderData() + + return ( +
+

Fast Serialization Test

+

This route tests when serialization completes before render.

+ +
+ Server: {serverData.value} @ {serverData.timestamp} +
+ +
+ Server function source: {serverData.source} +
+ +
Loader source: {loaderSource}
+ +
Static: {staticData}
+ +
Loader timestamp: {timestamp}
+
+ ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/index.tsx b/e2e/react-start/streaming-ssr/src/routes/index.tsx new file mode 100644 index 0000000000..10f426ae54 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/index.tsx @@ -0,0 +1,52 @@ +import { createFileRoute, Link } from '@tanstack/react-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 +
  • +
  • + + 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/react-start/streaming-ssr/src/routes/many-promises.tsx b/e2e/react-start/streaming-ssr/src/routes/many-promises.tsx new file mode 100644 index 0000000000..a6d0374972 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/many-promises.tsx @@ -0,0 +1,162 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { Suspense } from 'react' + +/** + * Tests streaming with many (15+) deferred promises resolving in various orders. + * This stresses the serialization system with concurrent promise resolutions. + */ + +// Helper to create a promise with a specific delay and value +function createDelayedPromise(value: T, delayMs: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), delayMs)) +} + +export const Route = createFileRoute('/many-promises')({ + loader: async () => { + // Create 15 promises with varying delays to test ordering + // Some resolve quickly, some slowly, some at similar times + return { + // Immediate/very fast (0-50ms) + immediate1: createDelayedPromise('immediate-1', 0), + immediate2: createDelayedPromise('immediate-2', 10), + immediate3: createDelayedPromise('immediate-3', 20), + + // Fast (50-150ms) + fast1: createDelayedPromise('fast-1', 50), + fast2: createDelayedPromise('fast-2', 75), + fast3: createDelayedPromise('fast-3', 100), + fast4: createDelayedPromise('fast-4', 125), + + // Medium (150-300ms) + medium1: createDelayedPromise('medium-1', 150), + medium2: createDelayedPromise('medium-2', 200), + medium3: createDelayedPromise('medium-3', 250), + + // Slow (300-600ms) + slow1: createDelayedPromise('slow-1', 300), + slow2: createDelayedPromise('slow-2', 400), + slow3: createDelayedPromise('slow-3', 500), + + // Very slow (600ms+) + verySlow1: createDelayedPromise('very-slow-1', 600), + verySlow2: createDelayedPromise('very-slow-2', 800), + } + }, + component: ManyPromises, +}) + +function PromiseItem({ + promise, + testId, + label, +}: { + promise: Promise + testId: string + label: string +}) { + return ( + Loading {label}...} + > + ( +
+ {label}: {data} +
+ )} + /> +
+ ) +} + +function ManyPromises() { + const data = Route.useLoaderData() + + return ( +
+

Many Promises Test (15 deferred)

+

Tests streaming with many concurrent deferred promises.

+ +
+ {/* Immediate group */} +
+

Immediate (0-20ms)

+ + + +
+ + {/* Fast group */} +
+

Fast (50-125ms)

+ + + + +
+ + {/* Medium group */} +
+

Medium (150-250ms)

+ + + +
+ + {/* Slow group */} +
+

Slow (300-500ms)

+ + + +
+ + {/* Very slow group */} +
+

Very Slow (600-800ms)

+ + +
+
+
+ ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/nested-deferred.tsx b/e2e/react-start/streaming-ssr/src/routes/nested-deferred.tsx new file mode 100644 index 0000000000..781a6e576f --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/nested-deferred.tsx @@ -0,0 +1,137 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { Suspense } from 'react' + +// Multiple server functions with different delays +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 { + // Multiple deferred promises that resolve at different times + level1: getLevel1Data(), + level2: getLevel2Data(), + level3: getLevel3Data(), + // Also a plain deferred promise + plainDeferred: new Promise((r) => + setTimeout(() => r('Plain deferred resolved!'), 300), + ), + } + }, + component: NestedDeferred, +}) + +// Nested component that renders more Await components +function Level1Content({ + level2, + level3, +}: { + level2: Promise<{ level: number; timestamp: number }> + level3: Promise<{ level: number; timestamp: number }> +}) { + return ( +
+ Loading level 2...
} + > + ( +
+ Level 2: {data.level} @ {data.timestamp} + +
+ )} + /> + + + ) +} + +function Level2Content({ + level3, +}: { + level3: Promise<{ level: number; timestamp: number }> +}) { + return ( +
+ Loading level 3...
} + > + ( +
+ Level 3: {data.level} @ {data.timestamp} +
+ )} + /> + + + ) +} + +function NestedDeferred() { + const { level1, level2, level3, plainDeferred } = Route.useLoaderData() + + return ( +
+

Nested Deferred Test

+

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

+ + {/* Plain deferred */} + Loading plain...
} + > +
{data}
} + /> + + + {/* Nested structure */} +
+ Loading level 1...
} + > + ( +
+ Level 1: {data.level} @ {data.timestamp} + +
+ )} + /> + + + + ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/query-heavy.tsx b/e2e/react-start/streaming-ssr/src/routes/query-heavy.tsx new file mode 100644 index 0000000000..b20921323c --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/query-heavy.tsx @@ -0,0 +1,285 @@ +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { Suspense } from 'react' + +/** + * Tests multiple useSuspenseQuery calls on a single route. + * Some queries have synchronous queryFn (return immediately), + * some have async queryFn with various delays. + * + * This stresses the SSR query streaming integration. + */ + +// Synchronous query - returns immediately (no await) +const syncQuery1 = queryOptions({ + queryKey: ['sync', 1], + queryFn: () => { + // Synchronous return - no Promise delay + return { + type: 'sync', + id: 1, + value: 'sync-value-1', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +const syncQuery2 = queryOptions({ + queryKey: ['sync', 2], + queryFn: () => { + return { + type: 'sync', + id: 2, + value: 'sync-value-2', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +const syncQuery3 = queryOptions({ + queryKey: ['sync', 3], + queryFn: () => { + return { + type: 'sync', + id: 3, + value: 'sync-value-3', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +// Fast async queries (50-100ms) +const fastAsyncQuery1 = queryOptions({ + queryKey: ['fast-async', 1], + queryFn: async () => { + await new Promise((r) => setTimeout(r, 50)) + return { + type: 'fast-async', + id: 1, + value: 'fast-async-1', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +const fastAsyncQuery2 = queryOptions({ + queryKey: ['fast-async', 2], + queryFn: async () => { + await new Promise((r) => setTimeout(r, 75)) + return { + type: 'fast-async', + id: 2, + value: 'fast-async-2', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +const fastAsyncQuery3 = queryOptions({ + queryKey: ['fast-async', 3], + queryFn: async () => { + await new Promise((r) => setTimeout(r, 100)) + return { + type: 'fast-async', + id: 3, + value: 'fast-async-3', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +// Slow async queries (200-400ms) +const slowAsyncQuery1 = queryOptions({ + queryKey: ['slow-async', 1], + queryFn: async () => { + await new Promise((r) => setTimeout(r, 200)) + return { + type: 'slow-async', + id: 1, + value: 'slow-async-1', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +const slowAsyncQuery2 = queryOptions({ + queryKey: ['slow-async', 2], + queryFn: async () => { + await new Promise((r) => setTimeout(r, 300)) + return { + type: 'slow-async', + id: 2, + value: 'slow-async-2', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +const slowAsyncQuery3 = queryOptions({ + queryKey: ['slow-async', 3], + queryFn: async () => { + await new Promise((r) => setTimeout(r, 400)) + return { + type: 'slow-async', + id: 3, + value: 'slow-async-3', + source: typeof window === 'undefined' ? 'server' : 'client', + } + }, + staleTime: Infinity, +}) + +export const Route = createFileRoute('/query-heavy')({ + component: QueryHeavy, +}) + +// Individual query components to test Suspense boundaries +function SyncQueryDisplay({ + queryOpts, + testId, +}: { + queryOpts: typeof syncQuery1 + testId: string +}) { + const { data } = useSuspenseQuery(queryOpts) + return ( +
+ {data.value} (source: {data.source}) +
+ ) +} + +function AsyncQueryDisplay({ + queryOpts, + testId, +}: { + queryOpts: typeof fastAsyncQuery1 + testId: string +}) { + const { data } = useSuspenseQuery(queryOpts) + return ( +
+ {data.value} (source: {data.source}) +
+ ) +} + +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 - should resolve immediately */} +
+

Sync Queries (immediate)

+ Loading sync 1...
} + > + + + Loading sync 2...
} + > + + + Loading sync 3...
} + > + + + + + {/* Fast async queries */} +
+

Fast Async Queries (50-100ms)

+ Loading fast 1...
+ } + > + + + Loading fast 2... + } + > + + + Loading fast 3... + } + > + + + + + {/* Slow async queries */} +
+

Slow Async Queries (200-400ms)

+ Loading slow 1...
+ } + > + + + Loading slow 2... + } + > + + + Loading slow 3... + } + > + + + + + + ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/slow-render.tsx b/e2e/react-start/streaming-ssr/src/routes/slow-render.tsx new file mode 100644 index 0000000000..5dd9772604 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/slow-render.tsx @@ -0,0 +1,85 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { Suspense } from 'react' + +// Server function that completes quickly +const getQuickData = createServerFn({ method: 'GET' }).handler(() => { + return { + name: 'Quick data', + timestamp: Date.now(), + // Track where this data came from - should always be 'server' if SSR works + source: 'server' as const, + } +}) + +// Simulate a slow component render by doing work during render +function SlowComponent({ data, index }: { data: string; index: number }) { + // This simulates a component that takes time to render + const startTime = Date.now() + while (Date.now() - startTime < 100) { + // Blocking loop to simulate slow render + } + return
{data}
+} + +export const Route = createFileRoute('/slow-render')({ + loader: async () => { + // All data loads quickly + const quickData = await getQuickData() + return { + quickData, + // Deferred data that resolves before render might complete + deferredData: new Promise<{ message: string; source: string }>((r) => + setTimeout( + () => + r({ + message: 'Deferred resolved!', + // Track where this data came from - should always be 'server' if SSR works + source: typeof window === 'undefined' ? 'server' : 'client', + }), + 50, + ), + ), + // Track where loader ran - should always be 'server' if SSR works + loaderSource: typeof window === 'undefined' ? 'server' : 'client', + } + }, + component: SlowRender, +}) + +function SlowRender() { + const { quickData, deferredData, loaderSource } = Route.useLoaderData() + + return ( +
+

Slow Render Test

+

Tests when render takes longer than serialization.

+ +
+ Quick: {quickData.name} @ {quickData.timestamp} +
+ +
+ Quick data source: {quickData.source} +
+ +
Loader source: {loaderSource}
+ + Loading...
}> + ( +
+ {data.message} (source: {data.source}) +
+ )} + /> + + + {/* Multiple slow components to extend render time */} + + + + + ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/stream.tsx b/e2e/react-start/streaming-ssr/src/routes/stream.tsx new file mode 100644 index 0000000000..c1629204c7 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/stream.tsx @@ -0,0 +1,116 @@ +import { Await, createFileRoute } from '@tanstack/react-router' +import { Suspense, useEffect, useRef, useState } from 'react' + +export const Route = createFileRoute('/stream')({ + component: StreamRoute, + loader() { + return { + // A promise that resolves after a short delay + promise: new Promise((resolve) => + setTimeout(() => resolve('promise-resolved'), 150), + ), + // A ReadableStream that emits chunks over time + 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 { promise, stream } = Route.useLoaderData() + const [streamData, setStreamData] = useState>([]) + const [streamComplete, setStreamComplete] = useState(false) + const readerRef = useRef(null) + const streamRef = useRef(null) + + useEffect(() => { + // If we're already reading this exact stream, don't start again + if (streamRef.current === stream && readerRef.current) { + return + } + + // Reset state for a new stream + if (streamRef.current !== stream) { + setStreamData([]) + setStreamComplete(false) + streamRef.current = stream + } + + // Check if stream is already locked (from a previous render) + if (stream.locked) { + return + } + + async function fetchStream() { + try { + const reader = stream.getReader() + readerRef.current = reader + 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) { + // Stream was cancelled or errored, ignore + if (!(e instanceof TypeError && String(e).includes('cancelled'))) { + console.error('Stream error:', e) + } + } + } + + fetchStream() + + return () => { + // Cancel the reader on cleanup + if (readerRef.current) { + readerRef.current.cancel().catch(() => {}) + readerRef.current = null + } + } + }, [stream]) + + return ( +
+

ReadableStream Test

+ + {/* Promise data */} + Loading promise...
} + > +
{data}
} + /> + + + {/* Stream data */} +
+

Stream chunks:

+
+ {streamData.map((chunk, i) => ( +
+ {chunk} +
+ ))} +
+ {streamComplete && ( +
Stream complete!
+ )} +
+ + ) +} diff --git a/e2e/react-start/streaming-ssr/src/routes/sync-only.tsx b/e2e/react-start/streaming-ssr/src/routes/sync-only.tsx new file mode 100644 index 0000000000..c95a1d467f --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/sync-only.tsx @@ -0,0 +1,41 @@ +import { createFileRoute } from '@tanstack/react-router' + +/** + * This route tests synchronous serialization - no deferred data, no streaming. + * The loader returns data synchronously (awaited), so crossSerializeStream + * completes immediately and all bootstrap scripts should be in the initial HTML. + */ +export const Route = createFileRoute('/sync-only')({ + loader: async () => { + // Simulate a fast synchronous data fetch + // This data is awaited, not deferred, so serialization completes synchronously + return { + message: 'Hello from sync loader!', + timestamp: Date.now(), + items: ['item-1', 'item-2', 'item-3'], + // Track where this data came from - should always be 'server' if SSR works + 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/react-start/streaming-ssr/tests/client-navigation.spec.ts b/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts new file mode 100644 index 0000000000..91138987ad --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts @@ -0,0 +1,372 @@ +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() + }, + ) +}) diff --git a/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts b/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts new file mode 100644 index 0000000000..10e32c6809 --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts @@ -0,0 +1,100 @@ +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, + }) + }, + ) +}) diff --git a/e2e/react-start/streaming-ssr/tests/deferred.spec.ts b/e2e/react-start/streaming-ssr/tests/deferred.spec.ts new file mode 100644 index 0000000000..90ee16edc0 --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/deferred.spec.ts @@ -0,0 +1,120 @@ +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', + ) + }) +}) diff --git a/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts b/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts new file mode 100644 index 0000000000..01314694d9 --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts @@ -0,0 +1,55 @@ +import { expect, test, testWithHydration } from './fixtures' + +test.describe('Fast serialization (serialization completes before render)', () => { + 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') + }) +}) diff --git a/e2e/react-start/streaming-ssr/tests/fixtures.ts b/e2e/react-start/streaming-ssr/tests/fixtures.ts new file mode 100644 index 0000000000..71c564aebe --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/fixtures.ts @@ -0,0 +1,90 @@ +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 new file mode 100644 index 0000000000..e231d007fb --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/home.spec.ts @@ -0,0 +1,34 @@ +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') + }, + ) +}) diff --git a/e2e/react-start/streaming-ssr/tests/many-promises.spec.ts b/e2e/react-start/streaming-ssr/tests/many-promises.spec.ts new file mode 100644 index 0000000000..034ab74220 --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/many-promises.spec.ts @@ -0,0 +1,93 @@ +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 }) + }) +}) diff --git a/e2e/react-start/streaming-ssr/tests/nested-deferred.spec.ts b/e2e/react-start/streaming-ssr/tests/nested-deferred.spec.ts new file mode 100644 index 0000000000..dbf6df4265 --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/nested-deferred.spec.ts @@ -0,0 +1,77 @@ +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, + }) + }) +}) diff --git a/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts b/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts new file mode 100644 index 0000000000..b175365acc --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/query-heavy.spec.ts @@ -0,0 +1,131 @@ +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) + }) +}) diff --git a/e2e/react-start/streaming-ssr/tests/slow-render.spec.ts b/e2e/react-start/streaming-ssr/tests/slow-render.spec.ts new file mode 100644 index 0000000000..c3631a7fc0 --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/slow-render.spec.ts @@ -0,0 +1,73 @@ +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', + ) + }) +}) diff --git a/e2e/react-start/streaming-ssr/tests/stream.spec.ts b/e2e/react-start/streaming-ssr/tests/stream.spec.ts new file mode 100644 index 0000000000..5e4c134f82 --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/stream.spec.ts @@ -0,0 +1,46 @@ +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, + }) + }, + ) +}) diff --git a/e2e/react-start/streaming-ssr/tests/sync-only.spec.ts b/e2e/react-start/streaming-ssr/tests/sync-only.spec.ts new file mode 100644 index 0000000000..07b5c5140b --- /dev/null +++ b/e2e/react-start/streaming-ssr/tests/sync-only.spec.ts @@ -0,0 +1,80 @@ +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()') +}) + +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/react-start/streaming-ssr/tsconfig.json b/e2e/react-start/streaming-ssr/tsconfig.json new file mode 100644 index 0000000000..3558337b0c --- /dev/null +++ b/e2e/react-start/streaming-ssr/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/app/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/streaming-ssr/vite.config.ts b/e2e/react-start/streaming-ssr/vite.config.ts new file mode 100644 index 0000000000..275bfacde2 --- /dev/null +++ b/e2e/react-start/streaming-ssr/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + server: { + port: 3000, + }, + plugins: [tanstackStart()], +}) diff --git a/packages/react-router/src/ssr/renderRouterToString.tsx b/packages/react-router/src/ssr/renderRouterToString.tsx index e13527b584..1633a7cfae 100644 --- a/packages/react-router/src/ssr/renderRouterToString.tsx +++ b/packages/react-router/src/ssr/renderRouterToString.tsx @@ -14,10 +14,12 @@ export const renderRouterToString = async ({ try { let html = ReactDOMServer.renderToString(children) router.serverSsr!.setRenderFinished() - const injectedHtml = await Promise.all(router.serverSsr!.injectedHtml).then( - (htmls) => htmls.join(''), - ) - html = html.replace(``, () => `${injectedHtml}`) + + const injectedHtml = router.serverSsr!.takeBufferedHtml() + if (injectedHtml) { + html = html.replace(``, () => `${injectedHtml}`) + } + return new Response(`${html}`, { status: router.state.statusCode, headers: responseHeaders, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 54da83a33e..b0748bc2c0 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -747,16 +747,27 @@ export type ClearCacheFn = (opts?: { }) => void export interface ServerSsr { - injectedHtml: Array - injectHtml: (getHtml: () => string | Promise) => Promise - injectScript: ( - getScript: () => string | Promise, - opts?: { logScript?: boolean }, - ) => Promise + /** + * Injects HTML synchronously into the stream. + * Emits an onInjectedHtml event that listeners can handle. + * If no subscriber is listening, the HTML is buffered and can be retrieved via takeBufferedHtml(). + */ + injectHtml: (html: string) => void + /** + * Injects a script tag synchronously into the stream. + */ + injectScript: (script: string) => void isDehydrated: () => boolean + isSerializationFinished: () => boolean onRenderFinished: (listener: () => void) => void + onSerializationFinished: (listener: () => void) => void dehydrate: () => Promise takeBufferedScripts: () => RouterManagedTag | undefined + /** + * Takes any buffered HTML that was injected. + * Returns the buffered HTML string (which may include multiple script tags) or undefined if empty. + */ + takeBufferedHtml: () => string | undefined liftScriptBarrier: () => void } diff --git a/packages/router-core/src/ssr/constants.ts b/packages/router-core/src/ssr/constants.ts index 8ce3dcfa0d..59d6c16100 100644 --- a/packages/router-core/src/ssr/constants.ts +++ b/packages/router-core/src/ssr/constants.ts @@ -1,2 +1,3 @@ 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/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index fcaf7bd155..1da972993f 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -1,11 +1,10 @@ import { crossSerializeStream, getCrossReferenceHeader } from 'seroval' import invariant from 'tiny-invariant' -import { createControlledPromise } from '../utils' import minifiedTsrBootStrapScript from './tsrScript?script-string' import { GLOBAL_TSR } from './constants' import { defaultSerovalPlugins } from './serializer/seroval-plugins' import { makeSsrSerovalPlugin } from './serializer/transformer' -import { TSR_SCRIPT_BARRIER_ID } from './transformStreamWithRouter' +import { TSR_SCRIPT_BARRIER_ID } from './constants' import type { DehydratedMatch, DehydratedRouter } from './types' import type { AnySerializationAdapter } from './serializer/transformer' import type { AnyRouter } from '../router' @@ -20,7 +19,9 @@ declare module '../router' { interface RouterEvents { onInjectedHtml: { type: 'onInjectedHtml' - promise: Promise + } + onSerializationFinished: { + type: 'onSerializationFinished' } } } @@ -56,50 +57,74 @@ const INITIAL_SCRIPTS = [ class ScriptBuffer { private router: AnyRouter | undefined - private _queue: Array = [...INITIAL_SCRIPTS] + private _queue: Array private _scriptBarrierLifted = false private _cleanedUp = false + private _pendingMicrotask = false constructor(router: AnyRouter) { this.router = router + // Copy INITIAL_SCRIPTS to avoid mutating the shared array + this._queue = INITIAL_SCRIPTS.slice() } enqueue(script: string) { if (this._cleanedUp) return - if (this._scriptBarrierLifted && this._queue.length === 0) { + this._queue.push(script) + // If barrier is lifted, schedule injection (if not already scheduled) + if (this._scriptBarrierLifted && !this._pendingMicrotask) { + this._pendingMicrotask = true queueMicrotask(() => { + this._pendingMicrotask = false this.injectBufferedScripts() }) } - this._queue.push(script) } liftBarrier() { if (this._scriptBarrierLifted || this._cleanedUp) return this._scriptBarrierLifted = true - if (this._queue.length > 0) { + if (this._queue.length > 0 && !this._pendingMicrotask) { + this._pendingMicrotask = true queueMicrotask(() => { + this._pendingMicrotask = false this.injectBufferedScripts() }) } } + /** + * Flushes any pending scripts synchronously. + * Call this before emitting onSerializationFinished to ensure all scripts are injected. + */ + flush() { + if (this._cleanedUp) return + this._pendingMicrotask = false + const scriptsToInject = this.takeAll() + if (scriptsToInject && this.router?.serverSsr) { + this.router.serverSsr.injectScript(scriptsToInject) + } + // Clear any remaining scripts to avoid double-injection if a queued microtask runs + this._queue = [] + } + takeAll() { const bufferedScripts = this._queue this._queue = [] if (bufferedScripts.length === 0) { return undefined } - bufferedScripts.push(`document.currentScript.remove()`) - const joinedScripts = bufferedScripts.join(';') - return joinedScripts + // Append cleanup script and join - avoid push() to not mutate then iterate + return bufferedScripts.join(';') + ';document.currentScript.remove()' } injectBufferedScripts() { if (this._cleanedUp) return + // Early return if queue is empty (avoids unnecessary takeAll() call) + if (this._queue.length === 0) return const scriptsToInject = this.takeAll() if (scriptsToInject && this.router?.serverSsr) { - this.router.serverSsr.injectScript(() => scriptsToInject) + this.router.serverSsr.injectScript(scriptsToInject) } } @@ -121,29 +146,26 @@ export function attachRouterServerSsrUtils({ manifest, } let _dehydrated = false - const listeners: Array<() => void> = [] + let _serializationFinished = false + const renderFinishedListeners: Array<() => void> = [] + const serializationFinishedListeners: Array<() => void> = [] const scriptBuffer = new ScriptBuffer(router) + let injectedHtmlBuffer: Array = [] router.serverSsr = { - injectedHtml: [], - injectHtml: (getHtml) => { - const promise = Promise.resolve().then(getHtml) - router.serverSsr!.injectedHtml.push(promise) + injectHtml: (html: string) => { + if (!html) return + // Buffer the HTML so it can be retrieved via takeBufferedHtml() + injectedHtmlBuffer.push(html) + // Emit event to notify subscribers that new HTML is available router.emit({ type: 'onInjectedHtml', - promise, }) - - return promise.then(() => {}) }, - injectScript: (getScript) => { - return router.serverSsr!.injectHtml(async () => { - const script = await getScript() - if (!script) { - return '' - } - return `${script}` - }) + injectScript: (script: string) => { + if (!script) return + const html = `${script}` + router.serverSsr!.injectHtml(html) }, dehydrate: async () => { invariant(!_dehydrated, 'router is already dehydrated!') @@ -201,18 +223,32 @@ export function attachRouterServerSsrUtils({ } _dehydrated = true - const p = createControlledPromise() const trackPlugins = { didRun: false } - const plugins = - ( - router.options.serializationAdapters as - | Array - | undefined - )?.map((t) => makeSsrSerovalPlugin(t, trackPlugins)) ?? [] + const serializationAdapters = router.options.serializationAdapters as + | Array + | undefined + const plugins = serializationAdapters + ? serializationAdapters + .map((t) => makeSsrSerovalPlugin(t, trackPlugins)) + .concat(defaultSerovalPlugins) + : defaultSerovalPlugins + + const signalSerializationComplete = () => { + _serializationFinished = true + try { + serializationFinishedListeners.forEach((l) => l()) + router.emit({ type: 'onSerializationFinished' }) + } catch (err) { + console.error('Serialization listener error:', err) + } finally { + serializationFinishedListeners.length = 0 + renderFinishedListeners.length = 0 + } + } crossSerializeStream(dehydratedRouter, { refs: new Map(), - plugins: [...plugins, ...defaultSerovalPlugins], + plugins, onSerialize: (data, initial) => { let serialized = initial ? GLOBAL_TSR + '.router=' + data : data if (trackPlugins.didRun) { @@ -223,21 +259,36 @@ export function attachRouterServerSsrUtils({ scopeId: SCOPE_ID, onDone: () => { scriptBuffer.enqueue(GLOBAL_TSR + '.e()') - p.resolve('') + // Flush all pending scripts synchronously before signaling completion + // This ensures all scripts are injected before onSerializationFinished is emitted + scriptBuffer.flush() + signalSerializationComplete() + }, + onError: (err) => { + console.error('Serialization error:', err) + signalSerializationComplete() }, - onError: (err) => p.reject(err), }) - // make sure the stream is kept open until the promise is resolved - router.serverSsr!.injectHtml(() => p) }, isDehydrated() { return _dehydrated }, - onRenderFinished: (listener) => listeners.push(listener), + isSerializationFinished() { + return _serializationFinished + }, + onRenderFinished: (listener) => renderFinishedListeners.push(listener), + onSerializationFinished: (listener) => + serializationFinishedListeners.push(listener), setRenderFinished: () => { - listeners.forEach((l) => l()) - // Clear listeners after calling them to prevent memory leaks - listeners.length = 0 + // Wrap in try-catch to ensure scriptBuffer.liftBarrier() is always called + try { + renderFinishedListeners.forEach((l) => l()) + } catch (err) { + console.error('Error in render finished listener:', err) + } finally { + // Clear listeners after calling them to prevent memory leaks + renderFinishedListeners.length = 0 + } scriptBuffer.liftBarrier() }, takeBufferedScripts() { @@ -256,12 +307,21 @@ export function attachRouterServerSsrUtils({ liftScriptBarrier() { scriptBuffer.liftBarrier() }, + takeBufferedHtml() { + if (injectedHtmlBuffer.length === 0) { + return undefined + } + const buffered = injectedHtmlBuffer.join('') + injectedHtmlBuffer = [] + return buffered + }, cleanup() { // Guard against multiple cleanup calls if (!router.serverSsr) return - listeners.length = 0 + renderFinishedListeners.length = 0 + serializationFinishedListeners.length = 0 + injectedHtmlBuffer = [] scriptBuffer.cleanup() - router.serverSsr.injectedHtml = [] router.serverSsr = undefined }, } diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 848f1a6800..fe1b7cdb6f 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -1,6 +1,6 @@ import { ReadableStream } from 'node:stream/web' import { Readable } from 'node:stream' -import { createControlledPromise } from '../utils' +import { TSR_SCRIPT_BARRIER_ID } from './constants' import type { AnyRouter } from '../router' export function transformReadableStreamWithRouter( @@ -19,316 +19,378 @@ export function transformPipeableStreamWithRouter( ) } -export const TSR_SCRIPT_BARRIER_ID = '$tsr-stream-barrier' - -// regex pattern for matching closing body and html tags -const patternBodyEnd = /(<\/body>)/ -const patternHtmlEnd = /(<\/html>)/ -// regex pattern for matching closing tags -const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g - -type ReadablePassthrough = { - stream: ReadableStream - write: (chunk: unknown) => void - end: (chunk?: string) => void - destroy: (error: unknown) => void - destroyed: boolean -} - -function createPassthrough(onCancel: () => void) { - let controller: ReadableStreamDefaultController - const encoder = new TextEncoder() - const stream = new ReadableStream({ - start(c) { - controller = c - }, - cancel() { - res.destroyed = true - onCancel() - }, - }) - - const res: ReadablePassthrough = { - stream, - write: (chunk) => { - // Don't write to destroyed stream - if (res.destroyed) return - if (typeof chunk === 'string') { - controller.enqueue(encoder.encode(chunk)) - } else { - controller.enqueue(chunk) - } - }, - end: (chunk) => { - // Don't end already destroyed stream - if (res.destroyed) return - if (chunk) { - res.write(chunk) +// Use string constants for simple indexOf matching +const BODY_END_TAG = '' +const HTML_END_TAG = '' + +// 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 = 60000 + +// Module-level encoder (stateless, safe to reuse) +const textEncoder = new TextEncoder() + +/** + * Finds the position just after the last valid HTML closing tag in the string. + * Uses optimized charCodeAt approach (5x-370x faster than regex for typical HTML). + * + * Valid closing tags match the pattern: + * Examples: , , + * + * @returns Position after the last closing tag, or -1 if none found + */ +function findLastClosingTagEnd(str: string): number { + const len = str.length + if (len < MIN_CLOSING_TAG_LENGTH) return -1 + + let i = len - 1 + + while (i >= MIN_CLOSING_TAG_LENGTH - 1) { + // Look for > (charCode 62) + if (str.charCodeAt(i) === 62) { + // Look backwards for valid tag name characters + let j = i - 1 + + // Skip through valid tag name characters + while (j >= 1) { + const code = str.charCodeAt(j) + // Check if it's a valid tag name char: [a-zA-Z0-9_:.-] + 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 // - + ) { + j-- + } else { + break + } } - res.destroyed = true - controller.close() - }, - destroy: (error) => { - // Don't destroy already destroyed stream - if (res.destroyed) return - res.destroyed = true - controller.error(error) - }, - destroyed: false, - } - return res -} - -async function readStream( - stream: ReadableStream, - opts: { - onData?: (chunk: ReadableStreamReadValueResult) => void - onEnd?: () => void - onError?: (error: unknown) => void - }, -) { - const reader = stream.getReader() - try { - let chunk - while (!(chunk = await reader.read()).done) { - opts.onData?.(chunk) + // Check if the first char after = 97 && startCode <= 122) || + (startCode >= 65 && startCode <= 90) + ) { + // Check for = 1 && + str.charCodeAt(j) === 47 && + str.charCodeAt(j - 1) === 60 + ) { + return i + 1 // Return position after the closing > + } + } + } } - opts.onEnd?.() - } catch (error) { - opts.onError?.(error) - } finally { - reader.releaseLock() + i-- } + return -1 } export function transformStreamWithRouter( router: AnyRouter, appStream: ReadableStream, opts?: { + /** Timeout for serialization to complete after app render finishes (default: 60000ms) */ timeoutMs?: number + /** Maximum lifetime of the stream transform (default: 60000ms). Safety net for cleanup. */ + lifetimeMs?: number }, ) { - let stopListeningToInjectedHtml: (() => void) | undefined = undefined - let timeoutHandle: NodeJS.Timeout + let stopListeningToInjectedHtml: (() => void) | undefined + let stopListeningToSerializationFinished: (() => void) | undefined + let serializationTimeoutHandle: ReturnType | undefined + let lifetimeTimeoutHandle: ReturnType | undefined let cleanedUp = false + let controller: ReadableStreamDefaultController + let isStreamClosed = false + + // Check upfront if serialization already finished synchronously + // This is the fast path for routes with no deferred data + const serializationAlreadyFinished = + router.serverSsr?.isSerializationFinished() ?? false + + /** + * Cleanup function with guards against multiple calls. + * Unsubscribes listeners, clears timeouts, frees buffers, and cleans up router SSR state. + */ function cleanup() { + // Guard against multiple cleanup calls - set flag first to prevent re-entry if (cleanedUp) return cleanedUp = true - if (stopListeningToInjectedHtml) { - stopListeningToInjectedHtml() - stopListeningToInjectedHtml = undefined + + // Unsubscribe listeners first (wrap in try-catch for safety) + try { + stopListeningToInjectedHtml?.() + stopListeningToSerializationFinished?.() + } catch (e) { + // Ignore errors during unsubscription + } + stopListeningToInjectedHtml = undefined + stopListeningToSerializationFinished = undefined + + // Clear all timeouts + if (serializationTimeoutHandle !== undefined) { + clearTimeout(serializationTimeoutHandle) + serializationTimeoutHandle = undefined + } + if (lifetimeTimeoutHandle !== undefined) { + clearTimeout(lifetimeTimeoutHandle) + lifetimeTimeoutHandle = undefined } - clearTimeout(timeoutHandle) + + // Clear buffers to free memory + pendingRouterHtmlParts = [] + leftover = '' + pendingClosingTags = '' + + // Clean up router SSR state (has its own guard) router.serverSsr?.cleanup() } - const finalPassThrough = createPassthrough(cleanup) const textDecoder = new TextDecoder() - let isAppRendering = true - let routerStreamBuffer = '' - let pendingClosingTags = '' - let streamBarrierLifted = false - let leftover = '' - let leftoverHtml = '' + function safeEnqueue(chunk: string | Uint8Array) { + if (isStreamClosed) return + if (typeof chunk === 'string') { + controller.enqueue(textEncoder.encode(chunk)) + } else { + controller.enqueue(chunk) + } + } - function getBufferedRouterStream() { - const html = routerStreamBuffer - routerStreamBuffer = '' - return html + function safeClose() { + if (isStreamClosed) return + isStreamClosed = true + try { + controller.close() + } catch { + // Stream may already be errored or closed by consumer - safe to ignore + } } - function decodeChunk(chunk: unknown): string { - if (chunk instanceof Uint8Array) { - return textDecoder.decode(chunk, { stream: true }) + function safeError(error: unknown) { + if (isStreamClosed) return + isStreamClosed = true + try { + controller.error(error) + } catch { + // Stream may already be errored or closed by consumer - safe to ignore } - return String(chunk) } - const injectedHtmlDonePromise = createControlledPromise() + const stream = new ReadableStream({ + start(c) { + controller = c + }, + cancel() { + isStreamClosed = true + cleanup() + }, + }) + + let isAppRendering = true + let streamBarrierLifted = false + let leftover = '' + let pendingClosingTags = '' + let serializationFinished = serializationAlreadyFinished - let processingCount = 0 + let pendingRouterHtmlParts: Array = [] - // Process any already-injected HTML - handleInjectedHtml() + // Take any HTML that was buffered before we started listening + const bufferedHtml = router.serverSsr?.takeBufferedHtml() + if (bufferedHtml) { + pendingRouterHtmlParts.push(bufferedHtml) + } - // Listen for any new injected HTML - stopListeningToInjectedHtml = router.subscribe( - 'onInjectedHtml', - handleInjectedHtml, - ) + function flushPendingRouterHtml() { + if (pendingRouterHtmlParts.length > 0) { + safeEnqueue(pendingRouterHtmlParts.join('')) + pendingRouterHtmlParts = [] + } + } - function handleInjectedHtml() { - // Don't process if already cleaned up - if (cleanedUp) return + /** + * Attempts to finish the stream if all conditions are met. + */ + function tryFinish() { + // Can only finish when app is done rendering and serialization is complete + if (isAppRendering || !serializationFinished) return + if (cleanedUp || isStreamClosed) return + + // Clear serialization timeout since we're finishing + if (serializationTimeoutHandle !== undefined) { + clearTimeout(serializationTimeoutHandle) + serializationTimeoutHandle = undefined + } - router.serverSsr!.injectedHtml.forEach((promise) => { - processingCount++ + // Flush any remaining bytes in the TextDecoder + const decoderRemainder = textDecoder.decode() - promise - .then((html) => { - // Don't write to destroyed stream or after cleanup - if (cleanedUp || finalPassThrough.destroyed) { - return - } - if (isAppRendering) { - routerStreamBuffer += html - } else { - finalPassThrough.write(html) - } - }) - .catch((err) => { - injectedHtmlDonePromise.reject(err) - }) - .finally(() => { - processingCount-- - - if (!isAppRendering && processingCount === 0) { - injectedHtmlDonePromise.resolve() - } - }) - }) - router.serverSsr!.injectedHtml = [] + if (leftover) safeEnqueue(leftover) + if (decoderRemainder) safeEnqueue(decoderRemainder) + flushPendingRouterHtml() + if (pendingClosingTags) safeEnqueue(pendingClosingTags) + + safeClose() + cleanup() } - injectedHtmlDonePromise - .then(() => { - // Don't process if already cleaned up or destroyed - if (cleanedUp || finalPassThrough.destroyed) { - return + // Set up lifetime timeout as a safety net + // This ensures cleanup happens even if the stream is never consumed or gets stuck + const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS + lifetimeTimeoutHandle = setTimeout(() => { + if (!cleanedUp && !isStreamClosed) { + console.warn( + `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`, + ) + safeError(new Error('Stream lifetime exceeded')) + cleanup() + } + }, lifetimeMs) + + // Only set up listeners if serialization hasn't already finished + // This avoids unnecessary subscriptions for the common case of no deferred data + if (!serializationAlreadyFinished) { + // Listen for injected HTML (for deferred data that resolves later) + stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => { + if (cleanedUp || isStreamClosed) return + + // Retrieve buffered HTML + const html = router.serverSsr?.takeBufferedHtml() + if (!html) return + + if (isAppRendering) { + // Buffer for insertion at next valid position + pendingRouterHtmlParts.push(html) + } else { + // App is done rendering, write directly to output + safeEnqueue(html) } + }) - clearTimeout(timeoutHandle) - const finalHtml = - leftover + leftoverHtml + getBufferedRouterStream() + pendingClosingTags + // Listen for serialization finished + stopListeningToSerializationFinished = router.subscribe( + 'onSerializationFinished', + () => { + serializationFinished = true + tryFinish() + }, + ) + } - leftover = '' - leftoverHtml = '' - pendingClosingTags = '' + // Transform the appStream + ;(async () => { + const reader = appStream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + // Don't process if already cleaned up + if (cleanedUp || isStreamClosed) return + + const text = + value instanceof Uint8Array + ? textDecoder.decode(value, { stream: true }) + : String(value) + const chunkString = leftover + text + + // Check for stream barrier (script placeholder) - use indexOf for efficiency + if (!streamBarrierLifted) { + if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) { + streamBarrierLifted = true + router.serverSsr?.liftScriptBarrier() + } + } - finalPassThrough.end(finalHtml) - }) - .catch((err) => { - // Don't process if already cleaned up - if (cleanedUp || finalPassThrough.destroyed) { - return - } + // Check for body/html end tags + const bodyEndIndex = chunkString.indexOf(BODY_END_TAG) + const htmlEndIndex = chunkString.indexOf(HTML_END_TAG) - console.error('Error reading routerStream:', err) - finalPassThrough.destroy(err) - }) - .finally(cleanup) + // If we have both and in proper order, + // insert router HTML before and hold the closing tags + if ( + bodyEndIndex !== -1 && + htmlEndIndex !== -1 && + bodyEndIndex < htmlEndIndex + ) { + pendingClosingTags = chunkString.slice(bodyEndIndex) - // Transform the appStream - readStream(appStream, { - onData: (chunk) => { - // Don't process if already cleaned up - if (cleanedUp || finalPassThrough.destroyed) { - return - } + safeEnqueue(chunkString.slice(0, bodyEndIndex)) + flushPendingRouterHtml() - const text = decodeChunk(chunk.value) - const chunkString = leftover + text - const bodyEndMatch = chunkString.match(patternBodyEnd) - const htmlEndMatch = chunkString.match(patternHtmlEnd) - - if (!streamBarrierLifted) { - const streamBarrierIdIncluded = chunkString.includes( - TSR_SCRIPT_BARRIER_ID, - ) - if (streamBarrierIdIncluded) { - streamBarrierLifted = true - router.serverSsr!.liftScriptBarrier() + leftover = '' + continue } - } - // If either the body end or html end is in the chunk, - // We need to get all of our data in asap - if ( - bodyEndMatch && - htmlEndMatch && - bodyEndMatch.index! < htmlEndMatch.index! - ) { - const bodyEndIndex = bodyEndMatch.index! - pendingClosingTags = chunkString.slice(bodyEndIndex) - - finalPassThrough.write( - chunkString.slice(0, bodyEndIndex) + - getBufferedRouterStream() + - leftoverHtml, - ) - - leftover = '' - leftoverHtml = '' - return - } - - let result: RegExpExecArray | null - let lastIndex = 0 - // Reset regex lastIndex since it's global and stateful across exec() calls - patternClosingTag.lastIndex = 0 - while ((result = patternClosingTag.exec(chunkString)) !== null) { - lastIndex = result.index + result[0].length - } + const lastClosingTagEnd = findLastClosingTagEnd(chunkString) - if (lastIndex > 0) { - const processed = - chunkString.slice(0, lastIndex) + - getBufferedRouterStream() + - leftoverHtml + if (lastClosingTagEnd > 0) { + // Found a closing tag - insert router HTML after it + safeEnqueue(chunkString.slice(0, lastClosingTagEnd)) + flushPendingRouterHtml() - finalPassThrough.write(processed) - leftover = chunkString.slice(lastIndex) - leftoverHtml = '' - } else { - leftover = chunkString - leftoverHtml += getBufferedRouterStream() - } - }, - onEnd: () => { - // Don't process if stream was already destroyed/cancelled or cleaned up - if (cleanedUp || finalPassThrough.destroyed) { - return + leftover = chunkString.slice(lastClosingTagEnd) + } else { + // No closing tag found - buffer the entire chunk + leftover = chunkString + // Any pending router HTML will be inserted when we find a valid position + } } + // Stream ended + if (cleanedUp || isStreamClosed) return + // Mark the app as done rendering isAppRendering = false - router.serverSsr!.setRenderFinished() + router.serverSsr?.setRenderFinished() - // If there are no pending promises, resolve the injectedHtmlDonePromise - if (processingCount === 0) { - injectedHtmlDonePromise.resolve() + // Try to finish if serialization is already done + if (serializationFinished) { + tryFinish() } else { - const timeoutMs = opts?.timeoutMs ?? 60000 - timeoutHandle = setTimeout(() => { - injectedHtmlDonePromise.reject( - new Error('Injected HTML timeout after app render finished'), - ) + // Set a timeout for serialization to complete + const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS + serializationTimeoutHandle = setTimeout(() => { + if (!cleanedUp && !isStreamClosed) { + console.error('Serialization timeout after app render finished') + safeError( + new Error('Serialization timeout after app render finished'), + ) + cleanup() + } }, timeoutMs) } - }, - onError: (error) => { - // Don't process if already cleaned up - if (cleanedUp) { - return - } - + } catch (error) { + if (cleanedUp) return console.error('Error reading appStream:', error) isAppRendering = false - router.serverSsr!.setRenderFinished() - // Clear timeout to prevent it from firing after error - clearTimeout(timeoutHandle) - // Clear string buffers to prevent memory leaks - leftover = '' - leftoverHtml = '' - routerStreamBuffer = '' - pendingClosingTags = '' - finalPassThrough.destroy(error) - injectedHtmlDonePromise.reject(error) - }, + router.serverSsr?.setRenderFinished() + safeError(error) + cleanup() + } finally { + reader.releaseLock() + } + })().catch((error) => { + // Handle any errors that occur outside the try block (e.g., getReader() failure) + if (cleanedUp) return + console.error('Error in stream transform:', error) + safeError(error) + cleanup() }) - return finalPassThrough.stream + return stream } diff --git a/packages/solid-router/src/ssr/renderRouterToString.tsx b/packages/solid-router/src/ssr/renderRouterToString.tsx index b0db666450..9d21769937 100644 --- a/packages/solid-router/src/ssr/renderRouterToString.tsx +++ b/packages/solid-router/src/ssr/renderRouterToString.tsx @@ -26,10 +26,11 @@ export const renderRouterToString = async ({ plugins: serovalPlugins, } as any) router.serverSsr!.setRenderFinished() - const injectedHtml = await Promise.all(router.serverSsr!.injectedHtml).then( - (htmls) => htmls.join(''), - ) - html = html.replace(``, () => `${injectedHtml}`) + + const injectedHtml = router.serverSsr!.takeBufferedHtml() + if (injectedHtml) { + html = html.replace(``, () => `${injectedHtml}`) + } return new Response(`${html}`, { status: router.state.statusCode, headers: responseHeaders, diff --git a/packages/vue-router/src/ssr/renderRouterToString.tsx b/packages/vue-router/src/ssr/renderRouterToString.tsx index fdc89b23dc..642082773f 100644 --- a/packages/vue-router/src/ssr/renderRouterToString.tsx +++ b/packages/vue-router/src/ssr/renderRouterToString.tsx @@ -17,10 +17,12 @@ export const renderRouterToString = async ({ let html = await vueRenderToString(app) router.serverSsr!.setRenderFinished() - const injectedHtml = await Promise.all(router.serverSsr!.injectedHtml).then( - (htmls) => htmls.join(''), - ) - html = html.replace(``, () => `${injectedHtml}`) + + const injectedHtml = router.serverSsr!.takeBufferedHtml() + if (injectedHtml) { + html = html.replace(``, () => `${injectedHtml}`) + } + return new Response(`${html}`, { status: router.state.statusCode, headers: responseHeaders, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a0b0552f7..e7082591f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1989,6 +1989,52 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.8.3)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + e2e/react-start/streaming-ssr: + dependencies: + '@tanstack/react-query': + specifier: ^5.90.7 + version: 5.90.7(react@19.2.0) + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-ssr-query': + specifier: workspace:* + version: link:../../../packages/react-router-ssr-query + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/node': + specifier: 22.10.2 + version: 22.10.2 + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + srvx: + specifier: ^0.9.8 + version: 0.9.8 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + e2e/react-start/virtual-routes: dependencies: '@tanstack/react-router': From 5b62483defd45e902c1ddde7f7f2be88be855c73 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 21 Dec 2025 23:17:42 +0100 Subject: [PATCH 3/6] fix lint --- packages/router-core/src/ssr/ssr-server.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 1da972993f..e1333e8abd 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -1,10 +1,9 @@ import { crossSerializeStream, getCrossReferenceHeader } from 'seroval' import invariant from 'tiny-invariant' import minifiedTsrBootStrapScript from './tsrScript?script-string' -import { GLOBAL_TSR } from './constants' +import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants' import { defaultSerovalPlugins } from './serializer/seroval-plugins' import { makeSsrSerovalPlugin } from './serializer/transformer' -import { TSR_SCRIPT_BARRIER_ID } from './constants' import type { DehydratedMatch, DehydratedRouter } from './types' import type { AnySerializationAdapter } from './serializer/transformer' import type { AnyRouter } from '../router' From 9c7d91b33e9c27482042406c661315a2aae77400 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sun, 21 Dec 2025 23:29:32 +0100 Subject: [PATCH 4/6] flush respects script barrier --- packages/router-core/src/ssr/ssr-server.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index e1333e8abd..248cf2980b 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -95,16 +95,18 @@ class ScriptBuffer { /** * Flushes any pending scripts synchronously. * Call this before emitting onSerializationFinished to ensure all scripts are injected. + * + * IMPORTANT: Only injects if the barrier has been lifted. Before the barrier is lifted, + * scripts should remain in the queue so takeBufferedScripts() can retrieve them */ flush() { + if (!this._scriptBarrierLifted) return if (this._cleanedUp) return this._pendingMicrotask = false const scriptsToInject = this.takeAll() if (scriptsToInject && this.router?.serverSsr) { this.router.serverSsr.injectScript(scriptsToInject) } - // Clear any remaining scripts to avoid double-injection if a queued microtask runs - this._queue = [] } takeAll() { From a17a59795b95eb4d7ee5e2b3d45127f90e792e25 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Mon, 22 Dec 2025 00:08:55 +0100 Subject: [PATCH 5/6] address review --- .../streaming-ssr/src/routes/concurrent.tsx | 10 +++++----- .../src/ssr/transformStreamWithRouter.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/e2e/react-start/streaming-ssr/src/routes/concurrent.tsx b/e2e/react-start/streaming-ssr/src/routes/concurrent.tsx index 052bb427b7..a1bed0fe07 100644 --- a/e2e/react-start/streaming-ssr/src/routes/concurrent.tsx +++ b/e2e/react-start/streaming-ssr/src/routes/concurrent.tsx @@ -19,13 +19,13 @@ function createConcurrentPromises( ) } -// Create batches of concurrent promises -const batch1 = createConcurrentPromises(5, 100) // 5 promises resolving at 100ms -const batch2 = createConcurrentPromises(5, 200) // 5 promises resolving at 200ms -const batch3 = createConcurrentPromises(5, 300) // 5 promises resolving at 300ms - export const Route = createFileRoute('/concurrent')({ loader: async () => { + // Create fresh batches of concurrent promises for each request + const batch1 = createConcurrentPromises(5, 100) // 5 promises resolving at 100ms + const batch2 = createConcurrentPromises(5, 200) // 5 promises resolving at 200ms + const batch3 = createConcurrentPromises(5, 300) // 5 promises resolving at 300ms + return { // Batch 1: 5 promises resolving at exactly the same time (100ms) concurrent1_1: batch1[0], diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index fe1b7cdb6f..3d447a183c 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -336,6 +336,23 @@ export function transformStreamWithRouter( continue } + // Handling partial closing tags split across chunks: + // + // Since `chunkString = leftover + text`, any incomplete tag fragment from the + // previous chunk is prepended to the current chunk, allowing split tags like + // "" to be re-detected as a complete "" in the combined string. + // + // - If a closing tag IS found (lastClosingTagEnd > 0): We enqueue content up to + // the end of that tag, flush router HTML, and store the remainder in `leftover`. + // This remainder may contain a partial tag (e.g., " 0) { From b17bf72aa48173d1a4e9272716233e7c10d67e09 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Mon, 22 Dec 2025 00:13:34 +0100 Subject: [PATCH 6/6] comment --- packages/router-core/src/ssr/transformStreamWithRouter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 3d447a183c..ef893bfa9e 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -35,7 +35,6 @@ const textEncoder = new TextEncoder() /** * Finds the position just after the last valid HTML closing tag in the string. - * Uses optimized charCodeAt approach (5x-370x faster than regex for typical HTML). * * Valid closing tags match the pattern: * Examples: , ,