diff --git a/.changeset/writable-memo-write-clears-error.md b/.changeset/writable-memo-write-clears-error.md new file mode 100644 index 000000000..835cf4182 --- /dev/null +++ b/.changeset/writable-memo-write-clears-error.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A write to a writable memo clears an error its derivation left behind (#4110). Clearing `STATUS_ERROR` only happened on a recompute or an async settle, and a direct write does neither, so a memo that had thrown kept throwing the stale error to every later reader: effects stayed on their error arm, error boundaries never recovered, and the written value sat committed underneath a node that still reported an error. A write equal to the last good value was dropped entirely by the equality gate. Writing to such a memo now recovers it, which is what "a write lands at once and never re-runs `fn` on its own" already promised. diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 5e610220f..874baa9e1 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -62,6 +62,7 @@ import { REACTIVE_SNAPSHOT_STALE, REACTIVE_ZOMBIE, STATUS_ERROR, + STATUS_NONE, STATUS_PENDING, STATUS_UNINITIALIZED, type Refreshable @@ -2154,6 +2155,10 @@ export function setMemo(el: Computed, v: T | ((prev: T) => T)): T { // The writer read the committed frame; its updater composes on it. if (held && typeof v === "function") v = (v as (prev: T) => T)(el._value); const result = setSignal(el, v); + // #4110: the write replaces a failed derivation's outcome. It lands without + // re-running `_fn`, and nothing else clears STATUS_ERROR, so without this + // every later read keeps throwing the error the derivation left behind. + if (el._statusFlags & STATUS_ERROR) notifyStatus(el, STATUS_NONE, undefined); if (held) { // The one write that re-runs: the staging it replaced was derived from // inputs the writer never saw, so the hold re-derives over the write. diff --git a/packages/signals/tests/writable-memo-write-clears-error-4110.test.ts b/packages/signals/tests/writable-memo-write-clears-error-4110.test.ts new file mode 100644 index 000000000..a8be2cc32 --- /dev/null +++ b/packages/signals/tests/writable-memo-write-clears-error-4110.test.ts @@ -0,0 +1,56 @@ +/** + * A writable memo that throws keeps throwing for every later reader. Clearing + * STATUS_ERROR only happens on a recompute or an async settle, and a direct + * write deliberately does neither, so the written value commits underneath a + * node that still reports an error and the memo cannot be recovered. + * + * @see https://github.com/solidjs/solid/issues/4110 + */ +import { expect, it } from "vitest"; +import { createEffect, createRoot, createSignal, flush } from "../src/index.js"; + +it("a write clears an error the derivation left behind", () => { + const seen: unknown[] = []; + const { value, setValue } = createRoot(() => { + const [failing] = createSignal(true); + const [value, setValue] = createSignal(() => { + if (failing()) throw new Error("boom"); + return 1; + }); + createEffect(() => value(), { + effect: v => void seen.push({ ok: v }), + error: e => void seen.push({ err: (e as Error).message }) + }); + return { value, setValue }; + }); + + flush(); + expect(seen).toEqual([{ err: "boom" }]); + + setValue(2); + flush(); + expect(seen).toEqual([{ err: "boom" }, { ok: 2 }]); + expect(value()).toBe(2); +}); + +it("a write of the last good value still recovers the memo", () => { + const { value, setValue, setFailing } = createRoot(() => { + const [failing, setFailing] = createSignal(false); + const [value, setValue] = createSignal(() => { + if (failing()) throw new Error("boom"); + return 1; + }); + return { value, setValue, setFailing }; + }); + + expect(value()).toBe(1); + + setFailing(true); + flush(); + expect(() => value()).toThrow("boom"); + + // equal to the last good value, so the equality gate must not drop it + setValue(1); + flush(); + expect(value()).toBe(1); +});