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"); +});