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<{}> }));