diff --git a/.changeset/error-hook-thrower-path.md b/.changeset/error-hook-thrower-path.md new file mode 100644 index 000000000..2d1d9a4ad --- /dev/null +++ b/.changeset/error-hook-thrower-path.md @@ -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 `` that rendered its fallback (client and server) or the `` 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. diff --git a/documentation/solid-2.0/03-control-flow.md b/documentation/solid-2.0/03-control-flow.md index 94adccdfb..ebe692792 100644 --- a/documentation/solid-2.0/03-control-flow.md +++ b/documentation/solid-2.0/03-control-flow.md @@ -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 } }); } }); @@ -162,7 +162,7 @@ render(() => , 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 ``'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. diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index 076743986..16b85c87c 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -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 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 `` rendered its fallback and the response completed normally; `"client"` — the enclosing `` 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 `` rendered its fallback and the response completed normally; `"client"` — the enclosing `` 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` diff --git a/documentation/solid-2.0/12-ssr-http.md b/documentation/solid-2.0/12-ssr-http.md index 39db57322..9b43d52df 100644 --- a/documentation/solid-2.0/12-ssr-http.md +++ b/documentation/solid-2.0/12-ssr-http.md @@ -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" } }); @@ -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 `` rendered its fallback; `boundary` is its hydration id, `ownerPath` the component labels 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). `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 `` 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/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 035fe649d..009efadd8 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -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); } } } diff --git a/packages/signals/src/core/error-hooks.ts b/packages/signals/src/core/error-hooks.ts index 193d178e7..301d638fc 100644 --- a/packages/signals/src/core/error-hooks.ts +++ b/packages/signals/src/core/error-hooks.ts @@ -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 `` + * 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; @@ -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; @@ -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) { diff --git a/packages/signals/tests/client-error-hook.test.ts b/packages/signals/tests/client-error-hook.test.ts index 8ecc503d0..a752986c4 100644 --- a/packages/signals/tests/client-error-hook.test.ts +++ b/packages/signals/tests/client-error-hook.test.ts @@ -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) => { @@ -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", () => { @@ -145,7 +150,10 @@ describe("caught", () => { flush(); expect(result).toBe("fallback"); expect(calls).toHaveLength(1); - expect(calls[0].context.ownerPath).toEqual([""]); + // Thrown inside the boundary's own computation under ; met by the + // boundary, whose chain is . + expect(calls[0].context.ownerPath).toEqual(["", "computed"]); + expect(calls[0].context.boundaryPath).toEqual([""]); }); }); diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index e7eafae2d..569ac3aab 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -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. */ diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 3abb53de9..4fc22cc21 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -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"; @@ -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?][] = []; diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index b8de5f4a7..a6788de06 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -295,11 +295,32 @@ export function runWithOwner(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(); +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; } @@ -1085,6 +1106,7 @@ function createSyncMemo( 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; @@ -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. */ @@ -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; @@ -2990,9 +3022,15 @@ export function createErrorBoundary( kind: "ssr", severity: "error", message: `[SSR_RENDER_ERROR_CONTAINED] Render error caught by : ${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 diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 44d54aa62..e57de38d0 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -5082,10 +5082,12 @@ export const RequestContext: unique symbol = Symbol.for("solid.RequestContext") * 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 - * findings use; `ownerPath` the component labels root-first, when the - * compiler emitted them; `functionId`/`direct` name the server function and - * whether it was an in-process call during SSR; `event` the request, when - * the failure happened inside one. + * 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 + * boundary's chain (what broke, and what the user saw); `functionId`/`direct` + * name the server function and whether it was an in-process call during + * SSR; `event` the request, when the failure happened inside one. */ export interface ServerErrorContext extends Omit { event?: RequestEvent; diff --git a/packages/web/test/client-error-hook.spec.tsx b/packages/web/test/client-error-hook.spec.tsx index 95d0dce3a..7c7395e10 100644 --- a/packages/web/test/client-error-hook.spec.tsx +++ b/packages/web/test/client-error-hook.spec.tsx @@ -43,10 +43,13 @@ describe("render's onError", () => { const [fail, setFail] = createSignal(false); function Widget() { // A reactive read: the component body runs once, the memo re-throws. - const text = createMemo(() => { - if (fail()) throw boom; - return "content"; - }); + const text = createMemo( + () => { + if (fail()) throw boom; + return "content"; + }, + { name: "text" } + ); return

{text()}

; } function App() { @@ -70,8 +73,17 @@ describe("render's onError", () => { expect(container.textContent).toBe("fallback"); expect(calls).toHaveLength(1); expect(calls[0].error).toBe(boom); - // The dev tier labels component owners; the boundary's is the innermost. - expect(calls[0].context.ownerPath).toEqual(["", ""]); + // Where it broke: the memo that threw, under its component (the + // compiler's inner memos ride along by their default name); where it + // was met: the boundary. The dev tier labels component owners. + expect(calls[0].context.ownerPath).toEqual([ + "", + "", + "computed", + "", + "text" + ]); + expect(calls[0].context.boundaryPath).toEqual(["", ""]); }); test("the root's hook wins over the ambient one; a root without one reports ambiently", () => { diff --git a/packages/web/test/server/server-boundary-records.spec.tsx b/packages/web/test/server/server-boundary-records.spec.tsx index 6c513a4da..393ffcb88 100644 --- a/packages/web/test/server/server-boundary-records.spec.tsx +++ b/packages/web/test/server/server-boundary-records.spec.tsx @@ -296,7 +296,10 @@ describe("the other outcomes", () => { const findings = capture.events.filter(e => e.code === "SSR_RENDER_ERROR_CONTAINED"); expect(findings.length).toBeGreaterThanOrEqual(1); expect(findings[0].data!.boundary).toBe(event.id); - expect(findings[0].ownerPath).toEqual(event.ownerPath); + // The record is the boundary's; the finding locates the throw (the + // component under it) and names the boundary's own path in `data`. + expect(findings[0].data!.boundaryPath).toEqual(event.ownerPath); + expect(findings[0].ownerPath).toEqual([...event.ownerPath!, ""]); } finally { capture.stop(); error.mockRestore(); diff --git a/packages/web/test/server/server-diagnostics.spec.tsx b/packages/web/test/server/server-diagnostics.spec.tsx index 75be44b52..242bd09be 100644 --- a/packages/web/test/server/server-diagnostics.spec.tsx +++ b/packages/web/test/server/server-diagnostics.spec.tsx @@ -96,10 +96,11 @@ describe("SSR_RENDER_ERROR_CONTAINED (wiring)", () => { expect((event.data!.error as Error).message).toBe("bad render"); // The server `createComponent` labels its owner (``), the same // field and walk as the client — and, as on the client, the compiled - // `` is a component call too, so the boundary that caught the - // error is the innermost label (the finding is the boundary's, raised - // from its owner; the failed `` owner is already gone). - expect(event.ownerPath).toEqual(["", ""]); + // `` is a component call too. The finding locates where the + // error was THROWN (the owner scope it escaped, stamped as it did) and + // names the boundary that met it in `data.boundaryPath`. + expect(event.ownerPath).toEqual(["", "", ""]); + expect(event.data!.boundaryPath).toEqual(["", ""]); // Wiring in the dev tier: the channel got it AND the console face // reported it once, with the location — a developer sees the contained // error `renderToStream`'s `onError` never hears. (The observe tier @@ -132,7 +133,8 @@ describe("SSR_RENDER_ERROR_CONTAINED (wiring)", () => { expect(events.every(e => e.data!.handling === "client")).toBe(true); expect(events[0].message).toContain("the fragment rejected and the client re-renders it"); expect(events[0].message).toContain("late-boom"); - expect(events[0].ownerPath).toEqual(["", ""]); + expect(events[0].ownerPath).toEqual(["", "", ""]); + expect(events[0].data!.boundaryPath).toEqual(["", ""]); expect(typeof events[0].data!.boundary).toBe("string"); }); diff --git a/packages/web/test/server/server-error-hook.spec.tsx b/packages/web/test/server/server-error-hook.spec.tsx index 766c185c2..284d93816 100644 --- a/packages/web/test/server/server-error-hook.spec.tsx +++ b/packages/web/test/server/server-error-hook.spec.tsx @@ -128,10 +128,12 @@ describe(" during SSR", () => { expect(calls).toHaveLength(1); expect(calls[0].error).toBe(boom); + // Thrown in , met by the : both facts, apart. expect(calls[0].context).toMatchObject({ kind: "render", handling: "fallback", - ownerPath: ["", ""] + ownerPath: ["", "", ""], + boundaryPath: ["", ""] }); expect(typeof calls[0].context.boundary).toBe("string"); expect(calls[0].context.event).toMatchObject({ locals: {} }); @@ -222,10 +224,12 @@ describe(" fragments and the failed request", () => { const html = await stream(() => ); expect(calls).toHaveLength(1); expect(calls[0].error).toBe(boom); + // Thrown by the async source in , met by the that ships it. expect(calls[0].context).toMatchObject({ kind: "render", handling: "client", - ownerPath: ["", ""] + ownerPath: ["", "", ""], + boundaryPath: ["", ""] }); expect(typeof calls[0].context.boundary).toBe("string"); // The fragment's `_fr` rejection carries the mapping — the boundary diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 6c7b563ce..9d1dc9be5 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -935,6 +935,11 @@ module.exports = [ // signals: core +13 B, +createStore -38 B, full bundle -72 B; brotli on the // pure-signals fixtures -4 / -29 / -5 B. This scenario's esbuild bundle // measured at 20,054 B rebased over #3507, against `next`'s 20,044 (+10 B). + // Error hook: where thrown, apart from where met (2026-09-17): measured + // at 20,098 B rebased over #3515, against `next`'s 20,054 (+44 B). + // `reportClientError` takes the thrower the status wrapper already names + // and fills `boundaryPath` beside `ownerPath`; this scenario renders + // , so it carries the hook module. limit: "20.10 KB", modifyEsbuildConfig }, @@ -1129,7 +1134,9 @@ module.exports = [ // measured at 30,107 B rebased over #3513 (+66 B); the solid-js createRoot / // resume-window claim gate, see the hydrating (no stores) note. 0 B in the // signals floor. - limit: "30.15 KB", + // Error hook thrower/boundary paths (2026-09-17): 30.15 -> 30.25 KB, + // measured at 30,203 B rebased over #3515, against `next`'s 30,130 (+73 B). + limit: "30.25 KB", modifyEsbuildConfig }, { @@ -1355,7 +1362,9 @@ module.exports = [ // One `unflushed` for signal and store (spec O4, 2026-09-16): 16,770 B (+20 over // the cap); CONFIG_ADOPTED_UNFLUSHED set at adoption, cleared by the carrying // flush; +56 B minified in the signals floor (25,193 -> 25,249). - limit: "16.80 KB", + // Error hook thrower/boundary paths (2026-09-17): 16.80 -> 16.85 KB, + // measured at 16,804 B rebased over #3515, against `next`'s 16,752 (+52 B). + limit: "16.85 KB", modifyEsbuildConfig: observeEsbuildConfig }, {