From 89394d3e7795d962baed5a738e9a7db8543222f5 Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 16 Oct 2026 20:59:19 +0300 Subject: [PATCH 1/4] Examples: a /stream page whose loader returns a promise it does not await The same page in both examples. Its loader returns a promise that resolves two seconds later, and the page renders it with Await behind a fallback. On a host that streams, the document goes out at once with the fallback and the value follows in a later chunk, with React's swap script; on a host that sends the document whole, everything arrives after two seconds. The Workers twin under wrangler dev, fetched with a browser user agent: 4.2 kB at 0 ms, the rest 2.0 s later. With curl's own user agent TanStack treats the client as a bot and waits for the whole document first. Co-Authored-By: Claude Fable 5.1 --- .../tanstack-ssr-workers/src/routeTree.gen.ts | 31 +++++++++++++++++-- .../src/routes/__root.tsx | 1 + .../src/routes/stream.tsx | 30 ++++++++++++++++++ examples/tanstack-ssr/src/routeTree.gen.ts | 31 +++++++++++++++++-- examples/tanstack-ssr/src/routes/__root.tsx | 1 + examples/tanstack-ssr/src/routes/stream.tsx | 30 ++++++++++++++++++ 6 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 examples/tanstack-ssr-workers/src/routes/stream.tsx create mode 100644 examples/tanstack-ssr/src/routes/stream.tsx diff --git a/examples/tanstack-ssr-workers/src/routeTree.gen.ts b/examples/tanstack-ssr-workers/src/routeTree.gen.ts index d67f1306..a40b99e4 100644 --- a/examples/tanstack-ssr-workers/src/routeTree.gen.ts +++ b/examples/tanstack-ssr-workers/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as AuthedRouteImport } from './routes/_authed' import { Route as AboutRouteImport } from './routes/about' import { Route as SigninRouteImport } from './routes/signin' +import { Route as StreamRouteImport } from './routes/stream' import { Route as AuthedTodosRouteImport } from './routes/_authed/todos' const IndexRoute = IndexRouteImport.update({ @@ -34,6 +35,11 @@ const SigninRoute = SigninRouteImport.update({ path: '/signin', getParentRoute: () => rootRouteImport, } as any) +const StreamRoute = StreamRouteImport.update({ + id: '/stream', + path: '/stream', + getParentRoute: () => rootRouteImport, +} as any) const AuthedTodosRoute = AuthedTodosRouteImport.update({ id: '/todos', path: '/todos', @@ -44,12 +50,14 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute '/signin': typeof SigninRoute + '/stream': typeof StreamRoute '/todos': typeof AuthedTodosRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute '/signin': typeof SigninRoute + '/stream': typeof StreamRoute '/todos': typeof AuthedTodosRoute } export interface FileRoutesById { @@ -58,14 +66,22 @@ export interface FileRoutesById { '/_authed': typeof AuthedRouteWithChildren '/about': typeof AboutRoute '/signin': typeof SigninRoute + '/stream': typeof StreamRoute '/_authed/todos': typeof AuthedTodosRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' | '/signin' | '/todos' + fullPaths: '/' | '/about' | '/signin' | '/stream' | '/todos' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' | '/signin' | '/todos' - id: '__root__' | '/' | '/_authed' | '/about' | '/signin' | '/_authed/todos' + to: '/' | '/about' | '/signin' | '/stream' | '/todos' + id: + | '__root__' + | '/' + | '/_authed' + | '/about' + | '/signin' + | '/stream' + | '/_authed/todos' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -73,6 +89,7 @@ export interface RootRouteChildren { AuthedRoute: typeof AuthedRouteWithChildren AboutRoute: typeof AboutRoute SigninRoute: typeof SigninRoute + StreamRoute: typeof StreamRoute } declare module '@tanstack/react-router' { @@ -105,6 +122,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SigninRouteImport parentRoute: typeof rootRouteImport } + '/stream': { + id: '/stream' + path: '/stream' + fullPath: '/stream' + preLoaderRoute: typeof StreamRouteImport + parentRoute: typeof rootRouteImport + } '/_authed/todos': { id: '/_authed/todos' path: '/todos' @@ -131,6 +155,7 @@ const rootRouteChildren: RootRouteChildren = { AuthedRoute: AuthedRouteWithChildren, AboutRoute: AboutRoute, SigninRoute: SigninRoute, + StreamRoute: StreamRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/examples/tanstack-ssr-workers/src/routes/__root.tsx b/examples/tanstack-ssr-workers/src/routes/__root.tsx index 628e2bbd..c87a1380 100644 --- a/examples/tanstack-ssr-workers/src/routes/__root.tsx +++ b/examples/tanstack-ssr-workers/src/routes/__root.tsx @@ -53,6 +53,7 @@ function Root() { Home About Todos + Stream diff --git a/examples/tanstack-ssr-workers/src/routes/stream.tsx b/examples/tanstack-ssr-workers/src/routes/stream.tsx new file mode 100644 index 00000000..b7739f19 --- /dev/null +++ b/examples/tanstack-ssr-workers/src/routes/stream.tsx @@ -0,0 +1,30 @@ +import { Await, createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/stream")({ + loader: () => ({ + // A promise the loader does not await. A host that streams sends the page at once, with the + // fallback below, and the value when the promise resolves; a host that buffers sends the whole + // page when the promise has resolved. Two seconds, so the two parts are telling apart. + later: new Promise((resolve) => + setTimeout(() => resolve(new Date().toISOString()), 2000), + ), + }), + component: Stream, +}); + +function Stream() { + const { later } = Route.useLoaderData(); + return ( +
+

Streaming

+

+ The loader of this page returns a promise that resolves two seconds later, without awaiting + it. The server sends what it has and the value when it arrives, or the whole page at the + end, depending on the host. The value is the time the promise resolved on the server. +

+ Waiting for the value...

}> + {(value) =>

Resolved at {value}.

} +
+
+ ); +} diff --git a/examples/tanstack-ssr/src/routeTree.gen.ts b/examples/tanstack-ssr/src/routeTree.gen.ts index d67f1306..a40b99e4 100644 --- a/examples/tanstack-ssr/src/routeTree.gen.ts +++ b/examples/tanstack-ssr/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as AuthedRouteImport } from './routes/_authed' import { Route as AboutRouteImport } from './routes/about' import { Route as SigninRouteImport } from './routes/signin' +import { Route as StreamRouteImport } from './routes/stream' import { Route as AuthedTodosRouteImport } from './routes/_authed/todos' const IndexRoute = IndexRouteImport.update({ @@ -34,6 +35,11 @@ const SigninRoute = SigninRouteImport.update({ path: '/signin', getParentRoute: () => rootRouteImport, } as any) +const StreamRoute = StreamRouteImport.update({ + id: '/stream', + path: '/stream', + getParentRoute: () => rootRouteImport, +} as any) const AuthedTodosRoute = AuthedTodosRouteImport.update({ id: '/todos', path: '/todos', @@ -44,12 +50,14 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute '/signin': typeof SigninRoute + '/stream': typeof StreamRoute '/todos': typeof AuthedTodosRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute '/signin': typeof SigninRoute + '/stream': typeof StreamRoute '/todos': typeof AuthedTodosRoute } export interface FileRoutesById { @@ -58,14 +66,22 @@ export interface FileRoutesById { '/_authed': typeof AuthedRouteWithChildren '/about': typeof AboutRoute '/signin': typeof SigninRoute + '/stream': typeof StreamRoute '/_authed/todos': typeof AuthedTodosRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' | '/signin' | '/todos' + fullPaths: '/' | '/about' | '/signin' | '/stream' | '/todos' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' | '/signin' | '/todos' - id: '__root__' | '/' | '/_authed' | '/about' | '/signin' | '/_authed/todos' + to: '/' | '/about' | '/signin' | '/stream' | '/todos' + id: + | '__root__' + | '/' + | '/_authed' + | '/about' + | '/signin' + | '/stream' + | '/_authed/todos' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -73,6 +89,7 @@ export interface RootRouteChildren { AuthedRoute: typeof AuthedRouteWithChildren AboutRoute: typeof AboutRoute SigninRoute: typeof SigninRoute + StreamRoute: typeof StreamRoute } declare module '@tanstack/react-router' { @@ -105,6 +122,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SigninRouteImport parentRoute: typeof rootRouteImport } + '/stream': { + id: '/stream' + path: '/stream' + fullPath: '/stream' + preLoaderRoute: typeof StreamRouteImport + parentRoute: typeof rootRouteImport + } '/_authed/todos': { id: '/_authed/todos' path: '/todos' @@ -131,6 +155,7 @@ const rootRouteChildren: RootRouteChildren = { AuthedRoute: AuthedRouteWithChildren, AboutRoute: AboutRoute, SigninRoute: SigninRoute, + StreamRoute: StreamRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/examples/tanstack-ssr/src/routes/__root.tsx b/examples/tanstack-ssr/src/routes/__root.tsx index 628e2bbd..c87a1380 100644 --- a/examples/tanstack-ssr/src/routes/__root.tsx +++ b/examples/tanstack-ssr/src/routes/__root.tsx @@ -53,6 +53,7 @@ function Root() { Home About Todos + Stream diff --git a/examples/tanstack-ssr/src/routes/stream.tsx b/examples/tanstack-ssr/src/routes/stream.tsx new file mode 100644 index 00000000..b7739f19 --- /dev/null +++ b/examples/tanstack-ssr/src/routes/stream.tsx @@ -0,0 +1,30 @@ +import { Await, createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/stream")({ + loader: () => ({ + // A promise the loader does not await. A host that streams sends the page at once, with the + // fallback below, and the value when the promise resolves; a host that buffers sends the whole + // page when the promise has resolved. Two seconds, so the two parts are telling apart. + later: new Promise((resolve) => + setTimeout(() => resolve(new Date().toISOString()), 2000), + ), + }), + component: Stream, +}); + +function Stream() { + const { later } = Route.useLoaderData(); + return ( +
+

Streaming

+

+ The loader of this page returns a promise that resolves two seconds later, without awaiting + it. The server sends what it has and the value when it arrives, or the whole page at the + end, depending on the host. The value is the time the promise resolved on the server. +

+ Waiting for the value...

}> + {(value) =>

Resolved at {value}.

} +
+
+ ); +} From c653a88ae0e6cd9136d5f17c8ccc03ad170c2e2f Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 16 Oct 2026 21:42:43 +0300 Subject: [PATCH 2/4] Twin: drop the nodejs_compat flag, on by default from compatibility date 2026-08-04 Cloudflare turns nodejs_compat on by default for Workers whose compatibility date is 2026-08-04 or later (changelog 2026-08-04-nodejs-compat-default); the twin's date is 2026-10-01. Checked by building and running the twin without the flag under wrangler dev: the server bundle still imports node:stream and node:async_hooks, and the Worker serves the pages. Co-Authored-By: Claude Fable 5.1 --- examples/tanstack-ssr-workers.patch | 5 ++--- examples/tanstack-ssr-workers/wrangler.jsonc | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/tanstack-ssr-workers.patch b/examples/tanstack-ssr-workers.patch index a0f70595..35d99464 100644 --- a/examples/tanstack-ssr-workers.patch +++ b/examples/tanstack-ssr-workers.patch @@ -313,14 +313,13 @@ index c1fa9a2d4c1224a8cbda8132e633b7b066e137b4..3e6badb4024aed63c5e612137eb9fc19 tanstackStart({ pages: [{ path: "/about", prerender: { enabled: true, crawlLinks: false } }] }), diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 -index 0000000000000000000000000000000000000000..0514290649d7d95f8a16f528db32522d804ba834 +index 0000000000000000000000000000000000000000..48c90a9e2b5789638c5baf349fe0cd88dcbbb282 --- /dev/null +++ b/wrangler.jsonc -@@ -0,0 +1,9 @@ +@@ -0,0 +1,8 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "compatibility_date": "2026-10-01", -+ "compatibility_flags": ["nodejs_compat"], + "main": "src/server.ts", + "preview_urls": true, + // `wrangler preview`, the Workers Builds preview command, requires this block. diff --git a/examples/tanstack-ssr-workers/wrangler.jsonc b/examples/tanstack-ssr-workers/wrangler.jsonc index 05142906..48c90a9e 100644 --- a/examples/tanstack-ssr-workers/wrangler.jsonc +++ b/examples/tanstack-ssr-workers/wrangler.jsonc @@ -1,7 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", "compatibility_date": "2026-10-01", - "compatibility_flags": ["nodejs_compat"], "main": "src/server.ts", "preview_urls": true, // `wrangler preview`, the Workers Builds preview command, requires this block. From 7a33acc648485a60ceb51aa135c3683bd7ff58ff Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 16 Oct 2026 21:45:51 +0300 Subject: [PATCH 3/4] Examples: the Stream link requests a document Codex: a navigation inside the app runs the page's loader in the browser, so the timer ran there on both hosts and the demo never reached the server. The header link now reloads the document. Co-Authored-By: Claude Fable 5.1 --- examples/tanstack-ssr-workers/src/routes/__root.tsx | 5 ++++- examples/tanstack-ssr/src/routes/__root.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/tanstack-ssr-workers/src/routes/__root.tsx b/examples/tanstack-ssr-workers/src/routes/__root.tsx index c87a1380..6efdbfd4 100644 --- a/examples/tanstack-ssr-workers/src/routes/__root.tsx +++ b/examples/tanstack-ssr-workers/src/routes/__root.tsx @@ -53,7 +53,10 @@ function Root() { Home About Todos - Stream + {/* A document request: a navigation inside the app would run the loader in the browser. */} + + Stream + diff --git a/examples/tanstack-ssr/src/routes/__root.tsx b/examples/tanstack-ssr/src/routes/__root.tsx index c87a1380..6efdbfd4 100644 --- a/examples/tanstack-ssr/src/routes/__root.tsx +++ b/examples/tanstack-ssr/src/routes/__root.tsx @@ -53,7 +53,10 @@ function Root() { Home About Todos - Stream + {/* A document request: a navigation inside the app would run the loader in the browser. */} + + Stream + From 4bb8c22e1fa15dbcbfeb7146508481b0928e0e0f Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 16 Oct 2026 22:04:47 +0300 Subject: [PATCH 4/4] Adapter 0.9.0: pages stream, as on a Worker The server entry is TanStack's own stream handler: React's Web Stream renderer, the document sent as it renders, each Suspense boundary and deferred loader value in a later chunk, status and redirects decided before the first byte, the whole document for a client TanStack takes for a bot. The whole-render wait and the progressiveChunkSize override of 0.6.0 are gone with it. The one obstacle was bundling: TanStack's stream renderer and router-core's stream transformer import Node's stream classes at the top of their files for a branch that runs only where React has no Web Stream renderer. Convex has that renderer, so the branch never runs, and Convex's bundler had no node:stream to resolve. convexSsr() now resolves node:stream to a stub whose classes throw if anything calls them. node:async_hooks already bundled, Convex provides AsyncLocalStorage. Measured on the SSR example's dev deployment, /stream with a browser user agent: first byte at 0.5 s with the fallback, last byte at 2.5 s with the value; with curl's user agent, the whole document at 2.2 s. The Workers twin: 0.1 s and 2.1 s. Closes #49. Co-Authored-By: Claude Fable 5.1 --- docs/design.md | 34 +++++++++---------- .../CHANGELOG.md | 9 +++++ .../convex-hosting-tanstack-start/README.md | 12 ++++--- .../package.json | 2 +- .../src/node-stream.ts | 26 ++++++++++++++ .../src/server.ts | 34 +++++-------------- .../convex-hosting-tanstack-start/src/vite.ts | 11 +++++- 7 files changed, 78 insertions(+), 50 deletions(-) create mode 100644 packages/convex-hosting-tanstack-start/src/node-stream.ts diff --git a/docs/design.md b/docs/design.md index 358e3661..4cfb7fed 100644 --- a/docs/design.md +++ b/docs/design.md @@ -466,23 +466,23 @@ Every export, with who uses it and why it exists. An export with no user in the or ChessMerge goes; the list is checked at every release. At 0.8.0 and 0.5.0 that removed `serveAsset` and `useSignedIn`. -| Export | Used by | Why | -| ------------------------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| hosting `serveSite` | `convex/http.ts` of every app | The one entry: files, then `worker`, which is the Worker script; its absence is the files-only Worker | -| hosting `convex.config` | `convex/convex.config.ts` of every app | Convex's component definition, for `app.use` | -| hosting `_generated/component.js` (types) | The generated `api.d.ts` of every app | Convex's codegen imports it for the component's API type | -| CLI `deploy --build --dist [--dev \| --preview-name]` | App scripts and CI | The upload must run between the build and the push, with the deployment chosen by the same flags as Convex's CLI | -| CLI `--html-handling`, `--not-found-handling` | SPA example; ChessMerge (`none`) | Cloudflare's own asset settings, stored with the version | -| CLI `--component` | None today | Convex's own `app.use(hosting, { name })` option; the CLI must match it; Bristol's production used it while hosting 0.3 and 0.4 ran under two names | -| CLI `upload` | `deploy` itself, inside `npx convex deploy --cmd` | The step after the build has to be a program that a shell can call | -| adapter `vite`: `convexSsr()` | `vite.config.ts` of server-rendered apps | The `ssr` environment settings that make Start's server build run in Convex's runtime | -| adapter `server` | `src/server.ts` of server-rendered apps | Start's handler with React's Web Stream renderer, typed as TanStack's `RequestHandler`, so `fetch(request, { context })` takes the app's registered context | -| adapter `server-build` (types) | `convex/http.ts` of server-rendered apps | The type of the built server module: `{ fetch(request) }`, the shape of the app's `src/server.ts` | -| adapter `convex-auth`: `sessionBeforeLoad` | The root route | The same `beforeLoad` in every app with cookie sessions | -| adapter `convex-auth`: `SessionProvider` | The root component | Convex Auth's provider with the server token, plus the release of `expectAuth` for a signed-out visitor | -| adapter `convex-auth`: `redirectWithoutSession`, `RedirectWithoutSession` | The `_authed` layout | The server render has the cookie; the browser follows the provider; two moments, two pieces | -| adapter `convex-auth`: `SignedIn`, `SignedOut` | Bristol's header and menus | Convex's provider reports loading during the server render and the hydration; these wait for the answer | -| adapter `convex-auth`: `useSignOutSession` | The header's sign-out in the SSR example and Bristol | Convex Auth's `signOut()` resolves when the cookie route failed; this asks the route first, then clears the client, then reloads | +| Export | Used by | Why | +| ------------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| hosting `serveSite` | `convex/http.ts` of every app | The one entry: files, then `worker`, which is the Worker script; its absence is the files-only Worker | +| hosting `convex.config` | `convex/convex.config.ts` of every app | Convex's component definition, for `app.use` | +| hosting `_generated/component.js` (types) | The generated `api.d.ts` of every app | Convex's codegen imports it for the component's API type | +| CLI `deploy --build --dist [--dev \| --preview-name]` | App scripts and CI | The upload must run between the build and the push, with the deployment chosen by the same flags as Convex's CLI | +| CLI `--html-handling`, `--not-found-handling` | SPA example; ChessMerge (`none`) | Cloudflare's own asset settings, stored with the version | +| CLI `--component` | None today | Convex's own `app.use(hosting, { name })` option; the CLI must match it; Bristol's production used it while hosting 0.3 and 0.4 ran under two names | +| CLI `upload` | `deploy` itself, inside `npx convex deploy --cmd` | The step after the build has to be a program that a shell can call | +| adapter `vite`: `convexSsr()` | `vite.config.ts` of server-rendered apps | The `ssr` environment settings that make Start's server build run in Convex's runtime, the `node:stream` stub included | +| adapter `server` | `src/server.ts` of server-rendered apps | Start's own stream handler, typed as TanStack's `RequestHandler`, so `fetch(request, { context })` takes the app's registered context; pages stream as on a Worker | +| adapter `server-build` (types) | `convex/http.ts` of server-rendered apps | The type of the built server module: `{ fetch(request) }`, the shape of the app's `src/server.ts` | +| adapter `convex-auth`: `sessionBeforeLoad` | The root route | The same `beforeLoad` in every app with cookie sessions | +| adapter `convex-auth`: `SessionProvider` | The root component | Convex Auth's provider with the server token, plus the release of `expectAuth` for a signed-out visitor | +| adapter `convex-auth`: `redirectWithoutSession`, `RedirectWithoutSession` | The `_authed` layout | The server render has the cookie; the browser follows the provider; two moments, two pieces | +| adapter `convex-auth`: `SignedIn`, `SignedOut` | Bristol's header and menus | Convex's provider reports loading during the server render and the hydration; these wait for the answer | +| adapter `convex-auth`: `useSignOutSession` | The header's sign-out in the SSR example and Bristol | Convex Auth's `signOut()` resolves when the cookie route failed; this asks the route first, then clears the client, then reloads | ## Repository diff --git a/packages/convex-hosting-tanstack-start/CHANGELOG.md b/packages/convex-hosting-tanstack-start/CHANGELOG.md index 57480a08..0cd97525 100644 --- a/packages/convex-hosting-tanstack-start/CHANGELOG.md +++ b/packages/convex-hosting-tanstack-start/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.9.0 + +- Pages stream, as on a Cloudflare Worker: the server entry is TanStack's own stream handler, the + document goes out as it renders, and each Suspense boundary or deferred loader value follows in + its own chunk. The entry no longer waits for the whole render, and the `progressiveChunkSize` + override of 0.6.0 is gone with it. `convexSsr()` resolves `node:stream` to a stub for the server + build: TanStack imports Node's stream classes for a branch that never runs where React's Web + Stream renderer exists, and Convex's bundler had nothing to resolve them to. + ## 0.8.0 - The published manifest no longer lists `vite` in `devDependencies`. 0.5.0 to 0.7.0 listed it as diff --git a/packages/convex-hosting-tanstack-start/README.md b/packages/convex-hosting-tanstack-start/README.md index 82ee2c4a..0cd64f2c 100644 --- a/packages/convex-hosting-tanstack-start/README.md +++ b/packages/convex-hosting-tanstack-start/README.md @@ -209,11 +209,13 @@ ignore the unmet-peer warning that pnpm prints; npm refuses the install instead, - `worker` selects TanStack's server exports; `browser` selects Web API adapters. - `react-dom/server.edge` avoids the browser renderer's `MessageChannel` dependency. - `ssr.noExternal` bundles dependencies instead of leaving runtime Node imports. -- React's Web Stream renderer waits for Suspense, then TanStack's `renderSsrHtmlResponse` handles - the buffered HTML, hydration scripts, status, and cleanup. The default streaming handler imports - Node stream modules that Convex cannot bundle. -- React emits a doctype and TanStack's HTML helper adds its own, so the server entry removes - React's. +- `node:stream` resolves to a stub. TanStack's stream renderer imports Node's stream classes at the + top of its files for a branch that runs only where React has no Web Stream renderer; on Convex + that renderer exists, the branch never runs, and the bundler has no `node:stream`. The stub's + classes throw if anything calls them. +- The server entry is TanStack's own stream handler, so pages stream as they do on a Worker: the + document goes out as it renders, and each Suspense boundary or deferred loader value follows in + its own chunk. A client that TanStack takes for a bot, by its user agent, gets the whole document. - The server entry supplies the missing `URLSearchParams.size` getter on Convex. TanStack needs it to normalize URLs with query parameters. Native implementations are left intact. diff --git a/packages/convex-hosting-tanstack-start/package.json b/packages/convex-hosting-tanstack-start/package.json index cac90dc2..042d393e 100644 --- a/packages/convex-hosting-tanstack-start/package.json +++ b/packages/convex-hosting-tanstack-start/package.json @@ -1,6 +1,6 @@ { "name": "@samebase/convex-hosting-tanstack-start", - "version": "0.8.0", + "version": "0.9.0", "description": "Render TanStack Start pages in a Convex HTTP action.", "license": "Apache-2.0", "repository": { diff --git a/packages/convex-hosting-tanstack-start/src/node-stream.ts b/packages/convex-hosting-tanstack-start/src/node-stream.ts new file mode 100644 index 00000000..47bc7b04 --- /dev/null +++ b/packages/convex-hosting-tanstack-start/src/node-stream.ts @@ -0,0 +1,26 @@ +// What `node:stream` resolves to in the server build. TanStack's stream renderer imports Node's +// stream classes at the top of its files for its Node branch, which runs only where React has no +// Web Stream renderer; on Convex, React's `renderToReadableStream` exists, so that branch never +// runs, and Convex's bundler has no `node:stream` to resolve. These classes are never called. + +function notOnConvex(): never { + throw new Error( + "node:stream is not available on Convex; TanStack's Web Stream path does not use it.", + ); +} + +export class Readable { + static toWeb(): never { + return notOnConvex(); + } + + static fromWeb(): never { + return notOnConvex(); + } +} + +export class PassThrough { + constructor() { + notOnConvex(); + } +} diff --git a/packages/convex-hosting-tanstack-start/src/server.ts b/packages/convex-hosting-tanstack-start/src/server.ts index e7ce3324..c643072e 100644 --- a/packages/convex-hosting-tanstack-start/src/server.ts +++ b/packages/convex-hosting-tanstack-start/src/server.ts @@ -3,10 +3,11 @@ // TanStack and React, never app code. import type { Register } from "@tanstack/react-start"; -import { createStartHandler, StartServer, type RequestHandler } from "@tanstack/react-start/server"; -import { renderSsrHtmlResponse } from "@tanstack/react-router/ssr/server"; -import { createElement } from "react"; -import { renderToReadableStream } from "react-dom/server"; +import { + createStartHandler, + defaultStreamHandler, + type RequestHandler, +} from "@tanstack/react-start/server"; // Convex omits size, which TanStack uses when normalizing query-string URLs. if (new URLSearchParams().size === undefined) { @@ -18,33 +19,14 @@ if (new URLSearchParams().size === undefined) { }); } +// TanStack's own stream handler, as on every other host: React's Web Stream renderer, the +// document sent as it renders, each Suspense boundary and deferred loader value in a later chunk. // Apps call only `fetch(request, { context })`. Start gives `context` to each route's `beforeLoad` // and loader on the server as `serverContext`. `context` follows the app's `Register`: with a // registered `requestContext`, such as the `convex-auth` entry's, it is required and has that type. // The explicit annotation is what the declaration emits, so it names only public entries. const server: { fetch: RequestHandler } = { - fetch: createStartHandler((context) => { - const { router, responseHeaders, request } = context; - return renderSsrHtmlResponse({ - router, - responseHeaders, - render: async () => { - // Await Suspense with React's Web Stream renderer. TanStack's stream - // handler also imports node:stream, which Convex cannot bundle. - const stream = await renderToReadableStream(createElement(StartServer, { router }), { - signal: request.signal, - // The document is sent only when it is complete, so React's progressive reveal of a - // large Suspense boundary (a hidden segment plus a script) would add bytes and a frame. - progressiveChunkSize: Number.POSITIVE_INFINITY, - ...(router.options.ssr?.nonce === undefined ? {} : { nonce: router.options.ssr.nonce }), - }); - await stream.allReady; - const html = await new Response(stream).text(); - // React emits a doctype; TanStack's HTML helper adds its own. - return html.replace(/^/i, ""); - }, - }); - }), + fetch: createStartHandler(defaultStreamHandler), }; export default server; diff --git a/packages/convex-hosting-tanstack-start/src/vite.ts b/packages/convex-hosting-tanstack-start/src/vite.ts index b9de1f16..f5148e76 100644 --- a/packages/convex-hosting-tanstack-start/src/vite.ts +++ b/packages/convex-hosting-tanstack-start/src/vite.ts @@ -1,5 +1,6 @@ // Vite settings that let TanStack Start's server build run in Convex HTTP actions. +import { fileURLToPath } from "node:url"; import type { Plugin } from "vite"; export function convexSsr(): Plugin { @@ -12,7 +13,15 @@ export function convexSsr(): Plugin { resolve: { conditions: ["worker", "browser", "module", "import"] }, }, resolve: { - alias: [{ find: /^react-dom\/server$/, replacement: "react-dom/server.edge" }], + alias: [ + { find: /^react-dom\/server$/, replacement: "react-dom/server.edge" }, + // TanStack's stream renderer imports Node's stream classes for a branch that never runs + // where React's Web Stream renderer exists; Convex has no `node:stream` to resolve. + { + find: /^node:stream$/, + replacement: fileURLToPath(new URL("./node-stream.js", import.meta.url)), + }, + ], }, }), };