diff --git a/.changeset/latest-uninitialized-throws.md b/.changeset/latest-uninitialized-throws.md new file mode 100644 index 000000000..fd02e7e3c --- /dev/null +++ b/.changeset/latest-uninitialized-throws.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +`latest()` of an uninitialized async source now throws `NotReadyError` in every scope. Unowned callers (event handlers, imperative code) used to receive `undefined` — a value the accessor's type excludes — because the uninitialized case shared the pending-shadow fallback's condition in `latestRead`. `isPending()` is unchanged: an unowned probe still answers `false` (A16), which `boolean` admits. Spec: A7 amended, A16 wording corrected (the boundary is ownership, not tracking), A17 authoritative-reader carve-out ruled, A32 added (children-forbidden readers see the frame). diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 753894092..4758295c2 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -22,13 +22,13 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | vocabulary | rules | cited in src | cited in tests | cited nowhere | | ---------- | ----- | ------------ | -------------- | ------------- | -| A | 30 | 12 | 30 | 0 | +| A | 31 | 13 | 31 | 0 | | V | 5 | 2 | 5 | 0 | | B | 5 | 0 | 5 | 0 | | C | 4 | 0 | 3 | 1 | | INV | 11 | 11 | 5 | 0 | | RUL | 13 | 6 | 6 | 5 | -| R (CS) | 59 | 18 | 9 | 36 | +| R (CS) | 59 | 18 | 11 | 34 | | R (OL) | 37 | 0 | 0 | 37 | | R (OS) | 46 | 2 | 0 | 44 | | R (PJ) | 36 | 6 | 1 | 30 | @@ -43,66 +43,67 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul ## A — spec propositions -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:221` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | -| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:229` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | -| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:237` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | -| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | -| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:253` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | -| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:261` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | -| A7 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | spec-async-semantics.test.ts×2 | [ruled] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. | -| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:109` | — | createMemo.test.ts×1 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | -| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | — | spec-async-semantics.test.ts×3 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:125` | invariants.ts×1 | createMemo.test.ts×1 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | -| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:51` | — | latest-isPending-consistency.test.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | -| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:133` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | -| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | -| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:183` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 treeshake.test.ts×3 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | -| A16 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:157` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 | [ruled 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read uninitialized asy… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×1 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | -| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | -| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:85` | async.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×3 uninitialized-visibility.test.ts×1 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | -| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:273` | invariants.ts×1 | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | -| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:280` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | -| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | -| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | -| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:93` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | -| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:211` | — | uninitialized-visibility.test.ts×3 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | -| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | scheduler.ts×1 | action-await-contract.test.ts×2 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | -| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:203` | — | loading-value.test.ts×2 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | -| A29 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 | held-conditional-memo.test.ts×1 | [ruled 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a live transaction h… | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:191` | attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | -| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×1 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +| --- | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:229` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | +| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:237` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | +| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | +| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:253` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | +| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:261` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | +| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:269` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | +| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:109` | verdict.ts×1 | spec-async-semantics.test.ts×2 visibility-oracle.test.ts×2 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. \*\*Amende… | +| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | — | createMemo.test.ts×1 visibility-oracle.test.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | +| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | — | spec-async-semantics.test.ts×3 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | invariants.ts×1 | createMemo.test.ts×1 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:51` | — | latest-isPending-consistency.test.ts×1 visibility-oracle.test.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | +| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:141` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | +| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:149` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | +| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:191` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 treeshake.test.ts×3 visibility-oracle.test.ts×7 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×3 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×1 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle.test.ts×16 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 visibility-oracle.test.ts×12 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | +| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:93` | async.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×10 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | +| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:281` | invariants.ts×1 | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | +| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:288` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | +| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | +| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | +| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle.test.ts×4 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | +| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:219` | — | uninitialized-visibility.test.ts×3 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle.test.ts×2 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | +| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:211` | — | loading-value.test.ts×2 visibility-oracle.test.ts×10 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | +| A29 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 | held-conditional-memo.test.ts×1 visibility-oracle.test.ts×3 | [ruled 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a live transaction h… | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×1 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:83` | — | visibility-oracle.test.ts×7 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------ | -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:343` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | -| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:353` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | -| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:359` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:366` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | -| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:378` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:351` | async.ts×1 | spec-async-semantics.test.ts×7 | - **V1 (violated A13) — FIXED.** A _resting_ optimistic node reported | +| V2 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:361` | async.ts×1 | spec-async-semantics.test.ts×2 | - **V2 (violated A7/A13) — FIXED.** `latest()`'s verdict in the window was | +| V3 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:367` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:374` | — | spec-async-semantics.test.ts×5 | - \*\*V4 (violated the old A20's three-form algebra) — FIXED, then the rule it | +| V5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:386` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | ## B — tier B | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | ------------------------------------------------------------------------------- | -------------------------------------------------- | -| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:141` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A13 (A13's section carries the ruling). | -| B2 | live | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A14 (A14's section carries the ruling). | -| B3 | live | `docs/SPEC-ASYNC-SEMANTICS.md:183` | — | spec-async-semantics.test.ts×2 | PROMOTED → A15 (A15's section carries the ruling). | +| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:149` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A13 (A13's section carries the ruling). | +| B2 | live | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | createRevealOrder.test.ts×16 onCleanup.test.ts×2 spec-async-semantics.test.ts×2 | PROMOTED → A14 (A14's section carries the ruling). | +| B3 | live | `docs/SPEC-ASYNC-SEMANTICS.md:191` | — | spec-async-semantics.test.ts×2 | PROMOTED → A15 (A15's section carries the ruling). | | B4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:41` | — | spec-async-semantics.test.ts×2 | PROMOTED → A18 (A18's section carries the ruling). | -| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | +| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:165` | — | spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | ## C — tier C | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | -------------------------------------------------- | --------------------------------------------------------------------------- | -| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:85` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | -| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:309` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | -| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:319` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:93` | — | onCleanup.test.ts×2 spec-async-semantics.test.ts×1 | PROMOTED → A19 (A19's section carries the ruling). | +| C2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:317` | — | onCleanup.test.ts×2 | - [x] **C2 — RULED (2026-07-07): reverts do not trump other live lanes.** A | +| C3 | closed | `docs/SPEC-ASYNC-SEMANTICS.md:327` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | | C4 | live | `docs/SPEC-ASYNC-SEMANTICS.md:31` | — | spec-async-semantics.test.ts×1 | PROMOTED → A17 (A17's section carries the ruling). | ## INV — invariants @@ -144,17 +145,17 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | ------ | ------ | ------------------------------------- | ------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | CS-R1 | live | `docs/rules-mining/core-store.md:9` | — | flatten-async-iterable.test.ts×5 syncThenable.test.ts×14 | Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).\*\* | -| CS-R2 | live | `docs/rules-mining/core-store.md:12` | — | syncThenable.test.ts×12 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).\*\* | +| CS-R2 | live | `docs/rules-mining/core-store.md:12` | — | syncThenable.test.ts×12 visibility-oracle.test.ts×1 | Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).\*\* | | CS-R2a | live | `docs/INTERNALS-STORE-STATE.md:107` | reconcile.ts×1 store.ts×1 | — | Corollary R2a (Ryan, 2026-08-17): \*\*take no responsibility for mutation | | CS-R3 | live | `docs/rules-mining/core-store.md:16` | — | — | A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.\*\* | | CS-R4 | live | `docs/rules-mining/core-store.md:19` | — | — | Write isolation across a store chain: writing through the last store in a derived chain is visible only there; upstream stores and base objects untouched (shallow or deep middle).\*\* | -| CS-R5 | live | `docs/rules-mining/core-store.md:22` | — | — | Upstream writes propagate downstream through the chain without re-running structural machinery.\*\* | +| CS-R5 | live | `docs/rules-mining/core-store.md:22` | — | visibility-oracle.test.ts×4 | Upstream writes propagate downstream through the chain without re-running structural machinery.\*\* | | CS-R6 | live | `docs/rules-mining/core-store.md:25` | — | — | No store write path ever mutates a user-provided source object.\*\* Aligned with 2026-08-16b. | | CS-R7 | live | `docs/rules-mining/core-store.md:28` | — | — | Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).\*\* | | CS-R8 | live | `docs/rules-mining/core-store.md:31` | — | — | `snapshot` returns fully unwrapped values (no proxy anywhere, `$TARGET` undefined), incl. frozen objects/arrays; reflects committed written values incl. writes over inherited prototype props.\*\* | | CS-R9 | live | `docs/rules-mining/core-store.md:34` | store.ts×2 target.ts×1 | — | Proxy identity per logical slot is stable across writes and reconciles\*\* (mapArray keyed flows reuse rows across refetch/reconcile). | | CS-R10 | live | `docs/rules-mining/core-store.md:39` | — | next-smoke.test.ts×1 | Per-property tracking; same-value writes (direct or functional path setter returning prev) do not re-trigger.\*\* | -| CS-R11 | live | `docs/rules-mining/core-store.md:42` | — | — | Per-path tracking: reading `state.user.firstName` subscribes to that leaf; reading the reference `store[0]` does not subscribe to `store[0].i`.\*\* | +| CS-R11 | live | `docs/rules-mining/core-store.md:42` | — | visibility-oracle.test.ts×2 | Per-path tracking: reading `state.user.firstName` subscribes to that leaf; reading the reference `store[0]` does not subscribe to `store[0].i`.\*\* | | CS-R12 | live | `docs/rules-mining/core-store.md:45` | store.ts×1 | — | Reading an absent key subscribes to that key: other-key changes don't trigger; defining it later (assignment or defineProperty) does.\*\* | | CS-R13 | live | `docs/rules-mining/core-store.md:48` | target.ts×1 | — | `in` tracks presence, not value: undefined-write doesn't retrigger; delete does; adding absent key does. `in`/`has` never invokes source getters.\*\* | | CS-R14 | live | `docs/rules-mining/core-store.md:51` | — | — | `Object.keys` / `for…in` subscribe to key-set membership (root and nested) — distinct from property nodes.\*\* Aligned: key-set node. | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 98f8b6896..0f750a789 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -30,11 +30,11 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t ### A17. An active override is the displayed value until its transaction commits, and the graph's value until its own source answers -**Status:** **ruled, amended in place** 2026-07-06 (promoted from C4) — maintainer ruling, 2026-07-06/07; amended 2026-09-09 (#3331: "knowing otherwise" splits display from tracked derivations — see A18 supersession) +**Status:** **ruled, amended in place** 2026-07-06 (promoted from C4) — maintainer ruling, 2026-07-06/07; amended 2026-09-09 (#3331: "knowing otherwise" splits display from tracked derivations — see A18 supersession); carve-out ruled 2026-09-14 (`until()` reads the landed world) **Pinned by:** `tests/spec-async-semantics.test.ts`; downstream-async lane holding: `tests/createOptimistic.test.ts` (CategoryDisplay/News-Finance real-world sections) **Mechanism (index, 2026-09-14):** `_overrideValue` + `hasActiveOverride`; value selection in `read` / `readNodeFast` / store `serveDataKey`; authoritative-view carve-out for `until()` (`CONFIG_AUTHORITATIVE_READ`); held-truth mask `CONFIG_HELD_TRUTH` (#3164); after the own-source landing `supersededRead` routes tracked readers to the arrived value while untracked reads keep `_overrideValue` (`CONFIG_OVERRIDE_SUPERSEDED`, #3331). -**Statement (current).** (was C4) **Amended 2026-09-09 (#3331):** "until we know otherwise" is the landing of the node's own async, and knowing otherwise splits the readers: from that landing on, the override is the value for the _display_ — untracked/ambient reads and the applied frame — until the transaction commits, while tracked derivations (memos, async drivers, lane recomputes) see the arrived truth (A18 supersession). `latest(x)` returns the arrived value; `isPending(x)` is `true` iff it differs from the override. "It is the optimistic future value... it is both immediate and is the future until we know otherwise." "Knowing otherwise" is its own async source resolving (see A18); a transition whose optimistic node is still pending on its own fetch is not complete, so the override cannot be dropped early. **No-tearing is an effect-level concern, not a read-level one**: when async _derived from_ the optimistic value is in flight, the lane holds its render effects (the rendered view keeps the committed state as a unit) — but direct reads still return the override ("direct read shows optimistic, effect waits"). +**Statement (current).** (was C4) **Amended 2026-09-09 (#3331):** "until we know otherwise" is the landing of the node's own async, and knowing otherwise splits the readers: from that landing on, the override is the value for the _display_ — untracked/ambient reads and the applied frame — until the transaction commits, while tracked derivations (memos, async drivers, lane recomputes) see the arrived truth (A18 supersession). `latest(x)` returns the arrived value; `isPending(x)` is `true` iff it differs from the override. "It is the optimistic future value... it is both immediate and is the future until we know otherwise." "Knowing otherwise" is its own async source resolving (see A18); a transition whose optimistic node is still pending on its own fetch is not complete, so the override cannot be dropped early. **No-tearing is an effect-level concern, not a read-level one**: when async _derived from_ the optimistic value is in flight, the lane holds its render effects (the rendered view keeps the committed state as a unit) — but direct reads still return the override ("direct read shows optimistic, effect waits"). **Authoritative-reader carve-out (ruled 2026-09-14, visibility oracle):** `until()`'s predicate reads the _landed_ world — values a source has actually produced, staged or committed, before they have become visible — and never the caller's optimism. Maintainer: "it needs to work off landed values, but before they have become visible." So under a held write it sees the staged value; over an override it sees the truth beneath (staged if one arrived, else committed); over a pending or uninitialized node it suspends like any reader; over a loading-window node it sees the loading value. Mechanism: `CONFIG_AUTHORITATIVE_READ` on the predicate's computation, `authoritativeServe()` on the store side. **History (superseded formulation, kept verbatim).** The 2026-07-06 wording — its "tracked alike" and "any read path" clauses are replaced by the 2026-09-09 amendment the statement now leads with; the display half of both sentences still holds: An _active_ optimistic override is THE value for every **read** — ambient/untracked and tracked alike — regardless of transition entanglement. Do NOT mask the override from any read path to prevent tearing; that breaks the real-world optimistic-UI contract. @@ -80,6 +80,14 @@ A tracked computation served a node's staged `_pendingValue` — a value a live A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself owns — OPT-dirty, or adopted through its dependencies — and never under the lane of whichever reader happened to pull it. Lane posture changes what a read serves: under a lane, a pending node on no lane (or another lane) serves its committed value instead of throwing, and the entanglement gates serve committed values for the lane's own view. Those carve-outs are sound for the lane's effects, whose runs are that view, and unsound for a memo, whose result is cached for everyone. Before: the probe effect of `isPending(() => [fast(), copy()])` carried the companion lane of the pending signals it reads, and its pull of `copy = createMemo(() => slow())` ran under it; `copy` read the in-flight `slow` as its committed `0`, published a clean value, dropped its pending status, and its readers stopped holding `slow` — the transaction settled on `fast`'s landing with `slow` still in flight (`Fast: 1` beside `Slow: 0`, `Pending: false`). Now the pull throws `NotReady` as a plain reader would, `copy` stays pending, and the hold lasts until both flights land. A plain getter in place of `copy` never had the gap: the probe read `slow` directly, and a probe observes without deriving (A23). +### A32. Children-forbidden readers see the frame, not the graph + +**Status:** **ruled** 2026-09-14 — maintainer ruling (visibility oracle): "createTrackedEffect is like an after effect, it can't really participate in any meaningful way. It will be held for any transition it is linked to if something else holds it, but it's too late on creation… onSettled is a similar issue." +**Pinned by:** `tests/visibility-oracle.test.ts` (childrenForbidden column, every state) +**Mechanism (index, 2026-09-14):** `CONFIG_CHILDREN_FORBIDDEN` on the reader; `read()` / `readNodeFast()` serve `_value` for it after the override arm (#3006); `PENDING_ASYNC_FORBIDDEN_SCOPE` dev warning on a pending read. + +`createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the frame as it stands: committed values, and an active override where one is displayed (the override _is_ the frame — it shows through, superseded or not); a write held by a transaction is never visible to them. They cannot open or enter a hold of their own — a transaction reaches them only through the computation that linked them, and then only as a hold, never as a view of the staged world. On a pending node they read its committed value (the frame); on an uninitialized node there is none and they receive the `NotReadyError` (dev: `PENDING_ASYNC_FORBIDDEN_SCOPE` warns on any pending read); on a loading-window node, the loading value (A27). + ## Verdicts — `isPending()` and `latest()` ### A19. `isPending(x)` ≡ the observable value is not final (three causes) @@ -100,11 +108,11 @@ A memo's value is one shared slot every reader sees, so its pass runs under the ### A7. Resolved async never reads `[false, undefined]` -**Status:** **ruled** — #2829 (the `[false, undefined]` pins were a regression) +**Status:** **ruled, amended in place** — #2829 (the `[false, undefined]` pins were a regression); amended 2026-09-14 (uninitialized: `latest()` throws in every scope, never `undefined`) **Pinned by:** `tests/latest-async.test.ts`, `tests/createMemo.test.ts` **Mechanism (index, 2026-09-14):** `latest()` shadow (`_latestValueComputed`) is backfilled from `_pendingValue` when created after the write (verdict.ts, #3041). -After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. +After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. **Amended 2026-09-14:** before the first landing there is no visible value, and `latest()` never fabricates one — it throws `NotReadyError` in every scope, owned or unowned, exactly like the plain read. (Maintainer: returning `undefined` "would mess with types" — `latest` returns `T`.) The unowned scope used to return `undefined` by sharing the pending-shadow fallback's condition; the `isPending` twin stays `false` there because `false` inhabits `boolean` (A16). ### A8. `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel @@ -156,11 +164,11 @@ A resting optimistic node reports pending via exactly the causes a plain async m ### A16. `isPending` never throws in untracked contexts -**Status:** **ruled** 2026-07-06 (promoted from B5) — maintainer keep, 2026-07-06 +**Status:** **ruled, amended in place** 2026-07-06 (promoted from B5) — maintainer keep, 2026-07-06; wording corrected 2026-09-14 (the boundary is ownership, not tracking) **Pinned by:** `tests/spec-async-semantics.test.ts` **Mechanism (index, 2026-09-14):** `pendingCheckRead` swallows `NotReadyError` / errors when `getObserver() === null`; tracked carve-out B5a. -(was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read uninitialized async sources yield `false`. Carve-out (B5a, pinned as current behavior): in _tracked_ contexts the `NotReadyError` of an uninitialized source propagates so the reader participates in loading boundaries. +(was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read uninitialized async sources yield `false`. Carve-out (B5a, pinned as current behavior): in _tracked_ contexts the `NotReadyError` of an uninitialized source propagates so the reader participates in loading boundaries. **Wording corrected 2026-09-14 (visibility oracle):** the boundary is _ownership_ (`context === null`), not tracking. Inside an owner — a `createRoot` body, a computation, `untrack()` within either — the `NotReadyError` propagates, tracked or not, because an owner can route it to a boundary; only an unowned caller (event handler, imperative scope) gets `false`. `latest()` shares the mechanism but not the verdict: it throws in every scope (A7). ### A22. Pending is per-node; store-wide only for the firewall's own work diff --git a/packages/signals/scripts/rules-index.mjs b/packages/signals/scripts/rules-index.mjs index b26205891..a23aed3be 100644 --- a/packages/signals/scripts/rules-index.mjs +++ b/packages/signals/scripts/rules-index.mjs @@ -211,7 +211,6 @@ if (process.argv.includes("--check")) { cited.src.size + " ids); every live A-rule is cited by a test" ); - process.exit(0); } const order = { A: 0, V: 1, B: 2, C: 3, INV: 4, RUL: 5, R: 6, "§": 7 }; const rows = [...rules.values()].sort( @@ -307,7 +306,22 @@ for (const r of rows) { `| ${r.key} | ${r.status} | \`${r.def}\` | ${esc(fmtCites(cited.src.get(r.key)))} | ${esc(fmtCites(cited.tests.get(r.key)))} | ${esc(r.text.slice(0, 200))}${r.text.length > 200 ? "…" : ""} |` ); } -fs.writeFileSync(path.join(DOCS, "RULES-INDEX.md"), L.join("\n") + "\n"); +const INDEX = path.join(DOCS, "RULES-INDEX.md"); +const generated = L.join("\n") + "\n"; +if (process.argv.includes("--check")) { + // The committed index must be current. Prettier reflows table padding, + // escapes underscores, rewrites *em* as _em_ and adds blank lines around + // headings on commit, so compare with whitespace, backslashes, emphasis + // markers and dash runs collapsed. + const norm = t => t.replace(/[\s\\*_]+/g, "").replace(/-+/g, "-"); + if (norm(read(INDEX)) !== norm(generated)) { + console.error("rules-index: docs/RULES-INDEX.md is stale — run `node scripts/rules-index.mjs`"); + process.exit(1); + } + console.log("rules-index: docs/RULES-INDEX.md is current"); + process.exit(0); +} +fs.writeFileSync(INDEX, generated); console.log( `RULES-INDEX.md: ${rows.length} rules; src citations ${cited.src.size} ids (${un.length} unresolved); tests ${cited.tests.size} ids (${unT.length} unresolved)` ); diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index b7966361c..0aee57f87 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -425,10 +425,12 @@ function latestRead(el: Signal | Computed): T { } value = read(pendingComputed); } catch (e) { - if ( - e instanceof NotReadyError && - (!context || !((el as Computed)._statusFlags & STATUS_UNINITIALIZED)) - ) + // A NotReady from the shadow of an INITIALIZED source means the shadow + // is mid-flight: serve the visible (committed / override) value. An + // uninitialized source has no visible value — latest() throws in every + // scope rather than fabricate `undefined` for a `T` that excludes it + // (A7; the unowned scope used to return undefined here). + if (e instanceof NotReadyError && !((el as Computed)._statusFlags & STATUS_UNINITIALIZED)) return visibleValue; throw e; } finally { diff --git a/packages/signals/tests/rules-index.test.ts b/packages/signals/tests/rules-index.test.ts index f50e563bf..9868223a2 100644 --- a/packages/signals/tests/rules-index.test.ts +++ b/packages/signals/tests/rules-index.test.ts @@ -27,4 +27,13 @@ describe("rules index", () => { const out = execFileSync(process.execPath, [script, "--check"], { encoding: "utf8" }); expect(out).toContain("every live A-rule is cited by a test"); }); + + it("docs/RULES-INDEX.md is current (regenerate with `node scripts/rules-index.mjs`)", () => { + const script = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../scripts/rules-index.mjs" + ); + const out = execFileSync(process.execPath, [script, "--check"], { encoding: "utf8" }); + expect(out).toContain("RULES-INDEX.md is current"); + }); }); diff --git a/packages/signals/tests/visibility-oracle.test.ts b/packages/signals/tests/visibility-oracle.test.ts new file mode 100644 index 000000000..d9065a83a --- /dev/null +++ b/packages/signals/tests/visibility-oracle.test.ts @@ -0,0 +1,665 @@ +/** + * Visibility oracle — node state × reader kind → what is served. + * + * The runtime decides what a read serves at eleven sites (read, readNodeFast, + * supersededRead, latestRead, gatedRead, laneReadsCommitted, heldFromStale, + * store nodeValue/serveDataKey/optimisticView). Every new visibility rule is + * threaded through them by hand. This file pins the OBSERVABLE outcome per + * cell so a rule that reaches one site and misses another shows up as a red + * cell instead of an audit finding. + * + * Rows: node states. Columns: reader kinds. Each expected cell cites the rule + * that fixes it (SPEC-ASYNC-SEMANTICS.md). Cells the spec does not fix are + * `observed(...)`: pinned as current behavior, listed by `reportUnspecified` + * so they can be brought to a ruling — changing one is a design decision. + * + * Reader kinds: + * - untracked x() with no observer + * - derivesFrom the value a mainline memo's PASS read (compute-side log) + * - published what a render effect over that memo published; HELD if nothing + * - preexisting what a render effect built before the state published after it (HELD if nothing) + * - staleForeign that same effect re-run by an unrelated mainline write + * - childrenForbidden createTrackedEffect reading x + * - latest latest(() => x()) + * - isPending isPending(() => x()) + * - authoritative until()'s predicate reading x (CONFIG_AUTHORITATIVE_READ) + */ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + NotReadyError, + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + createTrackedEffect, + flush, + isPending, + latest, + until +} from "../src/index.js"; + +/** The #3331 reporter graph: an optimistic computed over an async fetch of a + * signal, with an async memo downstream. `prime()` lands the initial fetch + * (and, when `initDownstream`, the downstream's first flight); `supersede()` + * runs an action that changes the question and guesses wrong, then lands the + * refetch with the differing truth while the action stays live. */ +function supersededGraph(initDownstream: boolean) { + const [value, setValue] = createSignal(0); + const fetchResolvers: Array<() => void> = []; + const flights: Array<() => void> = []; + let x!: () => unknown; + let setDouble!: (v: number) => void; + const dispose = createRoot(d => { + [x, setDouble] = createOptimistic(() => { + const v = value(); + return new Promise(r => fetchResolvers.push(() => r(v * 2))); + }); + const downstream = createMemo(() => { + const n = x(); + return new Promise(r => flights.push(() => r(`${n}!`))); + }); + createRenderEffect(downstream, () => {}); + return d; + }); + holds.push(() => flights.splice(0).forEach(f => f())); + return { + x, + dispose, + async prime() { + flush(); + fetchResolvers.shift()!(); + await settle(); + if (initDownstream) { + flights.shift()!(); + await settle(); + } + }, + async supersede() { + const run = action(function* () { + setValue(1); // new question → refetch + setDouble(3); // wrong guess; truth will be 2 + yield never(); + }); + run(); + flush(); + fetchResolvers.shift()!(); // own source lands 2 ≠ 3 while the action is live + await settle(); + } + }; +} + +// ── cell values ───────────────────────────────────────────────────────────── +const HELD = "HELD" as const; // nothing published / the reader is holding +const NOT_READY = "throws:NotReady" as const; +type Cell = unknown | typeof HELD | typeof NOT_READY | `throws:${string}`; + +type Expect = + | { value: Cell; rule: string } + | { observed: Cell; note: string } + | { violation: { rule: Cell; current: Cell }; note: string } + | { na: string }; +const rule = (value: Cell, rule: string): Expect => ({ value, rule }); +const observed = (observed: Cell, note: string): Expect => ({ observed, note }); +/** The spec fixes this cell to `rule`; the runtime currently serves `current`. + * Pinned at `current` so the suite is green; listed red by the report. When + * the runtime is fixed this cell fails — flip it to `rule(...)`. */ +const violation = (ruleValue: Cell, current: Cell, note: string): Expect => ({ + violation: { rule: ruleValue, current }, + note +}); +const na = (why: string): Expect => ({ na: why }); + +const READERS = [ + "untracked", + "derivesFrom", + "published", + "preexisting", + "staleForeign", + "childrenForbidden", + "latest", + "isPending", + "authoritative" +] as const; +type Reader = (typeof READERS)[number]; + +type State = { + name: string; + /** Build the state. Returns the node accessor and a disposer. The stale + * foreign reader must be created BEFORE the state is entered, so builders + * receive a hook to install it. */ + build: ( + installStale: (x: () => unknown) => void + ) => + | { x: () => unknown; dispose: () => void } + | Promise<{ x: () => unknown; dispose: () => void }>; + expect: Record; +}; + +// ── helpers ───────────────────────────────────────────────────────────────── +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + flush(); +}; +// Every open-ended promise a state creates is registered here and released +// between cells: a never-settling action would otherwise leave its transaction +// live across the next cell (observed: an unrelated async memo's NotReady then +// escaped flush()). +let holds: Array<() => void> = []; +const never = () => new Promise(r => holds.push(r as () => void)); +const releaseAll = async () => { + for (const r of holds.splice(0)) r(); + await settle(); + await settle(); +}; +const classify = (fn: () => unknown): Cell => { + try { + return fn(); + } catch (e) { + return e instanceof NotReadyError + ? NOT_READY + : `throws:${(e as Error)?.constructor?.name ?? String(e)}`; + } +}; + +function readCell( + reader: Reader, + x: () => unknown, + stale: { log: Cell[]; bump: () => void } | null +): Cell { + switch (reader) { + case "untracked": + return classify(x); + case "latest": + return classify(() => latest(() => x())); + case "isPending": + return classify(() => isPending(() => x())); + case "derivesFrom": { + const log: Cell[] = []; + const dispose = createRoot(d => { + const m = createMemo(() => { + let v: Cell; + try { + v = x(); + } catch (e) { + log.push( + e instanceof NotReadyError ? NOT_READY : `throws:${(e as Error)?.constructor?.name}` + ); + throw e; // suspend like a real derivation + } + log.push(v); + return v; + }); + createRenderEffect(m, () => {}); + return d; + }); + flush(); + dispose(); + return log.length ? log[log.length - 1] : HELD; + } + case "published": { + const log: Cell[] = []; + const dispose = createRoot(d => { + const m = createMemo(() => x()); + createRenderEffect(m, v => { + log.push(v); + }); + return d; + }); + flush(); + dispose(); + return log.length ? log[log.length - 1] : HELD; + } + case "preexisting": { + if (!stale) return na("no pre-existing reader") as any; + return stale.log.length ? stale.log[stale.log.length - 1] : HELD; + } + case "staleForeign": { + if (!stale) return na("no stale reader") as any; + stale.log.length = 0; + stale.bump(); + flush(); + return stale.log.length ? stale.log[stale.log.length - 1] : HELD; + } + case "childrenForbidden": { + const log: Cell[] = []; + const dispose = createRoot(d => { + createTrackedEffect(() => { + log.push(classify(x)); + }); + return d; + }); + flush(); + dispose(); + return log.length ? log[log.length - 1] : HELD; + } + case "authoritative": { + let seen: Cell = HELD; + // until()'s predicate is the authoritative reader; it runs synchronously + // on the first evaluation. Resolve immediately so nothing is left open. + until(() => { + seen = classify(x); + return true; + }).catch(() => {}); + flush(); + return seen; + } + } +} + +// ── states ────────────────────────────────────────────────────────────────── +const STATES: State[] = [ + { + name: "committed (signal, no write)", + build(installStale) { + const [x] = createSignal(0); + installStale(x); + return { x, dispose() {} }; + }, + expect: { + untracked: rule(0, "baseline"), + derivesFrom: rule(0, "baseline"), + published: rule(0, "baseline"), + preexisting: rule(HELD, "baseline: nothing new to publish"), + staleForeign: rule(0, "baseline"), + childrenForbidden: rule(0, "baseline"), + latest: rule(0, "baseline"), + isPending: rule(false, "baseline"), + authoritative: rule(0, "baseline") + } + }, + { + name: "staged, ambient (set(1) before the flush)", + build(installStale) { + const [x, set] = createSignal(0); + installStale(x); + set(1); + return { x, dispose() {} }; + }, + expect: { + // Readers that flush observe the write land — trivially 1. + untracked: observed( + 0, + "pre-flush untracked read serves committed; A28 (#3337, unmerged) rules on this tick" + ), + derivesFrom: rule(1, "the flush carries the write"), + published: rule(1, "the flush carries the write"), + preexisting: observed(HELD, "pre-flush: nothing has run yet"), + staleForeign: rule(1, "the flush carries the write"), + childrenForbidden: rule(1, "the flush carries the write"), + latest: observed( + 1, + "pre-flush latest() serves the unflushed write; A28 (#3337) would make this 0 until the flush" + ), + isPending: observed( + true, + "pre-flush verdict flips on the unflushed write; A28 (#3337) territory" + ), + authoritative: observed( + 1, + "until() predicate pre-flush sees the unflushed write; A28 (#3337) territory" + ) + } + }, + { + name: "held by a live action (set(1) inside action, yield forever)", + build(installStale) { + const [x, set] = createSignal(0); + installStale(x); + const run = action(function* () { + set(1); + yield never(); + }); + run(); // imperative scope: the test body + flush(); + return { x, dispose() {} }; + }, + expect: { + untracked: rule( + 0, + "A19 (i): the observable value is the committed one while the write is held" + ), + derivesFrom: rule( + 1, + "A29: a tracked pass served the staged value derives from the transaction's world" + ), + published: violation( + HELD, + 1, + "A29: a fresh mainline memo + render effect created during the hold publishes the HELD value into the mainline frame; a fresh effect reading the signal directly, and pre-existing readers, correctly show 0" + ), + preexisting: rule(HELD, "A19 (i): the held write is not on screen"), + staleForeign: rule( + 0, + "A15 reveal corollary / A26: a stale reader of a parallel transaction shows committed, no entanglement" + ), + childrenForbidden: rule( + 0, + "A32: children-forbidden readers see the frame; a held write is never visible to them" + ), + latest: rule(1, "A8/A11: the held value exists from the write; latest serves it"), + isPending: rule(true, "A19 (i)"), + authoritative: rule( + 1, + "A17 carve-out (2026-09-14): until() reads the landed world — the staged value" + ) + } + }, + { + name: "override active (createOptimistic set to 5 inside a live action)", + build(installStale) { + let x!: () => unknown; + let setO!: (v: number) => void; + const dispose = createRoot(d => { + [x, setO] = createOptimistic(0); + return d; + }); + installStale(x); + const run = action(function* () { + setO(5); + yield never(); + }); + run(); + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule(5, "A17: the override is the displayed value"), + derivesFrom: rule(5, "A17: the override is the graph's value until its own source answers"), + published: observed( + 5, + "A17: direct read shows optimistic; whether a mainline memo over it publishes or holds is not stated" + ), + preexisting: rule( + 5, + "A17: the override is the displayed value; no downstream async, so the lane has nothing to wait for" + ), + staleForeign: rule(5, "A17: the applied frame shows the override"), + childrenForbidden: rule(5, "A32: the override is the frame; it shows through"), + latest: rule(5, "A17 / OL-R11: the override is the value on every channel"), + isPending: rule(false, "A24 (3): optimistic writes are verdict-inert"), + authoritative: rule(0, "A17: until()'s predicate never sees the caller's own optimism") + } + }, + { + name: "override, ambient (createOptimistic set to 5 outside any action, before the flush)", + build(installStale) { + let x!: () => unknown; + let setO!: (v: number) => void; + const dispose = createRoot(d => { + [x, setO] = createOptimistic(0); + return d; + }); + installStale(x); + setO(5); + return { x, dispose }; + }, + expect: { + untracked: rule(5, "OL-R2: synchronously visible before any flush"), + derivesFrom: rule( + 0, + "OL-R5: an ambient optimistic write reverts at the next flush (the flush the reader forces)" + ), + published: rule(0, "OL-R5"), + preexisting: observed(HELD, "pre-flush: nothing has run yet"), + staleForeign: rule(0, "OL-R5"), + childrenForbidden: rule(0, "OL-R5"), + latest: rule(5, "OL-R11 pre-flush"), + isPending: rule(false, "A24 (3)"), + authoritative: rule( + 0, + "A17 carve-out: never the caller's optimism (and the ambient override reverts at the flush the reader forces)" + ) + } + }, + { + name: "superseded (own source landed 2 ≠ override 3; initialized downstream flight keeps the action live)", + async build(installStale) { + const built = supersededGraph(true); + await built.prime(); + installStale(built.x); + await built.supersede(); + return built; + }, + expect: { + untracked: rule( + 3, + "A18 (c): untracked reads keep the override until the transaction commits" + ), + derivesFrom: rule(2, "A18 (b): tracked derivations recompute from the arrived value"), + published: violation( + HELD, + 2, + "A18 (c): a fresh mainline memo + render effect over the superseded node publishes the TRUTH (2) while a fresh DIRECT render effect in the same root publishes the override (3) and every pre-existing reader holds — a tear between readers of one frame" + ), + preexisting: rule(HELD, "A18 (c): the applied frame keeps the override until commit"), + staleForeign: rule( + 3, + "A18 (c) / A17 amended: a stale reader of another transaction displays the override" + ), + childrenForbidden: rule(3, "A32: the displayed override shows through, superseded or not"), + latest: rule(2, "A18 (d): latest returns the arrived value"), + isPending: rule(true, "A18 (d): pending iff the arrival differs from the override"), + authoritative: rule(2, "A17 carve-out: the authoritative reader sees the staged truth") + } + }, + { + name: "superseded, downstream never initialized (its first flight never lands)", + async build(installStale) { + const built = supersededGraph(false); + await built.prime(); + installStale(built.x); + await built.supersede(); + return built; + }, + expect: { + untracked: rule(3, "A18 (c)"), + derivesFrom: rule(2, "A18 (b)"), + published: violation(HELD, 2, "as above"), + preexisting: observed( + HELD, + "this reader (created after the node initialized) holds. A render effect on the node created BEFORE its first landing published the truth (2) at the supersession in a side probe — while untracked reads still served 3 — so the hold here is shape-dependent; follow-up" + ), + staleForeign: observed(3, "displays the override, as in the initialized case"), + childrenForbidden: rule(3, "A32"), + latest: rule(2, "A18 (d)"), + isPending: violation( + true, + false, + "A18 (d): the arrival differs from the override yet the verdict is false — the uninitialized downstream reporter is not counted as observing the flight" + ), + authoritative: rule(2, "A17 carve-out") + } + }, + { + name: "pending own async (initialized memo refetching on a new question, flight up)", + async build(installStale) { + const [q, setQ] = createSignal(0); + const resolvers: Array<(v: number) => void> = []; + let x!: () => unknown; + const dispose = createRoot(d => { + const m = createMemo(() => { + const v = q(); + return new Promise(r => resolvers.push(r)).then(() => v * 10); + }); + x = m; + createRenderEffect(m, () => {}); + return d; + }); + flush(); + resolvers.shift()!(0); + await settle(); + installStale(x); + setQ(1); // new question; flight never lands + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule( + 0, + "A19 (ii): the observable value is the committed one while the node's own async is in flight" + ), + derivesFrom: rule( + NOT_READY, + "A15: a fresh derivation suspends on the observed flight. (With no reader holding on the flight the pass reads the committed value instead and the reveal holds — the frame is identical; observation-driven transactions make the difference inherent, ruled 2026-09-14.)" + ), + published: rule(HELD, "A15"), + preexisting: rule(HELD, "A15: the reader that observed the flight holds"), + staleForeign: observed( + 0, + "A15 reveal corollary says a stale reader holds when the flight's inputs are published (#3305); here the new question was a mainline write and the reader shows the pre-flight committed value — does INPUTS_PUBLISHED cover a flight opened by the same batch?" + ), + childrenForbidden: rule(0, "A32: a pending node's committed value is the frame"), + latest: rule( + 0, + "A8: latest shows the stale value while the own fetch for a new question is in flight" + ), + isPending: rule(true, "A19 (ii) / A24 (2): a new question pends"), + authoritative: rule( + NOT_READY, + "A17 carve-out: nothing has landed for the new question; the predicate suspends like any reader" + ) + } + }, + { + name: "uninitialized (memo whose first flight never lands)", + build(installStale) { + let x!: () => unknown; + const dispose = createRoot(d => { + const m = createMemo(() => never()); + x = m; + createRenderEffect(m, () => {}); + return d; + }); + installStale(x); + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule( + NOT_READY, + "A19 exception (1): loading, not pending — the NotReady propagates" + ), + derivesFrom: rule( + NOT_READY, + "A16 carve-out (B5a): tracked contexts propagate NotReady to loading boundaries" + ), + published: rule(HELD, "A19 exception (1)"), + preexisting: rule(HELD, "A19 exception (1)"), + staleForeign: rule( + HELD, + "A15 reveal corollary: an uninitialized node has no committed value to show" + ), + childrenForbidden: rule(NOT_READY, "A32: no frame to read yet"), + latest: rule( + NOT_READY, + "A7 (amended 2026-09-14): latest() of an uninitialized node throws in every scope, never undefined" + ), + isPending: rule( + false, + "A16: an unowned isPending never throws; A19 exception (1): loading is not pending" + ), + authoritative: rule(NOT_READY, "A17 carve-out: nothing landed") + } + }, + { + name: "loading window (createMemo with loadingValue -1, first flight up)", + build(installStale) { + let x!: () => unknown; + const dispose = createRoot(d => { + const m = createMemo(() => never(), { loadingValue: -1 }); + x = m; + createRenderEffect(m, () => {}); + return d; + }); + installStale(x); + flush(); + return { x, dispose }; + }, + expect: { + untracked: rule(-1, "A27: the loading value is commit #0"), + derivesFrom: rule(-1, "A27: never suspends readers during the first flight"), + published: rule(-1, "A27"), + preexisting: rule(HELD, "A27: the loading value was published before the state; nothing new"), + staleForeign: rule(-1, "A27"), + childrenForbidden: rule(-1, "A27: loading-class, no suspension"), + latest: rule(-1, "A27"), + isPending: rule(false, "A27: verdict-quiet"), + authoritative: rule( + -1, + "A17 carve-out / A27: the loading value is commit #0 — landed by declaration" + ) + } + } +]; + +// ── run ───────────────────────────────────────────────────────────────────── +const unspecified: string[] = []; +const violations: string[] = []; +afterEach(async () => { + await releaseAll(); + flush(); +}); + +afterAll(() => { + if (process.env.VISIBILITY_ORACLE_REPORT) + require("node:fs").writeFileSync( + process.env.VISIBILITY_ORACLE_REPORT, + ["# violations", ...violations, "", "# unspecified", ...unspecified].join("\n") + "\n" + ); +}); + +describe("visibility oracle (A7, A15, A16, A17, A18, A19, A24, A26, A27, A29, A32)", () => { + for (const state of STATES) { + describe(state.name, () => { + for (const reader of READERS) { + const exp = state.expect[reader]; + if ("na" in exp) continue; + const title = + "value" in exp + ? `${String(exp.value)} [${exp.rule}]` + : "violation" in exp + ? `VIOLATION: rule says ${String(exp.violation.rule)}, runtime serves ${String(exp.violation.current)} (${exp.note})` + : `${String(exp.observed)} (observed; ${exp.note})`; + it(`${reader} → ${title}`, async () => { + let stale: { log: Cell[]; bump: () => void } | null = null; + const [u, setU] = createSignal(0); + const installStale = (x: () => unknown) => { + const log: Cell[] = []; + createRoot(() => { + createRenderEffect( + () => { + u(); + return x(); // a throw suspends the pass → HELD + }, + v => { + log.push(v); + } + ); + }); + flush(); + log.length = 0; + stale = { log, bump: () => setU(n => n + 1) }; + }; + const built = state.build(installStale); + const { x, dispose } = built instanceof Promise ? await built : built; + const got = readCell(reader, x, stale); + dispose(); + await releaseAll(); + if ("value" in exp) expect(got).toEqual(exp.value); + else if ("violation" in exp) { + violations.push( + `${state.name} × ${reader}: rule ${String(exp.violation.rule)}, runtime ${String(got)} — ${exp.note}` + ); + expect(got).toEqual(exp.violation.current); + } else { + unspecified.push(`${state.name} × ${reader} = ${String(got)} — ${exp.note}`); + expect(got).toEqual(exp.observed); + } + }); + } + }); + } +});