From bfca1a182475ae98674551f89e1b6aa12def9830 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 23:19:34 -0700 Subject: [PATCH] Drop the async render head-commit workaround; require solid 2.0.0-rc.7 rc.7 (solidjs/solid#3292) freezes the response head when an awaited renderToStream completes, before the render is disposed, so the generated SSR entry no longer needs to commit the stub from onCompleteAll and authored entries need no hook. Peer floors move to ^2.0.0-rc.7; README caveat and the unreleased render-mode changeset drop the workaround wording. Against rc.6 the render-mode suite fails exactly the six async head assertions (status/header/Location, dev+prod); the rc.7 ride commit (catalog + lockfile + minimumReleaseAgeExclude) turns them green. Co-authored-by: Cursor --- .changeset/start-render-mode.md | 2 +- README.md | 13 ++++--------- package.json | 4 ++-- src/ssr/index.ts | 17 ++--------------- 4 files changed, 9 insertions(+), 27 deletions(-) diff --git a/.changeset/start-render-mode.md b/.changeset/start-render-mode.md index b4586c4..91f864e 100644 --- a/.changeset/start-render-mode.md +++ b/.changeset/start-render-mode.md @@ -2,4 +2,4 @@ '@solidjs/vite-plugin': minor --- -`start.renderMode: 'stream' | 'async'` (default `'stream'`), plus a per-request form and a runtime override — the fix for streaming SSR leaving `` fallbacks unresolved for clients that never run JavaScript (solidjs/solid#3280). `'async'` makes the generated handler adopt the `renderToStream` result's thenable, which resolves with the complete HTML once every boundary has settled: nothing has flushed, so each boundary's content is spliced in place of its placeholder — no fallback markup, no swap templates or scripts — while hydration data still serializes and JavaScript clients hydrate as before. The string then takes `createSSRResponse`'s string path: the response head commits, the document gets the doctype and client-entry injection, and a `Location` written mid-render becomes a real 3xx instead of the post-flush script redirect. The tradeoffs are inherent and documented: time-to-first-byte waits for the slowest boundary and the whole page buffers in memory; `deferStream` is moot (everything defers). The per-request form follows the `middleware`/`setup` convention — `renderMode: './src/render-mode.ts'`, a module default-exporting `(event) => 'stream' | 'async' | Promise<...>` run inside the request scope after the middleware chain — for policies like "complete documents for crawler user agents or `?nojs`, streaming for everyone else". Hosts driving the handler directly pass `handleRequest(request, { renderMode })`; precedence is that runtime option, then the module function, then the static config, and an invalid value from any source is rejected with an actionable error (unknown literals and missing module paths fail at config time). Works identically for authored entries. Generated entries also commit the response head at render completion (`onCompleteAll`) so `httpStatus`/`httpHeader` declarations survive the runtime's dispose-before-resolve in the awaited path; stream mode is unchanged. +`start.renderMode: 'stream' | 'async'` (default `'stream'`), plus a per-request form and a runtime override — the fix for streaming SSR leaving `` fallbacks unresolved for clients that never run JavaScript (solidjs/solid#3280). `'async'` makes the generated handler adopt the `renderToStream` result's thenable, which resolves with the complete HTML once every boundary has settled: nothing has flushed, so each boundary's content is spliced in place of its placeholder — no fallback markup, no swap templates or scripts — while hydration data still serializes and JavaScript clients hydrate as before. The string then takes `createSSRResponse`'s string path: the response head commits, the document gets the doctype and client-entry injection, and a `Location` written mid-render becomes a real 3xx instead of the post-flush script redirect. The tradeoffs are inherent and documented: time-to-first-byte waits for the slowest boundary and the whole page buffers in memory; `deferStream` is moot (everything defers). The per-request form follows the `middleware`/`setup` convention — `renderMode: './src/render-mode.ts'`, a module default-exporting `(event) => 'stream' | 'async' | Promise<...>` run inside the request scope after the middleware chain — for policies like "complete documents for crawler user agents or `?nojs`, streaming for everyone else". Hosts driving the handler directly pass `handleRequest(request, { renderMode })`; precedence is that runtime option, then the module function, then the static config, and an invalid value from any source is rejected with an actionable error (unknown literals and missing module paths fail at config time). Works identically for authored entries; stream mode is unchanged. Requires `solid-js` / `@solidjs/web` `^2.0.0-rc.7`, which freezes the response head when the awaited render completes so `httpStatus` / `httpHeader` declarations reach the response (solidjs/solid#3292). diff --git a/README.md b/README.md index c523f1b..30734d5 100644 --- a/README.md +++ b/README.md @@ -435,15 +435,10 @@ runtime option, then the module function's result, then the static config; an unknown value from any of the three is an error naming its source. The mode applies to generated and authored entries alike — an authored `render()` returning a `renderToStream` result is awaited the same way (and -in production its client-entry reference is still rewritten). One caveat for -authored entries: `httpStatus()` / `httpHeader()` declarations made during -the render are reverted when the runtime disposes it, which under `'async'` -happens before the response head is committed — the generated entry commits -the head at render completion (`renderToStream`'s `onCompleteAll`) to keep -them, so an authored entry that needs them under `'async'` should pass the -same hook (`onCompleteAll: () => commitResponseStub(getRequestEvent().response)`); -a `Location` written straight onto `event.response.headers` is unaffected. -Server mode only — in client mode the served shell has no boundaries to +in production its client-entry reference is still rewritten). `httpStatus()` / +`httpHeader()` declarations survive either mode: the runtime freezes the +response head when the awaited render completes (`@solidjs/web` 2.0.0-rc.7+), +just as streaming freezes it at shell flush. Server mode only — in client mode the served shell has no boundaries to settle, so the option is a documented no-op there. **`env`** — first-party typed environment variables. A schema file at the diff --git a/package.json b/package.json index 464d6cd..322511e 100644 --- a/package.json +++ b/package.json @@ -91,9 +91,9 @@ }, "peerDependencies": { "@solidjs/start-devtools": "^1.0.0-next.2", - "@solidjs/web": "^2.0.0-rc.0", + "@solidjs/web": "^2.0.0-rc.7", "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", - "solid-js": "^2.0.0-rc.0", + "solid-js": "^2.0.0-rc.7", "vite": "^8.0.0 || ^9.0.0" }, "peerDependenciesMeta": { diff --git a/src/ssr/index.ts b/src/ssr/index.ts index daf7480..90f0005 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -762,9 +762,9 @@ export function startServe( ].join('\n'); } const { app } = requireEntries(); - const streamOptions = `{ manifest, onCompleteAll: commitResponseHead${serverComponents ? ', plugins: [ServerComponentPlugin]' : ''} }`; + const streamOptions = `{ manifest${serverComponents ? ', plugins: [ServerComponentPlugin]' : ''} }`; return [ - `import { renderToStream, getRequestEvent, commitResponseStub } from '@solidjs/web';`, + `import { renderToStream${setupPath ? ', getRequestEvent' : ''} } from '@solidjs/web';`, ...(serverComponents ? [ `import { configureServerFunctionsServer } from '@solidjs/web/server-functions';`, @@ -797,19 +797,6 @@ export function startServe( ``, ] : []), - // Commits the response head when the render completes — BEFORE the - // runtime disposes the render. `httpStatus`/`httpHeader` register - // cleanups that revert their declarations unless the stub is already - // committed; streaming commits at shell flush (this is a no-op there), - // but the async render mode resolves the complete document only after - // that disposal (`renderToStream(...).then` disposes, then resolves), - // so without this the settled 404 / Location would be reverted before - // createSSRResponse's string path could commit them. - `function commitResponseHead() {`, - ` const event = getRequestEvent();`, - ` if (event) commitResponseStub(event.response);`, - `}`, - ``, ...(setupPath ? [ // The per-request seam: the hook sees the same event the