diff --git a/.changeset/freeze-awaited-render-head.md b/.changeset/freeze-awaited-render-head.md
new file mode 100644
index 000000000..695273298
--- /dev/null
+++ b/.changeset/freeze-awaited-render-head.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/web": patch
+---
+
+An awaited `renderToStream(...)` result now freezes the request's response head at completion — the render commits `event.response` right before its final dispose — so `httpStatus`/`httpHeader` declarations survive into `createSSRResponse(html, event)`, which sees the already-committed stub and passes it through. Previously the thenable disposed the render owner before resolving, while the head was still open, so every scope-tied declaration's cleanup retracted it: a page calling `httpStatus(404)` rendered through `await renderToStream(...)` came back as a 200 and its `httpHeader` writes vanished. The piped forms are unchanged (they already froze at shell flush), and so are the retraction semantics themselves — a scope disposed mid-render, such as an errored boundary that recovered, still retracts its declarations. Integrations no longer need to commit the stub from `onCompleteAll` to work around this.
diff --git a/packages/web/src/index.server.ts b/packages/web/src/index.server.ts
index d7298adf4..49823bc25 100644
--- a/packages/web/src/index.server.ts
+++ b/packages/web/src/index.server.ts
@@ -270,9 +270,14 @@ const headerLedgers = /* @__PURE__ */ new WeakMap>(
* `event.response` status at write time and restores it when the owning
* scope is disposed — so a boundary that errored, declared a status, and
* then recovered retracts its write instead of stomping a status a
- * surviving part of the tree legitimately set. Once the integration marks
- * the response head `committed` (head derived/sent), writes and
- * retractions are no-ops.
+ * surviving part of the tree legitimately set. Once the response head is
+ * `committed` (head derived/sent — the shell flush of a piped
+ * `renderToStream`, the completion of an awaited one, `createSSRResponse`
+ * for a `renderToString` result), writes and retractions are no-ops.
*/
export function httpStatus(_code: number, _text?: string): void {}
@@ -543,7 +544,9 @@ export function httpStatus(_code: number, _text?: string): void {}
* Retraction semantics (server): the header's prior value is snapshotted at
* write time and restored when the owning scope is disposed (deleted if
* there was none) — a boundary that errors or recovers retracts its writes.
- * Once the integration marks the response head `committed` (head
- * derived/sent), writes and retractions are no-ops.
+ * Once the response head is `committed` (head derived/sent — the shell
+ * flush of a piped `renderToStream`, the completion of an awaited one,
+ * `createSSRResponse` for a `renderToString` result), writes and
+ * retractions are no-ops.
*/
export function httpHeader(_name: string, _value: string, _options?: { append?: boolean }): void {}
diff --git a/packages/web/src/server-mock.ts b/packages/web/src/server-mock.ts
index aa3f67a57..b4b68d2b0 100644
--- a/packages/web/src/server-mock.ts
+++ b/packages/web/src/server-mock.ts
@@ -270,8 +270,9 @@ export function getExpectedRedirectStatus(response: ResponseStub): number {
* shell flush, a pre-flush `Location` becomes a real redirect
* (`getExpectedRedirectStatus`), and a post-flush one appends the
* nonce-aware `` fallback. String
- * results return a `Response` synchronously; stream results resolve at
- * shell flush. Server-only.
+ * results return a `Response` synchronously (an awaited `renderToStream`
+ * result arrives already committed — its head froze at completion); stream
+ * results resolve at shell flush. Server-only.
*/
export function createSSRResponse(
result: string,
diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts
index 72207eb11..f824aeef5 100644
--- a/packages/web/src/server.ts
+++ b/packages/web/src/server.ts
@@ -1582,6 +1582,14 @@ export function renderToStream(
* await renderToStream(...)`). Render errors route through `onError` and
* the promise resolves with whatever HTML the render produced; it never
* rejects.
+ *
+ * Completion is this path's head-freeze point: the request event's
+ * `response` head (the request scope the render was started in) is
+ * committed right before the render's final dispose, so
+ * `httpStatus`/`httpHeader` declarations still live at completion survive
+ * into `createSSRResponse(html, event)` — which sees the already-committed
+ * stub and passes it through. The piped forms freeze at shell flush
+ * instead.
*/
then(
onfulfilled?: ((html: string) => TResult1 | PromiseLike) | null,
@@ -1605,6 +1613,14 @@ export function renderToStream(
export function renderToStream(code, options = {}) {
let { onCompleteShell, onCompleteAll, renderId = "", noScripts, manifest, onHead } = options;
const nonce = normalizeNonce(options.nonce);
+ // The request this render serves, read at start: the scope-tied response
+ // primitives (`httpStatus`/`httpHeader`) write to ITS `response` head, and
+ // the awaited path freezes that same head at completion (see `then`).
+ // Captured here, not in `then`, because the thenable may legitimately be
+ // awaited outside the request scope it was started in —
+ // `await provideRequestEvent(event, () => renderToStream(...))` is the
+ // storage module's own documented shape.
+ const requestEvent = peekRequestEvent();
let dispose;
let dead = false;
// Client-disconnect teardown. A sink that throws from `write`/`end` (its
@@ -2486,9 +2502,28 @@ export function renderToStream(code, options = {}) {
// renderToStringAsync. Render errors route through `onError` (the
// promise resolves with whatever HTML the render produced; it never
// rejects), matching the pipe/pipeTo contract.
+ //
+ // Head-freeze point: completion. The pipe paths freeze the request's
+ // response head when the shell reaches the sink (`createSSRResponse`
+ // commits the stub on the first write, before the final dispose runs).
+ // This path has no shell flush — the whole document resolves at once,
+ // and the consumer derives the head from the SAME stub afterwards
+ // (`createSSRResponse`'s string path) — so the render commits the stub
+ // itself, immediately before disposing the render owner. Without that,
+ // the final dispose would run every `httpStatus`/`httpHeader` cleanup
+ // against a still-open head and retract the declarations before the
+ // consumer ever saw the HTML (a page's `httpStatus(404)` came back 200).
+ // Retraction semantics are untouched: a scope disposed mid-render (an
+ // errored, recovered boundary) still retracts, because the head is
+ // still open then. Consumers see an already-committed stub, which
+ // `createSSRResponse`/`commitEventResponse` pass through idempotently.
then(onFulfilled, onRejected) {
+ const freezeHead = () => {
+ if (requestEvent && requestEvent.response) commitResponseStub(requestEvent.response);
+ };
const p = new Promise(resolve => {
function complete() {
+ freezeHead();
dispose();
resolve(tmp);
}
@@ -2519,7 +2554,11 @@ export function renderToStream(code, options = {}) {
} catch (err) {
// Contain retry-pass errors (see failRender); the thenable
// contract already routes render errors through onError and
- // resolves with whatever HTML the render produced.
+ // resolves with whatever HTML the render produced. The head
+ // is deliberately NOT frozen on this path: the render died,
+ // its teardown retracts the declarations as it always did,
+ // and the consumer keeps a writable head for whatever error
+ // response it builds around the partial HTML.
failRender(err);
return resolve(tmp);
}
@@ -4476,6 +4515,19 @@ export function getRequestEvent() {
"RequestEvent is missing. This is most likely due to accessing `getRequestEvent` non-managed async scope in a partially polyfilled environment. Try moving it above all `await` calls."
)
: undefined;
+}
+
+// The runtime's own silent read of the request scope's event, for
+// `renderToStream` at render start: whether there is a response head to
+// freeze at completion. Only the scope store is consulted — no
+// `sharedConfig.context.event` fallback, since at render start that context
+// is the PREVIOUS render's and could name another request's head — and no
+// missing-event warning: that warning is for application code reading the
+// event where it should exist, while a render outside any request scope
+// (tests, static generation, a bare script) is a normal thing.
+function peekRequestEvent() {
+ const store = (globalThis as any)[RequestContext];
+ return store ? store.getStore() : undefined;
} /** A fresh, uncommitted response head. */
export function createResponseStub(): ResponseStub;
@@ -4539,8 +4591,9 @@ function reportLostHeaderWrite(method, name) {
* reads are untouched) so a post-commit write fails loudly instead of
* silently missing the wire: it throws in the dev build and reports +
* no-ops otherwise. Every head materialization path commits through here
- * (`createSSRResponse`, the server-function handler's commit seam);
- * integrations deriving their own heads should too.
+ * (`createSSRResponse`, an awaited `renderToStream` result's completion,
+ * the server-function handler's commit seam); integrations deriving their
+ * own heads should too.
*
* `allowLateLocation` is the stream path's documented exception: a
* `Location` set after the shell flushed is still honored client-side
@@ -4560,7 +4613,9 @@ export function commitResponseStub(
* instead of silently missing the wire: it throws in the dev build and
* reports + no-ops otherwise. Every head materialization path commits
* through here — `createSSRResponse` (string results and the stream's
- * shell flush) and the server-function handler's commit seam — so the
+ * shell flush), an awaited `renderToStream` result's completion (the
+ * render commits before its final dispose so scope-tied declarations
+ * survive), and the server-function handler's commit seam — so the
* guarantee holds for every writer, not just core's own primitives.
*
* `allowLateLocation` is the stream path's documented exception: a
@@ -4765,9 +4820,13 @@ export function createSSRResponse(
* Derives the outgoing `Response` for an SSR render result, running the
* response-head lifecycle against `event.response`:
*
- * - String results (sync/async renders) commit the stub and return a
- * `Response` synchronously; a `Location` on the stub becomes a real
- * redirect (`getExpectedRedirectStatus`) instead of an HTML response.
+ * - String results commit the stub and return a `Response` synchronously;
+ * a `Location` on the stub becomes a real redirect
+ * (`getExpectedRedirectStatus`) instead of an HTML response. An awaited
+ * `renderToStream(...)` result arrives with its stub ALREADY committed —
+ * the render froze the head at completion, before its final dispose, so
+ * `httpStatus`/`httpHeader` declarations survive into the derived head —
+ * and the commit here is an idempotent pass-through for it.
* - Stream results (`renderToStream(...)`) resolve at shell flush — the
* moment the head freezes: the stub is committed there (post-commit
* header writes fail loudly — see `commitResponseStub`), its
diff --git a/packages/web/test/server/http-components.spec.tsx b/packages/web/test/server/http-components.spec.tsx
index f71a158ca..ee52d8131 100644
--- a/packages/web/test/server/http-components.spec.tsx
+++ b/packages/web/test/server/http-components.spec.tsx
@@ -16,10 +16,13 @@ import {
clientOnly,
renderToString,
renderToStream,
- Loading
+ createRequestEvent,
+ createSSRResponse,
+ Loading,
+ Errored
} from "@solidjs/web";
import type { RequestEvent, ResponseStub } from "@solidjs/web";
-import { createRoot, type Component } from "solid-js";
+import { createMemo, createRoot, type Component } from "solid-js";
// `response` is integration-augmented (see core's ResponseStub); model an
// integration event here.
@@ -453,6 +456,153 @@ describe("httpHeader (server primitive)", () => {
});
});
+// The awaited path: `await renderToStream(...)` resolves with the complete
+// document, and the consumer derives the head from the SAME stub the render
+// wrote to (`createSSRResponse`'s string path). There is no shell flush to
+// freeze the head at, so render completion is the freeze point — the runtime
+// commits the stub before the final dispose, or every scope-tied declaration
+// would retract before the consumer ever saw the HTML (a bare
+// `httpStatus(404)` coming back as a 200).
+describe("awaited renderToStream: response head freezes at completion", () => {
+ function delay(ms: number) {
+ return new Promise(r => setTimeout(r, ms));
+ }
+
+ test("httpStatus/httpHeader declarations survive into createSSRResponse", async () => {
+ const event = createRequestEvent(new Request("http://localhost/missing"));
+ const Page = () => {
+ httpStatus(404, "Not Found");
+ httpHeader("x-test", "yes");
+ return not found
;
+ };
+ // The storage doc's own shape: the render starts inside the request
+ // scope, the thenable is awaited outside it — the head to freeze is the
+ // one the render was started under.
+ const html = await storage.run(event, () => renderToStream(() => ));
+ expect(html).toContain("not found");
+ // Completion froze the head: the final dispose's retractions were no-ops.
+ expect(event.response.committed).toBe(true);
+ expect(event.response.status).toBe(404);
+ expect(event.response.headers.get("x-test")).toBe("yes");
+
+ // An already-committed stub passes through createSSRResponse untouched.
+ const response = createSSRResponse(html, event);
+ expect(response.status).toBe(404);
+ expect(response.statusText).toBe("Not Found");
+ expect(response.headers.get("x-test")).toBe("yes");
+ expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
+ expect(await response.text()).toContain("not found");
+ });
+
+ test("declarations made under a boundary that settles asynchronously survive too", async () => {
+ // No shell flush on this path: the freeze must wait for COMPLETION (every
+ // boundary settled), not fire when the shell would have gone out.
+ const event = createRequestEvent(new Request("http://localhost/item"));
+ function Item() {
+ const data = createMemo(async () => {
+ await delay(5);
+ return "gone";
+ });
+ httpStatus(410, "Gone");
+ httpHeader("x-late", "declared");
+ return {data()}
;
+ }
+ const html = await storage.run(event, () =>
+ renderToStream(() => (
+ loading}>
+
+
+ ))
+ );
+ expect(html).toContain("gone");
+ const response = createSSRResponse(html, event);
+ expect(response.status).toBe(410);
+ expect(response.statusText).toBe("Gone");
+ expect(response.headers.get("x-late")).toBe("declared");
+ });
+
+ test("a scope disposed mid-render still retracts (recovered-boundary semantics preserved)", async () => {
+ // The freeze lands at completion only: a boundary that errored, declared
+ // a status, and recovered BEFORE the render completed has already
+ // retracted — and the surviving page-level declaration stands.
+ const event = createRequestEvent(new Request("http://localhost/missing"));
+ const Page = () => {
+ httpStatus(404, "Not Found");
+ httpHeader("x-page", "kept");
+ // Model the errored-and-recovered scope: it declares, then its owner is
+ // disposed while the head is still open.
+ createRoot(dispose => {
+ httpStatus(500, "Server Error");
+ httpHeader("x-errored", "1");
+ expect(event.response.status).toBe(500);
+ dispose();
+ });
+ return not found
;
+ };
+ const html = await storage.run(event, () => renderToStream(() => ));
+ const response = createSSRResponse(html, event);
+ expect(response.status).toBe(404);
+ expect(response.statusText).toBe("Not Found");
+ expect(response.headers.get("x-page")).toBe("kept");
+ expect(response.headers.has("x-errored")).toBe(false);
+ });
+
+ test("a synchronously erroring child under Errored keeps parity with the pipe path", async () => {
+ // Existing semantics on both paths: a child that declares and then throws
+ // synchronously is caught by the boundary, and its owner is not disposed
+ // until the render's final dispose — after the head froze. The awaited
+ // result must derive the same head the pipe path's shell flush would.
+ const Page = () => {
+ httpStatus(404, "Not Found");
+ return (
+ caught
}>
+ {(() => {
+ httpStatus(500, "Server Error");
+ httpHeader("x-errored", "1");
+ throw new Error("boom");
+ })()}
+
+ );
+ };
+
+ const awaited = createRequestEvent(new Request("http://localhost/"));
+ const html = await storage.run(awaited, () => renderToStream(() => ));
+ const awaitedResponse = createSSRResponse(html, awaited);
+
+ const piped = createRequestEvent(new Request("http://localhost/"));
+ const pipedResponse = await storage.run(piped, () =>
+ createSSRResponse(
+ renderToStream(() => ),
+ piped
+ )
+ );
+
+ expect(html).toContain("caught");
+ expect(awaitedResponse.status).toBe(pipedResponse.status);
+ expect(awaitedResponse.headers.get("x-errored")).toBe(pipedResponse.headers.get("x-errored"));
+ });
+
+ test("the pipe path is unchanged: the head freezes at shell flush", async () => {
+ const event = createRequestEvent(new Request("http://localhost/missing"));
+ const Page = () => {
+ httpStatus(404, "Not Found");
+ httpHeader("x-test", "yes");
+ return not found
;
+ };
+ const response = await storage.run(event, () =>
+ createSSRResponse(
+ renderToStream(() => ),
+ event
+ )
+ );
+ expect(event.response.committed).toBe(true);
+ expect(response.status).toBe(404);
+ expect(response.statusText).toBe("Not Found");
+ expect(response.headers.get("x-test")).toBe("yes");
+ expect(await response.text()).toContain("not found");
+ });
+});
+
describe("clientOnly (server)", () => {
test("SSRs the fallback and never starts the import", () => {
const importer = vi.fn(() => Promise.resolve({ default: (() => null) as Component<{}> }));