From 927a360d1e0a46e745a09a34a162eecd27ce0768 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:48:21 +0200 Subject: [PATCH] optimize streaming --- .changeset/steady-streams-flow.md | 22 + docs/router/api/router/RouterEventsType.md | 5 +- .../src/routeTree.gen.ts | 24 +- .../src/routes/__root.tsx | 3 + .../src/routes/raw-stream.tsx | 29 + .../static-server-functions/tests/app.spec.ts | 27 + e2e/react-start/streaming-ssr/package.json | 28 + .../streaming-ssr/src/routeTree.gen.ts | 21 + .../src/routes/router-html-buffer.tsx | 73 + .../tests/client-navigation.spec.ts | 373 +-- .../streaming-ssr/tests/concurrent.spec.ts | 101 +- .../tests/deferred-rejection.spec.ts | 22 +- .../streaming-ssr/tests/deferred.spec.ts | 121 +- .../streaming-ssr/tests/fast-serial.spec.ts | 73 +- .../streaming-ssr/tests/fixtures.ts | 90 - .../streaming-ssr/tests/home.spec.ts | 35 +- .../streaming-ssr/tests/many-promises.spec.ts | 94 +- .../tests/nested-deferred.spec.ts | 78 +- .../tests/preview-streaming.spec.ts | 22 +- .../streaming-ssr/tests/query-heavy.spec.ts | 184 +- .../tests/router-html-buffer.spec.ts | 33 + .../streaming-ssr/tests/slow-render.spec.ts | 74 +- .../streaming-ssr/tests/stream.spec.ts | 47 +- .../streaming-ssr/tests/sync-only.spec.ts | 85 +- e2e/react-start/streaming-ssr/vite.config.ts | 3 +- e2e/solid-start/streaming-ssr/package.json | 59 + .../streaming-ssr/playwright.config.ts | 41 + .../streaming-ssr/src/routeTree.gen.ts | 282 ++ e2e/solid-start/streaming-ssr/src/router.tsx | 18 + .../streaming-ssr/src/routes/__root.tsx | 99 + .../streaming-ssr/src/routes/concurrent.tsx | 80 + .../src/routes/deferred-rejection.tsx | 38 + .../streaming-ssr/src/routes/deferred.tsx | 93 + .../streaming-ssr/src/routes/fast-serial.tsx | 47 + .../streaming-ssr/src/routes/index.tsx | 58 + .../src/routes/many-promises.tsx | 112 + .../src/routes/nested-deferred.tsx | 112 + .../streaming-ssr/src/routes/query-heavy.tsx | 162 ++ .../streaming-ssr/src/routes/slow-render.tsx | 73 + .../streaming-ssr/src/routes/stream.tsx | 91 + .../streaming-ssr/src/routes/sync-only.tsx | 31 + .../tests/client-navigation.spec.ts | 1 + .../streaming-ssr/tests/concurrent.spec.ts | 1 + .../tests/deferred-rejection.spec.ts | 1 + .../streaming-ssr/tests/deferred.spec.ts | 1 + .../streaming-ssr/tests/fast-serial.spec.ts | 1 + .../streaming-ssr/tests/home.spec.ts | 1 + .../streaming-ssr/tests/many-promises.spec.ts | 1 + .../tests/nested-deferred.spec.ts | 1 + .../tests/preview-streaming.spec.ts | 1 + .../streaming-ssr/tests/query-heavy.spec.ts | 1 + .../streaming-ssr/tests/slow-render.spec.ts | 1 + .../streaming-ssr/tests/stream.spec.ts | 1 + .../streaming-ssr/tests/sync-only.spec.ts | 1 + e2e/solid-start/streaming-ssr/tsconfig.json | 22 + e2e/solid-start/streaming-ssr/vite.config.ts | 10 + e2e/streaming-ssr-assertions.ts | 176 ++ e2e/streaming-ssr-specs/client-navigation.ts | 372 +++ e2e/streaming-ssr-specs/concurrent.ts | 86 + e2e/streaming-ssr-specs/deferred-rejection.ts | 21 + e2e/streaming-ssr-specs/deferred.ts | 113 + e2e/streaming-ssr-specs/fast-serial.ts | 72 + e2e/streaming-ssr-specs/home.ts | 34 + e2e/streaming-ssr-specs/many-promises.ts | 82 + e2e/streaming-ssr-specs/nested-deferred.ts | 52 + e2e/streaming-ssr-specs/preview-streaming.ts | 10 + e2e/streaming-ssr-specs/query-heavy.ts | 142 + e2e/streaming-ssr-specs/slow-render.ts | 71 + e2e/streaming-ssr-specs/stream.ts | 55 + e2e/streaming-ssr-specs/sync-only.ts | 83 + e2e/vue-start/streaming-ssr/package.json | 59 + .../streaming-ssr/playwright.config.ts | 42 + .../streaming-ssr/src/routeTree.gen.ts | 282 ++ e2e/vue-start/streaming-ssr/src/router.tsx | 18 + .../streaming-ssr/src/routes/__root.tsx | 103 + .../streaming-ssr/src/routes/concurrent.tsx | 87 + .../src/routes/deferred-rejection.tsx | 45 + .../streaming-ssr/src/routes/deferred.tsx | 106 + .../streaming-ssr/src/routes/fast-serial.tsx | 48 + .../streaming-ssr/src/routes/index.tsx | 58 + .../src/routes/many-promises.tsx | 115 + .../src/routes/nested-deferred.tsx | 137 + .../streaming-ssr/src/routes/query-heavy.tsx | 180 ++ .../streaming-ssr/src/routes/slow-render.tsx | 78 + .../streaming-ssr/src/routes/stream.tsx | 161 ++ .../streaming-ssr/src/routes/sync-only.tsx | 31 + .../tests/client-navigation.spec.ts | 1 + .../streaming-ssr/tests/concurrent.spec.ts | 1 + .../tests/deferred-rejection.spec.ts | 1 + .../streaming-ssr/tests/deferred.spec.ts | 1 + .../streaming-ssr/tests/fast-serial.spec.ts | 1 + .../streaming-ssr/tests/home.spec.ts | 1 + .../streaming-ssr/tests/many-promises.spec.ts | 1 + .../tests/nested-deferred.spec.ts | 1 + .../tests/preview-streaming.spec.ts | 1 + .../streaming-ssr/tests/query-heavy.spec.ts | 1 + .../streaming-ssr/tests/slow-render.spec.ts | 1 + .../streaming-ssr/tests/stream.spec.ts | 1 + .../streaming-ssr/tests/sync-only.spec.ts | 7 + e2e/vue-start/streaming-ssr/tsconfig.json | 22 + e2e/vue-start/streaming-ssr/vite.config.ts | 10 + nx.json | 4 + packages/react-router/src/Scripts.tsx | 29 +- packages/react-router/src/index.tsx | 1 - .../src/ssr/renderRouterToStream.tsx | 236 +- .../src/ssr/renderRouterToString.tsx | 14 +- .../tests/errorComponent.test.tsx | 30 +- .../react-router/tests/not-found.test.tsx | 16 + .../renderRouterToStream.react19.test.tsx | 187 ++ .../tests/renderRouterToStream.test.tsx | 645 ++++- .../benchmarks/ssr-streaming/.gitignore | 2 + .../benchmarks/ssr-streaming/README.md | 152 + .../benchmarks/ssr-streaming/run.mjs | 841 ++++++ .../benchmarks/ssr-streaming/worker.ts | 2181 +++++++++++++++ packages/router-core/src/await-signal.ts | 38 +- packages/router-core/src/index.ts | 16 +- packages/router-core/src/load-client.ts | 5 +- packages/router-core/src/router.ts | 58 +- packages/router-core/src/ssr/STREAMING.md | 604 ++++ packages/router-core/src/ssr/client.ts | 8 + packages/router-core/src/ssr/constants.ts | 1 - .../src/ssr/createRequestHandler.ts | 114 +- .../router-core/src/ssr/handlerCallback.ts | 91 +- .../src/ssr/htmlBoundaryScanner.ts | 124 + .../router-core/src/ssr/hydrationScripts.ts | 623 +++++ .../src/ssr/serializer/RawStream.ts | 438 +-- .../serializer/RawStreamDeserializePlugin.ts | 21 + .../RawStreamJSONDeserializePlugin.client.ts | 14 + .../serializer/RawStreamJSONPlugin.client.ts | 100 + .../serializer/RawStreamJSONPlugin.server.ts | 22 + .../src/ssr/serializer/RawStreamRPCPlugin.ts | 25 + .../src/ssr/serializer/RawStreamSSRPlugin.ts | 175 ++ .../createRawStreamJSONDeserializePlugin.ts | 65 + .../src/ssr/serializer/makeSerovalPlugin.ts | 31 + .../ssr/serializer/makeSsrSerovalPlugin.ts | 35 + .../seroval-plugins.client-deserialize.ts | 10 + .../ssr/serializer/seroval-plugins.client.ts | 23 + .../ssr/serializer/seroval-plugins.server.ts | 11 + ...oval-plugins.ts => seroval-plugins.ssr.ts} | 3 +- .../src/ssr/serializer/transformer.ts | 92 +- packages/router-core/src/ssr/server.ts | 9 +- packages/router-core/src/ssr/ssr-server.ts | 599 ++-- .../src/ssr/transformStreamWithRouter.ts | 1598 ++++++----- packages/router-core/tests/RawStream.test.ts | 446 ++- .../tests/client-lane-adversarial.test.ts | 36 +- .../tests/htmlBoundaryScanner.test.ts | 249 ++ packages/router-core/tests/hydrate.test.ts | 72 +- .../tests/hydrated-stay-match-data.test.ts | 8 +- .../hydration-asset-context-order.test.ts | 8 +- .../tests/hydration-boundary-chunks.test.ts | 8 +- .../tests/hydration-currentness.test.ts | 8 +- ...ydration-terminal-error-child-head.test.ts | 8 +- .../router-core/tests/hydrationQueue.bench.ts | 103 + .../tests/hydrationScripts.test.ts | 756 +++++ .../tests/issue-7942-repro.test.ts | 276 ++ .../tests/load-client-wait-for.test.ts | 224 +- .../tests/public-hydration-contract.test.ts | 8 +- .../tests/server-loader-abort-error.test.ts | 79 +- .../tests/ssr-server-cleanup.test.ts | 850 ++++-- .../tests/ssr-server-manifest.test.ts | 175 +- .../transformStreamBackpressure.perf.test.ts | 373 ++- .../tests/transformStreamWithRouter.test.ts | 2482 ++++++++++++++--- packages/router-ssr-query-core/package.json | 4 +- packages/router-ssr-query-core/src/index.ts | 50 +- .../router-ssr-query-core/tests/index.test.ts | 110 + packages/solid-router/src/CatchBoundary.tsx | 31 +- packages/solid-router/src/Scripts.tsx | 16 +- packages/solid-router/src/awaited.tsx | 22 +- packages/solid-router/src/index.tsx | 1 - .../solid-router/src/lazyRouteComponent.tsx | 27 +- .../solid-router/src/ssr/RouterServer.tsx | 4 - .../src/ssr/renderRouterToStream.tsx | 166 +- .../src/ssr/renderRouterToString.tsx | 61 +- packages/solid-router/tests/Await.test.tsx | 28 + .../tests/issue-8115-context.test.tsx | 13 +- .../tests/renderRouterToStream.test.tsx | 286 +- .../solid-router/tests/server/Await.test.tsx | 72 + .../tests/server/doctype.test.tsx | 44 + .../tests/server/errorComponent.test.tsx | 135 +- .../server/renderToStreamRecords.test.tsx | 61 + .../src/defaultStreamHandler.tsx | 4 +- .../src/client-rpc/frame-decoder.ts | 579 ++-- .../src/client-rpc/serverFnFetcher.ts | 253 +- packages/start-client-core/src/constants.ts | 53 +- .../src/getDefaultSerovalPlugins.ts | 24 +- packages/start-client-core/src/index.tsx | 26 +- .../tests/frame-decoder.test.ts | 792 +++++- packages/start-server-core/INTERNALS.md | 89 + .../src/createStartHandler.ts | 487 ++-- .../start-server-core/src/frame-protocol.ts | 365 ++- packages/start-server-core/src/index.tsx | 5 +- .../src/server-functions-handler.ts | 441 +-- .../tests/createStartHandler.test.ts | 1025 +++++-- .../tests/frame-protocol.test.ts | 570 ++-- .../tests/server-functions-handler.test.ts | 417 +++ .../src/staticFunctionMiddleware.ts | 8 +- packages/vue-router-ssr-query/src/index.tsx | 7 + packages/vue-router/src/Scripts.tsx | 22 +- packages/vue-router/src/index.tsx | 1 - .../src/ssr/defaultStreamHandler.tsx | 4 +- .../src/ssr/renderRouterToStream.tsx | 221 +- .../src/ssr/renderRouterToString.tsx | 14 +- .../tests/renderRouterToStream.test.tsx | 338 ++- packages/vue-router/tests/ssr-test-utils.ts | 8 +- .../src/defaultStreamHandler.tsx | 4 +- pnpm-lock.yaml | 89 + 206 files changed, 21811 insertions(+), 6704 deletions(-) create mode 100644 .changeset/steady-streams-flow.md create mode 100644 e2e/react-start/static-server-functions/src/routes/raw-stream.tsx create mode 100644 e2e/react-start/streaming-ssr/src/routes/router-html-buffer.tsx delete mode 100644 e2e/react-start/streaming-ssr/tests/fixtures.ts create mode 100644 e2e/react-start/streaming-ssr/tests/router-html-buffer.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/package.json create mode 100644 e2e/solid-start/streaming-ssr/playwright.config.ts create mode 100644 e2e/solid-start/streaming-ssr/src/routeTree.gen.ts create mode 100644 e2e/solid-start/streaming-ssr/src/router.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/__root.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/concurrent.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/deferred-rejection.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/deferred.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/fast-serial.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/index.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/many-promises.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/nested-deferred.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/query-heavy.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/slow-render.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/stream.tsx create mode 100644 e2e/solid-start/streaming-ssr/src/routes/sync-only.tsx create mode 100644 e2e/solid-start/streaming-ssr/tests/client-navigation.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/concurrent.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/deferred-rejection.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/deferred.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/fast-serial.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/home.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/many-promises.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/nested-deferred.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/preview-streaming.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/query-heavy.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/slow-render.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/stream.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tests/sync-only.spec.ts create mode 100644 e2e/solid-start/streaming-ssr/tsconfig.json create mode 100644 e2e/solid-start/streaming-ssr/vite.config.ts create mode 100644 e2e/streaming-ssr-assertions.ts create mode 100644 e2e/streaming-ssr-specs/client-navigation.ts create mode 100644 e2e/streaming-ssr-specs/concurrent.ts create mode 100644 e2e/streaming-ssr-specs/deferred-rejection.ts create mode 100644 e2e/streaming-ssr-specs/deferred.ts create mode 100644 e2e/streaming-ssr-specs/fast-serial.ts create mode 100644 e2e/streaming-ssr-specs/home.ts create mode 100644 e2e/streaming-ssr-specs/many-promises.ts create mode 100644 e2e/streaming-ssr-specs/nested-deferred.ts create mode 100644 e2e/streaming-ssr-specs/preview-streaming.ts create mode 100644 e2e/streaming-ssr-specs/query-heavy.ts create mode 100644 e2e/streaming-ssr-specs/slow-render.ts create mode 100644 e2e/streaming-ssr-specs/stream.ts create mode 100644 e2e/streaming-ssr-specs/sync-only.ts create mode 100644 e2e/vue-start/streaming-ssr/package.json create mode 100644 e2e/vue-start/streaming-ssr/playwright.config.ts create mode 100644 e2e/vue-start/streaming-ssr/src/routeTree.gen.ts create mode 100644 e2e/vue-start/streaming-ssr/src/router.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/__root.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/concurrent.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/deferred-rejection.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/deferred.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/fast-serial.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/index.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/many-promises.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/nested-deferred.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/query-heavy.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/slow-render.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/stream.tsx create mode 100644 e2e/vue-start/streaming-ssr/src/routes/sync-only.tsx create mode 100644 e2e/vue-start/streaming-ssr/tests/client-navigation.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/concurrent.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/deferred-rejection.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/deferred.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/fast-serial.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/home.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/many-promises.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/nested-deferred.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/preview-streaming.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/query-heavy.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/slow-render.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/stream.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tests/sync-only.spec.ts create mode 100644 e2e/vue-start/streaming-ssr/tsconfig.json create mode 100644 e2e/vue-start/streaming-ssr/vite.config.ts create mode 100644 packages/react-router/tests/renderRouterToStream.react19.test.tsx create mode 100644 packages/router-core/benchmarks/ssr-streaming/.gitignore create mode 100644 packages/router-core/benchmarks/ssr-streaming/README.md create mode 100644 packages/router-core/benchmarks/ssr-streaming/run.mjs create mode 100644 packages/router-core/benchmarks/ssr-streaming/worker.ts create mode 100644 packages/router-core/src/ssr/STREAMING.md create mode 100644 packages/router-core/src/ssr/htmlBoundaryScanner.ts create mode 100644 packages/router-core/src/ssr/hydrationScripts.ts create mode 100644 packages/router-core/src/ssr/serializer/RawStreamDeserializePlugin.ts create mode 100644 packages/router-core/src/ssr/serializer/RawStreamJSONDeserializePlugin.client.ts create mode 100644 packages/router-core/src/ssr/serializer/RawStreamJSONPlugin.client.ts create mode 100644 packages/router-core/src/ssr/serializer/RawStreamJSONPlugin.server.ts create mode 100644 packages/router-core/src/ssr/serializer/RawStreamRPCPlugin.ts create mode 100644 packages/router-core/src/ssr/serializer/RawStreamSSRPlugin.ts create mode 100644 packages/router-core/src/ssr/serializer/createRawStreamJSONDeserializePlugin.ts create mode 100644 packages/router-core/src/ssr/serializer/makeSerovalPlugin.ts create mode 100644 packages/router-core/src/ssr/serializer/makeSsrSerovalPlugin.ts create mode 100644 packages/router-core/src/ssr/serializer/seroval-plugins.client-deserialize.ts create mode 100644 packages/router-core/src/ssr/serializer/seroval-plugins.client.ts create mode 100644 packages/router-core/src/ssr/serializer/seroval-plugins.server.ts rename packages/router-core/src/ssr/serializer/{seroval-plugins.ts => seroval-plugins.ssr.ts} (77%) create mode 100644 packages/router-core/tests/htmlBoundaryScanner.test.ts create mode 100644 packages/router-core/tests/hydrationQueue.bench.ts create mode 100644 packages/router-core/tests/hydrationScripts.test.ts create mode 100644 packages/router-core/tests/issue-7942-repro.test.ts create mode 100644 packages/solid-router/tests/Await.test.tsx create mode 100644 packages/solid-router/tests/server/Await.test.tsx create mode 100644 packages/solid-router/tests/server/doctype.test.tsx create mode 100644 packages/solid-router/tests/server/renderToStreamRecords.test.tsx create mode 100644 packages/start-server-core/INTERNALS.md create mode 100644 packages/start-server-core/tests/server-functions-handler.test.ts 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