diff --git a/.changeset/fix-derived-store-refetch-error.md b/.changeset/fix-derived-store-refetch-error.md new file mode 100644 index 000000000..af109ebc1 --- /dev/null +++ b/.changeset/fix-derived-store-refetch-error.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix a derived store (`createStore(fn)`) whose re-fetch rejects never reaching `Errored`: a render effect outside the flight's flush kept showing the last value instead of throwing the derive's error, for sync-to-async and async-to-async derives alike. Memos were unaffected. diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index f23eaf260..13414a199 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -1763,11 +1763,13 @@ function pullFamily(target: StoreTarget): void { // One that already observes the flight (linked by the pass that saw it // go up — the frame's hold, `blocked`) keeps observing it: a re-run for // another reason (a guess it read, the seam) that dropped the link would - // release the hold with the flight still up. + // release the hold with the flight still up. An errored derive has no + // landing to learn of: the error is what the frame shows. const c: any = context; if ( c !== null && c._type === EFFECT_RENDER && + !(fw._statusFlags & STATUS_ERROR) && fw._config & CONFIG_HELD && flushTransaction !== txOf(fw) && !linkedTo(c, fw) diff --git a/packages/signals/tests/store/derived-refetch-error-3887.test.ts b/packages/signals/tests/store/derived-refetch-error-3887.test.ts new file mode 100644 index 000000000..a9eb504e1 --- /dev/null +++ b/packages/signals/tests/store/derived-refetch-error-3887.test.ts @@ -0,0 +1,110 @@ +/** + * #3887: a derived store whose re-fetch rejects must reach the error + * boundary, like a memo's does. The reader is the JSX shape — a render + * effect under `Errored` > `Loading` — which `pullFamily` lets keep its frame + * while the derive's flight is up; an errored derive has no landing for that + * frame to wait for. + */ +import { + createErrorBoundary, + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush +} from "../../src/index.js"; + +async function settle() { + for (let i = 0; i < 4; i++) { + flush(); + await Promise.resolve(); + await new Promise(r => setTimeout(r)); + } + flush(); +} + +function mount(read: () => unknown) { + const shown: unknown[] = []; + const dispose = createRoot(dispose => { + const view = createErrorBoundary( + () => + createLoadingBoundary( + () => { + const text = createMemo(() => "content"); + createRenderEffect(read, () => {}); + return text; + }, + () => "pending" + ), + () => "error" + ); + createRenderEffect( + () => { + let v: any = view(); + while (typeof v === "function") v = v(); + return v; + }, + v => void shown.push(v) + ); + return dispose; + }); + return { shown, dispose }; +} + +function failingReplacement() { + let reject!: (error: Error) => void; + const promise = new Promise((_, fail) => (reject = fail)); + return { promise, fail: () => reject(new Error("replacement failed")) }; +} + +describe("a failed derived-store refetch reaches the error boundary (#3887)", () => { + it("sync derive replaced by a rejecting async one", async () => { + const replacement = failingReplacement<{ value: number }>(); + const [id, setId] = createSignal(0); + const [store] = createStore(() => (id() ? replacement.promise : { value: 0 }), { value: 0 }); + const { shown, dispose } = mount(() => store.value); + await settle(); + expect(shown.at(-1)).toBe("content"); + setId(1); + await settle(); + replacement.fail(); + await settle(); + expect(shown.at(-1)).toBe("error"); + dispose(); + }); + + it("async derive replaced by a rejecting async one", async () => { + const replacement = failingReplacement<{ value: number }>(); + const [id, setId] = createSignal(0); + const [store] = createStore( + () => (id() ? replacement.promise : Promise.resolve({ value: 0 })), + { value: -1 } + ); + const { shown, dispose } = mount(() => store.value); + await settle(); + expect(shown.at(-1)).toBe("content"); + setId(1); + await settle(); + replacement.fail(); + await settle(); + expect(shown.at(-1)).toBe("error"); + dispose(); + }); + + it("memo parity: a memo replaced by a rejecting async one", async () => { + const replacement = failingReplacement(); + const [id, setId] = createSignal(0); + const value = createRoot(() => createMemo(() => (id() ? replacement.promise : 0))); + const { shown, dispose } = mount(value); + await settle(); + expect(shown.at(-1)).toBe("content"); + setId(1); + await settle(); + replacement.fail(); + await settle(); + expect(shown.at(-1)).toBe("error"); + dispose(); + }); +});