Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/signals/docs/RULES-DERIVED.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,9 @@ An armed boundary catches a lane the same way it catches a transition (D-12, D-1

**From:** F-9, F-1. **Scenarios:** S-26, S-27.

`isPending(x)` is `true` while any of three causes holds: (i) a write held by a live hold; (ii) the node's own async in flight for a new question; (iii) a fresh value landed but held uncommitted by a hold it is entangled with — and `false` the moment none does. Exception 1: a node with no committed value — a first load, or a memo born held over a held value (D-2) — is loading, not pending. A node answered and then re-asked in the same flush — an async first answer that lands in the tick a write re-asks it — is not a first load (amended 2026-10-30): its answer is a produced value, so `latest` of it shows the answer and `isPending` of it is `true`, the re-ask being a new question; so is a derivation of it with no value of its own, at any depth (F-9, D-22). It has no verdict: inside an owner `isPending` of it does what its read does — a first load's `NotReadyError` goes to a boundary or holds the change (F-2); a reader of a memo born held is held with it, or caught by a boundary that has not shown content — and the reader runs at the commit, where the answer is final; unowned it is `false` (D-24). A node with a committed value is judged wherever its readers are: on screen, behind a fallback, or in a branch not yet attached. A verdict on content that may have no committed value is read inside the boundary that owns it — `<Loading><p>{m()}</p><span>{isPending(m) ? "updating" : ""}</span></Loading>` — where its reader is caught with the content; read outside, it holds the change that mounted it. Either way no answer is shown before the commit: `<input disabled={isPending(data)}>` is never seen enabled over an unjudged value (`packages/web/test/ispending-guard-born-held.spec.tsx`). No verdict is the honest answer: `true` would say "updating" for something never shown, and `false` would read as settled to a guard like `if (!isPending(x)) save(x())`. Exception 2: a re-ask of the same question is quiet; an `affects()` mark is a declared new question and pends exactly the marked data and what derives from it, released when the declaring hold settles (or at flush end, ambient). `[isPending(x), x()]` read in one scope never pairs `true` with the fresh value: a verdict reader sees the screen — in either read order — unless it is a node a hold's fetch is waiting on (D-25). `isPending(() => latest(x))` follows `x`'s own non-quiet async only — on a signal or sync memo the latest form is never pending — so `[isPending(() => latest(x)), latest(x)]` never pairs `true` with the fresh value either. A spinner driven by `isPending` fires while the owner's async is still in flight (it does not wait for the hold).
`isPending(x)` is `true` while any of three causes holds: (i) a write held by a live hold; (ii) the node's own async in flight for a new question; (iii) a fresh value landed but held uncommitted by a hold it is entangled with — and `false` the moment none does. Exception 1: a node with no committed value — a first load, or a memo born held over a held value (D-2) — is loading, not pending. A node answered and then re-asked in the same flush — an async first answer that lands in the tick a write re-asks it — is not a first load (amended 2026-10-30): its answer is a produced value, so `latest` of it shows the answer and `isPending` of it is `true`, the re-ask being a new question; so is a derivation of it with no value of its own, at any depth (F-9, D-22). It has no verdict: inside an owner `isPending` of it does what its read does — a first load's `NotReadyError` goes to a boundary or holds the change (F-2); a reader of a memo born held is held with it, or caught by a boundary that has not shown content — and the reader runs at the commit, where the answer is final; unowned it is `false` (D-24). A node with a committed value is judged wherever its readers are: on screen, behind a fallback, or in a branch not yet attached. **A node initialized while pending by propagation is committed and pending (ruled 2026-10-31, #4569):** an answer that lands on a node with no committed value while an upstream re-ask has marked it pending by propagation — its own flight still current, nothing superseded it (#3373) — initializes the node: a node's question changes only when the node itself re-runs, so the answer is current, and the node is committed and pending on the upstream flight. Content that reads only its verdict reveals with `true` (S-40: a fresh boundary reading only a display-ahead channel shows what it has); content that reads the node holds until the chain settles — a first reveal has no screen to keep (F-2). That asymmetry is the rule, not a bug; a reading that kept the node loading would need a supersession by propagation no rule has, a new resting state, and would treat an uninitialized node more strictly than a committed one. A verdict on content that may have no committed value is read inside the boundary that owns it — `<Loading><p>{m()}</p><span>{isPending(m) ? "updating" : ""}</span></Loading>` — where its reader is caught with the content; read outside, it holds the change that mounted it. Either way no answer is shown before the commit: `<input disabled={isPending(data)}>` is never seen enabled over an unjudged value (`packages/web/test/ispending-guard-born-held.spec.tsx`). No verdict is the honest answer: `true` would say "updating" for something never shown, and `false` would read as settled to a guard like `if (!isPending(x)) save(x())`. Exception 2: a re-ask of the same question is quiet; an `affects()` mark is a declared new question and pends exactly the marked data and what derives from it, released when the declaring hold settles (or at flush end, ambient). `[isPending(x), x()]` read in one scope never pairs `true` with the fresh value: a verdict reader sees the screen — in either read order — unless it is a node a hold's fetch is waiting on (D-25). `isPending(() => latest(x))` follows `x`'s own non-quiet async only — on a signal or sync memo the latest form is never pending — so `[isPending(() => latest(x)), latest(x)]` never pairs `true` with the fresh value either. A spinner driven by `isPending` fires while the owner's async is still in flight (it does not wait for the hold).

**Collapses:** [A19](./SPEC-ASYNC-SEMANTICS.md#a19-ispendingx--the-observable-value-is-not-final-three-causes), [A24](./SPEC-ASYNC-SEMANTICS.md#a24-question-scoped-pending-pending-iff-a-value-change-is-in-flight-or-an-affects-mark-is-live), [A10](./SPEC-ASYNC-SEMANTICS.md#a10-ispendingx-x-is-atomic-within-one-scope), [A8](./SPEC-ASYNC-SEMANTICS.md#a8-ispending--latestx-follows-xs-own-async-only--verdicts-are-per-channel), [A14](./SPEC-ASYNC-SEMANTICS.md#a14-companion-nodes-get-child-lanes-that-do-not-merge-with-the-owner), [A20](./SPEC-ASYNC-SEMANTICS.md#a20-superseded-optimistic-writes-announce-a-store-wide-pending)/[A21](./SPEC-ASYNC-SEMANTICS.md#a21-superseded-the-store-wide-mask) (superseded — the mask is gone), recovery 6.3. **Status:** standing. A probe alone holds nothing; a plain read beside it holds (`optimistic-settle-verdicts.test.ts` #3409, `question-scoped-pending.test.ts` 3.4; #3977). `isPending` of a memo born held, read inside and outside the boundary: `tests/fresh-boundary-born-held-reader.test.ts` (#3977; the engine joins the reader to the hold as the read does, which is the throw's observable effect). **Known deviation (#4469, 2026-10-30):** a verdict reader is re-run at every landing of a hold it observed, even when the landing changes nothing it read; the gate that would skip it needs a deps walk. Measured 2026-10-30: two cheaper gates broke F-9/D-23 (verdict-memo-flight-no-reask) and #4189 (errored-store-probe-settled-3962) pins and failed the frozen floors by +31/+36 B. A plain reader of the same recovered hold is not in this: it shows the committed value for the whole hold and runs once, at the commit, since a recovery to a value equal to the last good one is still a change of answer (D-41, D-9). Pinned in `tests/hold-recovers-landing-reruns-4469.test.ts` (the verdict rows `it.fails`). A verdict reader of a signal whose writes net to its committed value in the tick an action opens a hold is not in this either (#4485, fixed 2026-10-30): the writes made no proposal (D-8), so the reader is not the hold's stale reader and `isPending(s)` stays `false` (`tests/netted-verdict-reader-4485.test.ts`).
**Collapses:** [A19](./SPEC-ASYNC-SEMANTICS.md#a19-ispendingx--the-observable-value-is-not-final-three-causes), [A24](./SPEC-ASYNC-SEMANTICS.md#a24-question-scoped-pending-pending-iff-a-value-change-is-in-flight-or-an-affects-mark-is-live), [A10](./SPEC-ASYNC-SEMANTICS.md#a10-ispendingx-x-is-atomic-within-one-scope), [A8](./SPEC-ASYNC-SEMANTICS.md#a8-ispending--latestx-follows-xs-own-async-only--verdicts-are-per-channel), [A14](./SPEC-ASYNC-SEMANTICS.md#a14-companion-nodes-get-child-lanes-that-do-not-merge-with-the-owner), [A20](./SPEC-ASYNC-SEMANTICS.md#a20-superseded-optimistic-writes-announce-a-store-wide-pending)/[A21](./SPEC-ASYNC-SEMANTICS.md#a21-superseded-the-store-wide-mask) (superseded — the mask is gone), recovery 6.3. **Status:** standing. A probe alone holds nothing; a plain read beside it holds (`optimistic-settle-verdicts.test.ts` #3409, `question-scoped-pending.test.ts` 3.4; #3977). `isPending` of a memo born held, read inside and outside the boundary: `tests/fresh-boundary-born-held-reader.test.ts` (#3977; the engine joins the reader to the hold as the read does, which is the throw's observable effect). A first load answered while pending by propagation from an upstream re-ask, under a boundary (the issue's verdict-only content, a 3-deep chain, the plain-read controls that hold) and in a root: `tests/first-load-superseded-answer-4569.test.ts`, `packages/web/test/first-load-superseded-answer-4569.spec.tsx` (#4569, ruled 2026-10-31). **Known deviation (#4469, 2026-10-30):** a verdict reader is re-run at every landing of a hold it observed, even when the landing changes nothing it read; the gate that would skip it needs a deps walk. Measured 2026-10-30: two cheaper gates broke F-9/D-23 (verdict-memo-flight-no-reask) and #4189 (errored-store-probe-settled-3962) pins and failed the frozen floors by +31/+36 B. A plain reader of the same recovered hold is not in this: it shows the committed value for the whole hold and runs once, at the commit, since a recovery to a value equal to the last good one is still a change of answer (D-41, D-9). Pinned in `tests/hold-recovers-landing-reruns-4469.test.ts` (the verdict rows `it.fails`). A verdict reader of a signal whose writes net to its committed value in the tick an action opens a hold is not in this either (#4485, fixed 2026-10-30): the writes made no proposal (D-8), so the reader is not the hold's stale reader and `isPending(s)` stays `false` (`tests/netted-verdict-reader-4485.test.ts`).

### D-24. `isPending` never throws unowned; the probe is reads-only

Expand Down
2 changes: 1 addition & 1 deletion packages/signals/docs/RULES-FUNDAMENTALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ These are the only words the fundamentals use. Internal names (transaction, stag

**Immutable.** `isPending(x)` is `true` iff `x`'s value is not the final answer to a _new question_: an input changed and the new answer has not been committed, a landed answer waits in a hold, or in-flight work declared it will change the data (`affects()`). It is `false` the moment the value is final. Re-asking the same question — `refresh()`, polling, an action's confirming refetch — is quiet: the shown value still answers, `isPending` stays `false`, and the fresh value reveals silently; to make a reload read as pending, declare it (`affects(x); refresh(x)`). A node with no committed value has nothing to judge, so it has no verdict: a first load, or a memo born held over a held value (D-2). It is _loading_ — inside an owner `isPending` of it does what the read itself does: a first load's `NotReadyError` goes to a boundary or holds, and a reader of a memo born held is held with it (caught by a boundary that has not shown content, F-4, or held with the change, F-2) and runs at the commit, where the answer is final. No answer is shown before then. Unowned, `isPending` of it is `false` (D-24). `latest` of it is its pending value if it has one (a memo born held) and throws if it has none (a first load) (D-22). A declared first paint (`loadingValue`) is loading too. Optimistic values are verdict-inert: an optimistic write never makes its own slot pending and never silences anything. Pending is per node — per store key — never store-wide. For a node with a committed value, painting does not decide it: where `x`'s readers are — on screen, behind a fallback, in a branch not yet attached — does not change `isPending(x)`, and `latest(x)` is the newest value it has produced (D-22). A verdict on content that may have no committed value belongs inside the boundary that owns that content (D-23).

**What it does not say.** It does not say `isPending` can be `true` on creation (it cannot — hydration parity; a memo born held has no verdict until its commit). It does not say an action in progress is pending data (process affordances are co-written state, not verdicts).
**What it does not say.** It does not say `isPending` can be `true` on creation (it cannot — hydration parity; a memo born held has no verdict until its commit). It does not say an action in progress is pending data (process affordances are co-written state, not verdicts). It does not say a boundary's first frame cannot show `true`: "on creation" is the node's, not the screen's — a node whose first answer lands beside the write that re-asks it (#4459), or while an upstream re-ask has it pending by propagation (#4569), is committed and pending, and content that reads only its verdict reveals with it (D-23, S-40).

**Collapses:** A19 (three causes, two exceptions), A24 (question-scoped; `affects`), A27 (the loading window), A7, A8 (per channel), A9, A12, A13, A14, A16, A22, A23, OL-R12, OS-R17/R40, PJ-R28–R31, recovery 6.3/6.4. **Derived:** D-22–D-26, D-30, D-36. **Scenarios:** S-14, S-25–S-29, S-36, S-37.

Expand Down
17 changes: 17 additions & 0 deletions packages/signals/docs/SCENARIOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,23 @@ _Ruled 2026-10-09, from the read-order brief's sibling case (416). Not D-28: thi
| ✗ | | sibling `0`, slot `(empty)` | neither the fallback nor `1` — the boundary waited on `n0`, which it did not read |
| ✗ | | sibling `0 \| loading` | the same wait, shown as a fallback |

**The verdict form (ruled 2026-10-31, #4569; D-23, #3373):** a fresh `<Loading>` whose content reads only `isPending` of a first load reveals when the verdict exists. `a0 = async(s)`, `a2 = async(await a0())`; `a0:0` lands, `a2` asks `a2:11`; `setS(1)` re-asks `a0` and marks `a2` pending by propagation while `a2:11` is up and `a2` has no value; `a2:11` lands. `a2`'s question changes only when `a2` re-runs, so the answer is current: `a2` is committed and pending on `a0`. Content that reads `a2` holds (F-2: no screen to keep) — the asymmetry is the rule.

```tsx
<Loading fallback="loading">{isPending(() => a2()) ? "pending" : "settled"}</Loading>
```

| # | Action | verdict-only content | control: `{a2()}` — alone or beside the probe | Notes |
| --- | ---------------------------------------- | -------------------- | --------------------------------------------- | --------------------------------------------------------------------- |
| 1 | mount; `a0:0` lands; `a2` asks `a2:11` | `loading` | `loading` | first load |
| 2 | `setS(1)` — re-asks `a0:1` | `loading` | `loading` | `a2` pending by propagation, no value, own flight still current |
| 3 | `a2:11` lands | `pending` | `loading` | `a2` initializes, committed and pending on `a0`; the plain read waits |
| 4 | `a0:1` lands; `a2` re-runs, asks `a2:20` | `pending` | `loading` | |
| 5 | `a2:20` lands | `settled` | `21` / `21 settled` | |
| ✗ | | after 3: `loading` | | rejected: a first load kept loading by a supersession no rule has |

Pinned: `tests/first-load-superseded-answer-4569.test.ts` (also a 3-deep chain and the no-boundary root, which attaches at 3 with `pending`), `packages/web/test/first-load-superseded-answer-4569.spec.tsx`.

### S-45. A first answer landing in the same tick as an action that writes its input: `latest` shows it, the plain read waits — D-22, F-9 (async generator E9)

_Ruled 2026-10-28. The leaf form and the through-a-memo form show the same screen; today the leaf keeps the fallback up until the action ends — that is the ✗ row._
Expand Down
Loading
Loading