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
9 changes: 9 additions & 0 deletions .changeset/error-hook-thrower-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@solidjs/signals": patch
"solid-js": patch
"@solidjs/web": patch
---

The error hooks and `SSR_RENDER_ERROR_CONTAINED` tell where an error was thrown apart from where it was met.

`ownerPath` on `ClientErrorContext` and `ServerErrorContext` is now where the error was **thrown**: the labels root-first up the owner chain of the computation that threw — the component that broke — falling back to the boundary's chain when the throw crossed nothing the runtime could name. A new `boundaryPath` is where it was **met**: the same labels up the chain of the `<Errored>` that rendered its fallback (client and server) or the `<Loading>` that shipped the rejection (server, `handling: "client"`). Before, `ownerPath` was the boundary's on both sides, so every component under one boundary grouped into one path. On the client the engine's status wrapper already named the thrower (`StatusError.source`); on the server the owner scopes stamp it as the error escapes. The `SSR_RENDER_ERROR_CONTAINED` finding follows: `ownerPath` locates the throw, `data.boundary` / `data.boundaryPath` the boundary.
4 changes: 2 additions & 2 deletions documentation/solid-2.0/03-control-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ A fallback rendered is a failure handled — and, until now, one nothing outside
import { configureClientErrors } from "solid-js";

