From 420fd904bc24246c1ef9b7ca9217f168472a86fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:35:22 +0000 Subject: [PATCH 1/3] fix(signals): a pending error-boundary output read only behind fallbacks holds nothing (#4638) Two sibling `` boundaries, one reading its async through an error boundary built in the component body: after a `sel` write neither showed its fallback. The error boundary's output passes the pending by (A6) and is a CONFIG_REDERIVE frame reader in `blocked`; it is created outside the Loading, so `hiddenBy` never saw the fallback its only reader is behind, and it held the transaction the sibling's forwarded pending opened. A boundary output is transparent: hidden when every reader of it is (F-4, D-14). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- .../loading-on-errorboundary-sibling-4638.md | 5 + packages/signals/docs/RULES-INDEX.md | 2 +- packages/signals/src/boundaries.ts | 9 +- ...ding-on-errorboundary-sibling-4638.test.ts | 141 ++++++++++++++++++ ...ing-on-errorboundary-sibling-4638.spec.tsx | 77 ++++++++++ 5 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 .changeset/loading-on-errorboundary-sibling-4638.md create mode 100644 packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts create mode 100644 packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx diff --git a/.changeset/loading-on-errorboundary-sibling-4638.md b/.changeset/loading-on-errorboundary-sibling-4638.md new file mode 100644 index 000000000..9f7840b1a --- /dev/null +++ b/.changeset/loading-on-errorboundary-sibling-4638.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A `` re-arm whose content reads its async through an error boundary built outside the `` shows its fallback with the change's frame again, beside a sibling re-armed boundary (#4638). A pending error-boundary output read only behind fallbacks no longer holds the frame. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index d8be818a3..d88f49340 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -50,7 +50,7 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:337` | async.ts×1 core.ts×2 | derived-write-then-derivation-3733.test.ts×5 equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | | A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:345` | — | derived-write-then-derivation-3733.test.ts×1 equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | | A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:353` | — | derived-write-then-derivation-3733.test.ts×1 errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | -| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:361` | boundaries.ts×3 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | +| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:361` | boundaries.ts×4 | derived-write-then-derivation-3733.test.ts×1 enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | | A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:167` | seat.ts×1 truth.ts×1 verdict.ts×1 | fuzz-findings-l2.test.ts×1 latest-uninitialized-over-latest-4362.test.ts×2 spec-async-semantics.test.ts×2 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×1 visibility-oracle.test.ts×1 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amende… | | A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:175` | — | create-deferred-3710.test.ts×1 createMemo.test.ts×1 visibility-oracle-store.states.ts×1 visibility-oracle.states.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | | A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:183` | async.ts×1 projection.ts×1 target.ts×1 | spec-async-semantics.test.ts×3 visibility-oracle-store.states.ts×4 visibility-oracle-store.test.ts×1 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 02914b23f..8d23a49df 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -302,7 +302,14 @@ function armedOver(node: Computed): boolean | undefined { function hiddenBy(r: Computed): Boundary | undefined { for (let b: Boundary | null | undefined = boundaryOf(r); b; b = b._parent) if (b._fallback) return b; - return undefined; + // A boundary output passing a pending by (CONFIG_REDERIVE: an error + // boundary, A6) is transparent: on screen only through its readers. Read + // only behind fallbacks — an error boundary built outside the `` + // whose content reads it — it holds nothing (F-4, D-14, #4638). + let h: Boundary | undefined; + if (r._config & CONFIG_REDERIVE && !(r as any)._type) + for (let s = r._subs; s !== null; s = s._nextSub) if (!(h = hiddenBy(s._sub))) return; + return h; } /** `heldRunHook` (scheduler.ts): a queued run behind a fallback waits for the reveal diff --git a/packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts b/packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts new file mode 100644 index 000000000..ad885d6cf --- /dev/null +++ b/packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts @@ -0,0 +1,141 @@ +/** + * #4638 — two sibling `` boundaries; one reads its async + * memo through an error boundary created in the component body (outside the + * Loading, as a value-level catch), the other reads a plain async memo keyed + * by the same signal. After the key changes, both re-arm and both fallbacks + * show now (F-4, F-5, D-14, D-15): the error boundary is a derivation, not a + * render effect, and its only reader is under the first Loading, so nothing + * outside either boundary reads the flights (D-16 does not apply). + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + createErrorBoundary, + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + untrack +} from "../src/index.js"; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +/** A DOM-like part: a render effect under the owner writes its text. */ +function part(read: () => string) { + const el = { text: "-" }; + createRenderEffect(read, v => { + el.text = v; + }); + return el; +} + +function page(caught: boolean, sibling: boolean, header = false) { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise(res => asks.push({ key, res })); + const view: { + header: string; + detail: { text: string } | null; + comments: { text: string } | null; + } = { header: "-", detail: null, comments: null }; + const [sel, setSel] = createSignal(3); + let dispose!: () => void; + createRoot(d => { + dispose = d; + const detail = createMemo(() => ask(`detail:${sel()}`), { name: "detail" }); + const shown = caught + ? untrack(() => + createErrorBoundary( + () => detail(), + () => null + ) + ) + : detail; + if (header) + createRenderEffect( + () => (shown() as any)?.title, + v => { + view.header = String(v); + } + ); + const comments = createMemo(() => ask(`comments:${sel()}`), { name: "comments" }); + const a = untrack(() => + createLoadingBoundary( + () => part(() => (shown() as any)?.title), + () => part(() => `loading #${sel()}`), + { on: () => sel() } + ) + ); + createRenderEffect(a, v => { + view.detail = v; + }); + if (sibling) { + const b = untrack(() => + createLoadingBoundary( + () => part(() => `comments ${(comments() as any[]).length}`), + () => part(() => "loading comments"), + { on: () => sel() } + ) + ); + createRenderEffect(b, v => { + view.comments = v; + }); + } + }); + const settle = async (key: string, v: any) => { + const i = asks.findIndex(a => a.key === key); + if (i >= 0) asks.splice(i, 1)[0].res(v); + for (let k = 0; k < 4; k++) { + await vi.advanceTimersByTimeAsync(0); + flush(); + } + }; + const t = (el: { text: string } | null) => (el ? el.text : "-"); + const screen = () => + (header ? `${view.header} / ` : "") + + (sibling ? `${t(view.detail)} | ${t(view.comments)}` : t(view.detail)); + return { setSel, settle, screen, dispose }; +} + +describe("#4638 sibling Loading on through an error boundary", () => { + for (const caught of [false, true]) + for (const sibling of [false, true]) + test(`${caught ? "error boundary" : "plain"}${sibling ? " + sibling" : ""}: both fallbacks show now`, async () => { + const p = page(caught, sibling); + flush(); + await p.settle("detail:3", { title: "Crash" }); + await p.settle("comments:3", [1]); + expect(p.screen()).toBe(sibling ? "Crash | comments 1" : "Crash"); + p.setSel(1); + flush(); + await p.settle("", null); + expect(p.screen()).toBe(sibling ? "loading #1 | loading comments" : "loading #1"); + await p.settle("detail:1", { title: "One" }); + expect(p.screen()).toBe(sibling ? "One | loading comments" : "One"); + await p.settle("comments:1", [1, 2]); + expect(p.screen()).toBe(sibling ? "One | comments 2" : "One"); + p.dispose(); + }); + + // D-16: the error boundary also read by a render effect outside the + // Loading (a header) is an outside reader of the flight the change + // started — the frame waits on it, and the re-arms' fallbacks are never + // seen; the frame lands with `detail`. + test("error boundary also read outside (D-16): the frame holds until detail lands", async () => { + const p = page(true, true, true); + flush(); + await p.settle("detail:3", { title: "Crash" }); + await p.settle("comments:3", [1]); + expect(p.screen()).toBe("Crash / Crash | comments 1"); + p.setSel(1); + flush(); + await p.settle("", null); + expect(p.screen()).toBe("Crash / Crash | comments 1"); + await p.settle("detail:1", { title: "One" }); + expect(p.screen()).toBe("One / One | loading comments"); + await p.settle("comments:1", [1, 2]); + expect(p.screen()).toBe("One / One | comments 2"); + p.dispose(); + }); +}); diff --git a/packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx b/packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx new file mode 100644 index 000000000..19c7cd6e6 --- /dev/null +++ b/packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx @@ -0,0 +1,77 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * #4638 (F-4, F-5, D-14, D-15): two sibling `` + * boundaries. One reads its async memo through an error boundary built in the + * component body (a value-level catch, `createErrorBoundary`), the other a + * plain async memo keyed by the same signal. A `sel` write re-arms both: the + * error boundary is not a render effect and its only reader is under the + * first Loading, so nothing outside either boundary reads the new flights + * (D-16 does not apply) and both fallbacks show with the change's frame. + * The signals-only twin: `packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts`. + */ +import { expect, test } from "vitest"; +import { Loading, createErrorBoundary, createMemo, createSignal, flush, untrack } from "solid-js"; +import { render } from "../src/index.js"; + +const tick = async () => { + for (let i = 0; i < 4; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + +for (const caught of [true, false]) + test(`switch key (${caught ? "error boundary" : "plain"}): both fallbacks show now`, async () => { + const asks: { key: string; res: (v: any) => void }[] = []; + const ask = (key: string) => new Promise(res => asks.push({ key, res })); + const settle = async (key: string, v: any) => { + asks + .splice( + asks.findIndex(a => a.key === key), + 1 + )[0] + .res(v); + await tick(); + }; + const c = document.body.appendChild(document.createElement("div")); + let select!: (n: number) => void; + const dispose = render(() => { + const [sel, setSel] = createSignal(3); + select = setSel; + const detail = createMemo(() => ask(`detail:${sel()}`)); + const shown = caught + ? untrack(() => + createErrorBoundary( + () => detail(), + () => null + ) + ) + : detail; + const comments = createMemo(() => ask(`comments:${sel()}`)); + return ( + <> + loading #{sel()}}> + {(shown() as any)?.title} + + loading comments}> + comments {(comments() as any[]).length} + + + ); + }, c); + await tick(); + await settle("detail:3", { title: "Crash" }); + await settle("comments:3", [1]); + expect(c.textContent).toBe("Crash comments 1"); + select(1); + await tick(); + expect(c.textContent).toBe("loading #1 loading comments"); + await settle("detail:1", { title: "One" }); + expect(c.textContent).toBe("One loading comments"); + await settle("comments:1", [1, 2]); + expect(c.textContent).toBe("One comments 2"); + dispose(); + c.remove(); + }); From 5ab7f9c2b3be3823a9f843e79639f3a9c93e7c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:42:14 +0000 Subject: [PATCH 2/3] test(web): import createErrorBoundary from solid-js/internal in the #4638 spec The public entry keeps createErrorBoundary @internal, so its types live on solid-js/internal; the type-check step failed with TS2305. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- .../web/test/loading-on-errorboundary-sibling-4638.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx b/packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx index 19c7cd6e6..b2a585342 100644 --- a/packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx +++ b/packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx @@ -12,7 +12,8 @@ * The signals-only twin: `packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts`. */ import { expect, test } from "vitest"; -import { Loading, createErrorBoundary, createMemo, createSignal, flush, untrack } from "solid-js"; +import { Loading, createMemo, createSignal, flush, untrack } from "solid-js"; +import { createErrorBoundary } from "solid-js/internal"; import { render } from "../src/index.js"; const tick = async () => { From 0b908584113551dfe88593e099dbd74073047b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:35:05 +0000 Subject: [PATCH 3/3] perf(signals): hiddenBy walks readers with a while loop (-13 B minified) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE --- packages/signals/src/boundaries.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 0209b5ddd..37e837c66 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -314,9 +314,10 @@ function hiddenBy(r: Computed): Boundary | undefined { // boundary, A6) is transparent: on screen only through its readers. Read // only behind fallbacks — an error boundary built outside the `` // whose content reads it — it holds nothing (F-4, D-14, #4638). - let h: Boundary | undefined; + let h: Boundary | undefined, + s = r._subs; if (r._config & CONFIG_REDERIVE && !(r as any)._type) - for (let s = r._subs; s !== null; s = s._nextSub) if (!(h = hiddenBy(s._sub))) return; + while (s !== null && (h = hiddenBy(s._sub))) s = s._nextSub; return h; }