From 0097594ba855b65498182b670acb85d590c1248f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:47:40 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(signals):=20D-40,=20an=20error=20is=20?= =?UTF-8?q?an=20answer=20=E2=80=94=20proposed=20rule,=20scenarios=20and=20?= =?UTF-8?q?pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An error settles the async its node asked for, so a hold never waits on it; but settled is not shown. A value landing on a held node is staged until the frame reveals, an error publishes at once. F-1 and F-2 already decide this; D-40 states it as a narrowing of F-12, S-41/S-42 are the screens, Q-6 puts the two columns to the maintainer, Q-7 the loadingValue contract question #3886 raised beside it. Pinned it.fails. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015Q7JD9PktsxtBJJbWmtuwr --- packages/signals/docs/OPEN-QUESTIONS.md | 37 ++- packages/signals/docs/RULES-DERIVED.md | 16 ++ packages/signals/docs/RULES-FUNDAMENTALS.md | 4 +- packages/signals/docs/SCENARIOS.md | 38 ++- .../signals/tests/error-is-an-answer.test.ts | 220 ++++++++++++++++++ 5 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 packages/signals/tests/error-is-an-answer.test.ts diff --git a/packages/signals/docs/OPEN-QUESTIONS.md b/packages/signals/docs/OPEN-QUESTIONS.md index c7756beb6..5c781266b 100644 --- a/packages/signals/docs/OPEN-QUESTIONS.md +++ b/packages/signals/docs/OPEN-QUESTIONS.md @@ -2,7 +2,42 @@ **Status: one batch, 2026-10-07.** These are the places where two sources genuinely disagree, or where a ruling was read two ways within a day. The worker did not decide them. Each is laid out as a sequence with the candidate screens side by side; the answer is a column letter. Where the record already leans one way, that is said, with the evidence — but the fundamental is fixed by your answer here, not by the record. -Three sections were drafted. **Q** stays while a ruled screen is still written out here. A resolved confirmation is deleted once the rule carries it: Q-1 through Q-5, ruled 2026-10-09, are now carried by the fundamentals, the derived rules and the scenarios, and are deleted. **U** — ruled, not built (`it.fails` pins), confirm the ruling stands and say whether it is for 2.0. This file goes when U is answered. +Three sections were drafted. **Q** stays while a ruled screen is still written out here; Q-6 and Q-7 (2026-10-12, from #3886) are open. A resolved confirmation is deleted once the rule carries it: Q-1 through Q-5, ruled 2026-10-09, are now carried by the fundamentals, the derived rules and the scenarios, and are deleted. **U** — ruled, not built (`it.fails` pins), confirm the ruling stands and say whether it is for 2.0. This file goes when U is answered. + +--- + +## Q. Proposed, not ruled — the answer is a column letter + +### Q-6. Does an error wait for its frame the way a value does? (D-40, from #3886) + +The sequence is S-41: `count` 1 → 2; `data(2)` rejects while the sibling `slow(2)` is still in flight; an `` around `data`, a `createEffect(data, { effect, error })`, and `

{count()}

