Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion packages/signals/docs/OPEN-QUESTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,44 @@

**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 `<Errored>` around `data`, a `createEffect(data, { effect, error })`, and `<p>{count()}</p>` 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.

**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)

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

---

Expand Down
16 changes: 16 additions & 0 deletions packages/signals/docs/RULES-DERIVED.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,22 @@ An `<Errored>` 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, `<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.

**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 |
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 @@ -121,9 +121,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-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.

---

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-40)

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

Expand Down Expand Up @@ -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 | |

`<Errored>` 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
<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 — 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) | | | | |
Loading
Loading