diff --git a/docs/router/framework/react/api/router/RouterEventsType.md b/docs/router/framework/react/api/router/RouterEventsType.md index fd1b6dfb3d2..45d6431cdfd 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 00000000000..7c904629230 --- /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 00000000000..7f4b38a84ab --- /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 00000000000..5f9de5709e4 --- /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 00000000000..fb1073a34cf --- /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 00000000000..238fc5ef25d --- /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 00000000000..a208b3bfd2f --- /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 00000000000..a1bed0fe07a --- /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}`), + ) +} + +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], + 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 00000000000..d30450228e6 --- /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 00000000000..61a47469f4c --- /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 00000000000..10f426ae545 --- /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 00000000000..a6d03749723 --- /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 00000000000..781a6e576ff --- /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 00000000000..b20921323c0 --- /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 00000000000..5dd97726040 --- /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 00000000000..c1629204c70 --- /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 00000000000..c95a1d467fc --- /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 00000000000..91138987ada --- /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 00000000000..10e32c68097 --- /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 00000000000..90ee16edc06 --- /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 00000000000..01314694d9c --- /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 00000000000..71c564aebea --- /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 00000000000..e231d007fb5 --- /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 00000000000..034ab742206 --- /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 00000000000..dbf6df4265e --- /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 00000000000..b175365acc4 --- /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 00000000000..c3631a7fc03 --- /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 00000000000..5e4c134f82d --- /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 00000000000..07b5c5140bc --- /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 00000000000..3558337b0c1 --- /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 00000000000..275bfacde2a --- /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 e13527b5849..1633a7cfae4 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 54da83a33ec..b0748bc2c0c 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 8ce3dcfa0d6..59d6c161001 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 fcaf7bd1550..248cf2980b3 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -1,11 +1,9 @@ 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 { 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 './transformStreamWithRouter' import type { DehydratedMatch, DehydratedRouter } from './types' import type { AnySerializationAdapter } from './serializer/transformer' import type { AnyRouter } from '../router' @@ -20,7 +18,9 @@ declare module '../router' { interface RouterEvents { onInjectedHtml: { type: 'onInjectedHtml' - promise: Promise + } + onSerializationFinished: { + type: 'onSerializationFinished' } } } @@ -56,50 +56,76 @@ 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. + * + * 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) + } + } + 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 +147,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 +224,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 +260,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 +308,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 848f1a68008..ef893bfa9e2 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,394 @@ 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. + * + * 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 getBufferedRouterStream() { - const html = routerStreamBuffer - routerStreamBuffer = '' - return html + function safeEnqueue(chunk: string | Uint8Array) { + if (isStreamClosed) return + if (typeof chunk === 'string') { + controller.enqueue(textEncoder.encode(chunk)) + } else { + controller.enqueue(chunk) + } } - function decodeChunk(chunk: unknown): string { - if (chunk instanceof Uint8Array) { - return textDecoder.decode(chunk, { stream: true }) + function safeClose() { + if (isStreamClosed) return + isStreamClosed = true + try { + controller.close() + } catch { + // Stream may already be errored or closed by consumer - safe to ignore } - return String(chunk) } - const injectedHtmlDonePromise = createControlledPromise() - - let processingCount = 0 + 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 + } + } - // Process any already-injected HTML - handleInjectedHtml() + const stream = new ReadableStream({ + start(c) { + controller = c + }, + cancel() { + isStreamClosed = true + cleanup() + }, + }) - // Listen for any new injected HTML - stopListeningToInjectedHtml = router.subscribe( - 'onInjectedHtml', - handleInjectedHtml, - ) + let isAppRendering = true + let streamBarrierLifted = false + let leftover = '' + let pendingClosingTags = '' + let serializationFinished = serializationAlreadyFinished - function handleInjectedHtml() { - // Don't process if already cleaned up - if (cleanedUp) return + let pendingRouterHtmlParts: Array = [] - router.serverSsr!.injectedHtml.forEach((promise) => { - processingCount++ + // Take any HTML that was buffered before we started listening + const bufferedHtml = router.serverSsr?.takeBufferedHtml() + if (bufferedHtml) { + pendingRouterHtmlParts.push(bufferedHtml) + } - 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 = [] + function flushPendingRouterHtml() { + if (pendingRouterHtmlParts.length > 0) { + safeEnqueue(pendingRouterHtmlParts.join('')) + pendingRouterHtmlParts = [] + } } - injectedHtmlDonePromise - .then(() => { - // Don't process if already cleaned up or destroyed - if (cleanedUp || finalPassThrough.destroyed) { - 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 + } - clearTimeout(timeoutHandle) - const finalHtml = - leftover + leftoverHtml + getBufferedRouterStream() + pendingClosingTags + // Flush any remaining bytes in the TextDecoder + const decoderRemainder = textDecoder.decode() - leftover = '' - leftoverHtml = '' - pendingClosingTags = '' + if (leftover) safeEnqueue(leftover) + if (decoderRemainder) safeEnqueue(decoderRemainder) + flushPendingRouterHtml() + if (pendingClosingTags) safeEnqueue(pendingClosingTags) - finalPassThrough.end(finalHtml) - }) - .catch((err) => { - // Don't process if already cleaned up - if (cleanedUp || finalPassThrough.destroyed) { - return - } + safeClose() + cleanup() + } - console.error('Error reading routerStream:', err) - finalPassThrough.destroy(err) + // 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) + } }) - .finally(cleanup) - // Transform the appStream - readStream(appStream, { - onData: (chunk) => { - // Don't process if already cleaned up - if (cleanedUp || finalPassThrough.destroyed) { - return - } + // Listen for serialization finished + stopListeningToSerializationFinished = router.subscribe( + 'onSerializationFinished', + () => { + serializationFinished = true + tryFinish() + }, + ) + } - 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() + // 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() + } } - } - // 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 - } + // Check for body/html end tags + const bodyEndIndex = chunkString.indexOf(BODY_END_TAG) + const htmlEndIndex = chunkString.indexOf(HTML_END_TAG) - 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 - } + // 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) - if (lastIndex > 0) { - const processed = - chunkString.slice(0, lastIndex) + - getBufferedRouterStream() + - leftoverHtml + safeEnqueue(chunkString.slice(0, bodyEndIndex)) + 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 = '' + 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) { + // Found a closing tag - insert router HTML after it + safeEnqueue(chunkString.slice(0, lastClosingTagEnd)) + flushPendingRouterHtml() + + 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/router-core/tests/closing-tag-detection.bench.ts b/packages/router-core/tests/closing-tag-detection.bench.ts new file mode 100644 index 00000000000..a924256dc22 --- /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) + }) +}) diff --git a/packages/solid-router/src/ssr/renderRouterToString.tsx b/packages/solid-router/src/ssr/renderRouterToString.tsx index b0db666450d..9d217699377 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 fdc89b23dc7..642082773f4 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 7a0b0552f7c..e7082591f41 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':