` beside it. + +| | **A — today** | **B — D-40** | +| -------------------------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------- | +| screen after `data(2)` rejects, `slow(2)` up | `1 \| Error 2 \| 1` | `1 \| 1 \| 1` | +| the error arm | runs now | runs when `slow(2)` lands, in the frame that shows `2` | +| `data()` untracked, meanwhile | throws `Error 2` | `1` (the screen) | +| `isPending(data)` / `latest(data)`, meanwhile | `false` / throws `Error 2` | `true` / throws `Error 2` | +| `resolve(data)` / `until` / `refresh` called in the hold | reject now | reject at the landing (D-31: delivered by the hold they are called in) | +| what the hold waits for | `slow(2)` | `slow(2)` — unchanged in both columns | +| recovery to the last good value (S-42 step 6) | the arm never runs; the screen shows `1` with the arm still on `Error 2` | the arm runs with `1` | + +**Where the record leans.** B. F-1 (the fallback is content, F-4, and `Error 2` beside `slow`'s `1` is a value beside a stale derivation of its input); F-2 (a change that needs async shows nothing of itself until that async has landed; the error is part of the change); D-39's reading of the mirror. A was never ruled: the engine publishes an error straight into the node's working status, which is also what reads and effects see, so the error has one state where a value has two (staged, committed). "An error is the resolved future" is true of what the hold waits on, and both columns agree there. + +**Not in the question.** The standalone bugs #3886 found beside this, each its own fix with no rule: an async recovery to the last good value never notifies the effect even with nothing held; a write to a failed writable memo does not clear its error; a thrown value whose `toString`/`message` throws reaches the arm as the formatter's error; a failing async comparator does not release unobserved lazy readers; snapshot scopes do not capture a failure. + +### Q-7. `loadingValue` after a shown failure: does a pending retry serve the seed again? (from #3886) + +`createMemo(() => fetch(id()), { loadingValue: 42 })`; the first request rejects and the error shows; then `setId(2)` re-asks. + +| | **A — today** (`loading-value.test.ts:268`, #2989) | **B — #3886's contract change** | +| ----------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------- | +| screen while the retry is in flight | `42` — the seed serves again; `isPending` is `false` | the error stays; `isPending` is `true` | +| the seed as memo `prev` | `42` | `42` (unchanged: the seed stays the last successful `prev`) | + +**Where the record leans.** B is D-39's wording applied literally ("the error stays until a new answer replaces it; a pending is not a value"), and D-30 says a declared first paint is _loading_, not pending, which is A's ground: the seed is the first paint until a first answer. A revealed failure is a first answer, which favors B. A is pinned twice today and was ruled for #2989 before D-39 existed. Not needed for Q-6; decide on its own. + +**Answer format that is enough:** `Q-6 B, Q-7 A` — or the line you prefer. --- diff --git a/packages/signals/docs/RULES-DERIVED.md b/packages/signals/docs/RULES-DERIVED.md index c0ee2f8fc..0ec8f5b2d 100644 --- a/packages/signals/docs/RULES-DERIVED.md +++ b/packages/signals/docs/RULES-DERIVED.md @@ -334,6 +334,22 @@ An `` flipping to its fallback is a boundary going back to its fallback --- +### D-40. An error is an answer: it ends the wait like a value, and it shows when a value would + +**From:** F-2, F-1, F-12, D-39. **Scenarios:** S-41, S-42. **Proposed 2026-10-12, not ruled** (Q-6). + +A rejection, a compute-phase throw or a comparator throw is the answer to the question its node asked. As an answer it _settles_ the async: a hold never waits on an errored flight, and nothing here changes what a hold waits for. But settled is not shown. A value that lands on a held node is staged until the frame reveals (F-2); an error that lands on a held node is staged the same way, because the error fallback is content (F-4) and showing it beside the previous frame's other content is the torn screen F-1 forbids. Until the reveal: the screen keeps what it showed, an ordinary outside read serves the committed value (it reads the screen, D-29, D-34), `createEffect`'s error arm does not run, `` does not flip, `resolve`/`until`/`refresh` do not reject (D-31), `isPending` of it is `true`, and `latest` of it throws the proposed error — the one way to see the answer early (F-6). At the reveal the error and everything that waited with it show as one frame: the arm runs, the boundary shows its fallback, the outside read throws, `isPending` is `false`. + +Recovery is the mirror (D-39): a shown error stays the display until the frame that carries its replacement reveals — through a pending retry, and through a held recovery, success or a different failure. A recovery to a value equal to the last good one is still a change of answer: payload equality never silences it, held or not. + +A first load that rejects has no previous content to keep: born held (D-2), it stays loading until its frame commits, as a first load's value does; under an armed boundary it is the boundary's and shows with the boundary's frame (F-4). A derivation that reads a staged error throws it, as it would read a staged value: its own pass errors and stages with the same frame. + +**What it does not say.** It does not say an error keeps a hold open. It does not say an unheld error waits: with nothing held, an error publishes at the flush that found it, like a value (S-38). It does not say `latest` hides the error. It does not change what a boundary catches (F-12), the effect arms (D-38), or the halt. + +**Deviation today.** A held node's error publishes at once while its value would wait: the effect's error arm runs (with no boundary between), the untracked read throws, `isPending` reads `false`, the promise helpers reject; under a scheduled render effect the screen itself does hold. During a retry the untracked read of a shown error serves the pre-error value, and a held recovery drops the shown error ahead of its frame. A recovery to the last good value after an async rejection never reaches the effect, held or not (its equality gate ran before the status changed). From #3886's audit, which also found the standalone cases listed in `OPEN-QUESTIONS.md` Q-6. Pinned `it.fails` in `tests/error-is-an-answer.test.ts`. **Status:** proposed. + +--- + ## Flagged — rulings that cannot cite a fundamental, stale archive text, mistakes | item | finding | disposition | diff --git a/packages/signals/docs/RULES-FUNDAMENTALS.md b/packages/signals/docs/RULES-FUNDAMENTALS.md index 8bc32d74e..081ca6e97 100644 --- a/packages/signals/docs/RULES-FUNDAMENTALS.md +++ b/packages/signals/docs/RULES-FUNDAMENTALS.md @@ -121,9 +121,9 @@ These are the only words the fundamentals use. Internal names (transaction, stag **Immutable.** An error thrown by content under an `` sends that boundary to its error fallback, which is then the display (F-3): readers behind it hold nothing. A pending read is never an error: `` does not catch it and never shows its fallback for a wait, and `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning, not a failure. An error that escapes every boundary halts the reactive system (`REACTIVITY_HALTED`); later writes are ignored. In effects, compute-phase errors can be intercepted (`createEffect`'s error arm) and are logged and skipped when they are not; effect-phase errors escalate to the nearest boundary. -**What it does not say.** It does not say an error clears on the next write — the error fallback stays until a new answer replaces the errored one (D-39). +**What it does not say.** It does not say an error clears on the next write — the error fallback stays until a new answer replaces the errored one (D-39). It does not say an error shows before the frame it belongs to: an error is an answer — it ends the wait like a value, and it shows when a value would (D-40, proposed). -**Collapses:** A1–A6, §23.1 items 2–3 (2026-10-01), the 7b re-pin (2026-10-02), recovery 9.1/9.2. **Derived:** D-38, D-39. **Scenarios:** S-38. +**Collapses:** A1–A6, §23.1 items 2–3 (2026-10-01), the 7b re-pin (2026-10-02), recovery 9.1/9.2. **Derived:** D-38, D-39, D-40 (proposed). **Scenarios:** S-38, S-41, S-42. --- diff --git a/packages/signals/docs/SCENARIOS.md b/packages/signals/docs/SCENARIOS.md index fbcdc23ce..6bf080598 100644 --- a/packages/signals/docs/SCENARIOS.md +++ b/packages/signals/docs/SCENARIOS.md @@ -757,7 +757,7 @@ _Pinned: `tests/optimistic-lane-release.test.ts`, `tests/body-end-supersession-v --- -## E. First loads, stores, errors (D-6, D-30, D-38–D-39) +## E. First loads, stores, errors (D-6, D-30, D-38–D-40) ### S-36. A declared first paint is loading, not pending — D-30 (A27) @@ -814,3 +814,39 @@ const data = createMemo(() => fetchOrFail(count())); // rejects for every co | ✗ | | after 1: `1 \| loading` (the error clearing for a pending); or `0` held by the errored flight | | `` never catches a pending: `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning (A6). + +### S-41. A held rejection reveals with its frame — D-40 (proposed; #3886) + +_Pinned `it.fails`: `tests/error-is-an-answer.test.ts`._ + +```tsx +const [count, setCount] = createSignal(1); +const data = createMemo(() => fetchOrFail(count())); // rejects for count 2 +const slow = createMemo(() => fetchSlow(count())); // answers later than data +

{count()}

+ `Error ${e().count}`}>{data()} +

