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";