From 4b711c35c22fab8f53ebfd76476a6ab031f7734d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 01:10:29 -0700 Subject: [PATCH] fix(signals): a projection's synchronous throw reaches its readers (#3956) A re-run of a projection that threw synchronously only woke the leaves' readers for NotReadyError; any other error stayed with the derive, which the leaves' readers do not subscribe to, so the screen kept the last good value. Wake them the same way so they re-derive and meet the error. Fixes #3956 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LVm3wseHt9wRTY8LfPiHmQ --- .../projection-sync-throw-reaches-readers.md | 5 ++ packages/signals/src/store/projection.ts | 4 +- .../store/projection-sync-throw-3956.test.ts | 90 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .changeset/projection-sync-throw-reaches-readers.md create mode 100644 packages/signals/tests/store/projection-sync-throw-3956.test.ts diff --git a/.changeset/projection-sync-throw-reaches-readers.md b/.changeset/projection-sync-throw-reaches-readers.md new file mode 100644 index 000000000..83bc9a7da --- /dev/null +++ b/.changeset/projection-sync-throw-reaches-readers.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A `createProjection` whose re-run throws synchronously (for example, reading an entry that has since failed) now delivers the error to the readers of its fields, so the nearest `` shows it instead of the screen keeping the last good value. diff --git a/packages/signals/src/store/projection.ts b/packages/signals/src/store/projection.ts index 387eb1ba7..2015a8d99 100644 --- a/packages/signals/src/store/projection.ts +++ b/packages/signals/src/store/projection.ts @@ -279,7 +279,9 @@ export function runProjectionComputed( // A flight went up (NotReady out of the pass — the derive's own, with // the derive as its source): the leaves' readers learn it from here — // they do not subscribe to the derive. - if (e instanceof NotReadyError && !first) wakeFamily(fam, e); + // A synchronous error the same way (#3956: a rerun that finds the + // entry failed throws): every live reader re-derives and meets it. + if (!first) e instanceof NotReadyError ? wakeFamily(fam, e) : errorFamily(fam); throw e; } // A pass that returned with a flight up (an iterator's sync first yield): diff --git a/packages/signals/tests/store/projection-sync-throw-3956.test.ts b/packages/signals/tests/store/projection-sync-throw-3956.test.ts new file mode 100644 index 000000000..ad91561e8 --- /dev/null +++ b/packages/signals/tests/store/projection-sync-throw-3956.test.ts @@ -0,0 +1,90 @@ +import { expect, it } from "vitest"; +import { + createErrorBoundary, + createLoadingBoundary, + createProjection, + createRenderEffect, + createRoot, + createSignal, + flush +} from "../../src/index.js"; + +const settle = async (ms: number) => { + const end = Date.now() + ms; + while (Date.now() < end) { + await new Promise(r => setTimeout(r, 5)); + flush(); + } +}; + +it("projection: a synchronous throw on a version-bumped rerun reaches Errored (#3956)", async () => { + const fetcher = (k: string) => + new Promise<{ items: string[] }>((res, rej) => + setTimeout(() => (k === "bad" ? rej(new Error("refused")) : res({ items: [`row-${k}`] })), 20) + ); + const [key, setKey] = createSignal("good"); + const [version, setVersion] = createSignal(0, { ownedWrite: true }); + type E = { + status: "pending" | "ok" | "error"; + p: Promise<{ items: string[] }>; + v?: { items: string[] }; + e?: unknown; + }; + const cache = new Map(); + const entry = (k: string) => { + let e = cache.get(k); + if (!e) { + const ne: E = { status: "pending", p: fetcher(k) }; + ne.p.then( + v => { + ne.status = "ok"; + ne.v = v; + setVersion(x => x + 1); + }, + err => { + ne.status = "error"; + ne.e = err; + setVersion(x => x + 1); + } + ); + cache.set(k, ne); + e = ne; + } + return e; + }; + let screen: unknown; + createRoot(() => { + const p = createProjection<{ value?: { items: string[] } }>(() => { + version(); + const e = entry(key()); + if (e.status === "error") throw e.e; + if (e.status === "ok") return { value: e.v! } as never; + return e.p.then(v => ({ value: v })) as never; + }, {}); + const view = createErrorBoundary( + () => + createLoadingBoundary( + () => (p.value?.items ?? []).join(","), + () => "PENDING" + ), + () => "ERRORED" + ); + createRenderEffect( + () => { + let v: any = view(); + while (typeof v === "function") v = v(); + return v; + }, + v => { + screen = v; + } + ); + }); + flush(); + await settle(60); + expect(screen).toBe("row-good"); + setKey("bad"); + flush(); + await settle(200); + expect(screen).toBe("ERRORED"); +});