Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/request-failure-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"solid-js": patch
"@solidjs/web": patch
---

`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.
4 changes: 2 additions & 2 deletions documentation/solid-2.0/12-ssr-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Errored>` 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 `<Loading>` 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 `<Errored>` 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 `<Loading>` 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 `<Errored>` that contains it; the boundary reuses the verdict — the same replacement in the fallback and the record — and does not report again. A `<Loading>` 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`.

Expand Down
2 changes: 1 addition & 1 deletion packages/solid/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
8 changes: 7 additions & 1 deletion packages/web/src/server-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerErrorSite, "event"> {
event?: RequestEvent;
Expand All @@ -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 outside any render (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`);
Expand Down
50 changes: 45 additions & 5 deletions packages/web/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1702,8 +1702,10 @@ export function renderToString<T>(
* This render's server error hook, ahead of `configureServerErrors`'
* (see `ServerErrorHook`): every failure the render handles — an
* `<Errored>` 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;
/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -6120,12 +6135,17 @@ export const RequestContext: unique symbol = Symbol.for("solid.RequestContext")
*
* `kind: "render"` — `fallback`: an `<Errored>` rendered its fallback;
* `client`: a `<Loading>` 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. `boundary` is the hydration id the boundary records and
* 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
* them — and `boundaryPath` where it was MET, the same labels up the
Expand Down Expand Up @@ -6186,6 +6206,21 @@ 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 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;

/**
* The serializer's `onError`, when a render passed one: seroval reports a
* value that would not serialize here instead of throwing at the write
Expand Down Expand Up @@ -6214,6 +6249,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`
Expand Down
Loading
Loading