Skip to content
5 changes: 5 additions & 0 deletions .changeset/held-error-waits-for-frame.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

An error is an answer: it ends the wait like a value, and it shows when a value would (D-41). A rejection or throw on a node a transaction holds is staged and published with the frame that carries it: until then the screen, an untracked read, `createEffect`'s error arm and `<Errored>` keep the previous frame, `isPending` is `true`, `latest` throws the proposed error, and `resolve`/`until`/`refresh` reject at the landing. A held recovery keeps the shown error until its own commit, and a recovery equal to the last good value now reaches effects, held or not (it was swallowed by the equality gate). Replaces the separate staged-error publish path. From #3886's audit (GabbeV).
21 changes: 19 additions & 2 deletions packages/signals/docs/OPEN-QUESTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,24 @@

**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-7 (2026-10-12, from #3886) is open. A resolved confirmation is deleted once the rule carries it: Q-1 through Q-5, ruled 2026-10-09, and Q-6, ruled 2026-10-15 (B, now D-41), are 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-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. D-41 (ruled) does not depend on it; decide on its own.

**Answer format that is enough:** `Q-7 A` — or the line you prefer.

---

Expand All @@ -23,4 +40,4 @@ Each is pinned `it.fails`; the ruled screen is in `SCENARIOS.md`. Confirming her
| **U-9** | D-14 — a held tree under a re-armed boundary re-derives without a redraw loop | `loading-fallback-in-flush-3540.test.ts:1020` (the loop), `:520` (re-armed held mounts), `:859` (committed content under a re-armed boundary); `loading-on-frame-following-3540.test.ts` §3b, §3c | built (2026-10-14): a re-arm owns what an earlier hold staged under it (`arm` records it, `heldUnder`), and a pass under it that reads a hold is the boundary's, not the tick's (`_fresh`): the fallback shows now, the content at that hold's commit, and the hold does not wait for it. A re-arm whose own flush joins that hold folds back into it (D-16): the seam drops a fallback staged into the very hold its readers wait on. The pins are plain `it` | 0 B minified on the floors (optional calls golfed in the core); +417 B minified on the boundary apps, caps raised (accepted 2026-10-14) |
| **U-10** | F-1, F-6 — a memo that reads a verdict memo shows the verdict now, beside the held value it also reads (#4050) | `verdict-memo-under-held-memo-4050.test.ts`; web `held-shell-pending-feedback-4050.spec.tsx` (a Provider's children memo over a router shell) | built (2026-10-14): a memo's tracked read of a verdict reader makes it one (`read`, core.ts; CONFIG_VERDICT shares REACTIVE_PROBED's bit), so it reads the held value as the screen and re-derives in the verdict lane, as the inline probe does; boundaries.ts `verdictDerived` folds into the owner's own mark | +26 B minified on the floors (no cap raised: +18 B over the recorded minified, within the allowance), −66…−79 B minified on the boundary apps and pages |

**Answer format that is enough:** `U-1, U-3–U-9 stand, U-5 (#4071) and U-6 after 2.0` — or the line you prefer. U-2 is withdrawn with the direction rule's time half (once drafted as F-8; F-2). Q-1 through Q-5 are ruled and carried by the rules. C-1 is D-12. C-2 is D-7. C-3 is D-32.
**Answer format that is enough:** `U-1, U-3–U-9 stand, U-5 (#4071) and U-6 after 2.0` — or the line you prefer. U-2 is withdrawn with the direction rule's time half (once drafted as F-8; F-2). Q-1 through Q-6 are ruled and carried by the rules. C-1 is D-12. C-2 is D-7. C-3 is D-32.
18 changes: 18 additions & 0 deletions packages/signals/docs/RULES-DERIVED.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,24 @@ An `<Errored>` flipping to its fallback is a boundary going back to its fallback

---

### D-41. 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-42, S-43. **Ruled 2026-10-15** (Q-6 B, from #3886; first drafted as D-40 in #3995).

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, `<Errored>` 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.

**Built 2026-10-15.** An error a held node's flight or pass finds is staged in `_pendingValue` (CONFIG_STAGED_ERROR) and committed with the frame, which publishes it through the ordinary error path; a held recovery keeps the shown error (`_shownError`) until its own commit, and a recovery equal to the last good value is a change of answer, held or not. `watch` (`resolve`, `until`, `refresh`) rejects at the landing. The duplicated immediate-publish path is gone. Pins: `tests/error-is-an-answer.test.ts`, `tests/watch-error-outcomes.test.ts` (GabbeV, #3886).

**Deviation today.** An untracked read of a derived store's property (`createStore(fn)`, projections) throws a held rejection before its frame; the store's effect and the screen do wait. Pinned `it.fails` in `tests/error-is-an-answer.test.ts`. The standalone cases from #3886's audit (a write to a failed writable memo, error-wrapper formatting, comparator release, snapshot capture) are not this rule. **Status:** standing.

---

## F. Declared lag (F-1, F-2, F-7, F-9)

### D-40. A deferred source lags its readers and never leads them
Expand Down
4 changes: 2 additions & 2 deletions packages/signals/docs/RULES-FUNDAMENTALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ These are the only words the fundamentals use. Internal names (transaction, stag

**Immutable.** An error thrown by content under an `<Errored>` 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: `<Errored>` 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-41).

**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-41. **Scenarios:** S-38, S-42, S-43.

---

Expand Down
38 changes: 37 additions & 1 deletion packages/signals/docs/SCENARIOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-39, D-41)

### S-36. A declared first paint is loading, not pending — D-30 (A27)

Expand Down Expand Up @@ -815,6 +815,42 @@ const data = createMemo(() => fetchOrFail(count())); // rejects for every co

`<Errored>` never catches a pending: `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warning (A6).

### S-42. A held rejection reveals with its frame — D-41 (#3886)

_Pinned: `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
<p>{count()}</p>
<Errored fallback={e => `Error ${e().count}`}>{data()}</Errored>
<p>{slow()}</p>
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 — the screen before D-41, 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-43. A held recovery keeps the shown error; recovering to the last good value is a change — D-41, D-39 (#3886)

_Pinned: `tests/error-is-an-answer.test.ts`._ Continues S-42 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-42'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` — before D-41, and then 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) | | | | |

## F. Declared lag (D-40)

### S-41. Independent panels over one selection: the write shows now, each panel lags to its own landing — D-40
Expand Down
Loading
Loading