{slow()}

+createEffect(data, { effect: v => log(v), error: e => log(e) }); +``` + +| # | Action | Screen | `data()` untracked | `isPending(data)` | `latest(data)` | effect log | Notes | +| --- | ------------------------ | ---------------------------------------------------------------------------------------- | ------------------ | ----------------- | ---------------- | ------------ | ------------------------------------------------------------- | +| 0 | both landed for 1 | `1 \| 1 \| 1` | 1 | false | 1 | `1` | | +| 1 | `setCount(2); flush()` | `1 \| 1 \| 1` — held: `slow(2)` is in flight | 1 | true | throws NotReady | `1` | a change that needs async is held (F-2) | +| 2 | `fetchOrFail(2)` rejects | `1 \| 1 \| 1` — still held on `slow(2)`; the error settled `data` | 1 | true | throws `Error 2` | `1` | settled, not shown: the error is staged like a value would be | +| 3 | `fetchSlow(2)` lands | `2 \| Error 2 \| 2` | throws `Error 2` | false | throws `Error 2` | `1, Error 2` | one frame: input, error fallback and sibling | +| ✗ | | after 2: `1 \| Error 2 \| 1` with the arm already run — today's screen, F-1's torn frame | | | | | | + +The hold did not wait on the rejection: step 3 lands as soon as `slow(2)` does, whether `data(2)` rejected before or after it. + +### S-42. A held recovery keeps the shown error; recovering to the last good value is a change — D-40, D-39 (proposed; #3886) + +_Pinned `it.fails`: `tests/error-is-an-answer.test.ts`._ Continues S-41 from step 3; `fetchOrFail(3)` resolves to `1`, the value shown before the error. + +| # | Action | Screen | `data()` untracked | `isPending(data)` | effect log | Notes | +| --- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------------- | --------------- | ---------------------------------------------------------------------- | +| 3 | (S-41's end) | `2 \| Error 2 \| 2` | throws `Error 2` | false | `1, Error 2` | | +| 4 | `setCount(3); flush()` | `2 \| Error 2 \| 2` — held: both re-ask | throws `Error 2` | true | `1, Error 2` | the error stays the display through the retry (D-39) | +| 5 | `fetchOrFail(3)` resolves `1` | `2 \| Error 2 \| 2` — still held on `slow(3)` | throws `Error 2` | true | `1, Error 2` | settled, not shown | +| 6 | `fetchSlow(3)` lands | `3 \| 1 \| 3` | 1 | false | `1, Error 2, 1` | `1` equals the last good value and is still a new answer: the arm runs | +| ✗ | | after 5: `2 \| 1 \| 2` (the error clearing ahead of its frame); after 6: the arm never runs with `1` — today, and today also with nothing held at all (an async landing equal to the last good value is swallowed by the equality gate while the node is errored) | | | | | diff --git a/packages/signals/tests/error-is-an-answer.test.ts b/packages/signals/tests/error-is-an-answer.test.ts new file mode 100644 index 000000000..2b8fe8c8c --- /dev/null +++ b/packages/signals/tests/error-is-an-answer.test.ts @@ -0,0 +1,220 @@ +/** + * D-40 (proposed): an error is an answer — it ends the wait like a value and + * shows when a value would. S-41, S-42. Shapes from #3886's audit (GabbeV). + * + * `data` and `slow` both re-ask on `count`; a scheduled render effect over + * `slow` holds the frame (F-2). A rejection of `data` settles it — the hold + * never waits on an error — but must not SHOW until `slow` lands: the + * effect's error arm, the untracked read, the boundary and `isPending` all + * keep the previous frame; `latest` is the one early view (F-6). Recovery is + * the mirror (D-39), and recovering to the last good value is still a new + * answer. + * + * Pinned `it.fails` while D-40 is proposed, not ruled (OPEN-QUESTIONS Q-6). + * The no-hold case at the end is a plain bug, pinned as a failing `it`. + */ +import { expect, it } from "vitest"; +import { + createEffect, + createErrorBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest +} from "../src/index.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} +const tick = async () => { + await new Promise(r => setTimeout(r, 0)); + flush(); +}; + +function scene(boundary: boolean) { + let request = deferred(); + let sibling = deferred(); + const [count, setCount] = createSignal(1); + const screen = { count: 0, data: undefined as unknown, slow: 0 }; + const log: unknown[] = []; + let data!: () => number; + const dispose = createRoot(d => { + data = createMemo(() => { + count(); + return request.promise; + }); + const slow = createMemo(() => { + count(); + return sibling.promise; + }); + createRenderEffect(count, v => void (screen.count = v), { schedule: true }); + // With a boundary the error is content (F-4): the screen shows its + // fallback. Without one the user effect's arm is the only consumer. + if (boundary) { + const errored = createErrorBoundary(data, e => `Error ${(e() as Error).message}`); + createRenderEffect(errored, v => void (screen.data = v), { schedule: true }); + } else screen.data = "no boundary"; + createRenderEffect(slow, v => void (screen.slow = v), { schedule: true }); + createEffect(data, { effect: v => void log.push(v), error: e => void log.push(e) }); + return d; + }); + const ask = (n: number) => { + request = deferred(); + sibling = deferred(); + setCount(n); + flush(); + }; + const untracked = () => { + try { + return data(); + } catch (e) { + return e; + } + }; + return { + screen, + log, + data, + ask, + untracked, + answer: (v: number) => request.resolve(v), + fail: (e: unknown) => request.reject(e), + land: (v: number) => sibling.resolve(v), + dispose + }; +} + +it.fails.each([true, false])( + "S-41: a held rejection reveals with its frame (boundary: %s)", + async boundary => { + const s = scene(boundary); + const shown = boundary ? "Error 2" : "no boundary"; + const boom = new Error("2"); + try { + flush(); + s.answer(1); + s.land(1); + await tick(); + expect(s.screen).toEqual({ count: 1, data: boundary ? 1 : "no boundary", slow: 1 }); + expect(s.log).toEqual([1]); + + s.ask(2); + expect(s.screen).toEqual({ count: 1, data: boundary ? 1 : "no boundary", slow: 1 }); + expect(isPending(s.data)).toBe(true); + expect(latest(s.data)).toBe(1); + + // Settled, not shown. + s.fail(boom); + await tick(); + expect(s.screen).toEqual({ count: 1, data: boundary ? 1 : "no boundary", slow: 1 }); + expect(s.log).toEqual([1]); + expect(s.untracked()).toBe(1); + expect(isPending(s.data)).toBe(true); + expect(() => latest(s.data)).toThrow("2"); + + // One frame: input, error fallback and sibling. + s.land(2); + await tick(); + expect(s.screen).toEqual({ count: 2, data: shown, slow: 2 }); + expect(s.log).toEqual([1, boom]); + expect(s.untracked()).toBe(boom); + expect(isPending(s.data)).toBe(false); + } finally { + s.dispose(); + flush(); + } + } +); + +it.fails.each([true, false])( + "S-42: a held recovery keeps the shown error; the last good value is a new answer (boundary: %s)", + async boundary => { + const s = scene(boundary); + const shown = boundary ? "Error 2" : "no boundary"; + const recovered = boundary ? 1 : "no boundary"; + const boom = new Error("2"); + try { + flush(); + s.answer(1); + s.land(1); + await tick(); + s.ask(2); + s.fail(boom); + s.land(2); + await tick(); + expect(s.screen).toEqual({ count: 2, data: shown, slow: 2 }); + expect(s.log).toEqual([1, boom]); + + // The error stays the display through the retry (D-39). + s.ask(3); + expect(s.screen).toEqual({ count: 2, data: shown, slow: 2 }); + expect(s.untracked()).toBe(boom); + expect(isPending(s.data)).toBe(true); + + // Settled to the last good value, not shown. + s.answer(1); + await tick(); + expect(s.screen).toEqual({ count: 2, data: shown, slow: 2 }); + expect(s.untracked()).toBe(boom); + expect(s.log).toEqual([1, boom]); + expect(isPending(s.data)).toBe(true); + expect(latest(s.data)).toBe(1); + + // `1` equals the last good value and is still a new answer. + s.land(3); + await tick(); + expect(s.screen).toEqual({ count: 3, data: recovered, slow: 3 }); + expect(s.untracked()).toBe(1); + expect(s.log).toEqual([1, boom, 1]); + expect(isPending(s.data)).toBe(false); + } finally { + s.dispose(); + flush(); + } + } +); + +// No hold at all: the same recovery is a plain bug, not a rule question. The +// landing equal to the last good value passes the equality gate while the +// node is errored, so the effect never hears the recovery (#3886). Pinned +// `it.fails` until its own fix lands. +it.fails( + "recovering to the last good value after an async rejection reaches the effect", + async () => { + const boom = new Error("failed"); + const [count, setCount] = createSignal(0); + const log: unknown[] = []; + let data!: () => number; + const dispose = createRoot(d => { + data = createMemo(() => (count() === 1 ? Promise.reject(boom) : Promise.resolve(1))); + createEffect(data, { effect: v => void log.push(v), error: e => void log.push(e) }); + return d; + }); + try { + flush(); + await tick(); + setCount(1); + flush(); + await tick(); + expect(log).toEqual([1, boom]); + setCount(2); + flush(); + await tick(); + expect(data()).toBe(1); + expect(isPending(data)).toBe(false); + expect(log).toEqual([1, boom, 1]); + } finally { + dispose(); + flush(); + } + } +); From 0a44c6c02e12f532d338920bf0d60ddd3a3312af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:18:20 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(signals):=20Q-6=20=E2=80=94=20what=20D?= =?UTF-8?q?-40=20costs=20(prototype);=20pins=20unwrap=20the=20status=20err?= =?UTF-8?q?or?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015Q7JD9PktsxtBJJbWmtuwr --- packages/signals/docs/OPEN-QUESTIONS.md | 2 ++ packages/signals/tests/error-is-an-answer.test.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/signals/docs/OPEN-QUESTIONS.md b/packages/signals/docs/OPEN-QUESTIONS.md index 5c781266b..84ab6c3a5 100644 --- a/packages/signals/docs/OPEN-QUESTIONS.md +++ b/packages/signals/docs/OPEN-QUESTIONS.md @@ -24,6 +24,8 @@ The sequence is S-41: `count` 1 → 2; `data(2)` rejects while the sibling `slow **Where the record leans.** B. F-1 (the fallback is content, F-4, and `Error 2` beside `slow`'s `1` is a value beside a stale derivation of its input); F-2 (a change that needs async shows nothing of itself until that async has landed; the error is part of the change); D-39's reading of the mirror. A was never ruled: the engine publishes an error straight into the node's working status, which is also what reads and effects see, so the error has one state where a value has two (staged, committed). "An error is the resolved future" is true of what the hold waits on, and both columns agree there. +**What B costs (prototype, 2026-10-12, branch `proto/staged-error`).** An error a held pass finds is staged in `_pendingValue` (a config bit marks it), propagated to dependents as a staging of their own by the same walk a published error takes, thrown to readers in the hold, and published by the commit when the frame lands; a held recovery keeps the shown error until its landing. S-41 passes in full, S-42 except the untracked read through the retry (the re-ask replaces the shown error with its pending mark, D-39's open deviation), the full suite is otherwise green, and #3886's own tests go from 43 to 35 failures (the rest are the standalone bugs and its contract changes). Untuned cost: **+243 B brotli / +764 B minified on the core floor**, about the same on every scenario, 230 B over the caps. The mechanism duplicates the immediate error path rather than replacing it; a size-neutral build would make every error stage (a plain flush commits it at its seam) and retire the immediate path, which is a larger change than the prototype. + **Not in the question.** The standalone bugs #3886 found beside this, each its own fix with no rule: an async recovery to the last good value never notifies the effect even with nothing held; a write to a failed writable memo does not clear its error; a thrown value whose `toString`/`message` throws reaches the arm as the formatter's error; a failing async comparator does not release unobserved lazy readers; snapshot scopes do not capture a failure. ### Q-7. `loadingValue` after a shown failure: does a pending retry serve the seed again? (from #3886) diff --git a/packages/signals/tests/error-is-an-answer.test.ts b/packages/signals/tests/error-is-an-answer.test.ts index 2b8fe8c8c..7e88c2098 100644 --- a/packages/signals/tests/error-is-an-answer.test.ts +++ b/packages/signals/tests/error-is-an-answer.test.ts @@ -77,7 +77,8 @@ function scene(boundary: boolean) { try { return data(); } catch (e) { - return e; + // The engine throws its status wrapper; the user's error is its cause. + return (e as Error).cause ?? e; } }; return {