configureClientErrors({
onError(error, { ownerPath }) {
onError(error, { ownerPath, boundaryPath }) {
Sentry.captureException(error, { mechanism: { type: "solid.error_boundary", handled: true } });
}
});
Expand All @@ -162,7 +162,7 @@ render(() => <App />, root, undefined, { onError });

- Fires when an `Errored` (or `createErrorBoundary`) collected a failure and renders its fallback. An **uncaught** error is not this hook's: nothing contained it, the reactive system halts (`REACTIVITY_HALTED`), and the cause goes to the platform's `reportError` — `window.onerror`, the channel every monitor and every `addEventListener("error")` already listens on. One event, one channel.
- **Once per error object**: a `reset()` that recomputes the same failing node re-collects the same error and says nothing new; a primitive thrown has no identity and is reported per sight.
- `ownerPath` carries the component labels root-first where the runtime keeps owner names (the observe and dev artifacts; production owners carry none).
- `ownerPath` is where the error was **thrown** — labels root-first up the owner chain of the computation that threw (component labels and named primitives; the compiler's inner memos ride along by their default name) — and `boundaryPath` where it was **met**, the same labels up the `<Errored>`'s own chain: what broke, and what the user saw. Both where the runtime keeps owner names (the observe and dev artifacts; production owners carry none). When the throw crossed no computation the engine could name, `ownerPath` is the boundary's.
- No return: the client has no wire to map for. A throwing hook is reported on the console and ignored — a monitor never takes the app down.
- Pay-for-use: the hook machinery rides with `createErrorBoundary` or the app's own `configureClientErrors` import; a root's hook is parked on the root owner, so `render` retains nothing for an app that passes none.

Expand Down
2 changes: 1 addition & 1 deletion documentation/solid-2.0/08-dev-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ The server runtime reports on the same channel. Two groups, two tiers. **Finding
- "[SSR_RENDER_ERROR_CONTAINED] Render error in a <Loading> boundary — no boundary could contain it, the request failed: Error: …"
- "[SSR_RENDER_ERROR_CONTAINED] Render error outside any boundary — the request failed: Error: …"

Finding (`error`, observe + dev). A component threw during a server render and the runtime routed the error; `data.handling` says where it went: `"fallback"` — an `<Errored>` rendered its fallback and the response completed normally; `"client"` — the enclosing `<Loading>` fragment rejected and the client re-renders that subtree from scratch after hydration (the response completed, the user paid a client render); `"failed"` — nothing could contain it and the request failed through `onError`. `data.error` is the thrown value as thrown, `data.boundary` the boundary's hydration id, and `ownerPath` locates the boundary. This is the structured face of what `renderToStream`'s per-call `onError` receives: the same errors, on the process-wide channel an observer already subscribes to, with the containment named — so a fallback that renders on every request is visible without anyone watching a 200.
Finding (`error`, observe + dev). A component threw during a server render and the runtime routed the error; `data.handling` says where it went: `"fallback"` — an `<Errored>` rendered its fallback and the response completed normally; `"client"` — the enclosing `<Loading>` fragment rejected and the client re-renders that subtree from scratch after hydration (the response completed, the user paid a client render); `"failed"` — nothing could contain it and the request failed through `onError`. `data.error` is the thrown value as thrown; `ownerPath` locates where it was **thrown** — the labels up the owner chain the error escaped (the component that broke), falling back to the boundary's when the throw crossed no owner — and `data.boundary` / `data.boundaryPath` where it was **met**, the boundary's hydration id and its own labels (what the user saw). This is the structured face of what `renderToStream`'s per-call `onError` receives: the same errors, on the process-wide channel an observer already subscribes to, with the containment named — so a fallback that renders on every request is visible without anyone watching a 200.

#### `SSR_SUBTREE_ABANDONED`

Expand Down
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 @@ -150,7 +150,7 @@ Everything above is what the runtime does on its own. What it _handles_ — a fa
import { configureServerErrors } from "@solidjs/web";

configureServerErrors({
onError(error, { kind, handling, boundary, functionId, direct, ownerPath, event }) {
onError(error, { kind, handling, boundary, boundaryPath, functionId, direct, ownerPath, event }) {
Sentry.captureException(error, {
mechanism: { type: `solid.${kind}.${handling}`, handled: handling !== "failed" }
});
Expand All @@ -163,7 +163,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 `<Errored>` rendered its fallback; `boundary` is its hydration id, `ownerPath` the component labels 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), `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.
- **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.
Expand Down
9 changes: 6 additions & 3 deletions packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,12 @@ export class CollectionQueue extends Queue {
setSignal(this._error!, caught);
// The client error hook: this boundary renders its fallback for
// it — the one road a rendered failure took that no global handler
// ever saw. Once per error object; a `reset()` re-collecting the
// same failure says nothing new.
reportClientError(caught, this._owner);
// ever saw. `source` is the computation that threw (the status
// wrapper's, made at the first landing and kept downstream), so the
// hook hears where it broke as well as where it was met. Once per
// error object; a `reset()` re-collecting the same failure says
// nothing new.
reportClientError(caught, this._owner, source);
}
}
}
Expand Down
32 changes: 26 additions & 6 deletions packages/signals/src/core/error-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,20 @@ import type { Owner } from "./types.js";
* boundary carries none of this.
*/
export interface ClientErrorContext {
/** Component labels root-first, when the runtime keeps owner names. */
/**
* Where the error was THROWN: labels root-first up the owner chain of the
* computation that threw — component labels and named primitives — when
* the runtime keeps owner names (the observe and dev artifacts). The
* boundary's own chain when the thrower is unknown (a value thrown
* outside any computation).
*/
ownerPath?: string[];
/**
* Where the error was MET: the same labels up the chain of the `<Errored>`
* that rendered its fallback for it — what the user saw, as against
* `ownerPath`, which is what broke.
*/
boundaryPath?: string[];
}
export type ClientErrorHook = (error: unknown, context: ClientErrorContext) => void;

Expand Down Expand Up @@ -81,12 +93,18 @@ function labels(owner: Owner | null | undefined): string[] | undefined {
}

/**
* Tells the client error hook about `error`, caught by a boundary in
* `owner`'s tree — once per error object. A throwing hook is reported on the
* console and otherwise ignored — a monitor must never take the app down.
* Tells the client error hook about `error`, caught by the boundary whose
* owner is `owner`, thrown by `thrower` (the computation the engine's status
* wrapper named; unknown for a value that never crossed one) — once per
* error object. A throwing hook is reported on the console and otherwise
* ignored — a monitor must never take the app down.
* @internal
*/
export function reportClientError(error: unknown, owner: Owner | null | undefined): void {
export function reportClientError(
error: unknown,
owner: Owner | null | undefined,
thrower?: Owner | null
): void {
const isObject = error !== null && (typeof error === "object" || typeof error === "function");
if (isObject) {
if (reported.has(error as object)) return;
Expand All @@ -95,8 +113,10 @@ export function reportClientError(error: unknown, owner: Owner | null | undefine
const hook = hookFor(owner);
if (hook === undefined) return;
const context: ClientErrorContext = {};
const path = labels(owner);
const boundary = labels(owner);
const path = labels(thrower) ?? boundary;
if (path !== undefined) context.ownerPath = path;
if (boundary !== undefined) context.boundaryPath = boundary;
try {
hook(error, context);
} catch (hookError) {
Expand Down
20 changes: 14 additions & 6 deletions packages/signals/tests/client-error-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,13 @@ function boundaryOver(fail: () => boolean, boom: () => unknown) {
let result: unknown;
let reset!: () => void;
createRoot(() => {
const memo = createMemo(() => {
if (fail()) throw boom();
return "content";
});
const memo = createMemo(
() => {
if (fail()) throw boom();
return "content";
},
{ name: "view" }
);
const b = createErrorBoundary(
() => memo(),
(_err, r) => {
Expand Down Expand Up @@ -76,7 +79,9 @@ describe("caught", () => {
expect(view.value()).toBe("fallback");
expect(calls).toHaveLength(1);
expect(calls[0].error).toBe(boom);
expect(calls[0].context).toEqual({});
// Where it was thrown: the memo, by name. No labelled owner above it,
// so no boundary path either.
expect(calls[0].context).toEqual({ ownerPath: ["view"] });
});

it("a reset that recomputes the same failure says nothing new; a different error is a new report", () => {
Expand Down Expand Up @@ -145,7 +150,10 @@ describe("caught", () => {
flush();
expect(result).toBe("fallback");
expect(calls).toHaveLength(1);
expect(calls[0].context.ownerPath).toEqual(["<App>"]);
// Thrown inside the boundary's own computation under <App>; met by the
// boundary, whose chain is <App>.
expect(calls[0].context.ownerPath).toEqual(["<App>", "computed"]);
expect(calls[0].context.boundaryPath).toEqual(["<App>"]);
});
});

Expand Down
3 changes: 3 additions & 0 deletions packages/solid/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ export interface ServerErrorSite {
kind: "render" | "server-function";
handling: "fallback" | "client" | "failed" | "serialize" | "thrown" | "channel";
boundary?: string;
/** Where the error was thrown — labels root-first up the owner chain it escaped. */
ownerPath?: string[];
/** Where it was met — the labels up the chain of the boundary named by `boundary`. */
boundaryPath?: string[];
functionId?: string;
direct?: boolean;
/** The request event, when the caller has it in hand; else read from the request scope. */
Expand Down
9 changes: 6 additions & 3 deletions packages/solid/src/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
setContext,
runWithBoundaryErrorContext,
RevealGroupContext,
reportServerError
reportServerError,
throwerOf
} from "./signals.js";
import { OBSERVE, ownerPath } from "@solidjs/signals";
import { sharedConfig, NoHydrateContext } from "./shared.js";
Expand Down Expand Up @@ -224,9 +225,11 @@ function ssrLoadingBoundary(
? `— the fragment rejected and the client re-renders it: `
: `— no boundary could contain it, the request failed: `) +
errorText(err),
data: { handling, boundary: id, error: err }
data: { handling, boundary: id, boundaryPath: ownerPath(o), error: err }
},
o
// Located where it was THROWN (the owner it escaped), the boundary that
// met it in `data` — the same two facts the server error hook carries.
throwerOf(err) ?? o
);
};
let serializeBuffer: [string, any, boolean?][] = [];
Expand Down
46 changes: 42 additions & 4 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,32 @@ export function runWithOwner<T>(owner: Owner | null, fn: () => T): T {
currentOwner = owner as unknown as SSROwner | null;
try {
return fn();
} catch (error) {
stampThrower(error, owner);
throw error;
} finally {
currentOwner = prev;
}
}

/**
* Where an error was THROWN, for the server error hook and the findings: the
* innermost owner whose scope it escaped — stamped at the first owner scope
* it crosses (component bodies and effects run through `runWithOwner`, a
* memo's pull through its inlined twin), kept as it propagates to the
* boundary that meets it. Object errors only; a `NotReadyError` is the
* engine's own signal, not a failure.
*/
const throwers = new WeakMap<object, Owner>();
function stampThrower(error: unknown, owner: Owner | null): void {
if (owner === null || !isObject(error) || error instanceof NotReadyError) return;
if (!throwers.has(error)) throwers.set(error, owner);
}
/** @internal The owner `error` was stamped as thrown under, if it crossed one. */
export function throwerOf(error: unknown): Owner | undefined {
return isObject(error) ? throwers.get(error) : undefined;
}

export function getOwner(): Owner | null {
return currentOwner as unknown as Owner | null;
}
Expand Down Expand Up @@ -1085,6 +1106,7 @@ function createSyncMemo<T>(
return value;
} catch (err) {
if (err instanceof NotReadyError) throw err; // don't latch — engine re-pulls
stampThrower(err, owner);
error = err;
errored = true;
cached = true;
Expand Down Expand Up @@ -2720,7 +2742,14 @@ export interface ServerErrorSite {
kind: "render" | "server-function";
handling: "fallback" | "client" | "failed" | "serialize" | "thrown" | "channel";
boundary?: string;
/**
* Where the error was THROWN: component labels root-first up the owner
* chain it escaped (the observe and dev builds label owners). The
* boundary's chain when the thrower is unknown.
*/
ownerPath?: string[];
/** Where it was MET: the labels up the chain of the boundary named by `boundary`. */
boundaryPath?: string[];
functionId?: string;
direct?: boolean;
/** The request event, when the caller has it in hand; else read from the request scope. */
Expand Down Expand Up @@ -2783,10 +2812,13 @@ export function reportServerError(
const target = hook ?? (ctx && ctx.errorPolicy) ?? ambientServerErrorHook();
if (target === undefined) return { mapped: false };
const context: ServerErrorSite = { ...site };
if (context.ownerPath === undefined && subject) {
const path = ownerLabels(subject);
const boundary = subject ? ownerLabels(subject) : undefined;
if (context.ownerPath === undefined) {
const thrower = throwerOf(value);
const path = (thrower !== undefined ? ownerLabels(thrower) : undefined) ?? boundary;
if (path !== undefined) context.ownerPath = path;
}
if (context.boundaryPath === undefined && boundary !== undefined) context.boundaryPath = boundary;
if (context.event === undefined) {
const event = currentRequestEvent();
if (event !== undefined) context.event = event;
Expand Down Expand Up @@ -2990,9 +3022,15 @@ export function createErrorBoundary<T, U>(
kind: "ssr",
severity: "error",
message: `[SSR_RENDER_ERROR_CONTAINED] Render error caught by <Errored>: ${text}`,
data: { handling: "fallback", boundary: owner.id, error: err }
data: {
handling: "fallback",
boundary: owner.id,
boundaryPath: ownerLabels(owner),
error: err
}
},
owner
// Located where it was THROWN, the boundary that met it in `data`.
throwerOf(err) ?? owner
);
};
// The finding first, with the original; then the one value the client may
Expand Down
Loading
Loading