diff --git a/.changeset/steady-streams-flow.md b/.changeset/steady-streams-flow.md new file mode 100644 index 00000000000..c2ad94a15e4 --- /dev/null +++ b/.changeset/steady-streams-flow.md @@ -0,0 +1,22 @@ +--- +'@tanstack/router-core': patch +'@tanstack/router-ssr-query-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/solid-start-server': patch +'@tanstack/start-client-core': patch +'@tanstack/start-server-core': patch +'@tanstack/vue-router': patch +'@tanstack/vue-router-ssr-query': patch +'@tanstack/vue-start-server': patch +--- + +Stream large deferred SSR hydration payloads through a backpressure-aware router transport, fail known setup errors before response creation, and close cancelled or expired transforms safely. + +Start now cancels discarded middleware and HEAD response bodies, including plain streams and derived branches. + +Server-function raw streams now use bounded transport backpressure instead of buffering unread raw data on the client. + +Solid SSR now emits one document type, preserves blocking Await values, and renders late lazy errors through route boundaries. + +SSR Query integrations now keep request cleanup and stream ownership aligned with the router lifecycle. diff --git a/docs/router/api/router/RouterEventsType.md b/docs/router/api/router/RouterEventsType.md index c79bb47f817..c27887fba1f 100644 --- a/docs/router/api/router/RouterEventsType.md +++ b/docs/router/api/router/RouterEventsType.md @@ -47,9 +47,6 @@ type RouterEvents = { hrefChanged: boolean hashChanged: boolean } - onInjectedHtml: { - type: 'onInjectedHtml' - } onRendered: { type: 'onRendered' fromLocation?: ParsedLocation @@ -67,7 +64,7 @@ Once an event is emitted, the following properties will be present on the event ### `type` property -- Type: `onBeforeNavigate | onBeforeLoad | onLoad | onBeforeRouteMount | onResolved | onRendered | onInjectedHtml` +- Type: `onBeforeNavigate | onBeforeLoad | onLoad | onBeforeRouteMount | onResolved | onRendered` - The type of the event - This is useful for discriminating between events in a listener function. diff --git a/e2e/react-start/static-server-functions/src/routeTree.gen.ts b/e2e/react-start/static-server-functions/src/routeTree.gen.ts index ea6a6a38061..558b1ab4d95 100644 --- a/e2e/react-start/static-server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/static-server-functions/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as PostsRouteImport } from './routes/posts' +import { Route as RawStreamRouteImport } from './routes/raw-stream' import { Route as PostsIndexRouteImport } from './routes/posts.index' const IndexRoute = IndexRouteImport.update({ @@ -23,6 +24,11 @@ const PostsRoute = PostsRouteImport.update({ path: '/posts', getParentRoute: () => rootRouteImport, } as any) +const RawStreamRoute = RawStreamRouteImport.update({ + id: '/raw-stream', + path: '/raw-stream', + getParentRoute: () => rootRouteImport, +} as any) const PostsIndexRoute = PostsIndexRouteImport.update({ id: '/', path: '/', @@ -32,29 +38,33 @@ const PostsIndexRoute = PostsIndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/raw-stream': typeof RawStreamRoute '/posts/': typeof PostsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/raw-stream': typeof RawStreamRoute '/posts': typeof PostsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/raw-stream': typeof RawStreamRoute '/posts/': typeof PostsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/posts' | '/posts/' + fullPaths: '/' | '/posts' | '/raw-stream' | '/posts/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/posts' - id: '__root__' | '/' | '/posts' | '/posts/' + to: '/' | '/raw-stream' | '/posts' + id: '__root__' | '/' | '/posts' | '/raw-stream' | '/posts/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute PostsRoute: typeof PostsRouteWithChildren + RawStreamRoute: typeof RawStreamRoute } declare module '@tanstack/react-router' { @@ -73,6 +83,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PostsRouteImport parentRoute: typeof rootRouteImport } + '/raw-stream': { + id: '/raw-stream' + path: '/raw-stream' + fullPath: '/raw-stream' + preLoaderRoute: typeof RawStreamRouteImport + parentRoute: typeof rootRouteImport + } '/posts/': { id: '/posts/' path: '/' @@ -96,6 +113,7 @@ const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, PostsRoute: PostsRouteWithChildren, + RawStreamRoute: RawStreamRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/react-start/static-server-functions/src/routes/__root.tsx b/e2e/react-start/static-server-functions/src/routes/__root.tsx index b768e8b9a14..a300613fa87 100644 --- a/e2e/react-start/static-server-functions/src/routes/__root.tsx +++ b/e2e/react-start/static-server-functions/src/routes/__root.tsx @@ -30,6 +30,9 @@ function RootComponent() { Posts + + Raw Stream +
{Route.useLoaderData()}
+} diff --git a/e2e/react-start/static-server-functions/tests/app.spec.ts b/e2e/react-start/static-server-functions/tests/app.spec.ts index 895566aa353..d86fd61a757 100644 --- a/e2e/react-start/static-server-functions/tests/app.spec.ts +++ b/e2e/react-start/static-server-functions/tests/app.spec.ts @@ -59,5 +59,32 @@ test.describe('Static Server Functions with Nitro', () => { await expect(page.getByTestId('post-2')).toHaveText('Second Post') await expect(page.getByTestId('post-3')).toHaveText('Third Post') }) + + test('should decode a cached RawStream during client navigation', async ({ + page, + }) => { + await page.goto('/') + // Nitro snapshots public assets before Start prerenders (see #6787). + // Serve the generated file as a static host would to isolate decoding. + await page.route('**/__tsr/staticServerFnCache/*.json', (route) => + route.fulfill({ + path: join( + process.cwd(), + '.output', + 'public', + new URL(route.request().url()).pathname, + ), + }), + ) + const cacheResponse = page.waitForResponse((response) => + response.url().includes('/__tsr/staticServerFnCache/'), + ) + await page.getByTestId('link-raw-stream').click() + const response = await cacheResponse + expect(response.status(), response.url()).toBe(200) + await expect(page.getByTestId('raw-stream')).toHaveText( + 'Static cache stream', + ) + }) }) }) diff --git a/e2e/react-start/streaming-ssr/package.json b/e2e/react-start/streaming-ssr/package.json index fcf78262d54..ae94404482c 100644 --- a/e2e/react-start/streaming-ssr/package.json +++ b/e2e/react-start/streaming-ssr/package.json @@ -26,9 +26,37 @@ "@types/node": "^22.10.2", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", "srvx": "^0.11.9", "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2", "vite": "^8.0.14" + }, + "nx": { + "targets": { + "build": { + "inputs": [ + "buildProduction", + "^buildProduction", + "dependentTaskOutputs", + "streamingSsrTests" + ] + }, + "test:e2e": { + "inputs": [ + "default", + "^production", + "dependentTaskOutputs", + "streamingSsrTests" + ] + }, + "test:e2e:preview": { + "inputs": [ + "default", + "^production", + "streamingSsrTests" + ] + } + } } } diff --git a/e2e/react-start/streaming-ssr/src/routeTree.gen.ts b/e2e/react-start/streaming-ssr/src/routeTree.gen.ts index c1a51b62f8f..169121003dc 100644 --- a/e2e/react-start/streaming-ssr/src/routeTree.gen.ts +++ b/e2e/react-start/streaming-ssr/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as FastSerialRouteImport } from './routes/fast-serial' import { Route as ManyPromisesRouteImport } from './routes/many-promises' import { Route as NestedDeferredRouteImport } from './routes/nested-deferred' import { Route as QueryHeavyRouteImport } from './routes/query-heavy' +import { Route as RouterHtmlBufferRouteImport } from './routes/router-html-buffer' import { Route as SlowRenderRouteImport } from './routes/slow-render' import { Route as StreamRouteImport } from './routes/stream' import { Route as SyncOnlyRouteImport } from './routes/sync-only' @@ -61,6 +62,11 @@ const QueryHeavyRoute = QueryHeavyRouteImport.update({ path: '/query-heavy', getParentRoute: () => rootRouteImport, } as any) +const RouterHtmlBufferRoute = RouterHtmlBufferRouteImport.update({ + id: '/router-html-buffer', + path: '/router-html-buffer', + getParentRoute: () => rootRouteImport, +} as any) const SlowRenderRoute = SlowRenderRouteImport.update({ id: '/slow-render', path: '/slow-render', @@ -86,6 +92,7 @@ export interface FileRoutesByFullPath { '/many-promises': typeof ManyPromisesRoute '/nested-deferred': typeof NestedDeferredRoute '/query-heavy': typeof QueryHeavyRoute + '/router-html-buffer': typeof RouterHtmlBufferRoute '/slow-render': typeof SlowRenderRoute '/stream': typeof StreamRoute '/sync-only': typeof SyncOnlyRoute @@ -99,6 +106,7 @@ export interface FileRoutesByTo { '/many-promises': typeof ManyPromisesRoute '/nested-deferred': typeof NestedDeferredRoute '/query-heavy': typeof QueryHeavyRoute + '/router-html-buffer': typeof RouterHtmlBufferRoute '/slow-render': typeof SlowRenderRoute '/stream': typeof StreamRoute '/sync-only': typeof SyncOnlyRoute @@ -113,6 +121,7 @@ export interface FileRoutesById { '/many-promises': typeof ManyPromisesRoute '/nested-deferred': typeof NestedDeferredRoute '/query-heavy': typeof QueryHeavyRoute + '/router-html-buffer': typeof RouterHtmlBufferRoute '/slow-render': typeof SlowRenderRoute '/stream': typeof StreamRoute '/sync-only': typeof SyncOnlyRoute @@ -128,6 +137,7 @@ export interface FileRouteTypes { | '/many-promises' | '/nested-deferred' | '/query-heavy' + | '/router-html-buffer' | '/slow-render' | '/stream' | '/sync-only' @@ -141,6 +151,7 @@ export interface FileRouteTypes { | '/many-promises' | '/nested-deferred' | '/query-heavy' + | '/router-html-buffer' | '/slow-render' | '/stream' | '/sync-only' @@ -154,6 +165,7 @@ export interface FileRouteTypes { | '/many-promises' | '/nested-deferred' | '/query-heavy' + | '/router-html-buffer' | '/slow-render' | '/stream' | '/sync-only' @@ -168,6 +180,7 @@ export interface RootRouteChildren { ManyPromisesRoute: typeof ManyPromisesRoute NestedDeferredRoute: typeof NestedDeferredRoute QueryHeavyRoute: typeof QueryHeavyRoute + RouterHtmlBufferRoute: typeof RouterHtmlBufferRoute SlowRenderRoute: typeof SlowRenderRoute StreamRoute: typeof StreamRoute SyncOnlyRoute: typeof SyncOnlyRoute @@ -231,6 +244,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof QueryHeavyRouteImport parentRoute: typeof rootRouteImport } + '/router-html-buffer': { + id: '/router-html-buffer' + path: '/router-html-buffer' + fullPath: '/router-html-buffer' + preLoaderRoute: typeof RouterHtmlBufferRouteImport + parentRoute: typeof rootRouteImport + } '/slow-render': { id: '/slow-render' path: '/slow-render' @@ -264,6 +284,7 @@ const rootRouteChildren: RootRouteChildren = { ManyPromisesRoute: ManyPromisesRoute, NestedDeferredRoute: NestedDeferredRoute, QueryHeavyRoute: QueryHeavyRoute, + RouterHtmlBufferRoute: RouterHtmlBufferRoute, SlowRenderRoute: SlowRenderRoute, StreamRoute: StreamRoute, SyncOnlyRoute: SyncOnlyRoute, diff --git a/e2e/react-start/streaming-ssr/src/routes/router-html-buffer.tsx b/e2e/react-start/streaming-ssr/src/routes/router-html-buffer.tsx new file mode 100644 index 00000000000..bf22923b906 --- /dev/null +++ b/e2e/react-start/streaming-ssr/src/routes/router-html-buffer.tsx @@ -0,0 +1,73 @@ +import { Suspense, useState } from 'react' +import { Await, createFileRoute } from '@tanstack/react-router' + +// Keep the expensive allocation request-driven. The corresponding E2E test is +// opt-in, so the normal streaming suite never creates this payload. +const ROUTER_HTML_PAYLOAD_CHARS = 17 * 1024 * 1024 + +export const Route = createFileRoute('/router-html-buffer')({ + loader: () => { + return { + // Resolving after the shell starts streaming makes this value arrive in + // a router hydration script instead of ordinary rendered HTML. + payload: new Promise<{ + value: string + source: 'server' | 'client' + }>((resolve) => { + setTimeout(() => { + resolve({ + value: 'x'.repeat(ROUTER_HTML_PAYLOAD_CHARS), + source: typeof window === 'undefined' ? 'server' : 'client', + }) + }, 100) + }), + } + }, + component: RouterHtmlBufferRoute, +}) + +function RouterHtmlBufferRoute() { + const { payload } = Route.useLoaderData() + + return ( +{payload.length}
+ +{hydrationResult}
+ > + ) +} diff --git a/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts b/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts index 91138987ada..8b9b3a8dcc5 100644 --- a/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts +++ b/e2e/react-start/streaming-ssr/tests/client-navigation.spec.ts @@ -1,372 +1 @@ -import { expect, test, testWithHydration } from './fixtures' - -test.describe('Client-side navigation between all routes', () => { - test.beforeEach(async ({ page }) => { - // Start from home page - await page.goto('/') - await page.waitForLoadState('networkidle') - }) - - test('home -> sync-only -> home works', async ({ page }) => { - // Navigate to sync-only - await page - .getByRole('navigation') - .getByRole('link', { name: 'Sync Only' }) - .click() - await expect(page).toHaveURL('/sync-only') - await expect(page.getByTestId('sync-message')).toBeVisible() - - // Navigate back to home - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - await expect(page.getByTestId('index-title')).toBeVisible() - }) - - test('home -> deferred -> home works', async ({ page }) => { - // Navigate to deferred (use exact: true to avoid matching "Nested Deferred") - await page - .getByRole('navigation') - .getByRole('link', { name: 'Deferred', exact: true }) - .click() - await expect(page).toHaveURL('/deferred') - await expect(page.getByTestId('immediate-data')).toBeVisible() - - // Wait for deferred data - await expect(page.getByTestId('deferred-data')).toBeVisible({ - timeout: 5000, - }) - - // Navigate back to home - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - }) - - test('home -> stream -> home works (no stream locking error)', async ({ - page, - }) => { - // This test specifically validates the ReadableStream locking fix - // Console errors are monitored by the fixture automatically - - // Navigate to stream - await page - .getByRole('navigation') - .getByRole('link', { name: 'Stream' }) - .click() - await expect(page).toHaveURL('/stream') - - // Wait for stream to start (at least one chunk or promise resolved) - await expect(page.getByTestId('promise-data')).toBeVisible({ - timeout: 5000, - }) - - // Navigate back to home before stream completes - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - }) - - test('home -> stream -> wait for completion -> home works', async ({ - page, - }) => { - // Navigate to stream - await page - .getByRole('navigation') - .getByRole('link', { name: 'Stream' }) - .click() - await expect(page).toHaveURL('/stream') - - // Wait for stream to complete - await expect(page.getByTestId('stream-complete')).toBeVisible({ - timeout: 10000, - }) - - // Navigate back to home - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - await expect(page.getByTestId('index-title')).toBeVisible() - }) - - test('home -> stream -> home -> stream again works (fresh stream each time)', async ({ - page, - }) => { - // Console errors are monitored by the fixture automatically - - // First navigation to stream - await page - .getByRole('navigation') - .getByRole('link', { name: 'Stream' }) - .click() - await expect(page).toHaveURL('/stream') - await expect(page.getByTestId('promise-data')).toBeVisible({ - timeout: 5000, - }) - - // Navigate back to home - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - - // Second navigation to stream - should get fresh stream without errors - await page - .getByRole('navigation') - .getByRole('link', { name: 'Stream' }) - .click() - await expect(page).toHaveURL('/stream') - - // Wait for stream to complete - await expect(page.getByTestId('stream-complete')).toBeVisible({ - timeout: 10000, - }) - - // Verify all chunks are present - await expect(page.getByTestId('stream-chunk-0')).toBeVisible() - await expect(page.getByTestId('stream-chunk-4')).toBeVisible() - }) - - test('home -> fast-serial -> home works', async ({ page }) => { - await page - .getByRole('navigation') - .getByRole('link', { name: 'Fast Serial' }) - .click() - await expect(page).toHaveURL('/fast-serial') - await expect(page.getByTestId('server-data')).toBeVisible() - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - }) - - test('home -> slow-render -> home works', async ({ page }) => { - await page - .getByRole('navigation') - .getByRole('link', { name: 'Slow Render' }) - .click() - await expect(page).toHaveURL('/slow-render') - await expect(page.getByTestId('quick-data')).toBeVisible() - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - }) - - test('home -> nested-deferred -> home works', async ({ page }) => { - await page - .getByRole('navigation') - .getByRole('link', { name: 'Nested Deferred' }) - .click() - await expect(page).toHaveURL('/nested-deferred') - - // Wait for all levels to load - await expect(page.getByTestId('level3-data')).toBeVisible({ timeout: 5000 }) - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - }) - - test('rapid navigation between routes works', async ({ page }) => { - // Console errors are monitored by the fixture automatically - - // Rapid navigation sequence - await page - .getByRole('navigation') - .getByRole('link', { name: 'Sync Only' }) - .click() - await expect(page).toHaveURL('/sync-only') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Deferred', exact: true }) - .click() - await expect(page).toHaveURL('/deferred') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Stream' }) - .click() - await expect(page).toHaveURL('/stream') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Fast Serial' }) - .click() - await expect(page).toHaveURL('/fast-serial') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Slow Render' }) - .click() - await expect(page).toHaveURL('/slow-render') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Nested Deferred' }) - .click() - await expect(page).toHaveURL('/nested-deferred') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - }) -}) - -test.describe('Direct navigation followed by client navigation', () => { - test('direct to stream -> client nav to deferred works', async ({ page }) => { - // Direct navigation to stream - await page.goto('/stream') - await expect(page.getByTestId('promise-data')).toBeVisible({ - timeout: 5000, - }) - - // Client navigation to deferred (use exact: true) - await page - .getByRole('navigation') - .getByRole('link', { name: 'Deferred', exact: true }) - .click() - await expect(page).toHaveURL('/deferred') - await expect(page.getByTestId('immediate-data')).toBeVisible() - }) - - test('direct to deferred -> client nav to stream works', async ({ page }) => { - // Console errors are monitored by the fixture automatically - - // Direct navigation to deferred - await page.goto('/deferred') - await expect(page.getByTestId('immediate-data')).toBeVisible() - - // Client navigation to stream - await page - .getByRole('navigation') - .getByRole('link', { name: 'Stream' }) - .click() - await expect(page).toHaveURL('/stream') - await expect(page.getByTestId('stream-complete')).toBeVisible({ - timeout: 10000, - }) - }) - - test('direct to sync-only -> client nav to all routes works', async ({ - page, - }) => { - await page.goto('/sync-only') - await expect(page.getByTestId('sync-message')).toBeVisible() - - // Navigate through all routes - await page - .getByRole('navigation') - .getByRole('link', { name: 'Deferred', exact: true }) - .click() - await expect(page).toHaveURL('/deferred') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Stream' }) - .click() - await expect(page).toHaveURL('/stream') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Fast Serial' }) - .click() - await expect(page).toHaveURL('/fast-serial') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Home' }) - .click() - await expect(page).toHaveURL('/') - }) -}) - -testWithHydration.describe('Hydration after client navigation', () => { - testWithHydration( - 'interactive elements work after navigating to deferred', - async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Deferred', exact: true }) - .click() - await expect(page).toHaveURL('/deferred') - - // Wait for the page to be fully loaded - await expect(page.getByTestId('immediate-data')).toBeVisible() - }, - ) - - testWithHydration( - 'interactive elements work after navigating to fast-serial', - async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Fast Serial' }) - .click() - await expect(page).toHaveURL('/fast-serial') - - // Wait for page to load - await expect(page.getByTestId('server-data')).toBeVisible() - }, - ) - - testWithHydration( - 'interactive elements work after navigating to nested-deferred', - async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Nested Deferred' }) - .click() - await expect(page).toHaveURL('/nested-deferred') - - // Wait for page to load - await expect(page.getByTestId('plain-deferred')).toBeVisible({ - timeout: 5000, - }) - }, - ) - - testWithHydration( - 'interactive elements work after navigating to slow-render', - async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - - await page - .getByRole('navigation') - .getByRole('link', { name: 'Slow Render' }) - .click() - await expect(page).toHaveURL('/slow-render') - - // Wait for page to load (slow-render has blocking loops) - await expect(page.getByTestId('quick-data')).toBeVisible() - }, - ) -}) +import '../../../streaming-ssr-specs/client-navigation' diff --git a/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts b/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts index 10e32c68097..8625473a58e 100644 --- a/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts +++ b/e2e/react-start/streaming-ssr/tests/concurrent.spec.ts @@ -1,100 +1 @@ -import { expect, test, testWithHydration } from './fixtures' - -test.describe('Concurrent promise resolution (15 promises in 3 batches)', () => { - test('all concurrent promises resolve correctly', async ({ page }) => { - await page.goto('/concurrent') - - // Batch 1 (5 promises at 100ms) - await expect(page.getByTestId('concurrent-1-1')).toContainText( - 'concurrent-1', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-1-2')).toContainText( - 'concurrent-2', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-1-3')).toContainText( - 'concurrent-3', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-1-4')).toContainText( - 'concurrent-4', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-1-5')).toContainText( - 'concurrent-5', - { timeout: 5000 }, - ) - - // Batch 2 (5 promises at 200ms) - await expect(page.getByTestId('concurrent-2-1')).toContainText( - 'concurrent-1', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-2-2')).toContainText( - 'concurrent-2', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-2-3')).toContainText( - 'concurrent-3', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-2-4')).toContainText( - 'concurrent-4', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-2-5')).toContainText( - 'concurrent-5', - { timeout: 5000 }, - ) - - // Batch 3 (5 promises at 300ms) - await expect(page.getByTestId('concurrent-3-1')).toContainText( - 'concurrent-1', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-3-2')).toContainText( - 'concurrent-2', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-3-3')).toContainText( - 'concurrent-3', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-3-4')).toContainText( - 'concurrent-4', - { timeout: 5000 }, - ) - await expect(page.getByTestId('concurrent-3-5')).toContainText( - 'concurrent-5', - { timeout: 5000 }, - ) - }) - - test('batch 1 resolves before batch 3', async ({ page }) => { - await page.goto('/concurrent', { waitUntil: 'commit' }) - - // Batch 1 should be visible before batch 3 - await expect(page.getByTestId('concurrent-1-1')).toBeVisible({ - timeout: 3000, - }) - - // Eventually batch 3 should also be visible - await expect(page.getByTestId('concurrent-3-5')).toBeVisible({ - timeout: 5000, - }) - }) - - testWithHydration( - 'hydration works with concurrent resolutions', - async ({ page }) => { - await page.goto('/concurrent') - await page.waitForLoadState('networkidle') - - // Wait for all batches - await expect(page.getByTestId('concurrent-3-5')).toBeVisible({ - timeout: 5000, - }) - }, - ) -}) +import '../../../streaming-ssr-specs/concurrent' diff --git a/e2e/react-start/streaming-ssr/tests/deferred-rejection.spec.ts b/e2e/react-start/streaming-ssr/tests/deferred-rejection.spec.ts index 3b8144072c4..355b5019bc9 100644 --- a/e2e/react-start/streaming-ssr/tests/deferred-rejection.spec.ts +++ b/e2e/react-start/streaming-ssr/tests/deferred-rejection.spec.ts @@ -1,21 +1 @@ -import { expect, test } from './fixtures' - -test.use({ - whitelistErrors: ['Error in deferred object'], -}) - -test('rejected deferred Await renders the route error boundary without killing SSR', async ({ - page, -}) => { - await page.goto('/deferred-rejection') - - await expect(page.getByTestId('deferred-error-boundary')).toContainText( - 'Error in deferred object', - { timeout: 5000 }, - ) - - await page.goto('/sync-only') - await expect(page.getByTestId('sync-message')).toContainText( - 'Hello from sync loader!', - ) -}) +import '../../../streaming-ssr-specs/deferred-rejection' diff --git a/e2e/react-start/streaming-ssr/tests/deferred.spec.ts b/e2e/react-start/streaming-ssr/tests/deferred.spec.ts index 90ee16edc06..3145897d7be 100644 --- a/e2e/react-start/streaming-ssr/tests/deferred.spec.ts +++ b/e2e/react-start/streaming-ssr/tests/deferred.spec.ts @@ -1,120 +1 @@ -import { expect, test, testWithHydration } from './fixtures' - -test.describe('Deferred data streaming', () => { - test('shows immediate data right away and deferred data after loading', async ({ - page, - }) => { - await page.goto('/deferred') - - // Immediate data should be available right away - await expect(page.getByTestId('immediate-data')).toBeVisible() - await expect(page.getByTestId('immediate-data')).toContainText( - 'Immediate: Fast User', - ) - - // Verify immediate data came from server - await expect(page.getByTestId('immediate-source')).toContainText( - 'Immediate source: server', - ) - await expect(page.getByTestId('loader-source')).toContainText( - 'Loader source: server', - ) - - // Deferred data should eventually appear with server source - await expect(page.getByTestId('deferred-data')).toContainText( - 'Deferred data loaded!', - { timeout: 5000 }, - ) - await expect(page.getByTestId('deferred-data')).toContainText( - 'source: server', - { timeout: 5000 }, - ) - await expect(page.getByTestId('deferred-server-data')).toContainText( - 'Server: Slow User', - { timeout: 5000 }, - ) - await expect(page.getByTestId('deferred-server-data')).toContainText( - 'source: server', - { timeout: 5000 }, - ) - }) - - test('shows loading states for deferred content', async ({ page }) => { - // Navigate with cache disabled to ensure fresh load - await page.goto('/deferred', { waitUntil: 'commit' }) - - // Should see loading states initially (may be very brief) - // We check that deferred content eventually shows - await expect(page.getByTestId('deferred-data')).toBeVisible({ - timeout: 5000, - }) - await expect(page.getByTestId('deferred-server-data')).toBeVisible({ - timeout: 5000, - }) - }) - - testWithHydration( - 'hydration works - interactive elements respond', - async ({ page }) => { - await page.goto('/deferred') - await page.waitForLoadState('networkidle') - - // Wait for all deferred content to load - await expect(page.getByTestId('deferred-data')).toBeVisible({ - timeout: 5000, - }) - - // Verify all data came from server after hydration - await expect(page.getByTestId('loader-source')).toContainText( - 'Loader source: server', - ) - await expect(page.getByTestId('deferred-data')).toContainText( - 'source: server', - ) - }, - ) - - test('client-side navigation to deferred route works', async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - - // Navigate via client-side routing using nav link - await page.getByRole('link', { name: 'Deferred' }).first().click() - await expect(page).toHaveURL('/deferred') - - // Data should load - await expect(page.getByTestId('immediate-data')).toContainText('Fast User') - await expect(page.getByTestId('deferred-data')).toContainText( - 'Deferred data loaded!', - { timeout: 5000 }, - ) - }) - - test('all data sources are server - proves SSR streaming works', async ({ - page, - }) => { - await page.goto('/deferred') - - // Wait for all deferred content - await expect(page.getByTestId('deferred-data')).toBeVisible({ - timeout: 5000, - }) - await expect(page.getByTestId('deferred-server-data')).toBeVisible({ - timeout: 5000, - }) - - // Count all elements showing 'server' source - should be 4: - // 1. immediate-source - // 2. loader-source - // 3. deferred-data (contains "source: server") - // 4. deferred-server-data (contains "source: server") - await expect(page.getByTestId('immediate-source')).toContainText('server') - await expect(page.getByTestId('loader-source')).toContainText('server') - await expect(page.getByTestId('deferred-data')).toContainText( - 'source: server', - ) - await expect(page.getByTestId('deferred-server-data')).toContainText( - 'source: server', - ) - }) -}) +import '../../../streaming-ssr-specs/deferred' diff --git a/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts b/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts index 339d6408ea7..95df0555e26 100644 --- a/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts +++ b/e2e/react-start/streaming-ssr/tests/fast-serial.spec.ts @@ -1,72 +1 @@ -import { expect, test, testWithHydration } from './fixtures' - -test.describe('Fast serialization (serialization completes before render)', () => { - test('initial HTML contains router bootstrap + barrier', async ({ page }) => { - let responseHtml = '' - await page.route('/fast-serial', async (route) => { - const response = await route.fetch() - responseHtml = await response.text() - await route.fulfill({ response }) - }) - - await page.goto('/fast-serial') - await expect(page.getByTestId('server-data')).toBeVisible() - - expect(responseHtml).toContain('$_TSR') - expect(responseHtml).toContain('$_TSR.router') - expect(responseHtml).toContain('$_TSR.e()') - expect(responseHtml).toContain('$tsr-stream-barrier') - }) - - test('all data is available immediately', async ({ page }) => { - await page.goto('/fast-serial') - await page.waitForLoadState('networkidle') - - // All data should be visible - await expect(page.getByTestId('server-data')).toContainText('small-data') - await expect(page.getByTestId('static-data')).toContainText( - 'This is static data', - ) - await expect(page.getByTestId('loader-timestamp')).toBeVisible() - - // Verify data came from server (proves SSR streaming worked) - await expect(page.getByTestId('loader-source')).toContainText( - 'Loader source: server', - ) - await expect(page.getByTestId('server-fn-source')).toContainText( - 'Server function source: server', - ) - }) - - testWithHydration('hydration works correctly', async ({ page }) => { - await page.goto('/fast-serial') - await page.waitForLoadState('networkidle') - - // Verify data came from server after hydration - await expect(page.getByTestId('loader-source')).toContainText( - 'Loader source: server', - ) - }) - - test('direct navigation renders correctly', async ({ page }) => { - // Direct navigation (SSR) - await page.goto('/fast-serial') - - // Should render without errors and show server source - await expect(page.getByTestId('server-data')).toBeVisible() - await expect(page.getByTestId('loader-source')).toContainText( - 'Loader source: server', - ) - }) - - test('client-side navigation works', async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - - // Navigate via nav link - await page.getByRole('link', { name: 'Fast Serial' }).first().click() - await expect(page).toHaveURL('/fast-serial') - - await expect(page.getByTestId('server-data')).toContainText('small-data') - }) -}) +import '../../../streaming-ssr-specs/fast-serial' diff --git a/e2e/react-start/streaming-ssr/tests/fixtures.ts b/e2e/react-start/streaming-ssr/tests/fixtures.ts deleted file mode 100644 index 71c564aebea..00000000000 --- a/e2e/react-start/streaming-ssr/tests/fixtures.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { test as base, expect, type Page } from '@playwright/test' - -/** - * Verifies that React hydration has completed by clicking the global - * hydration check button and verifying the status changes. - * - * This is the canonical way to verify hydration in streaming-ssr tests. - * The HydrationCheck component is rendered in the root layout (__root.tsx). - * - * The function retries clicking until the status changes to 'hydrated', - * which handles the case where the button is visible from SSR before - * React has finished hydrating. - */ -async function verifyHydration( - page: Page, - options: { timeout?: number } = {}, -): Promise