From d1696a0e403e2e4329f62b16c7437a702f95f356 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 3 Nov 2026 06:37:33 +0000
Subject: [PATCH 1/5] fix(signals): an Errored heals when nothing reads the
failed source any more (#4644)
An error boundary waits on the source that threw. When the reader it caught
was its own tree (the error reached it only through memos, e.g. a Show/Match
condition), the tree re-ran clean after navigating away but prune kept it
because the source itself was still errored. A source read by nothing now
settles: no live reader holds the error (D-39, F-3, F-12).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
---
.changeset/errored-heal-memo-reader.md | 5 +
packages/signals/src/boundaries.ts | 6 +-
.../errored-heal-memo-reader-4644.test.ts | 81 ++++++++++++++++
.../errored-heal-memo-reader-4644.spec.tsx | 97 +++++++++++++++++++
4 files changed, 187 insertions(+), 2 deletions(-)
create mode 100644 .changeset/errored-heal-memo-reader.md
create mode 100644 packages/signals/tests/errored-heal-memo-reader-4644.test.ts
create mode 100644 packages/web/test/errored-heal-memo-reader-4644.spec.tsx
diff --git a/.changeset/errored-heal-memo-reader.md b/.changeset/errored-heal-memo-reader.md
new file mode 100644
index 000000000..b74e58115
--- /dev/null
+++ b/.changeset/errored-heal-memo-reader.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/signals": patch
+---
+
+An `` whose error was read only by memos (a ``/`` condition) heals once nothing reads the failed source any more, instead of staying on its fallback after navigating to another branch (#4644).
diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts
index b1aaf7bfd..197439fbd 100644
--- a/packages/signals/src/boundaries.ts
+++ b/packages/signals/src/boundaries.ts
@@ -362,7 +362,9 @@ function caughtBelow(b: Boundary, r: Computed, s: Computed): boolean {
}
/** Drop the readers that settled or died. Settled: a loading reader no
- * longer pending; an error's source neither errored nor in flight. A reader
+ * longer pending; an error's source neither errored nor in flight, or read by
+ * nothing — a reader that caught it and re-ran clean (the tree, a memo-only
+ * read gated away) no longer holds it (D-39, #4644). A reader
* that landed but is held (its value staged in a transaction) is not: the
* content reveals at the commit, with its run. The tree is: the output reads
* it and enters the transaction itself (its committed value shows until the
@@ -392,7 +394,7 @@ export function prune(b: Boundary, pass?: boolean | 0): number {
// settled before it ran, a fresh boundary revealed into a hold its
// pass then went pending in, which waited on that flight (#4568).
if (
- ((r._statusFlags | s._statusFlags) & mask &&
+ ((r._statusFlags | (s._subs ? s._statusFlags : 0)) & mask &&
!(mask & STATUS_ERROR && caughtBelow(b, r, s))) ||
(mask & STATUS_ERROR && s._config & CONFIG_STAGED_ERROR) ||
(r._config & CONFIG_VERDICT && r._flags & REACTIVE_IN_HEAP)
diff --git a/packages/signals/tests/errored-heal-memo-reader-4644.test.ts b/packages/signals/tests/errored-heal-memo-reader-4644.test.ts
new file mode 100644
index 000000000..2c4608142
--- /dev/null
+++ b/packages/signals/tests/errored-heal-memo-reader-4644.test.ts
@@ -0,0 +1,81 @@
+import { expect, it } from "vitest";
+import {
+ createErrorBoundary,
+ createMemo,
+ createRenderEffect,
+ createRoot,
+ createSignal,
+ flush
+} from "../src/index.js";
+
+// #4644 (D-39, F-3, F-12): a fallback no live reader holds heals. The shape:
+// `{X}
+// `, `rows` an async memo owned outside the boundary that rejects.
+// X = "memo-only": `rows` is read only by memos (a `` condition)
+// whose value the boundary's tree flattens, so the reader the boundary
+// caught is its own tree. X = "effect": a render effect in the branch reads
+// it. Navigating away disposes every reader of `rows`: nothing on the new
+// branch reads the error, so the boundary shows it, as a fresh render with
+// the same inputs would. With another live reader of `rows` under the same
+// boundary ("kept"), the error stays up.
+const tick = async () => {
+ for (let i = 0; i < 3; i++) {
+ await new Promise(r => setTimeout(r, 0));
+ flush();
+ }
+};
+
+for (const shape of ["memo-only", "effect", "kept"] as const)
+ it(`${shape}: navigating away from a failed memo (#4644)`, async () => {
+ let reject!: (e: Error) => void;
+ const [page, setPage] = createSignal("list");
+ const frames: unknown[] = [];
+ const dispose = createRoot(d => {
+ const rows = createMemo(
+ () =>
+ new Promise((_, r) => {
+ reject = r;
+ })
+ );
+ const view = createErrorBoundary(
+ () => {
+ if (shape === "kept")
+ createRenderEffect(
+ () => rows().length,
+ () => {}
+ );
+ const onList = createMemo(() => page() === "list");
+ return createMemo(() => {
+ if (!onList()) return "about";
+ if (shape === "effect") {
+ createRenderEffect(
+ () => rows().length,
+ () => {}
+ );
+ return "list";
+ }
+ const cond = createMemo(() => rows().length === 0);
+ return createMemo(() => (cond() ? "none" : "some"));
+ });
+ },
+ () => "ERR"
+ );
+ createRenderEffect(
+ () => view(),
+ v => {
+ frames.push(v);
+ }
+ );
+ return d;
+ });
+ flush();
+ await tick();
+ reject(new Error("boom"));
+ await tick();
+ expect(frames[frames.length - 1]).toBe("ERR");
+ setPage("about");
+ flush();
+ await tick();
+ dispose();
+ expect(frames[frames.length - 1]).toBe(shape === "kept" ? "ERR" : "about");
+ });
diff --git a/packages/web/test/errored-heal-memo-reader-4644.spec.tsx b/packages/web/test/errored-heal-memo-reader-4644.spec.tsx
new file mode 100644
index 000000000..6aa00f472
--- /dev/null
+++ b/packages/web/test/errored-heal-memo-reader-4644.spec.tsx
@@ -0,0 +1,97 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ */
+// #4644 (D-39): an whose fallback no live reader holds heals. A
+// list page reads a failed async memo and the boundary shows its fallback;
+// navigating to another branch under the same boundary disposes every reader
+// of the memo, so the new branch shows. A reader that is only a memo — the
+// condition of a or — used to keep the boundary stuck.
+import { expect, test } from "vitest";
+import { render } from "@solidjs/web";
+import {
+ createMemo,
+ createSignal,
+ Errored,
+ flush,
+ For,
+ Loading,
+ Match,
+ Show,
+ Switch,
+ type JSX
+} from "solid-js";
+
+const tick = async () => {
+ for (let i = 0; i < 2; i++) {
+ await new Promise(r => setTimeout(r, 0));
+ flush();
+ }
+};
+type Row = { id: number };
+
+const readers: Record Row[]) => JSX.Element> = {
+ "Show when={rows().length === 0}": rows => (
+ {rows().length} rows
}>
+ no rows
+
+ ),
+ "Show when={rows()}": rows => {r => {r().length} rows
},
+ "Show keyed": rows => (
+
+ {r => {r.length} rows
}
+
+ ),
+ "Switch/Match": rows => (
+ {rows().length} rows}>
+
+ no rows
+
+
+ ),
+ "memo of the condition": rows => {
+ const empty = createMemo(() => rows().length === 0);
+ return {empty() ? "none" : "some"}
;
+ },
+ text: rows => {rows().length} rows
,
+ For: rows => (
+
+ )
+};
+
+for (const withLoading of [true, false])
+ for (const [name, view] of Object.entries(readers))
+ test(`${withLoading ? "Loading" : "no Loading"} | ${name}: navigating away from a failed list heals (#4644)`, async () => {
+ let rejectList!: (e: Error) => void;
+ let setPage!: (p: string) => void;
+ const el = document.createElement("div");
+ const dispose = render(() => {
+ const [page, set] = createSignal("list");
+ setPage = set;
+ const rows = createMemo(
+ () =>
+ new Promise((_, reject) => {
+ rejectList = reject;
+ })
+ );
+ return (
+ could not load}>
+ about page}>
+ {withLoading ? loading}>{view(rows)} : view(rows)}
+
+
+ );
+ }, el);
+ await tick();
+ rejectList(new Error("boom"));
+ await tick();
+ const failed = el.textContent;
+ setPage("about");
+ flush();
+ await tick();
+ const after = el.textContent;
+ dispose();
+ expect({ failed, after }).toEqual({ failed: "could not load", after: "about page" });
+ });
From 1262f02e4db429a55506bc43804c8b0dfc1e3d84 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 3 Nov 2026 06:43:50 +0000
Subject: [PATCH 2/5] test(web): import JSX from @solidjs/web in the #4644 spec
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
---
packages/web/test/errored-heal-memo-reader-4644.spec.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/packages/web/test/errored-heal-memo-reader-4644.spec.tsx b/packages/web/test/errored-heal-memo-reader-4644.spec.tsx
index 6aa00f472..1505c4b8d 100644
--- a/packages/web/test/errored-heal-memo-reader-4644.spec.tsx
+++ b/packages/web/test/errored-heal-memo-reader-4644.spec.tsx
@@ -8,7 +8,7 @@
// of the memo, so the new branch shows. A reader that is only a memo — the
// condition of a or — used to keep the boundary stuck.
import { expect, test } from "vitest";
-import { render } from "@solidjs/web";
+import { render, type JSX } from "@solidjs/web";
import {
createMemo,
createSignal,
@@ -18,8 +18,7 @@ import {
Loading,
Match,
Show,
- Switch,
- type JSX
+ Switch
} from "solid-js";
const tick = async () => {
From d36248e4865edce6d3f7f970e708df7a7c605180 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 3 Nov 2026 08:41:39 +0000
Subject: [PATCH 3/5] fix(signals): only readers the Errored caught keep its
fallback up
prune kept an error boundary's reader while the source that threw was
errored and subscribed by anything at all. A memo hoisted outside the
boundary, a derived store or projection, or a reader under another
Errored kept the source subscribed, so the fallback stayed up after
every reader under the boundary was gone. D-39: the boundary waits on
what threw only while a reader that caught the error is alive. The
source now keeps the reader only while that reader still reads it,
directly or through derivations (#4644).
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
---
.changeset/errored-heal-memo-reader.md | 2 +-
packages/signals/src/boundaries.ts | 19 ++-
.../errored-heal-memo-reader-4644.test.ts | 113 ++++++++++++++++++
.../errored-heal-memo-reader-4644.spec.tsx | 67 +++++++++++
4 files changed, 196 insertions(+), 5 deletions(-)
diff --git a/.changeset/errored-heal-memo-reader.md b/.changeset/errored-heal-memo-reader.md
index b74e58115..7a740dde8 100644
--- a/.changeset/errored-heal-memo-reader.md
+++ b/.changeset/errored-heal-memo-reader.md
@@ -2,4 +2,4 @@
"@solidjs/signals": patch
---
-An `` whose error was read only by memos (a ``/`` condition) heals once nothing reads the failed source any more, instead of staying on its fallback after navigating to another branch (#4644).
+An `` heals once no reader it caught still reads the failed source, instead of staying on its fallback after navigating to another branch (#4644). This covers an error read only by memos (a ``/`` condition), and a source something outside the boundary still reads: a hoisted memo, a derived store or projection, a reader under another ``.
diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts
index 771b02009..352921df7 100644
--- a/packages/signals/src/boundaries.ts
+++ b/packages/signals/src/boundaries.ts
@@ -369,10 +369,21 @@ function caughtBelow(b: Boundary, r: Computed, s: Computed): boolean {
return false;
}
+/** `n` reads `s`, directly or through derivations wherever they live (a
+ * hoisted memo, a derived store's derive, a loading boundary's output). */
+function reads(n: Computed, s: Computed): boolean {
+ for (let l = n._deps; l; l = l._nextDep)
+ if (l._dep === s || reads(l._dep as Computed, s)) return true;
+ return false;
+}
+
/** Drop the readers that settled or died. Settled: a loading reader no
- * longer pending; an error's source neither errored nor in flight, or read by
- * nothing — a reader that caught it and re-ran clean (the tree, a memo-only
- * read gated away) no longer holds it (D-39, #4644). A reader
+ * longer pending; an error's source neither errored nor in flight, or no
+ * longer read by the reader that caught it: the boundary waits on what threw
+ * only while such a reader is alive. One that re-ran clean (the tree, a
+ * memo-only read gated away) holds nothing, and a reader outside the
+ * boundary — a hoisted memo, a derived store, a reader under another
+ * boundary — never caught its error (D-39, #4644). A reader
* that landed but is held (its value staged in a transaction) is not: the
* content reveals at the commit, with its run. The tree is: the output reads
* it and enters the transaction itself (its committed value shows until the
@@ -402,7 +413,7 @@ export function prune(b: Boundary, pass?: boolean | 0): number {
// settled before it ran, a fresh boundary revealed into a hold its
// pass then went pending in, which waited on that flight (#4568).
if (
- ((r._statusFlags | (s._subs ? s._statusFlags : 0)) & mask &&
+ ((r._statusFlags & mask || (s._statusFlags & mask && reads(r, s))) &&
!(mask & STATUS_ERROR && caughtBelow(b, r, s))) ||
(mask & STATUS_ERROR && s._config & CONFIG_STAGED_ERROR) ||
(r._config & CONFIG_VERDICT && r._flags & REACTIVE_IN_HEAP)
diff --git a/packages/signals/tests/errored-heal-memo-reader-4644.test.ts b/packages/signals/tests/errored-heal-memo-reader-4644.test.ts
index 2c4608142..3fe999f59 100644
--- a/packages/signals/tests/errored-heal-memo-reader-4644.test.ts
+++ b/packages/signals/tests/errored-heal-memo-reader-4644.test.ts
@@ -2,9 +2,11 @@ import { expect, it } from "vitest";
import {
createErrorBoundary,
createMemo,
+ createProjection,
createRenderEffect,
createRoot,
createSignal,
+ createStore,
flush
} from "../src/index.js";
@@ -79,3 +81,114 @@ for (const shape of ["memo-only", "effect", "kept"] as const)
dispose();
expect(frames[frames.length - 1]).toBe(shape === "kept" ? "ERR" : "about");
});
+
+// The boundary waits on what threw only while a reader THAT CAUGHT ITS ERROR
+// is alive (D-39). Something else still reading `rows` is not one: a memo
+// hoisted outside the boundary, a derived store or projection built from it,
+// a reader under another error boundary. Each still keeps `rows` subscribed,
+// so "is `rows` read at all" kept this boundary stuck. While the list is
+// mounted the error stays (across a prune: another reader's settling); away,
+// the boundary heals; back, the error shows again (`sync`: a sync throw
+// re-thrown by the new reader — an async source is re-asked on the new read
+// instead, which is not this rule).
+const flat = (v: unknown) => {
+ while (typeof v === "function") v = v();
+ return v;
+};
+for (const via of [
+ "hoisted memo",
+ "derived store",
+ "projection",
+ "other boundary",
+ "sync"
+] as const)
+ it(`${via}: a reader outside the boundary does not keep its error up (#4644)`, async () => {
+ let reject!: (e: Error) => void;
+ let resolveP!: (v: number) => void;
+ const [page, setPage] = createSignal("list");
+ const [fail, setFail] = createSignal(false);
+ const frames: unknown[] = [];
+ const other: unknown[] = [];
+ const dispose = createRoot(d => {
+ const rows = createMemo(() => {
+ if (via !== "sync") return new Promise((_, r) => (reject = r));
+ if (fail()) throw new Error("sync boom");
+ return [] as number[];
+ });
+ let read: () => number[] = rows;
+ if (via === "hoisted memo" || via === "sync") read = createMemo(() => [...rows()]);
+ if (via === "derived store") {
+ const [st] = createStore<{ list: number[] }>(
+ dr => {
+ dr.list = rows();
+ },
+ { list: [] }
+ );
+ read = () => st.list;
+ }
+ if (via === "projection") {
+ const st = createProjection<{ list: number[] }>(
+ dr => {
+ dr.list = rows();
+ },
+ { list: [] }
+ );
+ read = () => st.list;
+ }
+ if (via === "other boundary") {
+ const header = createErrorBoundary(
+ () => createMemo(() => rows().length),
+ () => "ERR2"
+ );
+ createRenderEffect(
+ () => flat(header()),
+ v => void other.push(v)
+ );
+ }
+ const p = createMemo(() => new Promise(r => (resolveP = r)));
+ const view = createErrorBoundary(
+ () => {
+ createRenderEffect(
+ () => p(),
+ () => {}
+ );
+ const onList = createMemo(() => page() === "list");
+ return createMemo(() => {
+ if (!onList()) return "about";
+ const cond = createMemo(() => read().length === 0);
+ return createMemo(() => (cond() ? "none" : "some"));
+ });
+ },
+ () => "ERR"
+ );
+ createRenderEffect(
+ () => flat(view()),
+ v => void frames.push(v)
+ );
+ return d;
+ });
+ const last = () => frames[frames.length - 1];
+ flush();
+ await tick();
+ if (via === "sync") setFail(true);
+ else reject(new Error("boom"));
+ await tick();
+ const failed = last();
+ resolveP(1);
+ await tick();
+ const afterPrune = last();
+ setPage("about");
+ await tick();
+ const away = last();
+ setPage("list");
+ await tick();
+ const back = last();
+ dispose();
+ expect({ failed, afterPrune, away }).toEqual({
+ failed: "ERR",
+ afterPrune: "ERR",
+ away: "about"
+ });
+ if (via === "sync") expect(back).toBe("ERR");
+ if (via === "other boundary") expect(other[other.length - 1]).toBe("ERR2");
+ });
diff --git a/packages/web/test/errored-heal-memo-reader-4644.spec.tsx b/packages/web/test/errored-heal-memo-reader-4644.spec.tsx
index 1505c4b8d..abb4bfb31 100644
--- a/packages/web/test/errored-heal-memo-reader-4644.spec.tsx
+++ b/packages/web/test/errored-heal-memo-reader-4644.spec.tsx
@@ -12,6 +12,7 @@ import { render, type JSX } from "@solidjs/web";
import {
createMemo,
createSignal,
+ createStore,
Errored,
flush,
For,
@@ -94,3 +95,69 @@ for (const withLoading of [true, false])
dispose();
expect({ failed, after }).toEqual({ failed: "could not load", after: "about page" });
});
+
+// Only a reader the boundary caught keeps it waiting (D-39): something
+// outside it still reading the failed memo — a memo or a derived store
+// hoisted above the , a header count under another — did
+// not catch this boundary's error and does not keep its fallback up.
+for (const shape of [
+ "derived memo outside",
+ "derived store outside",
+ "header reader outside"
+] as const)
+ test(`${shape}: navigating away from a failed list heals (#4644)`, async () => {
+ let rejectList!: (e: Error) => void;
+ let setPage!: (p: string) => void;
+ const el = document.createElement("div");
+ const dispose = render(() => {
+ const [page, set] = createSignal("list");
+ setPage = set;
+ const rows = createMemo(
+ () =>
+ new Promise((_, reject) => {
+ rejectList = reject;
+ })
+ );
+ let read: () => number[] = rows;
+ if (shape === "derived memo outside") read = createMemo(() => [...rows()].sort());
+ if (shape === "derived store outside") {
+ const [st] = createStore<{ l: number[] }>(
+ d => {
+ d.l = rows();
+ },
+ { l: [] }
+ );
+ read = () => st.l;
+ }
+ return (
+ <>
+ {shape === "header reader outside" && (
+ hdr-err}>
+ {rows().length}
+
+ )}
+ could not load}>
+ about page}>
+ some}>
+ no rows
+
+
+
+ >
+ );
+ }, el);
+ await tick();
+ rejectList(new Error("boom"));
+ await tick();
+ const failed = el.textContent;
+ setPage("about");
+ flush();
+ await tick();
+ const after = el.textContent;
+ dispose();
+ const hdr = shape === "header reader outside" ? "hdr-err" : "";
+ expect({ failed, after }).toEqual({
+ failed: hdr + "could not load",
+ after: hdr + "about page"
+ });
+ });
From 919967e8c6403cfac40f6d556d4c172dfa835c77 Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 3 Nov 2026 08:44:58 +0000
Subject: [PATCH 4/5] fix(signals): walk each upstream node once in the Errored
heal check (#4644)
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
---
packages/signals/src/boundaries.ts | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts
index 352921df7..5ff32e3c3 100644
--- a/packages/signals/src/boundaries.ts
+++ b/packages/signals/src/boundaries.ts
@@ -370,10 +370,13 @@ function caughtBelow(b: Boundary, r: Computed, s: Computed): boolean {
}
/** `n` reads `s`, directly or through derivations wherever they live (a
- * hoisted memo, a derived store's derive, a loading boundary's output). */
-function reads(n: Computed, s: Computed): boolean {
- for (let l = n._deps; l; l = l._nextDep)
- if (l._dep === s || reads(l._dep as Computed, s)) return true;
+ * hoisted memo, a derived store's derive, a loading boundary's output). Each
+ * upstream node is walked once (`seen`), so a shared upstream graph costs its
+ * size, not its paths. */
+function reads(n: Computed, s: Computed, seen = new Set()): boolean {
+ for (let l = n._deps, d; l; l = l._nextDep)
+ if ((d = l._dep) === s || (seen.size < seen.add(d).size && reads(d as Computed, s, seen)))
+ return true;
return false;
}
From 3f8be1dbd0584f8dcf379d0bb6b8971bea9f0d9e Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 3 Nov 2026 08:54:02 +0000
Subject: [PATCH 5/5] refactor(signals): shorter heal-check walk (#4644)
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_0117BoWNSSgtemsSYsaX2hXE
---
packages/signals/src/boundaries.ts | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts
index 5ff32e3c3..5ab25bad9 100644
--- a/packages/signals/src/boundaries.ts
+++ b/packages/signals/src/boundaries.ts
@@ -373,11 +373,10 @@ function caughtBelow(b: Boundary, r: Computed, s: Computed): boolean {
* hoisted memo, a derived store's derive, a loading boundary's output). Each
* upstream node is walked once (`seen`), so a shared upstream graph costs its
* size, not its paths. */
-function reads(n: Computed, s: Computed, seen = new Set()): boolean {
- for (let l = n._deps, d; l; l = l._nextDep)
- if ((d = l._dep) === s || (seen.size < seen.add(d).size && reads(d as Computed, s, seen)))
- return true;
- return false;
+function reads(n: Computed, s: Computed, seen?: Set): any {
+ for (let l = n._deps, h; l; l = l._nextDep)
+ if ((h = l._dep) === s || ((seen ||= new Set()).size < seen.add(h).size && reads(h, s, seen)))
+ return 1;
}
/** Drop the readers that settled or died. Settled: a loading reader no