From b9d03b46e1f799013d75c47c2d50ca7848898b94 Mon Sep 17 00:00:00 2001
From: Brenley Dueck
Date: Sat, 31 Oct 2026 01:15:40 +0000
Subject: [PATCH 1/2] fix(signals): link a store reader to its derive's staged
error (#4517)
A derive marked with a staged error (`stageMark`, #4456) carries no
STATUS_ERROR until the commit, so `pullFamily` took it for a settled
derive and pulled it untracked. The pull threw the staged error before
the reader read any leaf, so the reader subscribed to nothing in the
family: when the derive's source re-asked and landed, neither the wake
nor the settle reached it. An `` mounted in the change that
errored its content stayed on its fallback over a keyed
`createProjection`, while the same read of the memo recovered.
A reader now links to a derive carrying a staged error as it does to a
pending or errored one, and re-runs at the landing. The render-effect
early return in that branch is for a derive with a flight up only: a
staged error is an answer the frame reader has to meet (D-41; the #3887
pin).
Pins: packages/signals/tests/errored-born-staged-error-projection-4517.test.ts,
packages/web/test/errored-born-staged-error-projection-4517.spec.tsx
(the issue's repro).
Size: +8 to +10 B minified on the store scenarios, every one under its
brotli cap; the three over-cap warnings are +0 B over next.
---
...rojection-staged-error-reader-link-4517.md | 5 ++
packages/signals/src/store/store.ts | 9 +-
...-born-staged-error-projection-4517.test.ts | 86 +++++++++++++++++++
...born-staged-error-projection-4517.spec.tsx | 82 ++++++++++++++++++
4 files changed, 179 insertions(+), 3 deletions(-)
create mode 100644 .changeset/projection-staged-error-reader-link-4517.md
create mode 100644 packages/signals/tests/errored-born-staged-error-projection-4517.test.ts
create mode 100644 packages/web/test/errored-born-staged-error-projection-4517.spec.tsx
diff --git a/.changeset/projection-staged-error-reader-link-4517.md b/.changeset/projection-staged-error-reader-link-4517.md
new file mode 100644
index 0000000000..0c6ebf7fea
--- /dev/null
+++ b/.changeset/projection-staged-error-reader-link-4517.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/signals": patch
+---
+
+A reader of a derived store whose derive carries a staged error (D-41) now observes the derive, as a reader of a pending or errored one does (#4517). The pull threw the staged error before any leaf was read, so the reader subscribed to nothing in the family: when the derive's source re-asked and landed, neither the wake nor the settle reached it, and an `` mounted in the change that errored its content stayed on its fallback over a keyed `createProjection`. The reader now links to the derive and re-runs at the landing, so the answer replaces the error.
diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts
index d12476c0fd..4fe53c91d7 100644
--- a/packages/signals/src/store/store.ts
+++ b/packages/signals/src/store/store.ts
@@ -36,6 +36,7 @@ import {
CONFIG_MANUAL_WRITE,
CONFIG_OVERRIDE,
CONFIG_PROMOTED,
+ CONFIG_STAGED_ERROR,
CONFIG_VERDICT,
EFFECT_RENDER,
NOT_PENDING,
@@ -1843,8 +1844,10 @@ function pullFamily(target: StoreTarget): void {
// flight up (or errored): the reader observes the flight exactly as a
// reader of a memo would — linked, so it re-runs at the landing (the
// next settled pass pulls without linking and the stale link trims), a
- // verdict reader registered, a tracked pass suspended.
- if (fw._statusFlags & (STATUS_PENDING | STATUS_ERROR)) {
+ // verdict reader registered, a tracked pass suspended. A derive carrying a
+ // staged error (`stageMark`) the same: the pull throws it before any leaf
+ // is read, and only the link lets the reader learn of the answer (#4517).
+ if (fw._statusFlags & (STATUS_PENDING | STATUS_ERROR) || fw._config & CONFIG_STAGED_ERROR) {
// A render effect outside the flight's own flush is the frame, not a
// derivation: it keeps what it shows and learns of the landing from the
// leaves the landing changes (unchanged leaves say nothing — it is not a
@@ -1858,7 +1861,7 @@ function pullFamily(target: StoreTarget): void {
if (
c !== null &&
c._type === EFFECT_RENDER &&
- !(fw._statusFlags & STATUS_ERROR) &&
+ fw._statusFlags & STATUS_PENDING &&
fw._config & CONFIG_HELD &&
flushTransaction !== txOf(fw) &&
!linkedTo(c, fw)
diff --git a/packages/signals/tests/errored-born-staged-error-projection-4517.test.ts b/packages/signals/tests/errored-born-staged-error-projection-4517.test.ts
new file mode 100644
index 0000000000..1fb438ad01
--- /dev/null
+++ b/packages/signals/tests/errored-born-staged-error-projection-4517.test.ts
@@ -0,0 +1,86 @@
+import { expect, it } from "vitest";
+import {
+ createErrorBoundary,
+ createLoadingBoundary,
+ createMemo,
+ createProjection,
+ createRenderEffect,
+ createRoot,
+ createSignal,
+ flush,
+ mapArray,
+ untrack
+} from "../src/index.js";
+
+const tick = async () => {
+ for (let i = 0; i < 4; i++) {
+ await new Promise(r => setTimeout(r, 0));
+ flush();
+ }
+};
+
+type Row = { id: number; name: string };
+
+for (const keyed of [true, false])
+ it(`an answer replaces the error a boundary was born with (projection: ${keyed}) (#4517)`, async () => {
+ const asks: { key: string; res: (v: Row[]) => void }[] = [];
+ const ask = (key: string) => new Promise(res => asks.push({ key, res }));
+ const land = async (key: string, v: Row[]) => {
+ for (const a of asks.filter(a => a.key === key)) {
+ asks.splice(asks.indexOf(a), 1);
+ a.res(v);
+ }
+ await tick();
+ };
+ const [q, setQ] = createSignal("");
+ let screen: unknown;
+ const dispose = createRoot(d => {
+ const src = createMemo(() => {
+ const s = q();
+ if (s === "") return [] as Row[];
+ if (s.length < 2) throw new Error("too short");
+ return ask(`search:${s}`);
+ });
+ const results: () => Row[] = keyed
+ ? (() => {
+ const p = createProjection(() => src(), [] as Row[], { key: "id" });
+ return () => p;
+ })()
+ : src;
+ const show = createMemo(() => q() !== "");
+ const out = createMemo(() => {
+ if (!show()) return undefined;
+ return untrack(() =>
+ createErrorBoundary(
+ () =>
+ createLoadingBoundary(
+ () => mapArray(results, r => r.name),
+ () => "loading"
+ ),
+ e => `E:${(e() as Error).message}`
+ )
+ );
+ });
+ const resolve = (v: unknown): unknown =>
+ typeof v === "function" ? resolve(v()) : Array.isArray(v) ? v.join("") : v;
+ createRenderEffect(
+ () => resolve(out()),
+ v => void (screen = v)
+ );
+ return d;
+ });
+ await tick();
+ expect(screen).toBe(undefined);
+ setQ("a");
+ await tick();
+ expect(screen).toBe("E:too short");
+ setQ("ap");
+ await tick();
+ expect(screen).toBe("E:too short");
+ await land("search:ap", [
+ { id: 1, name: "apple" },
+ { id: 2, name: "apricot" }
+ ]);
+ expect(screen).toBe("appleapricot");
+ dispose();
+ });
diff --git a/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx b/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx
new file mode 100644
index 0000000000..32bc13f20a
--- /dev/null
+++ b/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx
@@ -0,0 +1,82 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ *
+ * #4517: an mounted in the change that errors its content, over a
+ * keyed projection of a memo, shows the memo's next answer.
+ */
+import { expect, test } from "vitest";
+import { render } from "../src/index.js";
+import {
+ createMemo,
+ createProjection,
+ createSignal,
+ Errored,
+ flush,
+ For,
+ Loading,
+ Show
+} from "solid-js";
+
+type Row = { id: number; name: string };
+
+const tick = async () => {
+ for (let i = 0; i < 4; i++) {
+ await new Promise(r => setTimeout(r, 0));
+ flush();
+ }
+};
+
+test.each([true, false])(
+ "an answer replaces the error a boundary was born with (projection: %s)",
+ async projection => {
+ const asks: { key: string; res: (v: Row[]) => void }[] = [];
+ const ask = (key: string) => new Promise(res => asks.push({ key, res }));
+ const land = async (key: string, v: Row[]) => {
+ for (const a of asks.filter(a => a.key === key)) {
+ asks.splice(asks.indexOf(a), 1);
+ a.res(v);
+ }
+ await tick();
+ };
+ const container = document.createElement("div");
+ const [q, setQ] = createSignal("");
+ const dispose = render(() => {
+ const src = createMemo(() => {
+ const s = q();
+ if (s === "") return [] as Row[];
+ if (s.length < 2) throw new Error("too short");
+ return ask(`search:${s}`);
+ });
+ const results: () => Row[] = projection
+ ? (() => {
+ const p = createProjection(() => src(), [] as Row[], { key: "id" });
+ return () => p;
+ })()
+ : src;
+ return (
+
+ any) => {e().message}
}>
+ loading
}>
+ {r => {r.name}}
+
+
+
+ );
+ }, container);
+ await tick();
+ expect(container.textContent).toBe("");
+ setQ("a");
+ await tick();
+ expect(container.textContent).toBe("too short");
+ setQ("ap");
+ await tick();
+ expect(container.textContent).toBe("too short");
+ await land("search:ap", [
+ { id: 1, name: "apple" },
+ { id: 2, name: "apricot" }
+ ]);
+ expect(container.textContent).toBe("appleapricot");
+ dispose();
+ }
+);
From 0d540e527aec2676de4eff492efb606b80ef9a1f Mon Sep 17 00:00:00 2001
From: Brenley Dueck
Date: Sat, 31 Oct 2026 01:32:04 +0000
Subject: [PATCH 2/2] fix: address review findings
The pullFamily comment block keeps its original lines, with one line on
the staged-error pull in place of the three that narrated the issue.
The web spec's header keeps only the jsxImportSource and
vitest-environment pragmas, as the neighbouring specs do. The changeset
states the behavior that changed in two sentences.
---
.changeset/projection-staged-error-reader-link-4517.md | 2 +-
packages/signals/src/store/store.ts | 5 ++---
.../test/errored-born-staged-error-projection-4517.spec.tsx | 3 ---
3 files changed, 3 insertions(+), 7 deletions(-)
diff --git a/.changeset/projection-staged-error-reader-link-4517.md b/.changeset/projection-staged-error-reader-link-4517.md
index 0c6ebf7fea..5170e6c34b 100644
--- a/.changeset/projection-staged-error-reader-link-4517.md
+++ b/.changeset/projection-staged-error-reader-link-4517.md
@@ -2,4 +2,4 @@
"@solidjs/signals": patch
---
-A reader of a derived store whose derive carries a staged error (D-41) now observes the derive, as a reader of a pending or errored one does (#4517). The pull threw the staged error before any leaf was read, so the reader subscribed to nothing in the family: when the derive's source re-asked and landed, neither the wake nor the settle reached it, and an `` mounted in the change that errored its content stayed on its fallback over a keyed `createProjection`. The reader now links to the derive and re-runs at the landing, so the answer replaces the error.
+A reader of a derived store whose derive carries a staged error (D-41) now links to the derive, as a reader of a pending or errored one does, so it re-runs when the derive's source answers. An `` mounted in the change that errored its content over a keyed `createProjection` showed its fallback for good; it now shows the answer.
diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts
index 4fe53c91d7..9d318b674a 100644
--- a/packages/signals/src/store/store.ts
+++ b/packages/signals/src/store/store.ts
@@ -1844,9 +1844,8 @@ function pullFamily(target: StoreTarget): void {
// flight up (or errored): the reader observes the flight exactly as a
// reader of a memo would — linked, so it re-runs at the landing (the
// next settled pass pulls without linking and the stale link trims), a
- // verdict reader registered, a tracked pass suspended. A derive carrying a
- // staged error (`stageMark`) the same: the pull throws it before any leaf
- // is read, and only the link lets the reader learn of the answer (#4517).
+ // verdict reader registered, a tracked pass suspended.
+ // A staged error (D-41) throws at the pull, before any leaf would link the reader.
if (fw._statusFlags & (STATUS_PENDING | STATUS_ERROR) || fw._config & CONFIG_STAGED_ERROR) {
// A render effect outside the flight's own flush is the frame, not a
// derivation: it keeps what it shows and learns of the landing from the
diff --git a/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx b/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx
index 32bc13f20a..ccd46a1784 100644
--- a/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx
+++ b/packages/web/test/errored-born-staged-error-projection-4517.spec.tsx
@@ -1,9 +1,6 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
- *
- * #4517: an mounted in the change that errors its content, over a
- * keyed projection of a memo, shows the memo's next answer.
*/
import { expect, test } from "vitest";
import { render } from "../src/index.js";