From a12ece17c9756639985ac9497a20f7479414c9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 00:27:37 -0300 Subject: [PATCH 1/4] feat(web): report request failures met before any render Add `reportRequestFailure(error, event)` to the server entry of `@solidjs/web`, with a no-op in the client view, and `"request"` to `ServerErrorSite.kind`. A framework's request handler that catches a failure before any render or server function met it (a middleware throw) reports it through the ambient server error hook as `{ kind: "request", handling: "failed", event }`, once per error object, or to `console.error` when no hook is registered, the same policy as a render that fails before its shell. Closes #3723. --- .changeset/request-failure-report.md | 6 ++ documentation/solid-2.0/12-ssr-http.md | 2 +- packages/solid/src/index.ts | 2 +- packages/solid/src/server/signals.ts | 2 +- packages/web/src/server-mock.ts | 8 +- packages/web/src/server.ts | 20 ++++- .../test/server/server-error-hook.spec.tsx | 73 +++++++++++++++++++ 7 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 .changeset/request-failure-report.md diff --git a/.changeset/request-failure-report.md b/.changeset/request-failure-report.md new file mode 100644 index 000000000..c067c260c --- /dev/null +++ b/.changeset/request-failure-report.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +`reportRequestFailure(error, event)` (`@solidjs/web`, server) reports a failure that fails a request before any render or server function met it, such as a middleware throw that a framework's request handler catches. The ambient server error hook hears it as `kind: "request"`, `handling: "failed"`, with the request event, once per error object, and its return is ignored. With no hook registered the failure goes to `console.error`, as a render that fails before its shell does. `ServerErrorSite.kind` (`solid-js`) gains `"request"`. On the client the function is a no-op. diff --git a/documentation/solid-2.0/12-ssr-http.md b/documentation/solid-2.0/12-ssr-http.md index cfb7f5a8c..a78024ac1 100644 --- a/documentation/solid-2.0/12-ssr-http.md +++ b/documentation/solid-2.0/12-ssr-http.md @@ -165,7 +165,7 @@ renderToStream(code, { onError }); handleServerFunctionRequest(request, { onError }); ``` -- **Called once per error object, at first sight**, with where the failure was met. `kind: "render"` — `fallback` (an `` rendered its fallback; `boundary` is its hydration id and `boundaryPath` its component labels — where the error was **met** — while `ownerPath` is where it was **thrown**, the labels up the owner chain it escaped, when the compiler emitted them), `client` (a `` fragment rejected, the client re-renders the subtree), `failed` (nothing contained it; the request fails), `serialize` (a hydration value would not serialize and the render went on without it — reported for a render that passed `onError`, as seroval's own `onError` always was). `kind: "server-function"` — `thrown` (the body threw; `functionId`, and `direct: true` for an in-process call during SSR) or `channel` (a rejection or throw escaping through the result graph, the head already committed). `event` is the request, when the failure happened inside one. +- **Called once per error object, at first sight**, with where the failure was met. `kind: "render"` — `fallback` (an `` rendered its fallback; `boundary` is its hydration id and `boundaryPath` its component labels — where the error was **met** — while `ownerPath` is where it was **thrown**, the labels up the owner chain it escaped, when the compiler emitted them), `client` (a `` fragment rejected, the client re-renders the subtree), `failed` (nothing contained it; the request fails), `serialize` (a hydration value would not serialize and the render went on without it — reported for a render that passed `onError`, as seroval's own `onError` always was). `kind: "server-function"` — `thrown` (the body threw; `functionId`, and `direct: true` for an in-process call during SSR) or `channel` (a rejection or throw escaping through the result graph, the head already committed). `kind: "request"` is always `failed`: a failure the framework's request handler met before any render or server function did (a middleware throw, a setup step), reported through `reportRequestFailure(error, event)`. It has no per-request tier, so only the ambient hook hears it. `event` is the request, when the failure happened inside one, and always for `kind: "request"`. - **The return is the wire value**: rendered into the fallback, serialized for hydration, sent as the RPC error. `undefined` leaves the default policy in place (generic outside dev, fidelity in dev). A returned value is the author's intent — like a `wrapInvocation` mapping — and is not sanitized again; `markSafeError` is not needed on it. Ignored for `failed` and `serialize`, which have no wire. One road a mapping does not reach: a rejected async source's serialized rejection is encoded the moment the source rejects, ahead of the boundary, and carries the default policy's value; the hydrating client renders from the boundary's record, which carries the mapping. - **Once means once across roads.** A direct server-function call that throws during SSR is met first by the invocation (`kind: "server-function"`, `direct: true`) and then by the `` that contains it; the boundary reuses the verdict — the same replacement in the fallback and the record — and does not report again. A `` re-pull recurring the same throw is the same object, the same verdict. The observe tier keeps the multi-event picture (an `"invocation"` record _and_ an `SSR_RENDER_ERROR_CONTAINED` finding) for consumers that want it. - **Two tiers, as `wrapInvocation` has.** Ambient: `configureServerErrors({ onError })`, once per process, registered on `globalThis` under a registered symbol so a bundled server build and an instrumented `--import`ed module share it — and the only tier that sees direct in-process calls. Per request, overriding it: `onError` on `renderToStream`/`renderToString`, and on `handleServerFunctionRequest` for the call it dispatches. A throwing hook is reported on the console and treated as silent. With no hook anywhere, a failure that fails the request goes to `console.error` — never silent. diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 94124e772..279fff7c4 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -161,7 +161,7 @@ export function reportServerError(): { mapped: boolean; value?: unknown } { } /** Where a server failure was met, as the server error hook hears it (see `@solidjs/web`'s `ServerErrorContext`). */ export interface ServerErrorSite { - kind: "render" | "server-function"; + kind: "render" | "server-function" | "request"; handling: "fallback" | "client" | "failed" | "serialize" | "thrown" | "channel"; boundary?: string; /** Where the error was thrown — labels root-first up the owner chain it escaped. */ diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index d59ef9d47..f3caa4579 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -3293,7 +3293,7 @@ const GENERIC_SERVER_ERROR_MESSAGE = "Internal Server Error"; /** Where a failure was met, as the hook hears it (the `event` is added at the call). */ export interface ServerErrorSite { - kind: "render" | "server-function"; + kind: "render" | "server-function" | "request"; handling: "fallback" | "client" | "failed" | "serialize" | "thrown" | "channel"; boundary?: string; /** diff --git a/packages/web/src/server-mock.ts b/packages/web/src/server-mock.ts index dc3d54c1e..d89d40afd 100644 --- a/packages/web/src/server-mock.ts +++ b/packages/web/src/server-mock.ts @@ -7,7 +7,7 @@ import type { ServerErrorSite } from "solid-js"; * Where a server-side failure was met, as the server error hook hears it — * the client's view of the server entry's type (see `configureServerErrors` * there). `kind: "render"`: `fallback`/`client`/`failed`; `kind: - * "server-function"`: `thrown`/`channel`. + * "server-function"`: `thrown`/`channel`; `kind: "request"`: `failed`. */ export interface ServerErrorContext extends Omit { event?: RequestEvent; @@ -22,6 +22,12 @@ export interface ServerErrorsConfig { * is a no-op so isomorphic setup code can call it unguarded. */ export function configureServerErrors(_config: ServerErrorsConfig): void {} +/** + * Reports a failure that fails a request before any render met it (see the + * server entry's `reportRequestFailure`). Server-only; on the client this is + * a no-op. + */ +export function reportRequestFailure(_error: unknown, _event: RequestEvent): void {} function throwInBrowser(func: Function) { const err = new Error(`${func.name} is not supported in the browser, returning undefined`); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index b337e09f4..8d60066c7 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -6125,7 +6125,9 @@ export const RequestContext: unique symbol = Symbol.for("solid.RequestContext") * without it (a render that passed `onError`). `kind: "server-function"` — `thrown`: * the body threw; `channel`: a rejection or throw escaping through the * result graph (a promise, an iterable, a stream) with the head already - * committed. `boundary` is the hydration id the boundary records and + * committed. `kind: "request"` (always `failed`): the framework's request + * handler met the failure before any render or server function did (see + * `reportRequestFailure`). `boundary` is the hydration id the boundary records and * findings use; `ownerPath` is where the error was THROWN — the component * labels root-first up the owner chain it escaped, when the compiler emitted * them — and `boundaryPath` where it was MET, the same labels up the @@ -6186,6 +6188,17 @@ const ServerErrors: unique symbol = Symbol.for("solid-js/server/errors") as any; */ export function configureServerErrors(config: ServerErrorsConfig): void; +/** + * Reports a failure that fails a request before any render or server + * function met it, such as a throw a framework's request handler catches + * from its middleware. The ambient server error hook hears it as + * `{ kind: "request", handling: "failed", event }`, once per error object, + * and its return is ignored, as for every `handling: "failed"`. With no hook + * registered the failure goes to `console.error`, as a render that fails + * before its shell does. There is no per-request hook at this layer. + */ +export function reportRequestFailure(error: unknown, event: RequestEvent): void; + /** * The serializer's `onError`, when a render passed one: seroval reports a * value that would not serialize here instead of throwing at the write @@ -6214,6 +6227,11 @@ export function configureServerErrors(config) { (g[ServerErrors] ||= {}).hook = config ? config.onError : undefined; } +export function reportRequestFailure(error, event) { + reportServerError(error, { kind: "request", handling: "failed", event }, null); + if (ambientServerErrorHook() === undefined) console.error(error); +} + /** * The current request event, when called on the server inside a request * scope (established by `provideRequestEvent` from `@solidjs/web/storage` diff --git a/packages/web/test/server/server-error-hook.spec.tsx b/packages/web/test/server/server-error-hook.spec.tsx index 284d93816..86ed1e487 100644 --- a/packages/web/test/server/server-error-hook.spec.tsx +++ b/packages/web/test/server/server-error-hook.spec.tsx @@ -23,6 +23,9 @@ * the latter winning; * - `handling: "failed"` has no wire — the return is ignored — and with no * hook anywhere it reaches `console.error`; + * - a failure the framework's request handler meets before any render + * (`reportRequestFailure`) is heard as `kind: "request"`, `handling: + * "failed"`, with its event, under the same once-per-error ledger; * - a throwing hook is reported and treated as silent. * * Runs the runtimes from source (the dev tier: the default is fidelity, so a @@ -37,8 +40,10 @@ import { Errored, Loading, configureServerErrors, + createRequestEvent, renderToStream, renderToString, + reportRequestFailure, type ServerErrorContext } from "@solidjs/web"; import { NotReadyError, createMemo } from "solid-js"; @@ -427,3 +432,71 @@ describe("server functions", () => { expect(html).not.toContain("ECONNREFUSED"); }); }); + +describe("a request that fails before any render (reportRequestFailure)", () => { + const event = () => createRequestEvent(new Request("https://app.example/page")); + + test("the ambient hook hears one `request`/`failed` call with the event; the return is ignored", () => { + const failure = new Error("middleware broke"); + configureServerErrors({ onError: hook(() => new Error("ignored")) }); + const request = event(); + expect(reportRequestFailure(failure, request)).toBeUndefined(); + expect(calls).toHaveLength(1); + expect(calls[0].error).toBe(failure); + expect(calls[0].context).toMatchObject({ kind: "request", handling: "failed" }); + expect(calls[0].context.event).toBe(request); + expect(reported).toHaveLength(0); + }); + + test("once per error object: the same failure reported again is not heard again", () => { + const failure = new Error("middleware broke"); + configureServerErrors({ onError: hook() }); + reportRequestFailure(failure, event()); + reportRequestFailure(failure, event()); + expect(calls).toHaveLength(1); + }); + + test("a direct server-function failure that escapes to the request handler is heard once, as the function's", () => { + const boom = new Error("connect ECONNREFUSED"); + configureServerErrors({ onError: hook() }); + const load = createServerReference( + registerServerReference("hook/request-escape", () => { + throw boom; + }) + ); + let escaped: unknown; + underRequest(() => { + try { + (load as any)(); + } catch (error) { + escaped = error; + } + }); + expect(escaped).toBe(boom); + reportRequestFailure(escaped, event()); + expect(calls).toHaveLength(1); + expect(calls[0].context).toMatchObject({ + kind: "server-function", + handling: "thrown", + functionId: "hook/request-escape", + direct: true + }); + }); + + test("without a hook the failure reaches console.error", () => { + const failure = new Error("middleware broke"); + reportRequestFailure(failure, event()); + expect(reported).toEqual([[failure]]); + }); + + test("a throwing hook is reported on the console and the call returns normally", () => { + const failure = new Error("middleware broke"); + configureServerErrors({ + onError: () => { + throw new Error("hook broke"); + } + }); + expect(() => reportRequestFailure(failure, event())).not.toThrow(); + expect(reported.map(args => (args[0] as Error).message)).toEqual(["hook broke"]); + }); +}); From fc096aed6d62781a3a19a8157c9ea8dd7709e15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Sat, 3 Oct 2026 20:08:42 -0300 Subject: [PATCH 2/4] fix(ci): skip size comments for fork pull requests --- .github/workflows/size.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 45ffe883f..373363a3d 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -116,7 +116,11 @@ jobs: path: scripts/size - run: node report.mjs size-head.json size-base.json > size-comment.md working-directory: scripts/size - - uses: actions/github-script@v7 + - run: cat size-comment.md >> "$GITHUB_STEP_SUMMARY" + working-directory: scripts/size + # Fork pull requests have a read-only token; their report remains in the job summary. + - if: github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v7 with: script: | const fs = require("fs"); From f2cab7908e2595a90f8e02a9110a08e32fd36130 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 3 Oct 2026 23:57:35 -0700 Subject: [PATCH 3/4] feat(web): report synchronous render throws as render/failed before rethrow A synchronous throw out of renderToString, or out of renderToStream's first pass (and so renderToFrameStream and serverComponentResponse), reached the caller without the server error hook hearing it. A request handler that caught it and called reportRequestFailure filed it as kind "request". The render now reports it as render/failed through its own onError or the ambient hook before rethrowing, so the handler's report of the same error is deduped by the once-per-error ledger. No console fallback on this road: the rethrow is the caller's. The "request" kind is documented as a failure that fails the request outside any render or server function the runtime reports. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/request-failure-report.md | 4 +- documentation/solid-2.0/12-ssr-http.md | 4 +- packages/web/src/server-mock.ts | 2 +- packages/web/src/server.ts | 48 ++++++++--- .../test/server/server-error-hook.spec.tsx | 86 +++++++++++++++++++ 5 files changed, 127 insertions(+), 17 deletions(-) diff --git a/.changeset/request-failure-report.md b/.changeset/request-failure-report.md index c067c260c..72bb6a1f5 100644 --- a/.changeset/request-failure-report.md +++ b/.changeset/request-failure-report.md @@ -3,4 +3,6 @@ "@solidjs/web": patch --- -`reportRequestFailure(error, event)` (`@solidjs/web`, server) reports a failure that fails a request before any render or server function met it, such as a middleware throw that a framework's request handler catches. The ambient server error hook hears it as `kind: "request"`, `handling: "failed"`, with the request event, once per error object, and its return is ignored. With no hook registered the failure goes to `console.error`, as a render that fails before its shell does. `ServerErrorSite.kind` (`solid-js`) gains `"request"`. On the client the function is a no-op. +`reportRequestFailure(error, event)` (`@solidjs/web`, server) reports a failure that fails a request outside any render or server function the runtime reports, such as a middleware throw that a framework's request handler catches. The ambient server error hook hears it as `kind: "request"`, `handling: "failed"`, with the request event, once per error object, and its return is ignored. With no hook registered the failure goes to `console.error`, as a render that fails before its shell does. `ServerErrorSite.kind` (`solid-js`) gains `"request"`. On the client the function is a no-op. + +A synchronous throw out of `renderToString`, or out of `renderToStream`'s first pass (and so `renderToFrameStream` and `serverComponentResponse`), is now reported to the server error hook as `kind: "render"`, `handling: "failed"` before it is rethrown, where it previously reached the caller without the hook hearing it. A request handler that catches it and calls `reportRequestFailure` adds nothing; the hook hears it once, as the render's. diff --git a/documentation/solid-2.0/12-ssr-http.md b/documentation/solid-2.0/12-ssr-http.md index a78024ac1..e428749fe 100644 --- a/documentation/solid-2.0/12-ssr-http.md +++ b/documentation/solid-2.0/12-ssr-http.md @@ -165,10 +165,10 @@ renderToStream(code, { onError }); handleServerFunctionRequest(request, { onError }); ``` -- **Called once per error object, at first sight**, with where the failure was met. `kind: "render"` — `fallback` (an `` rendered its fallback; `boundary` is its hydration id and `boundaryPath` its component labels — where the error was **met** — while `ownerPath` is where it was **thrown**, the labels up the owner chain it escaped, when the compiler emitted them), `client` (a `` fragment rejected, the client re-renders the subtree), `failed` (nothing contained it; the request fails), `serialize` (a hydration value would not serialize and the render went on without it — reported for a render that passed `onError`, as seroval's own `onError` always was). `kind: "server-function"` — `thrown` (the body threw; `functionId`, and `direct: true` for an in-process call during SSR) or `channel` (a rejection or throw escaping through the result graph, the head already committed). `kind: "request"` is always `failed`: a failure the framework's request handler met before any render or server function did (a middleware throw, a setup step), reported through `reportRequestFailure(error, event)`. It has no per-request tier, so only the ambient hook hears it. `event` is the request, when the failure happened inside one, and always for `kind: "request"`. +- **Called once per error object, at first sight**, with where the failure was met. `kind: "render"` — `fallback` (an `` rendered its fallback; `boundary` is its hydration id and `boundaryPath` its component labels — where the error was **met** — while `ownerPath` is where it was **thrown**, the labels up the owner chain it escaped, when the compiler emitted them), `client` (a `` fragment rejected, the client re-renders the subtree), `failed` (nothing contained it; the request fails — including a synchronous throw out of `renderToString` or `renderToStream`'s first pass, reported before it reaches the caller), `serialize` (a hydration value would not serialize and the render went on without it — reported for a render that passed `onError`, as seroval's own `onError` always was). `kind: "server-function"` — `thrown` (the body threw; `functionId`, and `direct: true` for an in-process call during SSR) or `channel` (a rejection or throw escaping through the result graph, the head already committed). `kind: "request"` is always `failed`: a failure that fails the request outside any render or server function the runtime reports (a middleware throw, a setup step), reported by the framework's request handler through `reportRequestFailure(error, event)`. A render failure, synchronous throws included, is reported as `render`, so a handler that catches one and passes it to `reportRequestFailure` adds nothing. It has no per-request tier, so only the ambient hook hears it. `event` is the request, when the failure happened inside one, and always for `kind: "request"`. - **The return is the wire value**: rendered into the fallback, serialized for hydration, sent as the RPC error. `undefined` leaves the default policy in place (generic outside dev, fidelity in dev). A returned value is the author's intent — like a `wrapInvocation` mapping — and is not sanitized again; `markSafeError` is not needed on it. Ignored for `failed` and `serialize`, which have no wire. One road a mapping does not reach: a rejected async source's serialized rejection is encoded the moment the source rejects, ahead of the boundary, and carries the default policy's value; the hydrating client renders from the boundary's record, which carries the mapping. - **Once means once across roads.** A direct server-function call that throws during SSR is met first by the invocation (`kind: "server-function"`, `direct: true`) and then by the `` that contains it; the boundary reuses the verdict — the same replacement in the fallback and the record — and does not report again. A `` re-pull recurring the same throw is the same object, the same verdict. The observe tier keeps the multi-event picture (an `"invocation"` record _and_ an `SSR_RENDER_ERROR_CONTAINED` finding) for consumers that want it. -- **Two tiers, as `wrapInvocation` has.** Ambient: `configureServerErrors({ onError })`, once per process, registered on `globalThis` under a registered symbol so a bundled server build and an instrumented `--import`ed module share it — and the only tier that sees direct in-process calls. Per request, overriding it: `onError` on `renderToStream`/`renderToString`, and on `handleServerFunctionRequest` for the call it dispatches. A throwing hook is reported on the console and treated as silent. With no hook anywhere, a failure that fails the request goes to `console.error` — never silent. +- **Two tiers, as `wrapInvocation` has.** Ambient: `configureServerErrors({ onError })`, once per process, registered on `globalThis` under a registered symbol so a bundled server build and an instrumented `--import`ed module share it — and the only tier that sees direct in-process calls. Per request, overriding it: `onError` on `renderToStream`/`renderToString`, and on `handleServerFunctionRequest` for the call it dispatches. A throwing hook is reported on the console and treated as silent. With no hook anywhere, a failure that fails the request goes to `console.error` — never silent. A synchronous render throw is the exception: it is rethrown to the caller, whose own handling (or `reportRequestFailure`) decides. - **One listener, every failure.** A `renderToStream` `onError` written for the old single-argument shape keeps working; it now hears every handled failure, not only the one that fails the request — filter on `context.handling === "failed"` for the old behaviour. The hook fires in every tier, dev included; only the _default_ mapping differs by tier. - **The client twin** is `configureClientErrors` / `render`'s `onError` ([RFC 03](03-control-flow.md#reporting-what-a-boundary-caught-the-client-error-hook)): a boundary's fallback, no wire to map for. An uncaught client error halts and reaches `reportError`. diff --git a/packages/web/src/server-mock.ts b/packages/web/src/server-mock.ts index d89d40afd..2a9ae0374 100644 --- a/packages/web/src/server-mock.ts +++ b/packages/web/src/server-mock.ts @@ -23,7 +23,7 @@ export interface ServerErrorsConfig { */ export function configureServerErrors(_config: ServerErrorsConfig): void {} /** - * Reports a failure that fails a request before any render met it (see the + * Reports a failure that fails a request outside any render (see the * server entry's `reportRequestFailure`). Server-only; on the client this is * a no-op. */ diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 8d60066c7..cd139de7a 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -1702,8 +1702,10 @@ export function renderToString( * This render's server error hook, ahead of `configureServerErrors`' * (see `ServerErrorHook`): every failure the render handles — an * `` fallback (`handling: "fallback"`), a hydration value that - * would not serialize (`"serialize"`) — once per error, with where it - * was met. A one-argument listener still works; it hears them all. + * would not serialize (`"serialize"`) — and the throw that fails it + * (`"failed"`, heard before it is rethrown to the caller), once per + * error, with where it was met. A one-argument listener still works; it + * hears them all. */ onError?: ServerErrorHook; /** @@ -1853,6 +1855,11 @@ export function renderToString(code, options = {}) { } rendered = true; return document; + } catch (err) { + // The rethrow is the caller's, so no console fallback; the hook hears it + // first, and a handler's `reportRequestFailure` of it is a repeat. + reportServerError(err, { kind: "render", handling: "failed" }, null, options.onError); + throw err; } finally { // The render record settles before the trace is let go: a listener // reading `getTraceContext()` from its callback finds the render's. @@ -2943,7 +2950,15 @@ export function renderToStream(code, options = {}) { }; rootOwner = claimRenderRoot(context); if (requestEvent) claimEventRender(requestEvent, context); - const res = resolveSSRNode(escape(code())); + let res; + try { + res = resolveSSRNode(escape(code())); + } catch (err) { + // The first pass throws out of `renderToStream` to the caller: the + // hook hears it first, as for `renderToString`. + reportServerError(err, { kind: "render", handling: "failed" }, null, options.onError); + throw err; + } if (!res.h.length) return res.t[0]; rootHoles = []; let out = res.t[0]; @@ -6120,13 +6135,16 @@ export const RequestContext: unique symbol = Symbol.for("solid.RequestContext") * * `kind: "render"` — `fallback`: an `` rendered its fallback; * `client`: a `` fragment rejected and the client re-renders the - * subtree; `failed`: nothing contained it and the request fails; + * subtree; `failed`: nothing contained it and the request fails — including + * a synchronous throw out of `renderToString` or `renderToStream`'s first + * pass, reported before it reaches the caller; * `serialize`: a hydration value would not serialize and the render went on * without it (a render that passed `onError`). `kind: "server-function"` — `thrown`: * the body threw; `channel`: a rejection or throw escaping through the * result graph (a promise, an iterable, a stream) with the head already - * committed. `kind: "request"` (always `failed`): the framework's request - * handler met the failure before any render or server function did (see + * committed. `kind: "request"` (always `failed`): a failure that fails the + * request outside any render or server function the runtime reports — a + * middleware throw the framework's request handler catches (see * `reportRequestFailure`). `boundary` is the hydration id the boundary records and * findings use; `ownerPath` is where the error was THROWN — the component * labels root-first up the owner chain it escaped, when the compiler emitted @@ -6189,13 +6207,17 @@ const ServerErrors: unique symbol = Symbol.for("solid-js/server/errors") as any; export function configureServerErrors(config: ServerErrorsConfig): void; /** - * Reports a failure that fails a request before any render or server - * function met it, such as a throw a framework's request handler catches - * from its middleware. The ambient server error hook hears it as - * `{ kind: "request", handling: "failed", event }`, once per error object, - * and its return is ignored, as for every `handling: "failed"`. With no hook - * registered the failure goes to `console.error`, as a render that fails - * before its shell does. There is no per-request hook at this layer. + * Reports a failure that fails a request outside any render or server + * function the runtime reports, such as a throw a framework's request + * handler catches from its middleware. The ambient server error hook hears + * it as `{ kind: "request", handling: "failed", event }`, once per error + * object, and its return is ignored, as for every `handling: "failed"`. A + * render failure is the render's: a synchronous throw out of + * `renderToString` or `renderToStream` was already heard as `kind: + * "render"` before the caller caught it, so reporting it here adds nothing. + * With no hook registered the failure goes to `console.error`, as a render + * that fails before its shell does. There is no per-request hook at this + * layer. */ export function reportRequestFailure(error: unknown, event: RequestEvent): void; diff --git a/packages/web/test/server/server-error-hook.spec.tsx b/packages/web/test/server/server-error-hook.spec.tsx index 86ed1e487..f406534d1 100644 --- a/packages/web/test/server/server-error-hook.spec.tsx +++ b/packages/web/test/server/server-error-hook.spec.tsx @@ -54,6 +54,7 @@ import { registerServerReference } from "@solidjs/web/server-functions/server"; import { createServerReference as clientReference } from "@solidjs/web/server-functions/client"; +import { renderToFrameStream } from "../../frames/src/frame-sink.js"; import type { JSX } from "@solidjs/web"; const RequestContext = Symbol.for("solid.RequestContext"); @@ -499,4 +500,89 @@ describe("a request that fails before any render (reportRequestFailure)", () => expect(() => reportRequestFailure(failure, event())).not.toThrow(); expect(reported.map(args => (args[0] as Error).message)).toEqual(["hook broke"]); }); + + test("a failure that is not a render's still reports as `request`, after a render that failed", () => { + configureServerErrors({ onError: hook() }); + expect(() => + renderToString(() => { + throw new Error("render broke"); + }) + ).toThrow("render broke"); + const failure = new Error("middleware broke"); + reportRequestFailure(failure, event()); + expect(calls.map(c => `${c.context.kind}/${c.context.handling}`)).toEqual([ + "render/failed", + "request/failed" + ]); + expect(calls[1].error).toBe(failure); + }); +}); + +describe("a render that throws synchronously to its caller", () => { + const event = () => createRequestEvent(new Request("https://app.example/page")); + function Bad(): JSX.Element { + throw boom; + } + let boom: Error; + beforeEach(() => { + boom = new Error("sync render boom"); + }); + /** What a framework's request handler does: catch the rethrow, report it. */ + function handle(render: () => unknown) { + let caught: unknown; + underRequest(() => { + try { + render(); + } catch (error) { + caught = error; + } + }); + expect(caught).toBe(boom); + reportRequestFailure(caught, event()); + } + + test("renderToString: the hook hears one `render`/`failed` before the rethrow; reportRequestFailure adds nothing", () => { + configureServerErrors({ onError: hook() }); + handle(() => renderToString(() => )); + expect(calls).toHaveLength(1); + expect(calls[0].error).toBe(boom); + expect(calls[0].context).toMatchObject({ kind: "render", handling: "failed" }); + expect(calls[0].context.event).toBeDefined(); + expect(reported).toHaveLength(0); + }); + + test("renderToStream: the hook hears one `render`/`failed` before the rethrow; reportRequestFailure adds nothing", () => { + configureServerErrors({ onError: hook() }); + handle(() => renderToStream(() => )); + expect(calls).toHaveLength(1); + expect(calls[0].error).toBe(boom); + expect(calls[0].context).toMatchObject({ kind: "render", handling: "failed" }); + expect(reported).toHaveLength(0); + }); + + test("renderToFrameStream: the error chunk it sends was heard first as `render`/`failed`", async () => { + configureServerErrors({ onError: hook() }); + const chunks: any[] = await (renderToFrameStream(() => ) as any); + expect(chunks.some(c => c.type === "error")).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0].error).toBe(boom); + expect(calls[0].context).toMatchObject({ kind: "render", handling: "failed" }); + }); + + test("the render's own onError hears it, ahead of the ambient hook", () => { + const heard: unknown[] = []; + configureServerErrors({ onError: hook() }); + expect(() => + renderToString(() => , { onError: (error: unknown) => void heard.push(error) }) + ).toThrow(boom); + expect(heard).toEqual([boom]); + expect(calls).toHaveLength(0); + }); + + test("without a hook the rethrow is the caller's: nothing reaches console.error until it reports", () => { + expect(() => renderToString(() => )).toThrow(boom); + expect(reported).toHaveLength(0); + reportRequestFailure(boom, event()); + expect(reported).toEqual([[boom]]); + }); }); From b3f30b438a0e610d4d2768bc519b6e0b12989224 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 3 Oct 2026 23:57:36 -0700 Subject: [PATCH 4/4] chore(size): raise the renderToString floor cap to 20.42 KB Measured at 20,412 B against next @ 4738c6dfe's 20,377 (+35 B; +72 B minified): the catch in renderToString that reports a synchronous throw as render/failed before rethrowing. Accepted by the maintainer; ledger note in scripts/size/scenarios.js. Size-Exception: renderToString reports synchronous render throws as render/failed before rethrowing (+35 B), accepted by the maintainer. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 28e6129ce..09f5fb856 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -5,5 +5,5 @@ "page: base server components (hydrating + dynamic + frames + sf reference)": "46.25 KB", "page: live server components (base + live/GET + action + isPending/latest)": "50.45 KB", "server: floor (getRequestEvent + isServer)": "1.34 KB", - "server: renderToString (the server-render floor)": "20.38 KB" + "server: renderToString (the server-render floor)": "20.42 KB" } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index ca6be9ea8..920b83fdf 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3243,6 +3243,15 @@ module.exports = [ // text positions themselves, so none of that code is retained here; the // code shrinks and brotli layout grows. Accepted by the maintainer. The // cap is frozen again at 20.38 KB. + // Size-Exception (#3763, 2026-10-03): 20.38 -> 20.42 KB, measured at + // 20,412 B against `next` @ 4738c6dfe's 20,377 (+35 B; 32 B over the cap; + // +72 B minified, 71,741 -> 71,813). A synchronous throw out of + // `renderToString` is reported to the server error hook as + // `render`/`failed` before it is rethrown, so a request handler's + // `reportRequestFailure` of the same error is deduped instead of being + // heard as `request`. The cost is the `catch` and its one + // `reportServerError` call (already retained here). Accepted by the + // maintainer. The cap is frozen again at 20.42 KB. path: "../../packages/web/dist/server.js", import: "{ renderToString }", limit: floorCaps["server: renderToString (the server-render floor)"],