From daabc91657fc75107d3c86ae5a0c146014f382d0 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 15 Sep 2026 11:41:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(signals):=20A28=20=E2=80=94=20a=20write=20?= =?UTF-8?q?becomes=20visible=20at=20flush,=20to=20every=20channel=20(read-?= =?UTF-8?q?side)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between set(x) and the flush that carries it the write is unflushed: not the committed value, not the staged value latest()/isPending() serve, and not an input to a derivation created meanwhile. Optimistic writes match (A28 (5)): setOptimistic(v) becomes the active override at the carrying flush; the writer's own channels compose on it. A rewrite of a held node keeps the staged value the last flush left for the verdict channels until the next flush. Companions and store keys first materialized under a hold are born as the holding transaction's (#3336). Landed as a read-side rule rather than #3337's deferred subscriber walk: "unflushed" is structural (an ambient staged value outside a flush), read sites test one module flag (`unflushedStaged`, set when a node is staged or a held node rewritten outside a flush, cleared at flush start), and the write-path arms are cold helpers gated on loads the write already pays (`_transition`, `context`) — setSignal stays within every setter's inlining budget (~360 B bytecode; the arms inline cost 140 B and 10–20% on the write-loop benches). Readers served the flushed value are latched for the carrying flush (REACTIVE_MISSED_WAKE). A companion created lazily while its source carries an unflushed write joins the flush-start re-sync like one that existed at the write, so a derivation over latest() direct-commits as the optimistic view it is rather than being staged under whatever hold the round entered (a memo — and an effect — over latest() of a held source created mid-tick answered the previous value, or never ran, until the fetch settled). Supersedes the #2922 mid-tick latest() pull (flush() first to read your own write); OL-R2 / OS-R1 / CS-R34 superseded. Spec: A28 ruled + mechanism, A29 creation-time form, A7 amendment (latest() throws in every uninitialized scope). Oracles: every pre-flush cell reads the rule; no violation cells remain. Size: core floor 23,752 → 24,480 (conscious bump, notes in treeshake.test.ts and .size-limit.js). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/a28-writes-visible-at-flush.md | 7 + packages/signals/docs/RULES-INDEX.md | 395 +++++++------- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 8 + .../signals/docs/rules-mining/core-store.md | 58 +- .../docs/rules-mining/optimistic-lanes.md | 53 +- .../docs/rules-mining/optimistic-store.md | 22 +- packages/signals/scripts/rules-index.mjs | 2 +- packages/signals/src/core/constants.ts | 5 + packages/signals/src/core/core.ts | 152 +++++- packages/signals/src/core/scheduler.ts | 15 +- packages/signals/src/core/types.ts | 3 + packages/signals/src/core/verdict.ts | 138 +++-- packages/signals/src/store/next/optimistic.ts | 17 +- packages/signals/src/store/next/store.ts | 155 ++++-- packages/signals/src/store/next/target.ts | 2 +- .../signals/tests/createOptimistic.test.ts | 94 +++- .../tests/isPending-memo-consistency.test.ts | 13 +- .../tests/latest-held-till-flush.test.ts | 513 ++++++++++++++++++ .../tests/latest-plain-write-purity.test.ts | 28 +- .../latest-probe-order-independence.test.ts | 11 +- .../tests/latest-repeated-writes.test.ts | 63 ++- .../tests/latest-unobserved-memo.test.ts | 12 +- .../optimistic-store-layer-scope.test.ts | 12 +- .../tests/question-scoped-pending.test.ts | 61 ++- .../tests/snapshot-derived-store-rows.test.ts | 26 +- .../tests/store/createOptimisticStore.test.ts | 288 ++++++++-- .../projection-transition-isolation.test.ts | 8 +- packages/signals/tests/store/shallow.test.ts | 20 +- packages/signals/tests/treeshake.test.ts | 15 +- .../tests/visibility-oracle-store.test.ts | 25 +- .../signals/tests/visibility-oracle.test.ts | 34 +- scripts/size/.size-limit.js | 41 +- 32 files changed, 1840 insertions(+), 456 deletions(-) create mode 100644 .changeset/a28-writes-visible-at-flush.md create mode 100644 packages/signals/tests/latest-held-till-flush.test.ts diff --git a/.changeset/a28-writes-visible-at-flush.md b/.changeset/a28-writes-visible-at-flush.md new file mode 100644 index 000000000..a4e140551 --- /dev/null +++ b/.changeset/a28-writes-visible-at-flush.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +--- + +A write becomes visible at flush — to every channel (A28). Between `set(x)` and the flush that carries it, the write is not the committed value, not the staged value `latest()` / `isPending()` serve, and not an input to any derivation created meanwhile: `latest(x)` answers the pre-write value, `isPending(x)` is false, `until()`'s predicate evaluated in the carrying flush sees it. Optimistic writes are writes too (A28 (5), "match React"): `setOptimistic(v)` becomes the active override at the flush that carries it — plain reads, `snapshot()`, `in`, keys, `length` and `isPending()` see nothing before — while the writer's own channels (a functional updater, the store draft, the `affects()` declaration walk) compose on it. A rewrite of a node a transaction holds keeps the staged value the last flush left for `latest()`/verdicts until the next flush. Companions and store keys first materialized under a hold are born as the holding transaction's (#3336). + +Landed as a read-side rule rather than #3337's deferred subscriber walk: "unflushed" is structural (an ambient staged value outside a flush), the plain write path is untouched, and readers served the flushed value are latched for the carrying flush. Supersedes the #2922 mid-tick `latest()` pull (`flush()` first to read your own write) and re-pins the pre-A28 expectations accordingly. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index f7c3cc8e0..055f66e8a 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 | 32 | 15 | 32 | 0 | +| A | 33 | 17 | 33 | 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 | 15 | 31 | +| R (CS) | 59 | 18 | 16 | 31 | | R (OL) | 37 | 0 | 0 | 37 | | R (OS) | 46 | 2 | 0 | 44 | | R (PJ) | 36 | 6 | 1 | 30 | @@ -39,72 +39,73 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul **src/:** none — every citation resolves. -**tests/:** A28 (test-only citations are informational; `--check` gates src/ only) +**tests/:** none. ## A — spec propositions -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:241` | — | 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:249` | — | 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:257` | — | 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:265` | — | 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:273` | — | 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:281` | — | 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×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 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-store.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 visibility-oracle-store.test.ts×5 | [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 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 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-store.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×3 core.ts×3 lanes.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×1 lane-hold-on-observation.test.ts×1 overlapping-flights.test.ts×3 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 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.test.ts×5 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-store.test.ts×2 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×2 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-store.test.ts×12 visibility-oracle.test.ts×26 | [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 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.test.ts×10 visibility-oracle.test.ts×25 | [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 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×6 visibility-oracle.test.ts×12 | [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:293` | 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:300` | — | 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 visibility-oracle-store.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-store.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:231` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [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-store.test.ts×1 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:223` | — | loading-value.test.ts×2 visibility-oracle.test.ts×19 | [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 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×4 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 held-conditional-memo.test.ts×1 treeshake.test.ts×1 visibility-oracle-store.test.ts×4 visibility-oracle.test.ts×6 | [ruled, amended in place 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 … | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | async-landing-deps-3461.test.ts×3 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×2 | 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-store.test.ts×6 visibility-oracle.test.ts×10 | [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… | -| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:211` | boundaries.ts×2 scheduler.ts×1 | async-chain-supersession.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +| --- | ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:249` | — | 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:257` | — | 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:265` | — | 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:273` | — | 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:281` | — | 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:289` | — | 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:117` | verdict.ts×2 | spec-async-semantics.test.ts×2 visibility-oracle-store.test.ts×1 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:125` | core.ts×1 verdict.ts×2 | createMemo.test.ts×1 visibility-oracle-store.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:133` | — | spec-async-semantics.test.ts×3 visibility-oracle-store.test.ts×5 | [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:141` | invariants.ts×1 verdict.ts×1 | createMemo.test.ts×1 ispending-memo-unstamped-hold-3457.test.ts×2 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:59` | — | latest-isPending-consistency.test.ts×1 visibility-oracle-store.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:149` | — | 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:157` | 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:165` | — | 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:199` | async.ts×3 core.ts×3 lanes.ts×1 scheduler.ts×2 | async-chain-supersession.test.ts×1 lane-hold-on-observation.test.ts×1 overlapping-flights.test.ts×3 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 superseded-source-blocks-3462.test.ts×2 treeshake.test.ts×4 visibility-oracle-store.test.ts×5 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:173` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×2 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×2 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-store.test.ts×12 visibility-oracle.test.ts×26 | [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 verdict.ts×2 optimistic.ts×1 | body-end-supersession-visibility.test.ts×4 createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 superseded-before-first-commit.test.ts×5 visibility-oracle-store.test.ts×10 visibility-oracle.test.ts×25 | [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:101` | async.ts×1 core.ts×1 optimistic.ts×1 verdict.ts×1 | spec-async-semantics.test.ts×3 superseded-before-first-commit.test.ts×1 uninitialized-visibility.test.ts×1 visibility-oracle-store.test.ts×6 visibility-oracle.test.ts×12 | [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:301` | 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:308` | — | 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:181` | — | spec-async-semantics.test.ts×1 visibility-oracle-store.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:189` | — | 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:109` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle-store.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:239` | verdict.ts×1 | uninitialized-visibility.test.ts×3 visibility-oracle-store.test.ts×8 | [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:67` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle-store.test.ts×1 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:231` | — | loading-value.test.ts×2 visibility-oracle.test.ts×19 | [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… | +| A28 | ruled, mechanism landed | `docs/SPEC-ASYNC-SEMANTICS.md:51` | constants.ts×1 core.ts×18 scheduler.ts×3 types.ts×1 verdict.ts×6 optimistic.ts×3 store.ts×1 | createOptimistic.test.ts×5 latest-held-till-flush.test.ts×1 optimistic-store-layer-scope.test.ts×1 question-scoped-pending.test.ts×3 snapshot-derived-store-rows.test.ts×1 createOptimisticStore.test.ts×10 shallow.test.ts×1 treeshake.test.ts×2 visibility-oracle-store.test.ts×8 visibility-oracle.test.ts×8 | [ruled, mechanism landed 2026-09-15] A write becomes visible at flush — to every channel — (**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) \*\*A write becomes visible at flush — to every chan… | +| A29 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×5 effect.ts×1 optimistic.ts×1 | body-end-supersession-visibility.test.ts×1 born-held.test.ts×3 held-conditional-memo.test.ts×1 latest-held-till-flush.test.ts×2 treeshake.test.ts×1 visibility-oracle-store.test.ts×4 visibility-oracle.test.ts×6 | [ruled, amended in place 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 … | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:207` | async.ts×1 attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | async-landing-deps-3461.test.ts×3 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:83` | core.ts×2 | 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:91` | — | visibility-oracle-store.test.ts×6 visibility-oracle.test.ts×10 | [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… | +| A33 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:219` | boundaries.ts×2 scheduler.ts×1 | async-chain-supersession.test.ts×2 loading-reset-collects-forwarded-3459.test.ts×3 | [ruled 2026-09-12 (#3375)] A fallback-caught flight holds no transaction; a Loading reset moves the hold onto the boundary — A `` boundary showing its fallback is the display of everything un… | ## V — fixed violations | id | status | defined | cited in src | cited in tests | statement (at definition) | | --- | ------ | ---------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------ | -| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:363` | 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:373` | 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:379` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | -| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:386` | — | 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:398` | — | spec-async-semantics.test.ts×3 | - \*\*V5 (A17 corollary — found and fixed with the revert-target elimination, | +| V1 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:371` | 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:381` | 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:387` | — | spec-async-semantics.test.ts×2 | - **V3 (violated A19) — FIXED.** After a reporter-less transition completed, | +| V4 | fixed | `docs/SPEC-ASYNC-SEMANTICS.md:394` | — | 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:406` | — | 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: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). | +| B1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | 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:165` | — | 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:199` | — | 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:165` | — | spec-async-semantics.test.ts×2 | PROMOTED → A16 (A16's section carries the ruling). | +| B5 | live | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | 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: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:329` | — | 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:339` | — | — | - [x] **C3 — CLOSED by A19 (2026-07-07): early completion is by design.** | +| C1 | live | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | 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:337` | — | 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:347` | — | — | - [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 @@ -143,160 +144,160 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul ## R — core-store (`CS-R`) -| 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 visibility-oracle-store.test.ts×1 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` | — | visibility-oracle-store.test.ts×4 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` | — | visibility-oracle-store.test.ts×2 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. | -| CS-R15 | live | `docs/rules-mining/core-store.md:54` | store.ts×1 | — | Array structural tracking is uniform across idioms: indexed length loop, `for…of`, mapArray ($TRACK) all re-run exactly once per flush on add/update/removal.\*\* | -| CS-R16 | live | `docs/rules-mining/core-store.md:57` | — | — | `length` independently trackable; index write extending the array notifies length subscribers.\*\* | -| CS-R17 | live | `docs/rules-mining/core-store.md:60` | — | — | Truncating via `length = N` notifies tracked index reads of removed slots (re-run, observe undefined) and clears has/index/keys for removed indices.\*\* | -| CS-R18 | live | `docs/rules-mining/core-store.md:63` | — | — | `snapshot` is non-tracking.\*\* Aligned with read table. | -| CS-R19 | live | `docs/rules-mining/core-store.md:66` | — | — | `untrack` scopes only the wrapped read; property access on the escaped value afterwards tracks normally.\*\* | -| CS-R20 | live | `docs/rules-mining/core-store.md:69` | store.ts×1 | — | Source getters (own, prototype, merge-installed) execute with the proxy as receiver, so their internal reads track — incl. through projections.\*\* | -| CS-R21 | live | `docs/rules-mining/core-store.md:72` | store.ts×3 | deep-chained-view.test.ts×1 | Structural subscriptions through a wrapper view (store-in-store) chain to the wrapped source: $TRACK/mapArray, ownKeys, snapshot/trackSelf through an outer derived store re-run when the inner store re… | -| CS-R22 | live | `docs/rules-mining/core-store.md:76` | — | — | Slots holding non-wrappable values (markRaw, Map/Date, function) track by reference: reassignment notifies; internal mutation doesn't.\*\* | -| CS-R23 | live | `docs/rules-mining/core-store.md:81` | store.ts×1 | — | The proxy is immutable from outside the setter: direct assignment and delete are silently ignored (no change, no notify, no TypeError — traps report success while discarding).\*\* | -| CS-R24 | live | `docs/rules-mining/core-store.md:84` | — | next-smoke.test.ts×1 | Writes batch like signals: inside the setter draft, reads are read-your-writes (values, length, `in` sync); outside the setter, ALL reads — value, `in`, length — return pre-write state until flush(). … | -| CS-R25 | live | `docs/rules-mining/core-store.md:88` | — | next-smoke.test.ts×1 | Writes to properties with ZERO observers still batch (no effects anywhere; pre-write value visible between setState and flush).\*\* | -| CS-R26 | live | `docs/rules-mining/core-store.md:92` | — | — | Setting a key to undefined is not deletion: key stays present (`in` true, no key-set notify); only delete / storePath.DELETE removes.\*\* | -| CS-R27 | live | `docs/rules-mining/core-store.md:95` | store.ts×1 | — | The setter may return a replacement value that swaps the root wholesale; symbol keys on the replacement preserved.\*\* | -| CS-R28 | live | `docs/rules-mining/core-store.md:98` | — | — | `storePath` addressing: string keys, numeric indices, index arrays, predicate filters ((value, index)), ranges, trailing functional setters address and update intended paths + trigger per-path subscri… | -| CS-R29 | live | `docs/rules-mining/core-store.md:101` | store.ts×2 | overlay.test.ts×1 | Merge/replacement preserve accessor descriptors and keep getters LIVE (re-evaluated per read, reactive reads track), for pre-existing and new keys.\*\* | -| CS-R30 | live | `docs/rules-mining/core-store.md:105` | store.ts×2 | — | Prototype pollution fully guarded: `__proto__` assignment inert; reading `constructor` on the draft returns undefined; storePath refuses `__proto__`/`constructor`/`prototype` segments; skips unsafe ow… | -| CS-R31 | live | `docs/rules-mining/core-store.md:108` | projection.ts×1 | reconcile-resend-identity.test.ts×1 | Derived-store manual writes win over the recompute for the tick: manual setStore beats a queued recompute in the same flush; a SAME-VALUE manual write still holds against the recompute for that tick; … | -| CS-R32 | live | `docs/rules-mining/core-store.md:112` | store.ts×1 | visibility-oracle-store.test.ts×1 | A setter-staged replacement followed by reconcile lands the reconciled value — staged writes fold into the diff.\*\* Aligned: O7's resolution (a test already exists). | -| CS-R33 | live | `docs/rules-mining/core-store.md:115` | — | visibility-oracle-store.test.ts×2 | Action/async lane semantics on store properties: a write held by an action makes isPending true for that property (per-property, not whole-store) while showing the committed value; applies on settle.\*… | -| CS-R34 | live | `docs/rules-mining/core-store.md:118` | store.ts×1 | — | Optimistic writes visible immediately at write time (before flush), never touch base raw; ambient (non-action) optimistic writes auto-revert at flush end.\*\* | -| CS-R35 | live | `docs/rules-mining/core-store.md:122` | — | — | Mid-refetch optimistic overlays are consumed when data lands — identical via direct reads, mapArray, wrapper views, Object.keys, snapshot.\*\* | -| CS-R36 | live | `docs/rules-mining/core-store.md:125` | — | — | An active optimistic hold on a wrapper view masks inner-store changes for the view's subscribers: mid-hold inner refresh landing causes ZERO re-runs of the view's structural subscribers; the reveal re… | -| CS-R37 | live | `docs/rules-mining/core-store.md:129` | — | visibility-oracle-store.test.ts×1 | Setting store state from effect callbacks and promise resolutions works, applying next flush.\*\* | -| CS-R38 | live | `docs/rules-mining/core-store.md:134` | — | — | Shallow stores: root keys reactive (per-key nodes, membership, length), values served raw by identity at every depth, arrays and objects.\*\* | -| CS-R39 | live | `docs/rules-mining/core-store.md:137` | — | visibility-oracle-store.test.ts×1 | Shallow setter-scope reads serve raws; in-place mutation of a served raw is reactively inert — records replaced, never edited.\*\* | -| CS-R40 | live | `docs/rules-mining/core-store.md:140` | — | — | Shallow reconcile is positional: per-index effects only where the reference changed; reference-identical rows skip entirely; length propagates; `key` option moot.\*\* Aligned: unowned-reference skip rul… | -| CS-R41 | live | `docs/rules-mining/core-store.md:143` | reconcile.ts×1 store.ts×2 | — | A plain record replaced into a shallow store is STICKY raw-marked: presents raw in this store AND in any deep store that later ingests it.\*\* | -| CS-R42 | live | `docs/rules-mining/core-store.md:147` | reconcile.ts×1 store.ts×1 | — | markRaw values never wrap through ANY store (deep included); leaves for reconcile (reference replacement, no recursion).\*\* | -| CS-R43 | live | `docs/rules-mining/core-store.md:150` | — | — | Store proxies are exempt from shallow raw treatment: shallow store ingesting another store's proxy passes it through unmarked and serves a live wrapped view (upstream visible, downstream isolated), se… | -| CS-R44 | live | `docs/rules-mining/core-store.md:153` | store.ts×1 | — | Ingesting an already-deep-tracked raw into a shallow store throws in dev.\*\* | -| CS-R45 | live | `docs/rules-mining/core-store.md:157` | — | — | A shallow store nested in a deep store participates in the parent's reconcile (raw replacement, per-index notify).\*\* | -| CS-R46 | live | `docs/rules-mining/core-store.md:160` | — | — | Shallow projections work end-to-end (derive re-runs, output reconciles at boundary, rows stay raw).\*\* | -| CS-R47 | live | `docs/rules-mining/core-store.md:165` | — | — | Platform objects (Map, Set, Date, Node instances, subclasses) are structurally non-wrappable: served raw by identity; internal-slot methods work on read and draft paths; draft mutations land on the ra… | -| CS-R48 | live | `docs/rules-mining/core-store.md:169` | — | — | User class instances (custom prototypes) DO wrap: prototype getters track; methods on the draft receive the proxy as `this` (reactive writes).\*\* | -| CS-R49 | live | `docs/rules-mining/core-store.md:172` | — | — | Null-prototype objects wrap and track; function-valued props callable through the proxy.\*\* | -| CS-R50 | live | `docs/rules-mining/core-store.md:175` | — | — | Frozen sources fully supported (read/snapshot; getters returning frozen don't throw).\*\* | -| CS-R51 | live | `docs/rules-mining/core-store.md:179` | store.ts×3 | overlay.test.ts×1 write-floor.test.ts×2 | Proxy-invariant compliance via target indirection: keys/spread/descriptor reads never throw regardless of source rigidity; source-non-configurable prop readable, writable through the store, reported `… | -| CS-R52 | live | `docs/rules-mining/core-store.md:182` | — | — | Symbol-keyed properties first-class: read/write/descriptors/preserved through root replacement + storePath root merge; on arrays symbol writes are metadata (never affect length).\*\* | -| CS-R53 | live | `docs/rules-mining/core-store.md:185` | — | — | Array key hygiene: non-index string keys never affect length; `s[len] = undefined` grows length AND creates a present key.\*\* | -| CS-R54 | live | `docs/rules-mining/core-store.md:188` | — | — | Array natives work through the proxy on read (filter/reduce/map/iterate) and draft (push/pop/shift) paths.\*\* | -| CS-R55 | live | `docs/rules-mining/core-store.md:190` | — | — | Functions stored as values served raw, replaceable, slot-tracked.\*\* | -| CS-R56 | live | `docs/rules-mining/core-store.md:194` | — | — | Multiple setter calls before one flush coalesce: even a deep-reading (structural clone) effect re-runs exactly once per flush.\*\* | -| CS-R57 | live | `docs/rules-mining/core-store.md:197` | — | — | Effect ordering: parent effects before child effects created inside them, incl. shared deps through memos.\*\* | -| CS-R58 | live | `docs/rules-mining/core-store.md:200` | — | — | Mid-flush read coherence: untracked store reads inside internal machinery running WITHIN a flush (mapArray keyed:false under a Root owner) must observe the value being written in that flush, not stale… | +| 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 visibility-oracle-store.test.ts×1 | 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:13` | — | 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:18` | — | — | 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:22` | — | — | 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:26` | — | visibility-oracle-store.test.ts×4 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:30` | — | — | 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:34` | — | — | Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).\*\* | +| CS-R8 | live | `docs/rules-mining/core-store.md:38` | — | — | `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:42` | 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:48` | — | 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:52` | — | visibility-oracle-store.test.ts×1 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:56` | 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:60` | 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:64` | — | — | `Object.keys` / `for…in` subscribe to key-set membership (root and nested) — distinct from property nodes.\*\* Aligned: key-set node. | +| CS-R15 | live | `docs/rules-mining/core-store.md:68` | store.ts×1 | — | Array structural tracking is uniform across idioms: indexed length loop, `for…of`, mapArray ($TRACK) all re-run exactly once per flush on add/update/removal.\*\* | +| CS-R16 | live | `docs/rules-mining/core-store.md:72` | — | — | `length` independently trackable; index write extending the array notifies length subscribers.\*\* | +| CS-R17 | live | `docs/rules-mining/core-store.md:76` | — | — | Truncating via `length = N` notifies tracked index reads of removed slots (re-run, observe undefined) and clears has/index/keys for removed indices.\*\* | +| CS-R18 | live | `docs/rules-mining/core-store.md:80` | — | — | `snapshot` is non-tracking.\*\* Aligned with read table. | +| CS-R19 | live | `docs/rules-mining/core-store.md:84` | — | — | `untrack` scopes only the wrapped read; property access on the escaped value afterwards tracks normally.\*\* | +| CS-R20 | live | `docs/rules-mining/core-store.md:88` | store.ts×1 | — | Source getters (own, prototype, merge-installed) execute with the proxy as receiver, so their internal reads track — incl. through projections.\*\* | +| CS-R21 | live | `docs/rules-mining/core-store.md:92` | store.ts×3 | deep-chained-view.test.ts×1 | Structural subscriptions through a wrapper view (store-in-store) chain to the wrapped source: $TRACK/mapArray, ownKeys, snapshot/trackSelf through an outer derived store re-run when the inner store re… | +| CS-R22 | live | `docs/rules-mining/core-store.md:97` | — | — | Slots holding non-wrappable values (markRaw, Map/Date, function) track by reference: reassignment notifies; internal mutation doesn't.\*\* | +| CS-R23 | live | `docs/rules-mining/core-store.md:103` | store.ts×1 | — | The proxy is immutable from outside the setter: direct assignment and delete are silently ignored (no change, no notify, no TypeError — traps report success while discarding).\*\* | +| CS-R24 | live | `docs/rules-mining/core-store.md:107` | — | next-smoke.test.ts×1 | Writes batch like signals: inside the setter draft, reads are read-your-writes (values, length, `in` sync); outside the setter, ALL reads — value, `in`, length — return pre-write state until flush(). … | +| CS-R25 | live | `docs/rules-mining/core-store.md:112` | — | next-smoke.test.ts×1 | Writes to properties with ZERO observers still batch (no effects anywhere; pre-write value visible between setState and flush).\*\* | +| CS-R26 | live | `docs/rules-mining/core-store.md:117` | — | — | Setting a key to undefined is not deletion: key stays present (`in` true, no key-set notify); only delete / storePath.DELETE removes.\*\* | +| CS-R27 | live | `docs/rules-mining/core-store.md:121` | store.ts×1 | — | The setter may return a replacement value that swaps the root wholesale; symbol keys on the replacement preserved.\*\* | +| CS-R28 | live | `docs/rules-mining/core-store.md:125` | — | — | `storePath` addressing: string keys, numeric indices, index arrays, predicate filters ((value, index)), ranges, trailing functional setters address and update intended paths + trigger per-path subscri… | +| CS-R29 | live | `docs/rules-mining/core-store.md:129` | store.ts×2 | overlay.test.ts×1 | Merge/replacement preserve accessor descriptors and keep getters LIVE (re-evaluated per read, reactive reads track), for pre-existing and new keys.\*\* | +| CS-R30 | live | `docs/rules-mining/core-store.md:134` | store.ts×2 | — | Prototype pollution fully guarded: `__proto__` assignment inert; reading `constructor` on the draft returns undefined; storePath refuses `__proto__`/`constructor`/`prototype` segments; skips unsafe ow… | +| CS-R31 | live | `docs/rules-mining/core-store.md:138` | projection.ts×1 | reconcile-resend-identity.test.ts×1 | Derived-store manual writes win over the recompute for the tick: manual setStore beats a queued recompute in the same flush; a SAME-VALUE manual write still holds against the recompute for that tick; … | +| CS-R32 | live | `docs/rules-mining/core-store.md:143` | store.ts×1 | visibility-oracle-store.test.ts×1 | A setter-staged replacement followed by reconcile lands the reconciled value — staged writes fold into the diff.\*\* Aligned: O7's resolution (a test already exists). | +| CS-R33 | live | `docs/rules-mining/core-store.md:147` | — | visibility-oracle-store.test.ts×2 | Action/async lane semantics on store properties: a write held by an action makes isPending true for that property (per-property, not whole-store) while showing the committed value; applies on settle.\*… | +| CS-R34 | live | `docs/rules-mining/core-store.md:151` | store.ts×1 | visibility-oracle-store.test.ts×1 | ~~Optimistic writes visible immediately at write time (before flush)~~, never touch base raw; ambient (non-action) optimistic writes auto-revert at flush end.\*\* \_Visibility superseded 2026-09-10 by A2… | +| CS-R35 | live | `docs/rules-mining/core-store.md:156` | — | — | Mid-refetch optimistic overlays are consumed when data lands — identical via direct reads, mapArray, wrapper views, Object.keys, snapshot.\*\* | +| CS-R36 | live | `docs/rules-mining/core-store.md:160` | — | — | An active optimistic hold on a wrapper view masks inner-store changes for the view's subscribers: mid-hold inner refresh landing causes ZERO re-runs of the view's structural subscribers; the reveal re… | +| CS-R37 | live | `docs/rules-mining/core-store.md:165` | — | visibility-oracle-store.test.ts×1 | Setting store state from effect callbacks and promise resolutions works, applying next flush.\*\* | +| CS-R38 | live | `docs/rules-mining/core-store.md:171` | — | — | Shallow stores: root keys reactive (per-key nodes, membership, length), values served raw by identity at every depth, arrays and objects.\*\* | +| CS-R39 | live | `docs/rules-mining/core-store.md:175` | — | visibility-oracle-store.test.ts×1 | Shallow setter-scope reads serve raws; in-place mutation of a served raw is reactively inert — records replaced, never edited.\*\* | +| CS-R40 | live | `docs/rules-mining/core-store.md:179` | — | — | Shallow reconcile is positional: per-index effects only where the reference changed; reference-identical rows skip entirely; length propagates; `key` option moot.\*\* Aligned: unowned-reference skip rul… | +| CS-R41 | live | `docs/rules-mining/core-store.md:183` | reconcile.ts×1 store.ts×2 | — | A plain record replaced into a shallow store is STICKY raw-marked: presents raw in this store AND in any deep store that later ingests it.\*\* | +| CS-R42 | live | `docs/rules-mining/core-store.md:188` | reconcile.ts×1 store.ts×1 | — | markRaw values never wrap through ANY store (deep included); leaves for reconcile (reference replacement, no recursion).\*\* | +| CS-R43 | live | `docs/rules-mining/core-store.md:192` | — | — | Store proxies are exempt from shallow raw treatment: shallow store ingesting another store's proxy passes it through unmarked and serves a live wrapped view (upstream visible, downstream isolated), se… | +| CS-R44 | live | `docs/rules-mining/core-store.md:196` | store.ts×1 | — | Ingesting an already-deep-tracked raw into a shallow store throws in dev.\*\* | +| CS-R45 | live | `docs/rules-mining/core-store.md:201` | — | — | A shallow store nested in a deep store participates in the parent's reconcile (raw replacement, per-index notify).\*\* | +| CS-R46 | live | `docs/rules-mining/core-store.md:205` | — | — | Shallow projections work end-to-end (derive re-runs, output reconciles at boundary, rows stay raw).\*\* | +| CS-R47 | live | `docs/rules-mining/core-store.md:211` | — | — | Platform objects (Map, Set, Date, Node instances, subclasses) are structurally non-wrappable: served raw by identity; internal-slot methods work on read and draft paths; draft mutations land on the ra… | +| CS-R48 | live | `docs/rules-mining/core-store.md:216` | — | — | User class instances (custom prototypes) DO wrap: prototype getters track; methods on the draft receive the proxy as `this` (reactive writes).\*\* | +| CS-R49 | live | `docs/rules-mining/core-store.md:220` | — | — | Null-prototype objects wrap and track; function-valued props callable through the proxy.\*\* | +| CS-R50 | live | `docs/rules-mining/core-store.md:224` | — | — | Frozen sources fully supported (read/snapshot; getters returning frozen don't throw).\*\* | +| CS-R51 | live | `docs/rules-mining/core-store.md:229` | store.ts×3 | overlay.test.ts×1 write-floor.test.ts×2 | Proxy-invariant compliance via target indirection: keys/spread/descriptor reads never throw regardless of source rigidity; source-non-configurable prop readable, writable through the store, reported `… | +| CS-R52 | live | `docs/rules-mining/core-store.md:233` | — | — | Symbol-keyed properties first-class: read/write/descriptors/preserved through root replacement + storePath root merge; on arrays symbol writes are metadata (never affect length).\*\* | +| CS-R53 | live | `docs/rules-mining/core-store.md:237` | — | — | Array key hygiene: non-index string keys never affect length; `s[len] = undefined` grows length AND creates a present key.\*\* | +| CS-R54 | live | `docs/rules-mining/core-store.md:241` | — | — | Array natives work through the proxy on read (filter/reduce/map/iterate) and draft (push/pop/shift) paths.\*\* | +| CS-R55 | live | `docs/rules-mining/core-store.md:243` | — | — | Functions stored as values served raw, replaceable, slot-tracked.\*\* | +| CS-R56 | live | `docs/rules-mining/core-store.md:247` | — | — | Multiple setter calls before one flush coalesce: even a deep-reading (structural clone) effect re-runs exactly once per flush.\*\* | +| CS-R57 | live | `docs/rules-mining/core-store.md:251` | — | — | Effect ordering: parent effects before child effects created inside them, incl. shared deps through memos.\*\* | +| CS-R58 | live | `docs/rules-mining/core-store.md:255` | — | — | Mid-flush read coherence: untracked store reads inside internal machinery running WITHIN a flush (mapArray keyed:false under a Root owner) must observe the value being written in that flush, not stale… | ## R — optimistic-lanes (`OL-R`) -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | ------ | ------------------------------------------- | ------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OL-R1 | live | `docs/rules-mining/optimistic-lanes.md:11` | — | — | `createOptimistic(value \| fn)` returns `[accessor, setter]`; the accessor returns the initial or computed value; the setter accepts a value or an updater function. | -| OL-R2 | live | `docs/rules-mining/optimistic-lanes.md:14` | — | — | An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context. | -| OL-R3 | live | `docs/rules-mining/optimistic-lanes.md:17` | — | — | The setter's updater receives the current _visible_ (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition'… | -| OL-R4 | live | `docs/rules-mining/optimistic-lanes.md:20` | — | — | Multiple optimistic writes before settle compose sequentially (each updater sees the prior override; last write wins). | -| OL-R5 | live | `docs/rules-mining/optimistic-lanes.md:23` | — | — | An optimistic write **outside any action** reverts at the next flush; subscribers observe the optimistic value and then the reverted value within that single flush (effect log `[1, 2, 1]` after one `f… | -| OL-R6 | live | `docs/rules-mining/optimistic-lanes.md:27` | — | — | An optimistic write inside an `action` holds for the entire action window and reverts when the action's transition completes; each intermediate write during a multi-yield action is observable in order… | -| OL-R7 | live | `docs/rules-mining/optimistic-lanes.md:30` | — | — | Computed-form `createOptimistic(fn)` with no overrides is a transparent passthrough of its (possibly async) source: promise resolutions, re-fired promises, and async-iterable yields all propagate; ove… | -| OL-R8 | live | `docs/rules-mining/optimistic-lanes.md:33` | — | — | Reset-on-settle targets the source's **newly computed value at settle time**, not the pre-write value: a wrong optimistic guess is auto-corrected to the real result; a correct guess settles silently (… | -| OL-R9 | live | `docs/rules-mining/optimistic-lanes.md:36` | — | — | Regular signals written in the same action are held (transition semantics) while optimistic writes display immediately; downstream memos and chained optimistic computeds see optimistic values and reve… | -| OL-R10 | live | `docs/rules-mining/optimistic-lanes.md:39` | — | — | `refresh()` of an optimistic accessor inside an action clears the override when the refetch settles; calling `refresh()` while the upstream source is still pending must not throw. | -| OL-R11 | live | `docs/rules-mining/optimistic-lanes.md:42` | — | — | Verdict channels: an optimistic override **is the value** on every channel — plain read and `latest()` both return it (including literal `undefined`); the override itself is **verdict-inert** — it nev… | -| OL-R12 | live | `docs/rules-mining/optimistic-lanes.md:45` | — | — | A bare `refresh()` is a quiet re-ask — never pending; a **declared** reload (`affects(x)` + `refresh(x)` inside an action) pends the slot for the whole reload window, even when the sole consumer is a … | -| OL-R13 | live | `docs/rules-mining/optimistic-lanes.md:48` | — | — | During the pending window, a source recompute that reveals a value **different** from the current override corrects the override in place (before the action settles), triggering downstream refetch; a … | -| OL-R14 | live | `docs/rules-mining/optimistic-lanes.md:54` | — | — | Independent optimistic writes to unrelated signals form independent lanes: notifications scoped to each signal's own subscribers; each action's overrides revert when _that_ action settles, regardless … | -| OL-R15 | live | `docs/rules-mining/optimistic-lanes.md:57` | — | — | A shared subscriber reading multiple optimistic sources merges lanes **for scheduling only**; it must not transfer transaction ownership of overrides. Disjoint-key work settles with its owning action … | -| OL-R16 | live | `docs/rules-mining/optimistic-lanes.md:61` | — | — | Same-key writes from multiple actions **entangle** those actions: the override (and transitively every override of the entangled actions) reverts only when the **last** entangled action settles. | -| OL-R17 | live | `docs/rules-mining/optimistic-lanes.md:65` | — | — | An equal-value write still registers ownership and still performs lane bookkeeping: a second action writing the same value keeps the override alive after the first settles; an override write whose val… | -| OL-R18 | live | `docs/rules-mining/optimistic-lanes.md:69` | — | — | All optimistic writes in one action share one transaction and revert together atomically; lanes/transactions clean up fully between cycles — the Nth cycle behaves exactly like the first, including aft… | -| OL-R19 | live | `docs/rules-mining/optimistic-lanes.md:72` | — | — | A shared **upstream** async resolving must not merge distinct downstream optimistic lanes — independent paths keep updating independently; genuine merge happens only at convergence points (a memo read… | -| OL-R20 | live | `docs/rules-mining/optimistic-lanes.md:75` | — | — | A later action's override wins over an earlier action's background settle: when action 1's refresh resolves _under_ action 2's live override, the visible value is unchanged, downstream must not recomp… | -| OL-R21 | live | `docs/rules-mining/optimistic-lanes.md:80` | — | — | An optimistic write of literal `undefined` is a full-fledged override: visible on plain read and `latest()`, verdict-inert on `isPending`, and it reverts at settle exactly like any other value. | -| OL-R22 | live | `docs/rules-mining/optimistic-lanes.md:84` | — | — | A follow-up optimistic write after an `undefined` override still rides the optimistic path and reverts at settle — `undefined` in the slot must never erase the node's optimistic identity or route late… | -| OL-R23 | live | `docs/rules-mining/optimistic-lanes.md:87` | — | — | Store form distinguishes "override to undefined" from "delete": optimistic set-to-undefined reads `undefined` with the key still present; optimistic `delete` reads `undefined` **and** `"key" in store … | -| OL-R24 | live | `docs/rules-mining/optimistic-lanes.md:93` | — | — | A transition completes only when **all** reachable asyncs (upstream source and downstream lane asyncs) resolve; held source values must never leak to subscribers before completion, even when the upstr… | -| OL-R25 | live | `docs/rules-mining/optimistic-lanes.md:96` | — | — | Lane readiness gating: subscribers reached _through a downstream async memo_ fire with optimistic values only once that async resolves; direct reads show the override immediately. The lane may flush \*… | -| OL-R26 | live | `docs/rules-mining/optimistic-lanes.md:99` | — | — | At settle, the commit of held transition writes and the revert of optimistic overrides are delivered **atomically**: one subscriber run observing both, never a torn intermediate. | -| OL-R27 | live | `docs/rules-mining/optimistic-lanes.md:102` | — | — | Rapid successive user writes replay correctly: the latest override wins; earlier lane flushes deliver the values current at their readiness time; final settled state reflects the last action's confirm… | -| OL-R28 | live | `docs/rules-mining/optimistic-lanes.md:107` | — | — | No-op settles are silent: if the optimistic write equals the current value, neither the write nor the revert notifies; if the settle-time computed value equals the override, no extra notification fire… | -| OL-R29 | live | `docs/rules-mining/optimistic-lanes.md:111` | — | — | Pre-flush writes coalesce: subscribers see only the latest override per flush (`[0, 2, 0]`, never intermediate `1`). | -| OL-R30 | live | `docs/rules-mining/optimistic-lanes.md:114` | — | — | Render-tier and user-tier effects must observe **identical value sequences** at every flush, including the mid-transition moment where an action finished but async reporters are still in flight. | -| OL-R31 | live | `docs/rules-mining/optimistic-lanes.md:117` | — | — | Optimistic lane notifications run even while an unrelated transition is stashed/pending; pending async in one lane never blocks another lane's write/revert notifications. | -| OL-R32 | live | `docs/rules-mining/optimistic-lanes.md:120` | — | — | `isPending` granularity: each async path's pending slot clears when its **own** async resolves; merged downstream nodes stay pending — emitting **no intermediate half-state values** — until all inputs… | -| OL-R33 | live | `docs/rules-mining/optimistic-lanes.md:123` | — | — | No pending flicker when the visible value is unchanged: background refresh phases with an unchanged visible override must not re-pend downstream; a genuinely new in-flight question must fire `isPendin… | -| OL-R34 | live | `docs/rules-mining/optimistic-lanes.md:126` | — | — | `latest()` readers opt into progressive per-path display while plain readers of merged memos wait for full resolution. | -| OL-R35 | live | `docs/rules-mining/optimistic-lanes.md:131` | — | — | `createOptimisticStore` returns `[proxy, setter]`; draft-style mutations inside an action are optimistic: immediately visible through the proxy, wholly reverted at settle. | -| OL-R36 | live | `docs/rules-mining/optimistic-lanes.md:134` | — | — | Array structural edits (e.g. filter-removal) are visible during the window through `length`, index reads, and iteration, and fully revert at settle. | -| OL-R37 | live | `docs/rules-mining/optimistic-lanes.md:138` | — | — | Store optimism is per-key: different keys written by different actions settle independently — same ownership rules as signals (R15/R16) at store-key granularity. | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +| ------ | ---------- | ------------------------------------------- | ------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OL-R1 | live | `docs/rules-mining/optimistic-lanes.md:11` | — | — | `createOptimistic(value \| fn)` returns `[accessor, setter]`; the accessor returns the initial or computed value; the setter accepts a value or an updater function. | +| OL-R2 | superseded | `docs/rules-mining/optimistic-lanes.md:15` | — | — | ~~An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context.~~ **Superseded 2026-09-10 by A28(5)** (SPEC-ASYN… | +| OL-R3 | live | `docs/rules-mining/optimistic-lanes.md:19` | — | — | The setter's updater receives the current _visible_ (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition'… | +| OL-R4 | live | `docs/rules-mining/optimistic-lanes.md:23` | — | — | Multiple optimistic writes before settle compose sequentially (each updater sees the prior override; last write wins). | +| OL-R5 | live | `docs/rules-mining/optimistic-lanes.md:27` | — | — | An optimistic write **outside any action** reverts at the next flush; subscribers observe the optimistic value and then the reverted value within that single flush (effect log `[1, 2, 1]` after one `f… | +| OL-R6 | live | `docs/rules-mining/optimistic-lanes.md:32` | — | — | An optimistic write inside an `action` holds for the entire action window and reverts when the action's transition completes; each intermediate write during a multi-yield action is observable in order… | +| OL-R7 | live | `docs/rules-mining/optimistic-lanes.md:36` | — | — | Computed-form `createOptimistic(fn)` with no overrides is a transparent passthrough of its (possibly async) source: promise resolutions, re-fired promises, and async-iterable yields all propagate; ove… | +| OL-R8 | live | `docs/rules-mining/optimistic-lanes.md:40` | — | — | Reset-on-settle targets the source's **newly computed value at settle time**, not the pre-write value: a wrong optimistic guess is auto-corrected to the real result; a correct guess settles silently (… | +| OL-R9 | live | `docs/rules-mining/optimistic-lanes.md:44` | — | — | Regular signals written in the same action are held (transition semantics) while optimistic writes display immediately; downstream memos and chained optimistic computeds see optimistic values and reve… | +| OL-R10 | live | `docs/rules-mining/optimistic-lanes.md:48` | — | — | `refresh()` of an optimistic accessor inside an action clears the override when the refetch settles; calling `refresh()` while the upstream source is still pending must not throw. | +| OL-R11 | live | `docs/rules-mining/optimistic-lanes.md:52` | — | — | Verdict channels: an optimistic override **is the value** on every channel — plain read and `latest()` both return it (including literal `undefined`); the override itself is **verdict-inert** — it nev… | +| OL-R12 | live | `docs/rules-mining/optimistic-lanes.md:56` | — | — | A bare `refresh()` is a quiet re-ask — never pending; a **declared** reload (`affects(x)` + `refresh(x)` inside an action) pends the slot for the whole reload window, even when the sole consumer is a … | +| OL-R13 | live | `docs/rules-mining/optimistic-lanes.md:60` | — | — | During the pending window, a source recompute that reveals a value **different** from the current override corrects the override in place (before the action settles), triggering downstream refetch; a … | +| OL-R14 | live | `docs/rules-mining/optimistic-lanes.md:67` | — | — | Independent optimistic writes to unrelated signals form independent lanes: notifications scoped to each signal's own subscribers; each action's overrides revert when _that_ action settles, regardless … | +| OL-R15 | live | `docs/rules-mining/optimistic-lanes.md:71` | — | — | A shared subscriber reading multiple optimistic sources merges lanes **for scheduling only**; it must not transfer transaction ownership of overrides. Disjoint-key work settles with its owning action … | +| OL-R16 | live | `docs/rules-mining/optimistic-lanes.md:76` | — | — | Same-key writes from multiple actions **entangle** those actions: the override (and transitively every override of the entangled actions) reverts only when the **last** entangled action settles. | +| OL-R17 | live | `docs/rules-mining/optimistic-lanes.md:81` | — | — | An equal-value write still registers ownership and still performs lane bookkeeping: a second action writing the same value keeps the override alive after the first settles; an override write whose val… | +| OL-R18 | live | `docs/rules-mining/optimistic-lanes.md:86` | — | — | All optimistic writes in one action share one transaction and revert together atomically; lanes/transactions clean up fully between cycles — the Nth cycle behaves exactly like the first, including aft… | +| OL-R19 | live | `docs/rules-mining/optimistic-lanes.md:90` | — | — | A shared **upstream** async resolving must not merge distinct downstream optimistic lanes — independent paths keep updating independently; genuine merge happens only at convergence points (a memo read… | +| OL-R20 | live | `docs/rules-mining/optimistic-lanes.md:94` | — | — | A later action's override wins over an earlier action's background settle: when action 1's refresh resolves _under_ action 2's live override, the visible value is unchanged, downstream must not recomp… | +| OL-R21 | live | `docs/rules-mining/optimistic-lanes.md:100` | — | — | An optimistic write of literal `undefined` is a full-fledged override: visible on plain read and `latest()`, verdict-inert on `isPending`, and it reverts at settle exactly like any other value. | +| OL-R22 | live | `docs/rules-mining/optimistic-lanes.md:105` | — | — | A follow-up optimistic write after an `undefined` override still rides the optimistic path and reverts at settle — `undefined` in the slot must never erase the node's optimistic identity or route late… | +| OL-R23 | live | `docs/rules-mining/optimistic-lanes.md:109` | — | — | Store form distinguishes "override to undefined" from "delete": optimistic set-to-undefined reads `undefined` with the key still present; optimistic `delete` reads `undefined` **and** `"key" in store … | +| OL-R24 | live | `docs/rules-mining/optimistic-lanes.md:116` | — | — | A transition completes only when **all** reachable asyncs (upstream source and downstream lane asyncs) resolve; held source values must never leak to subscribers before completion, even when the upstr… | +| OL-R25 | live | `docs/rules-mining/optimistic-lanes.md:120` | — | — | Lane readiness gating: subscribers reached _through a downstream async memo_ fire with optimistic values only once that async resolves; direct reads show the override immediately. The lane may flush \*… | +| OL-R26 | live | `docs/rules-mining/optimistic-lanes.md:124` | — | — | At settle, the commit of held transition writes and the revert of optimistic overrides are delivered **atomically**: one subscriber run observing both, never a torn intermediate. | +| OL-R27 | live | `docs/rules-mining/optimistic-lanes.md:128` | — | — | Rapid successive user writes replay correctly: the latest override wins; earlier lane flushes deliver the values current at their readiness time; final settled state reflects the last action's confirm… | +| OL-R28 | live | `docs/rules-mining/optimistic-lanes.md:134` | — | — | No-op settles are silent: if the optimistic write equals the current value, neither the write nor the revert notifies; if the settle-time computed value equals the override, no extra notification fire… | +| OL-R29 | live | `docs/rules-mining/optimistic-lanes.md:139` | — | — | Pre-flush writes coalesce: subscribers see only the latest override per flush (`[0, 2, 0]`, never intermediate `1`). | +| OL-R30 | live | `docs/rules-mining/optimistic-lanes.md:143` | — | — | Render-tier and user-tier effects must observe **identical value sequences** at every flush, including the mid-transition moment where an action finished but async reporters are still in flight. | +| OL-R31 | live | `docs/rules-mining/optimistic-lanes.md:147` | — | — | Optimistic lane notifications run even while an unrelated transition is stashed/pending; pending async in one lane never blocks another lane's write/revert notifications. | +| OL-R32 | live | `docs/rules-mining/optimistic-lanes.md:151` | — | — | `isPending` granularity: each async path's pending slot clears when its **own** async resolves; merged downstream nodes stay pending — emitting **no intermediate half-state values** — until all inputs… | +| OL-R33 | live | `docs/rules-mining/optimistic-lanes.md:155` | — | — | No pending flicker when the visible value is unchanged: background refresh phases with an unchanged visible override must not re-pend downstream; a genuinely new in-flight question must fire `isPendin… | +| OL-R34 | live | `docs/rules-mining/optimistic-lanes.md:159` | — | — | `latest()` readers opt into progressive per-path display while plain readers of merged memos wait for full resolution. | +| OL-R35 | live | `docs/rules-mining/optimistic-lanes.md:165` | — | — | `createOptimisticStore` returns `[proxy, setter]`; draft-style mutations inside an action are optimistic: immediately visible through the proxy, wholly reverted at settle. | +| OL-R36 | live | `docs/rules-mining/optimistic-lanes.md:169` | — | — | Array structural edits (e.g. filter-removal) are visible during the window through `length`, index reads, and iteration, and fully revert at settle. | +| OL-R37 | live | `docs/rules-mining/optimistic-lanes.md:174` | — | — | Store optimism is per-key: different keys written by different actions settle independently — same ownership rules as signals (R15/R16) at store-key granularity. | ## R — optimistic-store (`OS-R`) -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| ------ | ------ | ------------------------------------------- | --------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OS-R1 | live | `docs/rules-mining/optimistic-store.md:9` | — | — | Synchronous universal visibility.\*\* An optimistic write is visible to every reader immediately at write time, before any flush: tracked, untracked, inside the action body, outside any reactive context… | -| OS-R2 | live | `docs/rules-mining/optimistic-store.md:12` | — | — | Drafts compose on the live optimistic view.\*\* Each setter draft reads through all prior optimistic state (same tick, across ticks, across separate actions/refetches). | -| OS-R3 | live | `docs/rules-mining/optimistic-store.md:16` | — | — | Per-change notification.\*\* One notification per distinct optimistic value change; sequences like `[0, 1, 2, 0]` are contract. | -| OS-R4 | live | `docs/rules-mining/optimistic-store.md:19` | — | — | Equality cut.\*\* An optimistic write equal to current committed value: no notification on write or settle. | -| OS-R5 | live | `docs/rules-mining/optimistic-store.md:22` | — | — | Snapshot/deep read the optimistic view (resolves O1).\*\* snapshot()/deep() agree with every other reader: overlays, nested writes, optimistic deletes (key absent), array mutations; after settle show co… | -| OS-R6 | live | `docs/rules-mining/optimistic-store.md:26` | — | — | Snapshot allocates fresh objects while an overlay is live\*\* (not identity-stable across calls); settled returns raw identity. | -| OS-R7 | live | `docs/rules-mining/optimistic-store.md:30` | — | — | Propagation through derived graphs\*\* (memo chains, mapArray) like committed values. | -| OS-R8 | live | `docs/rules-mining/optimistic-store.md:32` | — | — | `latest()` returns the optimistic value\*\* during a pending refetch window. | -| OS-R9 | live | `docs/rules-mining/optimistic-store.md:34` | — | — | Cross-lane atomic flip.\*\* Regular store written in the same action holds old value while optimistic store shows overlay; at settle both land in ONE notification pass (mixed intermediates never observe… | -| OS-R10 | live | `docs/rules-mining/optimistic-store.md:39` | — | — | Settle reverts to base with one notification\*\* (`[0,1,0]`). | -| OS-R11 | live | `docs/rules-mining/optimistic-store.md:41` | — | — | Deep-state restoration.\*\* Revert restores complete pre-overlay state at every depth: nested writes, wholesale replacement, array length/indices/order, deletions (value + key membership). | -| OS-R12 | live | `docs/rules-mining/optimistic-store.md:43` | — | — | Revert target is the CURRENT derived base, not a stale snapshot\*\* (dependency changed mid-overlay → revert to recomputed value). | -| OS-R13 | live | `docs/rules-mining/optimistic-store.md:47` | — | — | Base data is not overlay data.\*\* Async-fetched/derived data commits to base and persists; only setter-originated optimistic state discards. | -| OS-R14 | live | `docs/rules-mining/optimistic-store.md:49` | — | — | No-flicker across the settle/refresh seam.\*\* From action-body return until refresh fetch lands, subscribers never observe the previously-committed value of an overridden property. | -| OS-R15 | live | `docs/rules-mining/optimistic-store.md:53` | — | — | Unaffected subscribers do not rerun on another action's settle.\*\* | -| OS-R16 | live | `docs/rules-mining/optimistic-store.md:55` | — | — | Cycles are independent\*\* (no residue between sequential write/settle cycles). | -| OS-R17 | live | `docs/rules-mining/optimistic-store.md:57` | — | — | Optimistic writes never pend.\*\* A plain optimistic store is never pending; an optimistic write alone never makes isPending true on any read (shallow, deep(), root or nested proxy, value or length, sam… | -| OS-R18 | live | `docs/rules-mining/optimistic-store.md:61` | — | — | Overlay lifetime is transaction-bound, per key\*\* (never a timer, never a mere flush boundary — under an action). | -| OS-R19 | live | `docs/rules-mining/optimistic-store.md:63` | — | — | Disjoint-key concurrent actions revert independently\*\* (incl. different rows, deletes) (#2899 ×3). | -| OS-R20 | live | `docs/rules-mining/optimistic-store.md:66` | — | — | Same-key writes entangle whole transactions:\*\* latest write displays; NOTHING in the merged transaction settles until the last member completes — including keys written by only one of them. | -| OS-R21 | live | `docs/rules-mining/optimistic-store.md:70` | — | — | Optimistic delete is per-transaction scoped\*\* (a concurrent action's settle must not resurrect another action's delete). | -| OS-R22 | live | `docs/rules-mining/optimistic-store.md:73` | — | — | Ambient (transaction-less) writes flash:\*\* visible until end of flush, then revert — without touching in-flight actions' keys. | -| OS-R23 | live | `docs/rules-mining/optimistic-store.md:76` | — | — | Actions scope globally (a transaction, not a store handle):\*\* writes made under action A belong to A regardless of which store; separate stores under separate actions settle independently. | -| OS-R24 | live | `docs/rules-mining/optimistic-store.md:78` | — | — | Re-override of a still-overridden key notifies and wins;\*\* the earlier action's completion never resurfaces its value. | -| OS-R25 | live | `docs/rules-mining/optimistic-store.md:82` | — | — | Array mutation overlays:\*\* push, splice, whole-array replacement, top-level array stores — length, index reads, holes, spread/iteration, .map all coherent mid-pending and restore exactly on revert. | -| OS-R26 | live | `docs/rules-mining/optimistic-store.md:84` | — | — | Length reactively consistent with contents;\*\* a consumer reading length then indices in one computation never observes a torn state. | -| OS-R27 | live | `docs/rules-mining/optimistic-store.md:87` | — | — | Key enumeration and `has` are lane-reactive\*\* (Object.keys / `in` reflect optimistic adds/deletes, notify, revert). | -| OS-R28 | live | `docs/rules-mining/optimistic-store.md:89` | — | — | Proxy identity survives truth adoption of optimistic rows:\*\* server data key-matching an optimistically pushed row recycles the proxy (identity preserved) and adopts server values. Single and multiple… | -| OS-R29 | live | `docs/rules-mining/optimistic-store.md:92` | — | — | Entity-swap key probes read committed base, not overlay\*\* (an optimistic `s.id = 99` must not confuse the swap); `key: null` → positional identity. | -| OS-R30 | live | `docs/rules-mining/optimistic-store.md:97` | store.ts×1 | — | Seed invisibility.\*\* Derived store's seed is a draft, never observable: before first resolution every read — get, `in`, keys, spread — throws NotReadyError untracked. Applies to createStore(fn, seed) … | -| OS-R31 | live | `docs/rules-mining/optimistic-store.md:100` | — | — | Dev strictRead scopes escalate:\*\* uninitialized read in a component body throws the `[PENDING_ASYNC_UNTRACKED_READ]` dev error (exact tag is contract), precedence over plain NotReadyError. | -| OS-R32 | live | `docs/rules-mining/optimistic-store.md:102` | — | — | Post-init untracked reads flow committed values,\*\* including during a later refetch window. | -| OS-R33 | live | `docs/rules-mining/optimistic-store.md:104` | — | — | Refetch window keeps the dev safeguard\*\* (committed value untracked; component-body read still dev-throws). | -| OS-R34 | live | `docs/rules-mining/optimistic-store.md:106` | — | — | isPending probes take the prod path in both builds:\*\* dev safeguard must not fire inside a probe; uninitialized + surrounding context ⇒ NotReadyError propagates out of isPending identically dev/prod; … | -| OS-R35 | live | `docs/rules-mining/optimistic-store.md:108` | — | — | Plain stores unaffected\*\* (read normally in every context incl. component bodies). | -| OS-R36 | live | `docs/rules-mining/optimistic-store.md:112` | — | — | Dependency-driven refetch pends the leaf and holds the committed view\*\* until the fetch lands. | -| OS-R37 | live | `docs/rules-mining/optimistic-store.md:114` | — | — | Optimistic writes are verdict-inert:\*\* a mid-refetch write displays but neither clears nor causes pending; the honest mixed state {value: 999, pending: true} is observable. (Re-ruled 2026-07-13, super… | -| OS-R38 | live | `docs/rules-mining/optimistic-store.md:116` | optimistic.ts×1 | — | No-op setters are fully inert:\*\* trap-firing no-ops (s => s, s => ({...s}), same-value write, delete of absent prop) mid-refetch display nothing, don't silence pending, don't entangle with the surroun… | -| OS-R39 | live | `docs/rules-mining/optimistic-store.md:119` | — | — | Landing truth wins over the override:\*\* fetch resolves → server/computed value displays, override consumed, pending clears — even if written mid-flight. | -| OS-R40 | live | `docs/rules-mining/optimistic-store.md:121` | — | — | Bare refresh is a quiet re-ask; affects + refresh is a declared reload.\*\* refresh(store) alone never pends reads; affects(store) + refresh pends them, clearing when data lands. Sync-back refresh insid… | -| OS-R41 | live | `docs/rules-mining/optimistic-store.md:123` | — | — | Streaming continuations are not pending windows.\*\* A generator-based derive (or wrapped createProjection) that yielded once reads settled while awaiting its next chunk, incl. with an override displaye… | -| OS-R42 | live | `docs/rules-mining/optimistic-store.md:125` | — | — | Bare writes ride an in-flight refetch (#2951).\*\* A transaction-less optimistic write while the store's own truth is in flight does NOT revert at flush end; holds until truth lands. Order-independent w… | -| OS-R43 | live | `docs/rules-mining/optimistic-store.md:128` | — | — | Refresh-in-action landings preserve still-pending overlays\*\* (same key ⇒ merged transaction: landing does not consume the pending action's optimistic value). | -| OS-R44 | live | `docs/rules-mining/optimistic-store.md:130` | — | — | Bare-refresh landings consume key-matched overlay content\*\* (optimistic "Optimistic" → server "Saved"); the action's later settle does not revert it. | -| OS-R45 | live | `docs/rules-mining/optimistic-store.md:132` | — | — | Separate-transition landings clear foreign optimistic rows (#2719):\*\* a different source transition resolving fresh data clears optimistic rows of a still-pending unrelated action immediately; later s… | -| OS-R46 | live | `docs/rules-mining/optimistic-store.md:135` | — | — | Refetch persistence across multi-action windows:\*\* overlay survives arbitrary interleaved refresh landings while any overlapping action is pending. | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +| ------ | ---------- | ------------------------------------------- | --------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OS-R1 | superseded | `docs/rules-mining/optimistic-store.md:9` | — | — | ~~Synchronous universal visibility.~~** ~~An optimistic write is visible to every reader immediately at write time, before any flush.~~ **Superseded 2026-09-10 by A28(5)\*\*: visible at the flush that c… | +| OS-R2 | live | `docs/rules-mining/optimistic-store.md:13` | — | — | Drafts compose on the live optimistic view.\*\* Each setter draft reads through all prior optimistic state (same tick, across ticks, across separate actions/refetches). | +| OS-R3 | live | `docs/rules-mining/optimistic-store.md:18` | — | — | Per-change notification.\*\* One notification per distinct optimistic value change; sequences like `[0, 1, 2, 0]` are contract. | +| OS-R4 | live | `docs/rules-mining/optimistic-store.md:22` | — | — | Equality cut.\*\* An optimistic write equal to current committed value: no notification on write or settle. | +| OS-R5 | live | `docs/rules-mining/optimistic-store.md:26` | — | — | Snapshot/deep read the optimistic view (resolves O1).\*\* snapshot()/deep() agree with every other reader: overlays, nested writes, optimistic deletes (key absent), array mutations; after settle show co… | +| OS-R6 | live | `docs/rules-mining/optimistic-store.md:31` | — | — | Snapshot allocates fresh objects while an overlay is live\*\* (not identity-stable across calls); settled returns raw identity. | +| OS-R7 | live | `docs/rules-mining/optimistic-store.md:36` | — | — | Propagation through derived graphs\*\* (memo chains, mapArray) like committed values. | +| OS-R8 | live | `docs/rules-mining/optimistic-store.md:38` | — | — | `latest()` returns the optimistic value\*\* during a pending refetch window. | +| OS-R9 | live | `docs/rules-mining/optimistic-store.md:40` | — | — | Cross-lane atomic flip.\*\* Regular store written in the same action holds old value while optimistic store shows overlay; at settle both land in ONE notification pass (mixed intermediates never observe… | +| OS-R10 | live | `docs/rules-mining/optimistic-store.md:46` | — | — | Settle reverts to base with one notification\*\* (`[0,1,0]`). | +| OS-R11 | live | `docs/rules-mining/optimistic-store.md:48` | — | — | Deep-state restoration.\*\* Revert restores complete pre-overlay state at every depth: nested writes, wholesale replacement, array length/indices/order, deletions (value + key membership). | +| OS-R12 | live | `docs/rules-mining/optimistic-store.md:50` | — | — | Revert target is the CURRENT derived base, not a stale snapshot\*\* (dependency changed mid-overlay → revert to recomputed value). | +| OS-R13 | live | `docs/rules-mining/optimistic-store.md:55` | — | — | Base data is not overlay data.\*\* Async-fetched/derived data commits to base and persists; only setter-originated optimistic state discards. | +| OS-R14 | live | `docs/rules-mining/optimistic-store.md:57` | — | — | No-flicker across the settle/refresh seam.\*\* From action-body return until refresh fetch lands, subscribers never observe the previously-committed value of an overridden property. | +| OS-R15 | live | `docs/rules-mining/optimistic-store.md:62` | — | — | Unaffected subscribers do not rerun on another action's settle.\*\* | +| OS-R16 | live | `docs/rules-mining/optimistic-store.md:64` | — | — | Cycles are independent\*\* (no residue between sequential write/settle cycles). | +| OS-R17 | live | `docs/rules-mining/optimistic-store.md:66` | — | — | Optimistic writes never pend.\*\* A plain optimistic store is never pending; an optimistic write alone never makes isPending true on any read (shallow, deep(), root or nested proxy, value or length, sam… | +| OS-R18 | live | `docs/rules-mining/optimistic-store.md:70` | — | — | Overlay lifetime is transaction-bound, per key\*\* (never a timer, never a mere flush boundary — under an action). | +| OS-R19 | live | `docs/rules-mining/optimistic-store.md:72` | — | — | Disjoint-key concurrent actions revert independently\*\* (incl. different rows, deletes) (#2899 ×3). | +| OS-R20 | live | `docs/rules-mining/optimistic-store.md:76` | — | — | Same-key writes entangle whole transactions:\*\* latest write displays; NOTHING in the merged transaction settles until the last member completes — including keys written by only one of them. | +| OS-R21 | live | `docs/rules-mining/optimistic-store.md:81` | — | — | Optimistic delete is per-transaction scoped\*\* (a concurrent action's settle must not resurrect another action's delete). | +| OS-R22 | live | `docs/rules-mining/optimistic-store.md:85` | — | — | Ambient (transaction-less) writes flash:\*\* visible until end of flush, then revert — without touching in-flight actions' keys. | +| OS-R23 | live | `docs/rules-mining/optimistic-store.md:89` | — | — | Actions scope globally (a transaction, not a store handle):\*\* writes made under action A belong to A regardless of which store; separate stores under separate actions settle independently. | +| OS-R24 | live | `docs/rules-mining/optimistic-store.md:91` | — | — | Re-override of a still-overridden key notifies and wins;\*\* the earlier action's completion never resurfaces its value. | +| OS-R25 | live | `docs/rules-mining/optimistic-store.md:95` | — | — | Array mutation overlays:\*\* push, splice, whole-array replacement, top-level array stores — length, index reads, holes, spread/iteration, .map all coherent mid-pending and restore exactly on revert. | +| OS-R26 | live | `docs/rules-mining/optimistic-store.md:97` | — | — | Length reactively consistent with contents;\*\* a consumer reading length then indices in one computation never observes a torn state. | +| OS-R27 | live | `docs/rules-mining/optimistic-store.md:101` | — | — | Key enumeration and `has` are lane-reactive\*\* (Object.keys / `in` reflect optimistic adds/deletes, notify, revert). | +| OS-R28 | live | `docs/rules-mining/optimistic-store.md:103` | — | — | Proxy identity survives truth adoption of optimistic rows:\*\* server data key-matching an optimistically pushed row recycles the proxy (identity preserved) and adopts server values. Single and multiple… | +| OS-R29 | live | `docs/rules-mining/optimistic-store.md:107` | — | — | Entity-swap key probes read committed base, not overlay\*\* (an optimistic `s.id = 99` must not confuse the swap); `key: null` → positional identity. | +| OS-R30 | live | `docs/rules-mining/optimistic-store.md:113` | store.ts×1 | — | Seed invisibility.\*\* Derived store's seed is a draft, never observable: before first resolution every read — get, `in`, keys, spread — throws NotReadyError untracked. Applies to createStore(fn, seed) … | +| OS-R31 | live | `docs/rules-mining/optimistic-store.md:117` | — | — | Dev strictRead scopes escalate:\*\* uninitialized read in a component body throws the `[PENDING_ASYNC_UNTRACKED_READ]` dev error (exact tag is contract), precedence over plain NotReadyError. | +| OS-R32 | live | `docs/rules-mining/optimistic-store.md:119` | — | — | Post-init untracked reads flow committed values,\*\* including during a later refetch window. | +| OS-R33 | live | `docs/rules-mining/optimistic-store.md:121` | — | — | Refetch window keeps the dev safeguard\*\* (committed value untracked; component-body read still dev-throws). | +| OS-R34 | live | `docs/rules-mining/optimistic-store.md:123` | — | — | isPending probes take the prod path in both builds:\*\* dev safeguard must not fire inside a probe; uninitialized + surrounding context ⇒ NotReadyError propagates out of isPending identically dev/prod; … | +| OS-R35 | live | `docs/rules-mining/optimistic-store.md:125` | — | — | Plain stores unaffected\*\* (read normally in every context incl. component bodies). | +| OS-R36 | live | `docs/rules-mining/optimistic-store.md:129` | — | — | Dependency-driven refetch pends the leaf and holds the committed view\*\* until the fetch lands. | +| OS-R37 | live | `docs/rules-mining/optimistic-store.md:131` | — | — | Optimistic writes are verdict-inert:\*\* a mid-refetch write displays but neither clears nor causes pending; the honest mixed state {value: 999, pending: true} is observable. (Re-ruled 2026-07-13, super… | +| OS-R38 | live | `docs/rules-mining/optimistic-store.md:133` | optimistic.ts×1 | — | No-op setters are fully inert:\*\* trap-firing no-ops (s => s, s => ({...s}), same-value write, delete of absent prop) mid-refetch display nothing, don't silence pending, don't entangle with the surroun… | +| OS-R39 | live | `docs/rules-mining/optimistic-store.md:137` | — | — | Landing truth wins over the override:\*\* fetch resolves → server/computed value displays, override consumed, pending clears — even if written mid-flight. | +| OS-R40 | live | `docs/rules-mining/optimistic-store.md:139` | — | — | Bare refresh is a quiet re-ask; affects + refresh is a declared reload.\*\* refresh(store) alone never pends reads; affects(store) + refresh pends them, clearing when data lands. Sync-back refresh insid… | +| OS-R41 | live | `docs/rules-mining/optimistic-store.md:141` | — | — | Streaming continuations are not pending windows.\*\* A generator-based derive (or wrapped createProjection) that yielded once reads settled while awaiting its next chunk, incl. with an override displaye… | +| OS-R42 | live | `docs/rules-mining/optimistic-store.md:143` | — | — | Bare writes ride an in-flight refetch (#2951).\*\* A transaction-less optimistic write while the store's own truth is in flight does NOT revert at flush end; holds until truth lands. Order-independent w… | +| OS-R43 | live | `docs/rules-mining/optimistic-store.md:147` | — | — | Refresh-in-action landings preserve still-pending overlays\*\* (same key ⇒ merged transaction: landing does not consume the pending action's optimistic value). | +| OS-R44 | live | `docs/rules-mining/optimistic-store.md:149` | — | — | Bare-refresh landings consume key-matched overlay content\*\* (optimistic "Optimistic" → server "Saved"); the action's later settle does not revert it. | +| OS-R45 | live | `docs/rules-mining/optimistic-store.md:151` | — | — | Separate-transition landings clear foreign optimistic rows (#2719):\*\* a different source transition resolving fresh data clears optimistic rows of a still-pending unrelated action immediately; later s… | +| OS-R46 | live | `docs/rules-mining/optimistic-store.md:155` | — | — | Refetch persistence across multi-action windows:\*\* overlay survives arbitrary interleaved refresh landings while any overlapping action is pending. | ## R — projections (`PJ-R`) diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 97728cc3a..468af3c9a 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -48,6 +48,14 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t **History (superseded mechanism, kept verbatim).** The 2026-07-07b mechanical model — replaced by the 2026-09-09 supersession, which the statement now leads with; take the mechanism from the statement, not from here: Mechanically: authoritative values arriving under an active override hold in `_pendingValue` like any other transition write and **elevate to `_value` only at their transition's commit** (`_value` changes at commit points, period); the elevation is unobservable under the override mask (A17); reverting is a pure drop — there is no revert target and reverts commit nothing. This supersedes the earlier "bound to its own async source, not its transition" formulation, which was implemented by escaping the transition commit (revert-target commit at revert) and allowed a mid-flight arrival to reveal before its own transition completed. +### A28. A write becomes visible at flush — to every channel + +**Status:** **ruled, mechanism landed 2026-09-15** — maintainer ruling 2026-09-08 ("since we can't derive downstream before flush happens I do like latest being invisible until flush"; "nothing should be 30 pre flush… because double count can't be 60"; "latest opts into the tearing but really only after a flush"); optimistic writes 2026-09-10 ("My gut is to match. I'm gathering React does."; "yeah lets match"). Landed as a READ-SIDE rule (see Mechanism), superseding #3337's deferred-walk mechanism; the runtime's pre-flush cells in both oracles read the rule. +**Pinned by:** `tests/latest-held-till-flush.test.ts` (A28 (1)–(4): held and ambient writes, companions created after the write, #3336 born-holding store keys and companions); `tests/visibility-oracle.test.ts` and `tests/visibility-oracle-store.test.ts` (the pre-flush states — every cell cites A28); `tests/latest-repeated-writes.test.ts` (#2922 re-pinned); `tests/latest-unobserved-memo.test.ts`; `tests/latest-probe-order-independence.test.ts`; `tests/isPending-memo-consistency.test.ts`; `tests/latest-plain-write-purity.test.ts`; `tests/store/projection-transition-isolation.test.ts`; optimistic (5): `tests/createOptimistic.test.ts`, `tests/store/createOptimisticStore.test.ts`, `tests/question-scoped-pending.test.ts` (same-tick `affects(parent)` covers the pushed row), `tests/optimistic-store-layer-scope.test.ts`, `tests/store/shallow.test.ts`. +**Mechanism (index, 2026-09-15 — as landed):** read-side, no marker on the plain write path. "Unflushed" is structural: outside a flush (`!globalQueue._running`), a node with an ambient staged value (`_pendingValue` set, `_transition` null) was written since the last flush — ambient staging commits at flush end, so nothing else leaves a node in that state (`unflushedValue`, core.ts). Exempt: engine companions (`_parentSource` — written at the source's write to mirror it, installing eagerly, A8) and writes issued inside a creation-time recompute (`CONFIG_PROMOTED`, set only when `!_running && context !== null` — A28 (4): promoted at that recompute's end). A rewrite of a HELD node stashes the staged value the last flush left (`_x._flushedStaged`, `unflushedRewrites`) and `latest()`/verdicts answer with it until the next flush. Optimistic writes (5): `_overrideTime === clock` outside a flush is the unflushed override (`unflushedOverride`); readers skip it (`read()`'s override arm, store `nodeValue`/`visibleOverride`/`optimisticView(…, draft=false)`), the writer's channels — the draft, the `affects()` declaration walk — compose on it. Readers served the flushed value because of an unflushed write are latched for the carrying flush (`REACTIVE_MISSED_WAKE`, `markLateLinker`) — the late-linker case #3337 deferred the walk for. Companion-bearing nodes written outside a flush are re-synced as the flush begins (`unflushedCompanions`, `resyncUnflushedCompanions` inside the running window); a companion created lazily backfills as the owner's transaction's write (`backfillCompanion`, #3336) and, when the source is unflushed at its creation, joins that re-sync (`joinUnflushedResync`) — brought current by the optimistic write like a companion that existed at the write, so a derivation over `latest()` direct-commits as the optimistic view it is instead of being staged under whatever hold the round entered. The read sites test one module flag (`unflushedStaged`, set when a node is staged or a held node rewritten outside a flush, cleared at flush start) rather than `_running`; the write-path arms (`stashHeldRewrite`, `notePromotedWrite`) are cold helpers gated on loads the write already pays. No mid-tick `latest()` pull (#2922 superseded). A write to a held node from mainline no longer enters the transaction eagerly (the node is already its; entering captured the caller's block). Store: a key first read under a held FOLD is born holding (`heldFoldTransition`, `stageHeldKey`), stale/owner-less readers of a transaction-held backing see committed through every channel (`heldFromReader`, `foreignHold`) — #3336's store half, lifted from #3337. + +(**ruled 2026-09-08**; supersedes the #2922 mid-tick pull) **A write becomes visible at flush — to every channel.** Between `set(x)` and the next `flush()` the write is _unflushed_: it is not the committed value, not the staged value `latest()`/`isPending()` serve, and not an input to any recompute. `latest(x)` reads the **flushed staged world** — the newest value a flush has processed, held or not (a transaction's hold governs what _effects_ publish, not what `latest` answers; `latest` opts into that tearing, but only once a flush has carried the write) — so `setCount(30); latest(count)` is the pre-write answer until the flush that carries the write, after which `latest(count)` is 30 and `latest(doubled)` is 60 in the same instant (the derivation flushed with it). Nothing is ever 30 while its derivations are still 20-shaped: there is no "read your own write" channel that bypasses the flush, because no channel can show downstream of an unflushed write, and a channel that shows the write alone tears against every derivation. Consequences: (1) `latest` is one rule regardless of reader — event handler, memo, prop getter — so wrapping a `latest` read in a memo does not change what it answers (the visibility mismatch that motivated a separate `readStaged` does not arise); (2) `isPending(x)` is false for an unflushed write (nothing is observable yet to be pending _from_); (3) a companion created lazily after several flushes needs no retained history — the flushed staged value is the only answer it could ever have given — and it answers **as if it had always existed** (#3336): its backfill is the write of the transaction _holding_ that value, not of the ambient window the reader happens to run in, so it lives and reverts with the hold instead of reverting at the reader's flush end; the same one level down for a store key first read under a hold — the node is born with the committed value and the held write staged as the holding transaction's, so plain reads and `latest()` answer for an unobserved key exactly as they do for one some other reader had materialized before the hold; (4) `flush()` is the boundary, so the imperative idiom is `set(x); flush(); latest(x)`, and within a recompute or a flush every write issued is promoted at the end of that recompute/round so the graph never runs a round against a value it wrote but cannot see; (5) (**extended 2026-09-10**, "let's match" — React's `useOptimistic` shows the optimistic value on the next render, never synchronously) **an optimistic write is a write**: `setOptimistic(x)` becomes the _active_ override (A17) at the flush that carries it, and until then no reader — plain, `snapshot()`, `isPending()` — sees it. An ambient one (no action in flight) is installed at the flush's start and reverted at its end, so effects are the channel that shows it (`[1, 2, 1]`); one an action holds stays readable after the flush for the action's lifetime. The writer's own composition channels see the parked write exactly as they see a plain write's staged value: a functional updater, and a store draft (`setState(s => …)` composes on the tick's earlier setters — two `count++` are +2, a push after a push lands in the next slot, a toggle toggled back diffs against the first and emits the write that cancels it). The `affects()` declaration walk is a writer channel too: tagging a parent covers the whole record as the writer sees it, so a same-tick `affects(state)` after an optimistic push covers the pushed row (the walk composes the tick's parked writes, as it already walks a plain store's pending backing). Only the slot form on a row born this tick needs the draft — `state.rows[2]` is not readable before the flush, so it is named as `affects(s.rows[2], key)` inside the setter (the same target the flush will serve). Engine companions (the `latest()` shadow, the `isPending()` verdict signal — `_parentSource` set) are the system's own overrides, written inside the flush after its promotions to mirror flushed state (A8), and install eagerly. + ### A11. Sync derivations of held sources are visible through `latest()`/`isPending()` **Status:** **ruled** — #2831 finding 3 diff --git a/packages/signals/docs/rules-mining/core-store.md b/packages/signals/docs/rules-mining/core-store.md index 2e866912f..59cf57626 100644 --- a/packages/signals/docs/rules-mining/core-store.md +++ b/packages/signals/docs/rules-mining/core-store.md @@ -7,182 +7,235 @@ Files: **CS** = `tests/store/createStore.test.ts`, **SP** = `tests/store/storePa ## A. Value residency & identity **R1. Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** + - CS "State wrapping > Setting plain object", "Setting plain array". No conflict. **R2. Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** + - SH "shallow store nested in a deep store reconciles through the parent". - **CONFLICT (mild):** under CoW, once a store privatizes a shared raw, the raw held by the other store diverges — dedupe key (raw identity) and logical node no longer coincide. Ruling needed on what dedupe means once backings diverge. **R3. 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.** + - SPC "each level serves its own proxies…", "set-trap ingest passes through without raw-marking", "seed ingest…". **R4. 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).** + - SPC "write to the last store stays in the last store (shallow middle)", "parity with the shallow:false control". **R5. Upstream writes propagate downstream through the chain without re-running structural machinery.** + - SPC "each level serves its own proxies; upstream writes stay fresh through the chain". **R6. No store write path ever mutates a user-provided source object.** Aligned with 2026-08-16b. + - SH "setter replacement never mutates the base rows", "optimistic shallow store…"; SPC. **R7. Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).** + - CS "State recursion > there is no infinite loop". **R8. `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 "Unwrapping Edge Cases" (×3), "writing over an inherited property…". **R9. Proxy identity per logical slot is stable across writes and reconciles** (mapArray keyed flows reuse rows across refetch/reconcile). + - SIS "read through derived optimistic store + mapArray", "mapArray directly over the base store"; SH. Aligned with §4. ## B. Tracking granularity **R10. Per-property tracking; same-value writes (direct or functional path setter returning prev) do not re-trigger.** + - CS "Track a state change"; SP "Functional setter no-op when returning same value". **R11. Per-path tracking: reading `state.user.firstName` subscribes to that leaf; reading the reference `store[0]` does not subscribe to `store[0].i`.** + - CS "Track a nested state change", "arrays > supports arrays". **R12. Reading an absent key subscribes to that key: other-key changes don't trigger; defining it later (assignment or defineProperty) does.** + - CS "Not Tracking Top level key addition/removal", "supports Object.defineProperty inside a setter". **R13. `in` tracks presence, not value: undefined-write doesn't retrigger; delete does; adding absent key does. `in`/`has` never invokes source getters.** + - CS "objects > has properties", "In Operator > wrapped nested class" (access === 0). **R14. `Object.keys` / `for…in` subscribe to key-set membership (root and nested) — distinct from property nodes.** Aligned: key-set node. + - CS "Tracking iteration Object key addition/removal", "Tracking Top level iteration…". **R15. Array structural tracking is uniform across idioms: indexed length loop, `for…of`, mapArray ($TRACK) all re-run exactly once per flush on add/update/removal.** + - CS "Tracking Top-Level Array iteration". **R16. `length` independently trackable; index write extending the array notifies length subscribers.** + - CS "Array length > Setting plain object", "direct array index extension updates length immediately". **R17. Truncating via `length = N` notifies tracked index reads of removed slots (re-run, observe undefined) and clears has/index/keys for removed indices.** + - CS "Array truncation notifies tracked index reads (#2768)", "Truncating array length clears stale indices…", "Track array item on removal". **R18. `snapshot` is non-tracking.** Aligned with read table. + - CS "Doesn't trigger object on addition/removal", "arrays > supports arrays". **R19. `untrack` scopes only the wrapped read; property access on the escaped value afterwards tracks normally.** + - RE "respects untracked". **R20. Source getters (own, prototype, merge-installed) execute with the proxy as receiver, so their internal reads track — incl. through projections.** + - CS "prototype getters track instance field updates", "…through projection stores", "State Getters"; NC. **R21. Structural subscriptions through a wrapper view (store-in-store) chain to the wrapped source: $TRACK/mapArray, ownKeys, snapshot/trackSelf through an outer derived store re-run when the inner store reconciles/changes shape.** + - SIS all of "#2864…". - **CONFLICT (design work):** key-set node is per-object; wrapper views give one logical object two node records (view + source). $TRACK/key-set chaining across wrapper views must be a first-class rule or #2864 regresses. **R22. Slots holding non-wrappable values (markRaw, Map/Date, function) track by reference: reassignment notifies; internal mutation doesn't.** + - SH "raw values are tracked by reference at their slot"; NC; CS "Track function change". ## C. Write semantics **R23. The proxy is immutable from outside the setter: direct assignment and delete are silently ignored (no change, no notify, no TypeError — traps report success while discarding).** + - CS "State immutability > Setting a property", "Deleting a property", "objects > is immutable from the outside". **R24. Writes batch like signals: inside the setter draft, reads are read-your-writes (values, length, `in` sync); outside the setter, ALL reads — value, `in`, length — return pre-write state until flush(). Holds for adds, deletes, array extension.** + - CS "Simple Key Value", "Test Array", "Test Array Nested", "direct array index extension…" (×2), "In Operator > batches like signals on cold writes", "State Getters"; SP nearly every test; "storePath.DELETE". - **CONFLICT (the big one):** §3 "urgent write — commit now: write raw" + read table routing untracked committed reads to raw ⇒ untracked read right after setState would see the new value. Dozens of assertions demand the old value until flush. Either "urgent" means applied-at-flush (synchronously within the flush), or pre-flush writes park in a pending home untracked reads bypass. **R25. Writes to properties with ZERO observers still batch (no effects anywhere; pre-write value visible between setState and flush).** + - CS "Simple Key Value"; SP non-reactive tests. - **CONFLICT:** laziness invariant says no node from observer-less urgent writes AND raw written immediately — but the pending value must live somewhere reads don't serve. Ruling needed before the `__TEST__` assertion is wired. **R26. Setting a key to undefined is not deletion: key stays present (`in` true, no key-set notify); only delete / storePath.DELETE removes.** + - CS "objects > has properties". **R27. The setter may return a replacement value that swaps the root wholesale; symbol keys on the replacement preserved.** + - CS "Tracking Top-Level Array iteration", "Returned object replacement keeps symbol keys"; SH "canonical filter-removal idiom…". **R28. `storePath` addressing: string keys, numeric indices, index arrays, predicate filters ((value, index)), ranges, trailing functional setters address and update intended paths + trigger per-path subscribers; nested plain-object arg MERGES (unlisted keys preserved), non-wrappables and arrays REPLACE; root object arg merges at root; storePath.DELETE deletes with full batching semantics.** + - SP "Triggers reactive updates", "Deeply nested reactive updates", "storePath.DELETE" + batching assertions. **R29. Merge/replacement preserve accessor descriptors and keep getters LIVE (re-evaluated per read, reactive reads track), for pre-existing and new keys.** + - SP "Root-level merge preserves getter descriptors", "Preserves getter descriptors when replacing an existing key"; CS "supports Object.defineProperty inside a setter". - **CONFLICT (mechanics):** CoW's first-write shallow clone must copy descriptors (Object.defineProperties-style), not values, or installed getters collapse to snapshots; merge writes must install descriptors onto owned raw. **R30. Prototype pollution fully guarded: `__proto__` assignment inert; reading `constructor` on the draft returns undefined; storePath refuses `__proto__`/`constructor`/`prototype` segments; skips unsafe own keys during merges while applying safe siblings; own keys literally named prototype/constructor land as data.** + - CS "ignores prototype pollution keys in draft setters"; SP "storePath prototype pollution guard". **R31. Derived-store manual writes win over the recompute for the tick: manual setStore beats a queued recompute in the same flush; a SAME-VALUE manual write still holds against the recompute for that tick; next source change reclaims.** + - CS "derived store manual writes" (#2692 ×2). - **CONFLICT (framing + mechanics):** "keeps the override for the tick" — override layers deleted. Manual-write-precedence-until-next-recompute incl. same-value writes must be reproduced by node/lane precedence; equality-checked signal write would no-op yet the mask must hold. **R32. A setter-staged replacement followed by reconcile lands the reconciled value — staged writes fold into the diff.** Aligned: O7's resolution (a test already exists). + - SH "setter write followed by reconcile lands the reconciled value". **R33. Action/async lane semantics on store properties: a write held by an action makes isPending true for that property (per-property, not whole-store) while showing the committed value; applies on settle.** Aligned: the node-lane model's purpose. + - CS "isPending sees a derived store property update held by an action", "…held by async work". -**R34. Optimistic writes visible immediately at write time (before flush), never touch base raw; ambient (non-action) optimistic writes auto-revert at flush end.** +**R34. ~~Optimistic writes visible immediately at write time (before flush)~~, never touch base raw; ambient (non-action) optimistic writes auto-revert at flush end.** _Visibility superseded 2026-09-10 by A28(5); landed 2026-09-15. The CONFLICT noted below (ordinary writes invisible pre-flush, optimistic writes visible) is resolved by A28: neither is visible before the flush._ + - SH "optimistic shallow store: replacement stages, base rows untouched, children raw". - **CONFLICT (asymmetry to define):** ordinary writes invisible pre-flush (R24) but optimistic writes visible pre-flush. Read-path table needs a "pre-flush" column. **R35. Mid-refetch optimistic overlays are consumed when data lands — identical via direct reads, mapArray, wrapper views, Object.keys, snapshot.** + - SIS 5 tests. **R36. An active optimistic hold on a wrapper view masks inner-store changes for the view's subscribers: mid-hold inner refresh landing causes ZERO re-runs of the view's structural subscribers; the reveal re-runs them with the mid-hold data.** + - SIS "an active override on the wrapper view holds…". - **CONFLICT:** a lane value on the wrapper's property node must actively SUPPRESS the chained structural notification from the inner store (which R21 says normally propagates). Precedence rule between R21 chaining and lane masking not yet in the doc. **R37. Setting store state from effect callbacks and promise resolutions works, applying next flush.** + - CS "Setting state from signal", "Select Promise". ## D. Shallow store contract **R38. Shallow stores: root keys reactive (per-key nodes, membership, length), values served raw by identity at every depth, arrays and objects.** + - SH "root keys are reactive, values are raw", "shallow OBJECT store…", "length changes propagate". **R39. Shallow setter-scope reads serve raws; in-place mutation of a served raw is reactively inert — records replaced, never edited.** + - SH "setter reads serve raws…", "canonical filter-removal idiom…", "shallow projection…". **R40. Shallow reconcile is positional: per-index effects only where the reference changed; reference-identical rows skip entirely; length propagates; `key` option moot.** Aligned: unowned-reference skip rule in shallow form. + - SH 4 tests. **R41. A plain record replaced into a shallow store is STICKY raw-marked: presents raw in this store AND in any deep store that later ingests it.** + - SH "record replacement through the setter works and marks raw"; SPC. - **CONFLICT (ruling needed):** global sticky marking caused #2932 for proxies. Cross-store stickiness for plain records is an implementation choice pinned as semantics — decide deliberately (O4). **R42. markRaw values never wrap through ANY store (deep included); leaves for reconcile (reference replacement, no recursion).** + - SH 2 tests. **R43. Store proxies are exempt from shallow raw treatment: shallow store ingesting another store's proxy passes it through unmarked and serves a live wrapped view (upstream visible, downstream isolated), seed or set-trap.** + - SPC "#2932…" + derived chain tests. **R44. Ingesting an already-deep-tracked raw into a shallow store throws in dev.** + - SH "ingesting a deep-tracked value into a shallow store throws in dev". - **CONFLICT (violates R1-unobservability):** the throw fires only because a prior READ lazily registered the child; whether createStore throws depends on materialization timing. Make the check materialization-independent or drop it. **R45. A shallow store nested in a deep store participates in the parent's reconcile (raw replacement, per-index notify).** + - SH. **R46. Shallow projections work end-to-end (derive re-runs, output reconciles at boundary, rows stay raw).** + - SH. ## E. Edge cases **R47. Platform objects (Map, Set, Date, Node instances, subclasses) are structurally non-wrappable: served raw by identity; internal-slot methods work on read and draft paths; draft mutations land on the raw collection (visible, un-notified); only the holding slot tracks.** + - NC "#2952" describe; CS "does not wrap Node instances". - Note: draft collection mutations ARE raw mutation of a user object — a deliberate carve-out from R6 the ownership WeakSet oracle must exempt. **R48. User class instances (custom prototypes) DO wrap: prototype getters track; methods on the draft receive the proxy as `this` (reactive writes).** + - CS "wrapped nested class", "not wrapped nested class" (historical name); NC ×2. **R49. Null-prototype objects wrap and track; function-valued props callable through the proxy.** + - NC; CS "#2771". **R50. Frozen sources fully supported (read/snapshot; getters returning frozen don't throw).** + - CS "Unwrapping Edge Cases", "supports getters that return frozen objects". - Note: no test writes INTO a frozen subtree — under CoW that becomes possible (clone unfreezes); open behavioral question. **R51. Proxy-invariant compliance via target indirection: keys/spread/descriptor reads never throw regardless of source rigidity; source-non-configurable prop readable, writable through the store, reported `configurable: true`; descriptors agree with reads after flush; non-enumerable stays non-enumerable; accessor descriptors preserve get/set identity; write over inherited prop yields own data descriptor.** + - CS "Proxy invariant correctness" (8 tests). Pins target-indirection architecture (kept). **R52. Symbol-keyed properties first-class: read/write/descriptors/preserved through root replacement + storePath root merge; on arrays symbol writes are metadata (never affect length).** + - CS "#2769" (4 tests) + descriptor test. **R53. Array key hygiene: non-index string keys never affect length; `s[len] = undefined` grows length AND creates a present key.** + - CS 2 tests. **R54. Array natives work through the proxy on read (filter/reduce/map/iterate) and draft (push/pop/shift) paths.** @@ -192,12 +245,15 @@ Files: **CS** = `tests/store/createStore.test.ts`, **SP** = `tests/store/storePa ## F. Recursive effects / re-entrancy **R56. Multiple setter calls before one flush coalesce: even a deep-reading (structural clone) effect re-runs exactly once per flush.** + - RE 3 tests (called === 2 after ≥2 writes). **R57. Effect ordering: parent effects before child effects created inside them, incl. shared deps through memos.** + - RE "runs parent effects before child effects". **R58. Mid-flush read coherence: untracked store reads inside internal machinery running WITHIN a flush (mapArray keyed:false under a Root owner) must observe the value being written in that flush, not stale committed.** + - MA "#2687", "updates when same-length primitive array items are replaced". - **CONFLICT (needs precision):** flip side of R24 — before flush reads see old values, during flush reads under any owner context see in-flight values. Read table needs a "mid-flush, un-noded, untracked" row; current fix threads owner context (`_parentComputed`). Combined with R24/R25 this defines when the pending→committed swap becomes readable. diff --git a/packages/signals/docs/rules-mining/optimistic-lanes.md b/packages/signals/docs/rules-mining/optimistic-lanes.md index 4d72b3ba4..36d85e2ed 100644 --- a/packages/signals/docs/rules-mining/optimistic-lanes.md +++ b/packages/signals/docs/rules-mining/optimistic-lanes.md @@ -9,133 +9,170 @@ Scope note: CO contains **no store-form tests** — it is entirely the signal/co ## A. createOptimistic contract (signal & computed form) **R1.** `createOptimistic(value | fn)` returns `[accessor, setter]`; the accessor returns the initial or computed value; the setter accepts a value or an updater function. + - Evidence: CO — "should store and return value on read", "should update signal via update function and revert on flush" -**R2.** An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context. +**R2.** ~~An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context.~~ **Superseded 2026-09-10 by A28(5)** (SPEC-ASYNC-SEMANTICS.md): an optimistic write becomes the active override at the flush that carries it; until then no reader sees it. _Landed 2026-09-15 (read-side A28)._ + - Evidence: CO — "should update signal via setter and revert on flush", "reading outside reactive context…", "rapid user actions: multiple selections before first resolves" -**R3.** The setter's updater receives the current *visible* (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition's *pending* value. The two compose independently. +**R3.** The setter's updater receives the current _visible_ (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition's _pending_ value. The two compose independently. + - Evidence: CO — "should provide current optimistic value in update callback", "should combine pending value with optimistic write when transition completes" **R4.** Multiple optimistic writes before settle compose sequentially (each updater sees the prior override; last write wins). + - Evidence: CO — "should allow multiple optimistic updates before flush" **R5.** An optimistic write **outside any action** reverts at the next flush; subscribers observe the optimistic value and then the reverted value within that single flush (effect log `[1, 2, 1]` after one `flush()`). + - Evidence: CO — "should update signal via setter and revert on flush", "independent optimistic writes create separate lanes", "optimistic effect runs before regular effect on same node" - **CONFLICT:** requires core lanes to support an ephemeral, auto-settling lane for un-actioned writes, with two subscriber runs inside one flush pass. **R6.** An optimistic write inside an `action` holds for the entire action window and reverts when the action's transition completes; each intermediate write during a multi-yield action is observable in order (`[0,1,2,0]`). + - Evidence: CO — "should show optimistic value during async transition and revert when complete", "should show each optimistic update during transition" **R7.** Computed-form `createOptimistic(fn)` with no overrides is a transparent passthrough of its (possibly async) source: promise resolutions, re-fired promises, and async-iterable yields all propagate; overrides still revert when the source is async. + - Evidence: CO — "identity pass-through with async source (no override)" describe, "should still revert overrides when source is async" **R8.** Reset-on-settle targets the source's **newly computed value at settle time**, not the pre-write value: a wrong optimistic guess is auto-corrected to the real result; a correct guess settles silently (see R28). + - Evidence: CO — "optimistic value does not match computed result", "first async resolves first…", "action pattern with mismatch…", "two full cycles with mismatch correction on second cycle" **R9.** Regular signals written in the same action are held (transition semantics) while optimistic writes display immediately; downstream memos and chained optimistic computeds see optimistic values and revert with them. + - Evidence: CO — "should hold regular signal value during transition while showing optimistic", "should chain optimistic signals correctly", "should propagate optimistic changes through memo chain", "nested optimistic computeds propagate through single lane" **R10.** `refresh()` of an optimistic accessor inside an action clears the override when the refetch settles; calling `refresh()` while the upstream source is still pending must not throw. + - Evidence: CO — "refreshing an optimistic async accessor clears the override when it settles", "refreshing an optimistic accessor does not throw upstream pending reads (#2694)" -**R11.** Verdict channels: an optimistic override **is the value** on every channel — plain read and `latest()` both return it (including literal `undefined`); the override itself is **verdict-inert** — it never makes its own slot pending, and it cannot silence pending when the source's *question* changed and is in flight (`isPending` reads true through the override). +**R11.** Verdict channels: an optimistic override **is the value** on every channel — plain read and `latest()` both return it (including literal `undefined`); the override itself is **verdict-inert** — it never makes its own slot pending, and it cannot silence pending when the source's _question_ changed and is in flight (`isPending` reads true through the override). + - Evidence: UO — "1b: verdict channels see the undefined override (latest/isPending)"; CO — "optimistic value matches computed result", "optimistic value does not match computed result", "isPending tracks optimistic node state alongside value effects", "action pattern: setOptimistic -> yield api -> refresh" **R12.** A bare `refresh()` is a quiet re-ask — never pending; a **declared** reload (`affects(x)` + `refresh(x)` inside an action) pends the slot for the whole reload window, even when the sole consumer is a reactive `isPending`. + - Evidence: CO — "refresh() of an async optimistic accessor is a quiet re-ask — not pending (#2799…)", "a declared reload (affects + refresh) fires isPending when it is the only consumer (#2806…)" **R13.** During the pending window, a source recompute that reveals a value **different** from the current override corrects the override in place (before the action settles), triggering downstream refetch; a recompute matching the override leaves everything untouched and silent. + - Evidence: CO — "shared async config resolves first: lanes stay separate despite shared dependency", "rapid action: correction should not be blocked…" - **CONFLICT:** the design's write model is binary (rollback = discard; commit = fold). Correction is a third behavior: source-driven mid-flight replacement of the lane value with cascade invalidation. Needs a defined path in §3. ## B. Lane / transaction ownership -**R14.** Independent optimistic writes to unrelated signals form independent lanes: notifications scoped to each signal's own subscribers; each action's overrides revert when *that* action settles, regardless of other in-flight actions. +**R14.** Independent optimistic writes to unrelated signals form independent lanes: notifications scoped to each signal's own subscribers; each action's overrides revert when _that_ action settles, regardless of other in-flight actions. + - Evidence: CO — "independent optimistic writes create separate lanes", "should show both optimistic updates immediately when two independent actions are triggered rapidly" **R15.** A shared subscriber reading multiple optimistic sources merges lanes **for scheduling only**; it must not transfer transaction ownership of overrides. Disjoint-key work settles with its owning action even when lanes merged through the shared effect. + - Evidence: LTO — "repro 1: #2899 test-3 shape with B's writes swapped", "repro 2: three actions on plain createOptimistic signals" - **CONFLICT (critical):** §7 defers collision to core lane semantics. Today this uses node-level owner stamps (`_overrideOwner`) + store-side `STORE_OPTIMISTIC_OWNERS`. Core lanes must natively carry per-node transaction ownership or §7's deference is insufficient. **R16.** Same-key writes from multiple actions **entangle** those actions: the override (and transitively every override of the entangled actions) reverts only when the **last** entangled action settles. + - Evidence: LTO — "repro 1"; CO — "holds same-value optimistic writes until all overlapping actions settle" - **CONFLICT:** per-property lane values must support multi-action refcounting/entanglement per key, including transitive spread across all keys those actions wrote. **R17.** An equal-value write still registers ownership and still performs lane bookkeeping: a second action writing the same value keeps the override alive after the first settles; an override write whose value equals the (corrected) current value must still dirty downstream to invalidate stale in-flight async. -- Evidence: CO — "holds same-value optimistic writes until all overlapping actions settle", "rapid action: unchanged override value should still dirty downstream to invalidate stale _inFlight" + +- Evidence: CO — "holds same-value optimistic writes until all overlapping actions settle", "rapid action: unchanged override value should still dirty downstream to invalidate stale \_inFlight" - **CONFLICT:** the core write path must not equality-short-circuit lane registration or downstream invalidation. **R18.** All optimistic writes in one action share one transaction and revert together atomically; lanes/transactions clean up fully between cycles — the Nth cycle behaves exactly like the first, including after rapid lane reuse. + - Evidence: CO — "should revert multiple optimistic signals together…", "concurrent optimistic writes in same action share a lane", "multiple sequential cycles", "two full cycles - lanes clean up properly…" (×3), "rapid action: correction should not be blocked when lane is reused across actions" **R19.** A shared **upstream** async resolving must not merge distinct downstream optimistic lanes — independent paths keep updating independently; genuine merge happens only at convergence points (a memo reading both), where the merged node waits for all inputs. + - Evidence: CO — "shared async config resolves first…", "latest() allows independent progressive display for parallel optimistic paths" -**R20.** A later action's override wins over an earlier action's background settle: when action 1's refresh resolves *under* action 2's live override, the visible value is unchanged, downstream must not recompute, and pending must not flicker. +**R20.** A later action's override wins over an earlier action's background settle: when action 1's refresh resolves _under_ action 2's live override, the visible value is unchanged, downstream must not recompute, and pending must not flicker. + - Evidence: CO — "second action while first still in flight…", "should NOT double-flicker isPending on rapid actions when background resolves" ## C. Undefined / absent-value semantics **R21.** An optimistic write of literal `undefined` is a full-fledged override: visible on plain read and `latest()`, verdict-inert on `isPending`, and it reverts at settle exactly like any other value. + - Evidence: UO — "1: optimistic undefined is visible during the action window", "1b: verdict channels see the undefined override" - **CONFLICT (critical, by design intent):** #2898 was `undefined` colliding with the no-override sentinel. Lane slots must use a sentinel distinct from `undefined` (NOT_PENDING-style brand); every surface exposing the lane value must unwrap it. **R22.** A follow-up optimistic write after an `undefined` override still rides the optimistic path and reverts at settle — `undefined` in the slot must never erase the node's optimistic identity or route later writes to permanent commit. + - Evidence: UO — "2: follow-up write reverts at settle (no permanent commit)" **R23.** Store form distinguishes "override to undefined" from "delete": optimistic set-to-undefined reads `undefined` with the key still present; optimistic `delete` reads `undefined` **and** `"key" in store === false`; at settle both restore the committed value and key presence. + - Evidence: UO — "4: optimistic store set-to-undefined is visible then reverts", "5: optimistic store delete is visible then reverts" -- **CONFLICT (critical):** a per-property lane *value* cannot express key absence — deletion must live in the key-set node overlay (§6/O2). The `has` trap must consult the key-set lane overlay; rollback must restore property view and key membership atomically. +- **CONFLICT (critical):** a per-property lane _value_ cannot express key absence — deletion must live in the key-set node overlay (§6/O2). The `has` trap must consult the key-set lane overlay; rollback must restore property view and key membership atomically. ## D. Settle / replay **R24.** A transition completes only when **all** reachable asyncs (upstream source and downstream lane asyncs) resolve; held source values must never leak to subscribers before completion, even when the upstream resolved first with a value matching the override. + - Evidence: CO — "transition holds when upstream resolves first…", "only first async resolves, second stays pending" -**R25.** Lane readiness gating: subscribers reached *through a downstream async memo* fire with optimistic values only once that async resolves; direct reads show the override immediately. The lane may flush **before** the upstream source resolves. +**R25.** Lane readiness gating: subscribers reached _through a downstream async memo_ fire with optimistic values only once that async resolves; direct reads show the override immediately. The lane may flush **before** the upstream source resolves. + - Evidence: CO — "first async resolves first, optimistic value matches computed result", "second async resolves first…", "multiple user actions before any async resolves", "action pattern: setOptimistic -> yield api -> refresh" **R26.** At settle, the commit of held transition writes and the revert of optimistic overrides are delivered **atomically**: one subscriber run observing both, never a torn intermediate. + - Evidence: CO — "should hold regular signal value during transition while showing optimistic", "should revert multiple optimistic signals together when transition completes" **R27.** Rapid successive user writes replay correctly: the latest override wins; earlier lane flushes deliver the values current at their readiness time; final settled state reflects the last action's confirmed result. + - Evidence: CO — "rapid user actions: multiple selections before first resolves", "multiple user actions before any async resolves" ## E. Notification granularity **R28.** No-op settles are silent: if the optimistic write equals the current value, neither the write nor the revert notifies; if the settle-time computed value equals the override, no extra notification fires. + - Evidence: CO — "should not trigger effect if optimistic value matches original", "first async resolves first, optimistic value matches computed result", "two full cycles…" - **CONFLICT:** lane-fold/discard on the node must equality-check against raw before notifying. **R29.** Pre-flush writes coalesce: subscribers see only the latest override per flush (`[0, 2, 0]`, never intermediate `1`). + - Evidence: CO — "lane reuses existing lane for same signal" **R30.** Render-tier and user-tier effects must observe **identical value sequences** at every flush, including the mid-transition moment where an action finished but async reporters are still in flight. + - Evidence: CO — "plain optimistic stays true through refresh-of-unrelated-async (issue #2685)" **R31.** Optimistic lane notifications run even while an unrelated transition is stashed/pending; pending async in one lane never blocks another lane's write/revert notifications. + - Evidence: CO — "lane effects run even when transition is stashed", "cross-lane reads return committed value during optimistic context" **R32.** `isPending` granularity: each async path's pending slot clears when its **own** async resolves; merged downstream nodes stay pending — emitting **no intermediate half-state values** — until all inputs resolve; multiple `isPending` consumers must agree at every flush. + - Evidence: CO — "isPending holds until merged lane completes…", "3-optimistic-node checkout…", "checkout: combined style effect…", "multiple isPending effects track independently" **R33.** No pending flicker when the visible value is unchanged: background refresh phases with an unchanged visible override must not re-pend downstream; a genuinely new in-flight question must fire `isPending` true even on the Nth rapid action. + - Evidence: CO — "no double pending flicker during refresh phase", "should NOT double-flicker isPending…", "isPending effect fires on second rapid action" **R34.** `latest()` readers opt into progressive per-path display while plain readers of merged memos wait for full resolution. + - Evidence: CO — "latest() allows independent progressive display…", "two full cycles - lanes clean up properly between country changes" ## F. Store-form specifics **R35.** `createOptimisticStore` returns `[proxy, setter]`; draft-style mutations inside an action are optimistic: immediately visible through the proxy, wholly reverted at settle. + - Evidence: UO — tests 3–5; LTO — "repro 1" **R36.** Array structural edits (e.g. filter-removal) are visible during the window through `length`, index reads, and iteration, and fully revert at settle. + - Evidence: UO — "3: optimistic store filter-removal reverts at settle" - **CONFLICT:** rollback must atomically discard all touched index lane values, the length lane value, and the key-set overlay — with mid-lane iteration consistency (O2). **R37.** Store optimism is per-key: different keys written by different actions settle independently — same ownership rules as signals (R15/R16) at store-key granularity. + - Evidence: LTO — "repro 1" - **CONFLICT:** current code stamps `STORE_OPTIMISTIC_OWNERS` in the store layer (deleted); per-key ownership must come from core lane values on nodes. diff --git a/packages/signals/docs/rules-mining/optimistic-store.md b/packages/signals/docs/rules-mining/optimistic-store.md index 6ef3abb97..9bf8f8789 100644 --- a/packages/signals/docs/rules-mining/optimistic-store.md +++ b/packages/signals/docs/rules-mining/optimistic-store.md @@ -6,24 +6,30 @@ Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-st ## A. Visibility -**R1 — Synchronous universal visibility.** An optimistic write is visible to every reader immediately at write time, before any flush: tracked, untracked, inside the action body, outside any reactive context, and subsequent setter drafts. +**R1 — ~~Synchronous universal visibility.~~** ~~An optimistic write is visible to every reader immediately at write time, before any flush.~~ **Superseded 2026-09-10 by A28(5)**: visible at the flush that carries it, to every channel. _Landed 2026-09-15 (read-side A28)._ + - createOptimisticStore — "should update store via setter and revert on flush", "should show optimistic value when read outside reactive context"; refetch-hold draft assertion. **R2 — Drafts compose on the live optimistic view.** Each setter draft reads through all prior optimistic state (same tick, across ticks, across separate actions/refetches). + - "should allow multiple optimistic updates before flush", "should accumulate rapid successive array pushes"; refetch-hold "#2951: consecutive bare writes stack…". - CONFLICT: the draft read path must resolve lane view, not raw, including structural array state (length/index nodes + key-set node jointly coherent for the next draft). **R3 — Per-change notification.** One notification per distinct optimistic value change; sequences like `[0, 1, 2, 0]` are contract. + - "should show each optimistic update during transition", "should track property changes through effects". **R4 — Equality cut.** An optimistic write equal to current committed value: no notification on write or settle. + - "should not trigger effect if optimistic value matches original". **R5 — Snapshot/deep read the optimistic view (resolves O1).** snapshot()/deep() agree with every other reader: overlays, nested writes, optimistic deletes (key absent), array mutations; after settle show committed; deep() re-runs on write and revert. + - entire "snapshot and deep see optimistic writes (#2850)" block. - Note: confirms O1 — snapshot = current view; a committed-only meaning would break these. **R6 — Snapshot allocates fresh objects while an overlay is live** (not identity-stable across calls); settled returns raw identity. + - "snapshot shows the overlay during a transition…" (`during !== snapshot(state)`). - CONFLICT (mild): pins allocation behavior; sparse CoW satisfies it, but internals-adjacent (see H2). @@ -32,6 +38,7 @@ Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-st **R8 — `latest()` returns the optimistic value** during a pending refetch window. **R9 — Cross-lane atomic flip.** Regular store written in the same action holds old value while optimistic store shows overlay; at settle both land in ONE notification pass (mixed intermediates never observed). + - "should hold regular store value during transition while showing optimistic". ## B. Rollback / settle @@ -41,12 +48,14 @@ Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-st **R11 — Deep-state restoration.** Revert restores complete pre-overlay state at every depth: nested writes, wholesale replacement, array length/indices/order, deletions (value + key membership). **R12 — Revert target is the CURRENT derived base, not a stale snapshot** (dependency changed mid-overlay → revert to recomputed value). + - "should derive from source signal and revert optimistic writes"; "optimistic write reverts to computed value after async completes". - Note: why backup snapshots were already wrong; "discard lane, read through to raw" satisfies it IF the projection recompute has adopted into raw by settle time. **R13 — Base data is not overlay data.** Async-fetched/derived data commits to base and persists; only setter-originated optimistic state discards. **R14 — No-flicker across the settle/refresh seam.** From action-body return until refresh fetch lands, subscribers never observe the previously-committed value of an overridden property. + - "should not flicker through previously-committed value on second toggle…". - CONFLICT: lane settle condition must be actions empty AND async reporters empty, jointly — pending async spawned by the transaction keeps the lane alive. @@ -61,16 +70,20 @@ Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-st **R18 — Overlay lifetime is transaction-bound, per key** (never a timer, never a mere flush boundary — under an action). **R19 — Disjoint-key concurrent actions revert independently** (incl. different rows, deletes) (#2899 ×3). + - Note: per-property nodes give this by construction — strongest validation of the new model. **R20 — Same-key writes entangle whole transactions:** latest write displays; NOTHING in the merged transaction settles until the last member completes — including keys written by only one of them. + - "#2899: same-key writes entangle…", "should handle 3 rapid toggles…", "rapid same-tick toggles…". - CONFLICT (high): §7 defers collision to core lanes; this demands transaction-level merge propagation. If core lanes merge per-property only, `s.b` reverts at B's settle and the test breaks. Ruling: is core lane merge transaction-granular? **R21 — Optimistic delete is per-transaction scoped** (a concurrent action's settle must not resurrect another action's delete). + - CONFLICT: key-set node is one per object — #2899's flat-record problem reappears at the key-set node; its overlay must carry per-transaction granularity for key adds/removes (O2 unspecified). **R22 — Ambient (transaction-less) writes flash:** visible until end of flush, then revert — without touching in-flight actions' keys. + - CONFLICT: needs an "ambient lane" with flush-end lifetime; combined with R42, ambient-lane lifetime is conditional. **R23 — Actions scope globally (a transaction, not a store handle):** writes made under action A belong to A regardless of which store; separate stores under separate actions settle independently. @@ -82,19 +95,23 @@ Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-st **R25 — Array mutation overlays:** push, splice, whole-array replacement, top-level array stores — length, index reads, holes, spread/iteration, .map all coherent mid-pending and restore exactly on revert. **R26 — Length reactively consistent with contents;** a consumer reading length then indices in one computation never observes a torn state. + - CONFLICT (mild): length on key-set node + elements on index nodes → tearing is the natural failure mode; §6's acceptance criteria. **R27 — Key enumeration and `has` are lane-reactive** (Object.keys / `in` reflect optimistic adds/deletes, notify, revert). **R28 — Proxy identity survives truth adoption of optimistic rows:** server data key-matching an optimistically pushed row recycles the proxy (identity preserved) and adopts server values. Single and multiple pushes. + - CONFLICT (high): an optimistic-only row does not exist in raw — key-matching and prev-length require the adoption channel to consult the LANE VIEW (the pinned regression: "reconcile was blind to STORE_OPTIMISTIC_OVERRIDE, prevLength was 0"). Raw-only diff is unsound here. **R29 — Entity-swap key probes read committed base, not overlay** (an optimistic `s.id = 99` must not confuse the swap); `key: null` → positional identity. + - Note: paired with R28: identity/key probes of incoming-vs-existing entity use committed base; length/row-matching of the previous arrangement must see the overlay. Both needed, explicitly. ## E. Strict-read / pending-read **R30 — Seed invisibility.** Derived store's seed is a draft, never observable: before first resolution every read — get, `in`, keys, spread — throws NotReadyError untracked. Applies to createStore(fn, seed) and createOptimisticStore(fn, seed). + - CONFLICT (high): with seed-as-initial-raw + raw fallthrough reads, uninitialized state must gate EVERY trap before the §2 raw fallthrough or the seed leaks. **R31 — Dev strictRead scopes escalate:** uninitialized read in a component body throws the `[PENDING_ASYNC_UNTRACKED_READ]` dev error (exact tag is contract), precedence over plain NotReadyError. @@ -114,6 +131,7 @@ Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-st **R37 — Optimistic writes are verdict-inert:** a mid-refetch write displays but neither clears nor causes pending; the honest mixed state {value: 999, pending: true} is observable. (Re-ruled 2026-07-13, superseding the A20 mask.) **R38 — No-op setters are fully inert:** trap-firing no-ops (s => s, s => ({...s}), same-value write, delete of absent prop) mid-refetch display nothing, don't silence pending, don't entangle with the surrounding transaction. + - CONFLICT (mild): "writes always materialize the node" is fine (unobservable), but LANE ENTANGLEMENT must be gated on actual value change — incl. recognizing a returned shallow copy of identical values as a no-op. Equality-cut before any lane linkage forms. **R39 — Landing truth wins over the override:** fetch resolves → server/computed value displays, override consumed, pending clears — even if written mid-flight. @@ -123,6 +141,7 @@ Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-st **R41 — Streaming continuations are not pending windows.** A generator-based derive (or wrapped createProjection) that yielded once reads settled while awaiting its next chunk, incl. with an override displayed. **R42 — Bare writes ride an in-flight refetch (#2951).** A transaction-less optimistic write while the store's own truth is in flight does NOT revert at flush end; holds until truth lands. Order-independent within the tick; also for later-tick writes during the same refetch. Optimistic state clears when truth lands or its transaction settles — never on a timer. + - CONFLICT (high): the rule that killed the old firewall/layer split. Must define which lane a bare write joins — attach to / held open by the store's in-flight recompute transition. R22 flash + R42 ride are ONE rule conditioned on in-flight truth; two mechanisms recreates #2951. **R43 — Refresh-in-action landings preserve still-pending overlays** (same key ⇒ merged transaction: landing does not consume the pending action's optimistic value). @@ -130,6 +149,7 @@ Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-st **R44 — Bare-refresh landings consume key-matched overlay content** (optimistic "Optimistic" → server "Saved"); the action's later settle does not revert it. **R45 — Separate-transition landings clear foreign optimistic rows (#2719):** a different source transition resolving fresh data clears optimistic rows of a still-pending unrelated action immediately; later settle does not resurrect. Returned-value and draft-mutating derive forms. + - CONFLICT (high, jointly with R43/R44): three different answers to "what does landed truth do to a pending action's optimistic state": preserve (refresh inside entangled action), adopt-and-consume per matched row (bare refresh), clear entirely (separate source transition). Needs one lane principle (plausibly: whether the landing occurs inside the overriding lane's transaction or supersedes it). The tightest constraint set in the suite. **R46 — Refetch persistence across multi-action windows:** overlay survives arbitrary interleaved refresh landings while any overlapping action is pending. diff --git a/packages/signals/scripts/rules-index.mjs b/packages/signals/scripts/rules-index.mjs index a23aed3be..d2ae6052d 100644 --- a/packages/signals/scripts/rules-index.mjs +++ b/packages/signals/scripts/rules-index.mjs @@ -46,7 +46,7 @@ function status(text) { .replace("ruled, amended in place", "amended") .replace(/ \d{4}-\d{2}-\d{2}.*$/, "") .replace(/ \(promoted.*$/, ""); - if (/SUPERSEDED/.test(text)) return "superseded"; + if (/SUPERSEDED|\*\*Superseded \d{4}/.test(text)) return "superseded"; if (/RETIRED/.test(head)) return "retired"; if (/RULED OUT/.test(head)) return "ruled out"; if (/— FIXED|FIXED\.\*\*/.test(head)) return "fixed"; diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index 0747839ab..eacb061cd 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -158,6 +158,11 @@ export const CONFIG_HELD_CHILDREN = 1 << 20; * lane-revealed. Set by `commitPendingNodes`; cleared when the node next * enters pending fresh (a new flight from a settled state). */ export const CONFIG_INPUTS_PUBLISHED = 1 << 21; +/** A28 (4): the node was written inside a recompute that ran OUTSIDE a flush + * (a creation-time compute — boundary machinery, a mapArray's first run). Such + * a write is promoted at that recompute's end: readers in the same block see + * it. Cleared when the next flush begins; set only on that rare path. */ +export const CONFIG_PROMOTED = 1 << 22; export const STATUS_NONE = 0; export const STATUS_PENDING = 1 << 0; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index c805a95f4..137c5d8e2 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -27,6 +27,7 @@ import { CONFIG_OPTIMISTIC, CONFIG_OVERRIDE_SUPERSEDED, CONFIG_OWNED_WRITE, + CONFIG_PROMOTED, CONFIG_SLOT_NODE, CONFIG_SYNC, CONFIG_TRANSPARENT, @@ -913,6 +914,7 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension { _overrideValue: undefined, _overrideOwner: undefined, _overrideTime: 0, + _flushedStaged: NOT_PENDING, _overrideStamp: 0, _optimisticLane: undefined, _pendingSignal: undefined, @@ -1511,6 +1513,12 @@ export function enterStagedRead( t: Transition | null | undefined = el._transition ): void { if (!t || t === activeTransition || pendingCheckActive) return; + // A companion (the latest() shadow, the isPending() verdict signal) is the + // engine's mirror of the flushed world — reading it, or being it, is an + // observation, not a derivation from the hold: latest(x) never enters x's + // transaction, and the shadow's own pass never enters either (it would + // flip activeTransition under the reader that pulled it). + if (el._x?._parentSource || (context as Computed | null)?._x?._parentSource) return; // Verdict machinery (GlobalQueue._verdictPull: companion creation and the // latest()/isPending() pulls — the latest() shadow is created before it is // marked optimistic, so the bit alone cannot tell) and optimistic nodes @@ -1535,6 +1543,92 @@ export function enterStagedRead( globalQueue.initTransition(t); } +/** A28 — set when a node is staged (queuePendingNode) or a held node rewritten + * (stashHeldRewrite) OUTSIDE a flush; cleared when the next flush begins. The + * read sites test this one module boolean instead of `globalQueue._running`: + * inside a flush it is false and the A28 arm costs nothing; outside, only a + * tick with unflushed writes pays the staged-node check. */ +export let unflushedStaged = false; +export function markUnflushedStaged(): void { + unflushedStaged = true; +} + +/** A28 — a write becomes visible at flush. Outside a flush, a node holding an + * AMBIENT staged value (no transaction stamp) was written since the last + * flush: ambient staging commits at flush end, so nothing else leaves a node + * in this state; a stamped value is the flushed held world, which A28 says + * latest() serves. Inside a flush the rule does not apply (A28 (4): promoted + * within the round). Structural — no marker on the write path. */ +export function unflushed(el: Signal | Computed): boolean { + return unflushedValue(el) !== NOT_PENDING; +} +/** The value an unflushed node serves — the committed value for an ambient + * write, the flushed staged value for a rewrite of a held node — or + * NOT_PENDING when nothing is unflushed. Exempt: owned-write nodes (A28 (4): + * a write issued inside a recompute is promoted at that recompute's end — + * boundary and loading machinery, until()'s internals, signals declared for + * in-computation writes) and engine companions (the isPending() verdict + * signal, the latest() shadow: the system's own writes, made at the source's + * write to mirror it, installing eagerly — A28, A8). */ +export function unflushedValue(el: Signal | Computed): unknown { + if ( + globalQueue._running || + el._pendingValue === NOT_PENDING || + el._config & CONFIG_PROMOTED || + el._x?._parentSource + ) + return NOT_PENDING; + if (el._transition === null) return el._value; + // A held node: unflushed only if rewritten since the last flush (stash). + return el._x === null ? NOT_PENDING : el._x._flushedStaged; +} +/** Held nodes rewritten since the last flush (setSignal); the flush clears + * their stash — from then on latest() answers with the rewrite. */ +const unflushedRewrites: Array | Computed> = []; +/** Nodes written inside a creation-time recompute (CONFIG_PROMOTED). */ +const promotedWrites: Array | Computed> = []; +/** A28 (5): an optimistic write becomes the ACTIVE override at the flush that + * carries it. `_overrideTime` is stamped with `clock` at the write and `clock` + * advances after every flush, so "this tick, outside a flush" is unflushed. */ +export function unflushedOverride(el: Signal | Computed): boolean { + // Companions are optimistic signals written by the engine (see unflushed). + return !globalQueue._running && el._x?._overrideTime === clock && !el._x?._parentSource; +} +/** A derivation served the committed value because of an unflushed write + * (A28) must run again in the flush that carries it — the late-linker case + * (#3337's reason to defer the walk): it linked after the write walked. */ +export function markLateLinker(c: Computed): true { + // The pass's own tail re-enqueues on this latch (recompute's finally) — + // a direct enqueue here would be wiped by the pass's flag reset. + c._flags |= REACTIVE_MISSED_WAKE; + return true; +} + +/** Companion-bearing nodes written outside a flush (setSignal); the flush + * that carries their writes re-syncs their companions (A28). */ +export const unflushedCompanions: Array | Computed> = []; +export function resyncUnflushedCompanions(): void { + unflushedStaged = false; + // Length-guarded: the common flush has nothing here, and must allocate nothing. + // Length-guarded: the common flush has nothing here and allocates nothing. + if (unflushedRewrites.length !== 0) { + for (const el of unflushedRewrites) el._x!._flushedStaged = NOT_PENDING; + unflushedRewrites.length = 0; + } + if (promotedWrites.length !== 0) { + for (const el of promotedWrites) el._config &= ~CONFIG_PROMOTED; + promotedWrites.length = 0; + } + if (unflushedCompanions.length !== 0) { + for (const el of unflushedCompanions) + GlobalQueue._syncCompanions!( + el, + el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value + ); + unflushedCompanions.length = 0; + } +} + export function readNodeFast(el: Signal): T | typeof READ_SLOW { if ( latestReadActive || @@ -1546,6 +1640,9 @@ export function readNodeFast(el: Signal): T | typeof READ_SLOW { activeTransition !== null || currentOptimisticLane !== null || snapshotCaptureActive || + // A28: a staged node read outside a flush may serve its committed value; + // that arm lives on the slow path so this body stays inlinable. + (unflushedStaged && el._pendingValue !== NOT_PENDING) || (__DEV__ && strictRead) ) return READ_SLOW; @@ -1602,6 +1699,7 @@ export function read(el: Signal | Computed): T { activeTransition === null && currentOptimisticLane === null && !snapshotCaptureActive && + (!unflushedStaged || el._pendingValue === NOT_PENDING) && // A28, see readNodeFast (!__DEV__ || !strictRead) ) { if (c && tracking) link(el, c as Computed); @@ -1755,7 +1853,10 @@ export function read(el: Signal | Computed): T { // authoritative — optimism never lives there); the sticky mark makes the // A17-silent "landing equals override" paths notify this node's subs so // the reader re-runs when truth arrives. - if (!(c && c._config & CONFIG_AUTHORITATIVE_READ)) { + // A28 (5): an optimistic write written this tick, outside a flush, is not + // yet the active override — fall through to the normal selection (the + // authoritative mark below still applies: until() must wake on landing). + if (!(c && c._config & CONFIG_AUTHORITATIVE_READ) && !unflushedOverride(el)) { // A18 supersession (#3331): the node's own source answered with a // DIFFERENT value. The optimism is over for the graph — a tracked // reader sees the staged truth — while the override remains the @@ -1798,6 +1899,12 @@ export function read(el: Signal | Computed): T { el._pendingValue !== NOT_PENDING && ((el as Computed)._statusFlags & STATUS_UNINITIALIZED) !== 0; if (noCommitted && !c) throw new NotReadyError(null); + const u = c && unflushedStaged ? unflushedValue(el) : NOT_PENDING; + if (u !== NOT_PENDING) { + markLateLinker(c as Computed); + if (pendingCheckActive) GlobalQueue._recordFresh!(el, u); + return u as T; + } const value = !c || (currentOptimisticLane !== null && @@ -1875,6 +1982,27 @@ function ownedScopeWriteMessage(owner: Owner) { : REACTIVE_WRITE_IN_OWNED_SCOPE_SIGNAL_MESSAGE; } +/** A28 — a rewrite of a HELD node outside a flush keeps the staged value the + * last flush left, for latest()/verdicts, until the next flush carries it + * (stash, cleared at that flush). Cold: only stamped nodes reach here. */ +function stashHeldRewrite(el: Signal | Computed): void { + if (globalQueue._running) return; + const x = ext(el); + if (x._flushedStaged === NOT_PENDING) { + x._flushedStaged = el._pendingValue; + unflushedRewrites.push(el); + unflushedStaged = true; + } +} +/** A28 (4) — written inside a recompute that runs OUTSIDE a flush (a + * creation-time compute): promoted at that pass's end, visible to the rest of + * the block. Cold: only contextual writes reach here. */ +function notePromotedWrite(el: Signal | Computed): void { + if (globalQueue._running || el._config & CONFIG_PROMOTED) return; + el._config |= CONFIG_PROMOTED; + promotedWrites.push(el); +} + export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T)): T { if ( __DEV__ && @@ -1896,7 +2024,13 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T throw new Error(ownedScopeWriteMessage(context)); } - if (el._transition && activeTransition !== el._transition) + // A write to a held node inside a flush joins the hold (the round's other + // work is the transaction's). From mainline it does not: the node is + // already the transaction's (stamped, in its pending list) and the rewrite + // commits with it; entering here left activeTransition set for the rest of + // the caller's block, so a memo created after the write — and the whole + // next flush — became the transaction's instead of mainline's (A28, A29). + if (el._transition && activeTransition !== el._transition && globalQueue._running) globalQueue.initTransition(el._transition); // The optimistic write path lives with the engine: only optimisticSignal / @@ -1929,17 +2063,27 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T const wasStaged = el._pendingValue !== NOT_PENDING; if (!wasStaged) queuePendingNode(el); + // A28 arms, gated on the loads the write already pays for (a plain ambient + // rewrite outside a flush — the write-loop shape — costs `_transition` and + // `context` here and nothing else; the arms themselves are cold helpers so + // setSignal stays within every setter's inlining budget, ~300 B bytecode). + else if (el._transition !== null) stashHeldRewrite(el); el._pendingValue = v; if (__DEV__) devTrackHeldPending(el); + if (context !== null) notePromotedWrite(el); // syncCompanions only pokes _pendingSignal/_latestValueComputed — with // neither companion present the call is a guaranteed no-op (companions are // only ever created, never removed, and creating one installs the hook and // sets CONFIG_HAS_COMPANIONS — one masked read replaces two optional-field // probes on every write). - el._config & CONFIG_HAS_COMPANIONS && - GlobalQueue._syncCompanions !== null && + if (el._config & CONFIG_HAS_COMPANIONS && GlobalQueue._syncCompanions !== null) { GlobalQueue._syncCompanions(el, v); + // A28 (2): the verdict computed here is the PRE-flush one (an unflushed + // write is not pending); the flush that carries the write re-syncs so + // the companions mirror the flushed world. Only companion nodes pay. + if (!globalQueue._running) unflushedCompanions.push(el); + } // _time is a computed-only slot (§12e): writing it on a signal would fork // the lean shape. Every read site is computed-typed. diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 1f22348fb..ed04dc0d5 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -28,7 +28,14 @@ import { STATUS_UNINITIALIZED } from "./constants.js"; import { attrHooks } from "./attribution-hooks.js"; -import { currentOptimisticLane, ext, slotUnobservedHook } from "./core.js"; +import { + currentOptimisticLane, + ext, + slotUnobservedHook, + markUnflushedStaged, + resyncUnflushedCompanions, + unflushedCompanions +} from "./core.js"; import { DEV, emitDiagnostic, GRAPH_SIZE_WARN_AT, noteFanOut, reportDiagnostic } from "./dev.js"; import { NotReadyError } from "./error.js"; import { sweepDormant, trimStaleDeps } from "./graph.js"; @@ -720,6 +727,10 @@ export class GlobalQueue extends Queue { ) { this._running = true; try { + // A28: companions of nodes written since the last flush mirror the + // flushed world — re-synced inside the running window (the write is + // "flushed" from here on). + resyncUnflushedCompanions(); // Sweep first: unobserved() pulls swept nodes out of the dirty heap, // so a dormant memo dirtied in the same tick is reclaimed instead of // recomputed (matching the old inline dispose-on-read counts). @@ -737,6 +748,7 @@ export class GlobalQueue extends Queue { return; } this._running = true; + resyncUnflushedCompanions(); // A28, see above try { if (__DEV__) devCheckFlushStart(); // Before runHeap for the same reason as the fast drain above; late @@ -990,6 +1002,7 @@ export class GlobalQueue extends Queue { export function queuePendingNode(node: Signal): void { if (__DEV__) lastStagedNodeName = (node as any)._name ?? null; currentBatch._pendingNodes.push(node); + if (!globalQueue._running) markUnflushedStaged(); // A28 } // Dev-only attribution for the flush loop guard (#3140): when the guard diff --git a/packages/signals/src/core/types.ts b/packages/signals/src/core/types.ts index 933bc38ed..41b556e7b 100644 --- a/packages/signals/src/core/types.ts +++ b/packages/signals/src/core/types.ts @@ -79,6 +79,9 @@ export interface NodeExtension { * tick derives from inputs that predate the override and does not * supersede it (A18 supersession ordering, #3331). */ _overrideTime: number; + /** A28: the staged value the last flush left on a HELD node that has since + * been rewritten (latest() keeps answering with it); NOT_PENDING otherwise. */ + _flushedStaged: unknown; /** Provenance of the active override's write: the scheduler's `origin` (the * asking action's invocation sequence; 0 = mainline). An arriving answer * whose flight an older action issued asked a question the override has diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index 430ecd17a..f3a406cab 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -31,8 +31,13 @@ import { read, setContextInternal, setLatestReadActive, + markLateLinker, setPendingCheckActive, setSignal, + unflushed, + unflushedCompanions, + unflushedOverride, + unflushedValue, setStrictRead, stale, strictRead, @@ -55,6 +60,7 @@ import { insertSubs, schedule, zombieQueue, + runAsTransitionBatch, type Transition } from "./scheduler.js"; import type { Computed, FirewallSignal, Signal } from "./types.js"; @@ -108,12 +114,61 @@ function getPendingSignal(el: Signal | Computed): Signal { el._config |= CONFIG_HAS_COMPANIONS; markFirewallChildCompanions(el); ext(ps)._parentSource = el; - if (computePendingState(el)) setSignal(ps, true); + if (computePendingState(el)) backfillCompanion(el, ps, true); + joinUnflushedResync(el); if (__DEV__) devTrackCompanionOwner(el); } return ps; } +/** + * A lazily created companion's first write mirrors state the owner already + * carries — a held write, a pending verdict. The companion is created wherever + * the first latest()/isPending() read happens to run, but the write belongs + * to whatever HOLDS that state: written in the ambient window, the override + * would register in the ambient batch and revert when that flush's round ends + * — the shadow re-derived from the committed view, the verdict flipped false — + * while the owner's hold was still on (#3336: A and B differing only in + * whether a companion existed before the hold). A companion created lazily + * answers as if it had always existed: its backfill is registered with the + * owner's transaction and lives and reverts with it. A hold with no + * transaction (a pending async, a same-flush staged write) is ambient and the + * write stays ambient. (A28 (3), lifted from #3337.) + */ +function backfillCompanion( + el: Signal | Computed, + companion: Signal | Computed, + value: unknown +): void { + const transition = el._transition; + if (transition) runAsTransitionBatch(transition, () => setSignal(companion, value)); + else setSignal(companion, value); +} + +/** A28: a companion created while its source carries an UNFLUSHED write joins + * the flush-start re-sync like a companion that existed at the write — + * syncCompanions only reaches companions that exist at write time. Without + * this the flush brings it current by a plain recompute instead of the + * optimistic write: its readers are then staged under whatever transaction + * the round entered (a memo over latest() of a held source was held with the + * source, and its untracked reads answered the previous value until the hold + * committed) rather than direct-committed as the optimistic view they are. */ +function joinUnflushedResync(el: Signal | Computed): void { + if (unflushed(el)) unflushedCompanions.push(el); +} + +/** The staged value the verdict channels answer for (A28): while a node + * carries an unflushed write its `_pendingValue` is not yet part of any + * flushed world, so the channels answer for the value the last flush left + * staged (a held node's stash) or for nothing (NOT_PENDING). */ +function flushedStaged(el: Signal | Computed): unknown { + return unflushed(el) + ? el._transition === null + ? NOT_PENDING + : el._x!._flushedStaged + : el._pendingValue; +} + function collectPendingSources(el: Signal | Computed): void { if (!pendingProbe) return; pendingProbe.sources.add(el); @@ -209,7 +264,8 @@ function computePendingState(el: Signal | Computed): boolean { const parent = (parentNode._firewall || parentNode) as Computed; return newQuestionInFlight(parent); } - if (firewall && el._pendingValue !== NOT_PENDING && !hasActiveOverride(el)) { + const staged = flushedStaged(el); + if (firewall && staged !== NOT_PENDING && !hasActiveOverride(el)) { return ( !!(firewall._flags & REACTIVE_MANUAL_WRITE) || (!firewall._x?._inFlight && !(firewall._statusFlags & STATUS_PENDING)) || @@ -226,10 +282,13 @@ function computePendingState(el: Signal | Computed): boolean { if ( el._config & CONFIG_OVERRIDE_SUPERSEDED && el._pendingValue === NOT_PENDING && - hasActiveOverride(el) + hasActiveOverride(el) && + !unflushedOverride(el) ) return !el._equals || !el._equals(el._value as any, unwrapOverride(el._x?._overrideValue)); - if (el._pendingValue !== NOT_PENDING && !comp._loading) { + // A28 (2): an unflushed write is not yet observable — the verdict answers + // for the flushed staged value. + if (staged !== NOT_PENDING && !comp._loading) { // A18 (d): under a displayed override the observable value is the // override, so the verdict is "the arrived truth differs from it" — // even before the node's first commit. The UNINITIALIZED suppression @@ -237,10 +296,8 @@ function computePendingState(el: Signal | Computed): boolean { // non-final"; an override is one (a node whose first landing was held // by a reveal it never got to commit, then superseded under its // override, read false here). - if (hasActiveOverride(el)) - return ( - !el._equals || !el._equals(el._pendingValue as any, unwrapOverride(el._x?._overrideValue)) - ); + if (hasActiveOverride(el) && !unflushedOverride(el)) + return !el._equals || !el._equals(staged as any, unwrapOverride(el._x?._overrideValue)); // A quiet re-ask's held landing still answers the same question: the // classification survives the landing (asyncWrite) and dies with the // commit (commitPendingNode) — verdict-quiet through the reveal, like @@ -391,8 +448,9 @@ function getLatestValueComputed(el: Signal | Computed): Computed { // created lazily, possibly after the write was processed — syncCompanions // only pushes into companions that already exist, so the first latest() // read inside a held transition showed the committed value (#3041). - if (el._pendingValue !== NOT_PENDING && !hasActiveOverride(el)) - setSignal(lvc, el._pendingValue as T); + const staged = flushedStaged(el); + if (staged !== NOT_PENDING && !hasActiveOverride(el)) backfillCompanion(el, lvc, staged); + joinUnflushedResync(el); if (__DEV__) devTrackCompanionOwner(el); setContextInternal(prevContext); setPendingCheckActive(prevCheck); @@ -417,42 +475,40 @@ function latestRead(el: Signal | Computed): T { const prevPending = latestReadActive; setLatestReadActive(false); const visibleValue = ( - el._x?._overrideValue !== undefined && el._x?._overrideValue !== NOT_PENDING + hasActiveOverride(el) && !unflushedOverride(el) ? unwrapOverride(el._x?._overrideValue) : el._value ) as T; + // A28: an unflushed write is not the staged value latest() serves. The + // shadow was written at the source's write to mirror it (A8) — consult it + // only once a flush has carried the write. + const u = unflushedValue(el); + if (u !== NOT_PENDING) { + // The reader derived from the flushed world because of an unflushed + // write: it runs again in the flush that carries it (late linker) — + // a pull may have cleared the mark the write's walk set. + if (context !== null && (context as Computed)._flags & REACTIVE_RECOMPUTING_DEPS) + markLateLinker(context as Computed); + // Link the reader to the shadow so the flush that carries the write + // updates it (the shadow itself already mirrors the write, A8). + try { + read(pendingComputed); + } catch { + /* the flushed value answers */ + } finally { + setLatestReadActive(prevPending); + } + // An ambient write: the visible value (override or committed). A rewrite + // of a held node: the staged value the last flush left. + return (el._transition === null ? visibleValue : u) as T; + } let value: T; try { - // An untracked latest() read has no reading context, so read() never - // performs its mid-tick pull — a plain write queued between two latest() - // calls left a still-subscribed shadow at its previous speculative value - // until the flush (#2922). Mirror the tracked-read pull here: mark the - // queued staleness through the graph, then bring the shadow up to date. - const queue = queueFor(pendingComputed); - if ( - pendingComputed._height >= queue._min && - !(pendingComputed._flags & (REACTIVE_DISPOSED | REACTIVE_ZOMBIE)) - ) { - markHeap(queue); - // Suspend probe collection during the pull (mirrors pendingCheckRead's - // prepare): a probe through latest() answers for the SHADOW — the - // read() dispatch collects it deliberately, so the verdict reflects - // async still in flight for the latest view, not the parent's held - // write. A stale shadow recomputing HERE ran its `read(parent)` with - // the probe still live and collected the parent too, so the verdict - // depended on whether anything had pulled the shadow current earlier - // in the tick (#3104: reading latest(m) flipped a later - // latest(() => isPending(x)) from true to false). - const prevCheck = pendingCheckActive; - setPendingCheckActive(false); - GlobalQueue._verdictPull = true; - try { - prepareComputed(pendingComputed as Computed, true); - } finally { - setPendingCheckActive(prevCheck); - GlobalQueue._verdictPull = false; - } - } + // No mid-tick pull: writes become visible at flush (A28), so before the + // flush the shadow is exactly as current as the flushed world — the read + // below serves it as is. (#2922's pull, which brought the shadow current + // against the unflushed write, is superseded: `flush()` first to read + // your own write.) value = read(pendingComputed); } catch (e) { // A NotReady from the shadow of an INITIALIZED source means the shadow diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 0c7f14beb..bff20cd6e 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -69,6 +69,7 @@ import { getNode, hasActiveOverride, heldMaskView, + visibleOverride, runAuthoritative, stagedTruthPB, storeSetterNext, @@ -108,7 +109,10 @@ function installNextBlockedHalf(): void { applyTentative, retainsOptimism: transitionHoldsOptimism }); - setNextOptimisticViewResolver((t: StoreNextTarget, raw: any) => optimisticView(t, raw)); + // The affects() declaration walk is a WRITER channel (A28 (5)): tagging a + // parent covers the record as the writer sees it, this tick's optimistic + // writes included — the draft view, not the reader view. + setNextOptimisticViewResolver((t: StoreNextTarget, raw: any) => optimisticView(t, raw, true)); // Scheduler flush tails call _clearOptimisticStores whenever tracked // stores exist; next has no layer to clear — reverts are engine-native — // so the hook only empties the batch set. @@ -632,7 +636,8 @@ export function notifyOptimisticWrites(t: StoreNextTarget, pb: Record + src: Record, + draft = false ): Record { if (t.fam?.opt !== true || authoritativeRead()) return src; let out: Record | null = null; @@ -641,7 +646,9 @@ export function optimisticView( if (nodes !== null) { for (const key of Reflect.ownKeys(nodes)) { const node = nodes[key as any]; - if (!hasActiveOverride(node)) continue; + // A28 (5): readers see an optimistic write once a flush carried it; + // the draft (writer channel) composes on it now. + if (!(draft ? hasActiveOverride(node) : visibleOverride(node))) continue; const ov = unwrapOverride(node._x?._overrideValue); if (key === "length" && Array.isArray(src)) { if ((src as any[]).length !== ov) (ensure() as any[]).length = ov; @@ -652,7 +659,9 @@ export function optimisticView( if (has !== null) { for (const key of Reflect.ownKeys(has)) { const node = has[key as any]; - if (!hasActiveOverride(node)) continue; + // A28 (5): readers see an optimistic write once a flush carried it; + // the draft (writer channel) composes on it now. + if (!(draft ? hasActiveOverride(node) : visibleOverride(node))) continue; const present = !!unwrapOverride(node._x?._overrideValue); if (!present && key in (out ?? src)) delete ensure()[key as any]; } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index f9170907b..af58d9d9b 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -35,6 +35,8 @@ import { devGuardStoreSetterWrite, isEqual, latestReadActive, + stale, + unflushedOverride, prepareComputed, read as readNode, READ_SLOW, @@ -279,11 +281,16 @@ export function getNode( // (stageHeldKey). Its held-adoption notification (stageHeldAdoptions) // ran before the node existed and the drain has nothing left to say. // Without this the node was born from whichever view its first reader - // saw and never learned the other. (The held-FOLD twin — a setter's - // write to an unobserved key landing only in the pending backing — is - // #3336's, and lands with #3337.) - const held = heldAdoptionTransition(target); + // saw and never learned the other. Two kinds of hold, one rule (#3336): + // - a held FOLD (pb): a setter's write to an unobserved key landed only + // in the pending backing; committed is `v[key]`, staged is `pb[key]` + // (undefined for a deleted key); + // - a held ADOPTION (ht): the adopted value already swapped into `v`; + // committed is the held view `hv[key]`, staged is `v[key]`. + const fold = heldFoldTransition(target); + let held = heldAdoptionTransition(target); if (held !== null) current = (target.hv as any)[key]; + else if ((held = fold) !== null) current = (target.v as any)[key]; // Create-floor diet: slotSignal bakes the whole node into one literal — // no options object, no equals/unobserved closures, no NodeExtension, // no post-construction expandos (acc + the wrap cache px/pxv are @@ -323,7 +330,16 @@ export function getNode( // A node born inside a live mark's identity scope inherits the mark // (the declaration walk could only cover nodes existing then). if (key !== $AFFECTS && affectsScopesLive()) inheritAffectsMarks(created, target.v, key); - if (held !== null) stageHeldKey(created, (target.v as any)[key], held); + if (held !== null) + stageHeldKey( + created, + fold !== null + ? target.del !== null && target.del.has(key) + ? undefined + : (target.pb as any)[key] + : (target.v as any)[key], + held + ); nodes[key] = node; target.nc++; markDescendants(target); @@ -353,6 +369,56 @@ function heldAdoptionTransition(target: StoreNextTarget): Transition | null { * it walks no subscriber (the node has none yet). Transition-stamped now, as * `runFolded` does — no parked-flush pass will stamp it. */ +/** The live transaction holding `target`'s pending backing (the #3089 + * write-time stamp, resolved through merges), else null. */ +function liveFoldTransition(target: StoreNextTarget): Transition | null { + if (target.pb === null) return null; + const fb = foldBatches.get(target); + if (fb === undefined) return null; + const txn = currentTransition(fb); + return txn._done === false ? txn : null; +} + +/** liveFoldTransition for node materialization (stageHeldKey). Inside the + * draft the backing is the setter's working copy, not a flushed hold — its + * nodes take their writes at setter exit (notifyWrites). Optimistic families + * hold at the backing: tentative writes are node overrides over a discarded + * clone, and a truth-staged landing is masked from ordinary readers by + * heldTruthMasked — neither is a plain staged write to mirror. Chained + * backings serve the inner store's live value, never a node value. */ +function heldFoldTransition(target: StoreNextTarget): Transition | null { + if (target.ch || target.fam?.opt === true || inDraft(target)) return null; + return liveFoldTransition(target); +} + +/** + * Core read()'s committed-visibility clause, at the backing (#3336): + * `(stale && el._transition !== null) ? _value : _pendingValue`. While a live + * transaction holds the pending backing, a stale (render) reader — and a + * reader with no owner at all — sees committed, through every channel: an + * untracked read in the effect, `in`, `Object.keys`, `deep()`/`snapshot()`. + * The node path already answers this way; without the backing twin the same + * effect read `0` through `a.count` and `1` through `untrack(() => a.count)` + * or `"added" in a`. Speculation stays visible to non-stale owner-context + * readers and to the peek from inside one; a pending backing with no + * transaction (a same-tick plain write) is unaffected. + */ +function heldFromReader(target: StoreNextTarget): boolean { + if (!stale && inOwnerContext()) return false; + const txn = liveFoldTransition(target); + return txn !== null && foreignHold(txn); +} + +/** Core read()'s `activeTransition !== el._transition`: a hold belongs to a + * FOREIGN transaction unless the flush running now is that transaction's — + * its own stale readers (a render effect recomputing in it, whose run the + * commit applies) see the staged world. */ +function foreignHold(txn: Transition): boolean { + return ( + activeTransition === null || currentTransition(activeTransition) !== currentTransition(txn) + ); +} + function stageHeldKey(node: Signal, nv: any, txn: Transition): void { if (slotNodeEquals.call(node, node._value, nv)) return; node._pendingValue = nv; @@ -1588,7 +1654,10 @@ function readSource(target: StoreNextTarget): Record { !latestReadActive && !inDraft(target) && !getWriteOverride() && - !inOwnerContext() + // A stale (render) reader of a FOREIGN transaction's hold is a + // committed-visibility reader whatever its owner context (#3336). + (!inOwnerContext() || + (stale && target.ht !== PLAIN_HOLD && foreignHold(currentTransition(target.ht)))) ) { const hv = heldMaskView(target); if (hv !== null) return hv; @@ -1623,13 +1692,20 @@ function pendingBackingVisible(target: StoreNextTarget, speculative: boolean): b // and only the authoritative postures and latest() see it (the // backing-level twin of core read()'s A17-for-held-truth arm; // ordinary readers keep committed until the transaction's reveal). - ((speculative || inOwnerContext()) && !heldTruthMasked(target)) || + // Stale readers and owner-less peeks of a TRANSACTION-held backing see + // committed, as core read() serves them (#3336, heldFromReader). + ((speculative || inOwnerContext()) && !heldTruthMasked(target) && !heldFromReader(target)) || // A projection's pending backing is authoritative-elect: serve it to // context-free readers too UNLESS a transition is holding the node // commits (downstream async hold — stale committed is the contract) // or the reader is a CHILDREN_FORBIDDEN scope, which never observes // its own unsettled write (#3082, signal parity per #3006). - (target.fam !== null && !heldTruthMasked(target) && !foldHeld(target) && !inForbiddenScope())) + // (The write-time stamp covers keys with no node, #3336.) + (target.fam !== null && + !heldTruthMasked(target) && + !foldHeld(target) && + liveFoldTransition(target) === null && + !inForbiddenScope())) ); } @@ -1681,6 +1757,13 @@ export function runAuthoritative(fn: () => T): T { export function hasActiveOverride(node: Signal): boolean { return node._x?._overrideValue !== undefined && node._x?._overrideValue !== NOT_PENDING; } +/** The override a READER sees: installed, and carried by a flush (A28 (5) — + * an optimistic write is a write; until its flush no reader sees it). The + * writer's own channels (the draft, `in`/keys inside the setter) compose on + * the installed override regardless — they use hasActiveOverride. */ +export function visibleOverride(node: Signal): boolean { + return hasActiveOverride(node) && !unflushedOverride(node); +} /** The reading computation is until()'s authoritative-view predicate — same * source of truth as core read()'s A17 carve-out (`context`, which persists @@ -1718,7 +1801,7 @@ function nodeValue(node: Signal, backing: any): any { // only: staged pending values are authoritative, overrides are the // caller's optimism. const v = - !authoritativeServe() && hasActiveOverride(node) + !authoritativeServe() && hasActiveOverride(node) && !unflushedOverride(node) ? unwrapOverride(node._x?._overrideValue) : node._pendingValue !== NOT_PENDING && (latestReadActive || @@ -1728,7 +1811,12 @@ function nodeValue(node: Signal, backing: any): any { // see (core read()'s A17-for-held-truth twin; ordinary readers // keep committed until the transaction's reveal — latest() is // exempted by the leading arm above). - ((inOwnerContext() || authoritativeServe()) && + // — and core read()'s stale-reader clause: a render effect's + // untracked read of a FOREIGN transaction's write sees committed + // (#3336; the tracked read reaches core read() and already does). + (((inOwnerContext() && + !(stale && node._transition !== null && foreignHold(node._transition))) || + authoritativeServe()) && !(node._config & CONFIG_HELD_TRUTH && !authoritativeServe()))) ? node._pendingValue : backing; @@ -1792,7 +1880,9 @@ function serveDataKey( // Truth authors read the backing's own length — an optimistic row from // the caller's transaction must not shift where the author's next write // lands (#3108). - return ((authoritativeServe() ? src : optHooks!.optimisticView(target, src)) as any[]).length; + return ( + (authoritativeServe() ? src : optHooks!.optimisticView(target, src, inDraft(target))) as any[] + ).length; } if (inDraft(target)) { // Optimistic drafts before their first write have no pending backing yet; @@ -1806,23 +1896,23 @@ function serveDataKey( v = unwrapOverride(node._x?._overrideValue); } } else { - if (node !== undefined) { - // §7b: a lane value on the outer node SHADOWS read-through — an active - // override pierces the chained gate; otherwise chained backings always - // serve the live inner value. - if (getObserver() !== null) { - // read()'s plain-signal fast path hoisted over the call (legacy trap - // parity): READ_SLOW = a global read window or non-plain node. - let nv = readNodeFast(node); - if (nv === READ_SLOW) nv = readNode(node); - if (!chained || hasActiveOverride(node)) v = nv === (FORCE as any) ? backingValue : nv; - } else if (!chained || hasActiveOverride(node)) { - v = nodeValue(node, backingValue); - } - } else if (getObserver() !== null) { - // First tracked read: create + link, and let the wrap-cache branch - // below populate px/pxv so read #2 skips wrapNext (slice 2). - readNode((node = getNode(target, key, backingValue, accKnown))); + // §7b: a lane value on the outer node SHADOWS read-through — an active + // override pierces the chained gate; otherwise chained backings always + // serve the live inner value. + if (getObserver() !== null) { + // First tracked read: create + link (the wrap-cache branch below + // populates px/pxv so read #2 skips wrapNext, slice 2). The value is + // served THROUGH the node from this read on — a node born under a held + // fold carries the hold (getNode, #3336), and the backing it was read + // from does not. + if (node === undefined) node = getNode(target, key, backingValue, accKnown); + // read()'s plain-signal fast path hoisted over the call (legacy trap + // parity): READ_SLOW = a global read window or non-plain node. + let nv = readNodeFast(node); + if (nv === READ_SLOW) nv = readNode(node); + if (!chained || hasActiveOverride(node)) v = nv === (FORCE as any) ? backingValue : nv; + } else if (node !== undefined && (!chained || hasActiveOverride(node))) { + v = nodeValue(node, backingValue); } } // Shallow stores serve data raw; store-proxy slots get boundary wrappers. @@ -2049,7 +2139,7 @@ const traps: ProxyHandler = { !authoritativeServe() ) { const node = target.n?.[key]; - if (node !== undefined && hasActiveOverride(node)) + if (node !== undefined && visibleOverride(node)) v = unwrapOverride(node._x?._overrideValue); } if (target.s) return serveShallow(target, key, v); @@ -2082,7 +2172,7 @@ const traps: ProxyHandler = { if (hasActiveOverride(node)) present = !!nv; } else if (!authoritativeServe()) { const node = target.h?.[key as any]; - if (node !== undefined && hasActiveOverride(node)) + if (node !== undefined && visibleOverride(node)) present = !!unwrapOverride(node._x?._overrideValue); } } else if (target.fam?.opt && draftSeesOverrides(target) && !authoritativeServe()) { @@ -2386,9 +2476,10 @@ function visibleKeys(target: StoreNextTarget, src: Record): (s (!inDraft(target) || draftSeesOverrides(target)) ) { let set: Set | null = null; + const draft = inDraft(target); for (const key of Reflect.ownKeys(target.h)) { const node = target.h[key as any]; - if (!hasActiveOverride(node)) continue; + if (!(draft ? hasActiveOverride(node) : visibleOverride(node))) continue; set ??= new Set(keys); if (unwrapOverride(node._x?._overrideValue)) set.add(key); else set.delete(key); @@ -2415,7 +2506,7 @@ function visibleDescriptor( } if (!authoritativeServe() && target.fam?.opt && !inDraft(target)) { const node = target.h?.[key as any]; - if (node !== undefined && hasActiveOverride(node)) { + if (node !== undefined && visibleOverride(node)) { if (!unwrapOverride(node._x?._overrideValue)) return undefined; // opt delete if (desc === undefined) { const vn = target.n?.[key as any]; diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 33cc544ec..29f32bd94 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -203,7 +203,7 @@ export function devAssertNeverUserMutation(target: object): void { * the optimistic channel entirely. */ export interface OptStoreHooks { notifyOptimisticWrites(t: any, pb: Record): void; - optimisticView(t: any, src: Record): Record; + optimisticView(t: any, src: Record, draft?: boolean): Record; applyTentative(t: any, incoming: any, keyFn: ((item: any) => any) | null): void; /** #3164 fold: does this live transaction still retain optimism (armed * nodes or tracked stores)? Backs the held-truth masks in next/store.ts so diff --git a/packages/signals/tests/createOptimistic.test.ts b/packages/signals/tests/createOptimistic.test.ts index 01cf9f14d..06137dff0 100644 --- a/packages/signals/tests/createOptimistic.test.ts +++ b/packages/signals/tests/createOptimistic.test.ts @@ -120,54 +120,88 @@ describe("createOptimistic", () => { expect($x()).toBe(1); }); + // A28: an optimistic write, like any write, becomes visible at flush. An + // ambient one (no action in flight) is installed when the flush starts and + // reverted when it ends, so effects are the only channel that sees it; + // plain reads answer the flushed value on both sides of the flush. it("should update signal via setter and revert on flush", () => { const [$x, setX] = createOptimistic(1); + const values: number[] = []; + createRoot(() => + createRenderEffect($x, v => { + values.push(v); + }) + ); + flush(); + setX(2); - // Optimistic signals write directly to _value, should be immediately visible - expect($x()).toBe(2); - // Without an active transition, flush reverts to original value + expect($x()).toBe(1); // unflushed — not visible yet flush(); + expect(values).toEqual([1, 2, 1]); // the flush shows 2, then reverts the ambient write expect($x()).toBe(1); }); it("should update signal via update function and revert on flush", () => { const [$x, setX] = createOptimistic(1); + const values: number[] = []; + createRoot(() => + createRenderEffect($x, v => { + values.push(v); + }) + ); + flush(); + setX(n => n + 1); - expect($x()).toBe(2); + expect($x()).toBe(1); flush(); + expect(values).toEqual([1, 2, 1]); expect($x()).toBe(1); }); it("should allow multiple optimistic updates before flush", () => { const [$x, setX] = createOptimistic(1); + const values: number[] = []; + createRoot(() => + createRenderEffect($x, v => { + values.push(v); + }) + ); + flush(); + setX(2); - expect($x()).toBe(2); setX(3); - expect($x()).toBe(3); - setX(n => n + 10); - expect($x()).toBe(13); - // All revert on flush + setX(n => n + 10); // the updater sees its own tick's write (3) + expect($x()).toBe(1); // readers do not flush(); + expect(values).toEqual([1, 13, 1]); // one flush carries the last write, then reverts expect($x()).toBe(1); }); it("should provide current optimistic value in update callback", () => { const [$x, setX] = createOptimistic(10); + const values: number[] = []; + createRoot(() => + createRenderEffect($x, v => { + values.push(v); + }) + ); + flush(); setX(prev => { expect(prev).toBe(10); return 20; }); - expect($x()).toBe(20); + expect($x()).toBe(10); setX(prev => { - // Should see the optimistic value, not original + // The updater is the one channel that sees the unflushed write expect(prev).toBe(20); return prev + 5; }); - expect($x()).toBe(25); + expect($x()).toBe(10); flush(); + expect(values).toEqual([10, 25, 10]); expect($x()).toBe(10); }); }); @@ -480,15 +514,22 @@ describe("createOptimistic", () => { const [$x, setX] = createSignal(1); const [$y, setY] = createOptimistic(() => $x() + 1); + const values: number[] = []; + createRoot(() => + createRenderEffect($y, v => { + values.push(v); + }) + ); flush(); expect($y()).toBe(2); - // Optimistic write + // Optimistic write — unflushed until the flush carries it (A28) setY(100); - expect($y()).toBe(100); + expect($y()).toBe(2); - // On flush, reverts to computed value + // The flush shows 100 to effects, then reverts the ambient write flush(); + expect(values).toEqual([2, 100, 2]); expect($y()).toBe(2); // Source change propagates through @@ -517,14 +558,14 @@ describe("createOptimistic", () => { await Promise.resolve(); // Extra tick for async function's internal await expect($y()).toBe(2); - // Optimistic write + // Optimistic write — not visible until the flush carries it (A28) setY(8); - expect($y()).toBe(8); + expect($y()).toBe(2); // Source update - but held during transition setX(5); flush(); - expect($y()).toBe(8); // still optimistic + expect($y()).toBe(8); // the flush installed the override; the transition holds it expect($x()).toBe(1); // held at original await Promise.resolve(); @@ -656,13 +697,17 @@ describe("createOptimistic", () => { const doAsync = action(function* () { setX(100); - // Read outside any effect/memo - expect($x()).toBe(100); + // A28: the writer's own plain read does not see its unflushed write + expect($x()).toBe(1); yield Promise.resolve(); }); doAsync(); - // Also readable outside + expect($x()).toBe(1); + + // Once a flush has carried it, the override is readable outside any + // effect/memo for as long as the action holds it + flush(); expect($x()).toBe(100); await Promise.resolve(); @@ -5089,12 +5134,13 @@ describe("createOptimistic", () => { expect($opt()).toBe(10); expect(values).toEqual([10]); - // Write an optimistic override + // Write an optimistic override — unflushed until the flush (A28) setOpt(999); - expect($opt()).toBe(999); + expect($opt()).toBe(10); - // On flush, the override reverts to the source value + // The flush shows the override, then reverts it to the source value flush(); + expect(values).toEqual([10, 999, 10]); expect($opt()).toBe(10); }); }); diff --git a/packages/signals/tests/isPending-memo-consistency.test.ts b/packages/signals/tests/isPending-memo-consistency.test.ts index ac04edbe2..eec3d5e71 100644 --- a/packages/signals/tests/isPending-memo-consistency.test.ts +++ b/packages/signals/tests/isPending-memo-consistency.test.ts @@ -61,11 +61,16 @@ describe("isPending memo consistency (#3078)", () => { try { // Mid-tick the memo serves its last-flushed verdict (false — created - // before the write); what matters is that reads do not flip it. + // before the write); what matters is that reads do not flip it. The + // direct probe agrees: writes become visible at flush, and an + // unflushed write is not pending on any channel yet. expect(r1).toBe(false); expect(r2).toBe(r1); expect(r3).toBe(r1); + expect(isPending(count)).toBe(false); + flush(); // the action holds the transition: the flushed write is pending expect(isPending(count)).toBe(true); + expect(untrack(m)).toBe(true); } finally { release(); await done; @@ -90,9 +95,11 @@ describe("isPending memo consistency (#3078)", () => { const mOwnerless = createMemo(() => isPending(count)); setCount(v => v + 1); - // m2: created after the write; its creation compute reads the live verdict + // m2: created after the write. Writes become visible at flush, so its + // creation compute answers for the flushed world (nothing pending yet) + // and is marked by the write's flush like any other subscriber. const m2 = runWithOwner(owner, () => createMemo(() => isPending(count)))!; - expect(untrack(m2)).toBe(true); + expect(untrack(m2)).toBe(false); const done = act() as Promise; flush(); // what the browser render loop does while the action runs diff --git a/packages/signals/tests/latest-held-till-flush.test.ts b/packages/signals/tests/latest-held-till-flush.test.ts new file mode 100644 index 000000000..5195f4397 --- /dev/null +++ b/packages/signals/tests/latest-held-till-flush.test.ts @@ -0,0 +1,513 @@ +/** + * Writes become visible at flush — to every read channel. + * + * `latest()` reads the FLUSHED staged world: the value a held transition has + * computed but not yet committed. An unflushed write is visible to no reader + * at all — not the plain read, not `latest()`, not `isPending()`. The only + * consumer of an unflushed value is the setter's own functional updater. + * Read-your-own-writes is therefore `flush()` then read, and it works the + * same whether or not the write is held: + * + * setFoo(2); flush(); latest(foo) // 2, held or not + * + * This supersedes the #2922 ruling (untracked latest() pulled the shadow up + * to date mid-tick so `setS(1); latest(m)` derived pre-flush). That answer + * was per-node: the signal read new, a sync derivation read new, an async + * derivation read old — a torn view that also changed depending on whether a + * companion happened to exist already (a lazily created companion backfilled + * from the eager `_pendingValue` and disagreed with an existing one). One + * rule replaces it: nothing pre-flush, the flushed world after. + */ +import { describe, expect, it } from "vitest"; +import { + NotReadyError, + action, + createEffect, + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + deep, + flush, + isPending, + latest, + snapshot, + untrack +} from "../src/index.js"; + +/** A signal whose write is held by an async memo a render effect observes. */ +async function heldSetup(initial = 10) { + let resolve!: (v: number) => void; + const fetch = (n: number) => + new Promise(r => { + resolve = r; + }); + const [count, setCount] = createSignal(initial); + let data!: () => number; + createRoot(() => { + data = createMemo(() => fetch(count())); + createRenderEffect( + () => data(), + () => {} + ); + }); + flush(); + resolve(initial); + await new Promise(r => setTimeout(r, 0)); + flush(); + expect(count()).toBe(initial); + return { count, setCount, data, settle: () => resolve }; +} + +describe("latest() is held till flush", () => { + it("latest(signal): unflushed write invisible; visible after flush", () => { + const [s, setS] = createSignal(0); + flush(); + setS(1); + expect(latest(s)).toBe(0); + expect(s()).toBe(0); + flush(); + expect(latest(s)).toBe(1); + expect(s()).toBe(1); + }); + + it("latest(memo): does not derive downstream pre-flush", () => { + const [s, setS] = createSignal(0); + let m!: () => number; + createRoot(() => { + m = createMemo(() => s() * 2 + 1); + }); + flush(); + expect(m()).toBe(1); + setS(1); + expect(latest(m)).toBe(1); // was 3 under #2922 + expect(m()).toBe(1); + setS(2); + expect(latest(m)).toBe(1); + flush(); + expect(m()).toBe(5); + expect(latest(m)).toBe(5); + }); + + it("flush() then latest() is read-your-own-writes under a held transition", async () => { + const { count, setCount } = await heldSetup(10); + setCount(20); + expect(latest(count)).toBe(10); // unflushed + flush(); // held by the fetch — plain read stays committed + expect(count()).toBe(10); + expect(latest(count)).toBe(20); // the flushed staged world + }); + + it("a rewrite of a held node is invisible until its own flush (existing companion)", async () => { + const { count, setCount } = await heldSetup(10); + setCount(20); + flush(); + expect(latest(count)).toBe(20); // companion exists now + setCount(30); + expect(latest(count)).toBe(20); // not 30, not 10 + expect(count()).toBe(10); + flush(); + expect(latest(count)).toBe(30); + expect(count()).toBe(10); + }); + + it("a companion created after the rewrite agrees with one that existed before it", async () => { + // Gabriel's case: committed 10, flushed-held 20, unflushed 30. A brand-new + // companion has no history to consult — the node must retain the flushed + // staged value itself. + const { count, setCount } = await heldSetup(10); + setCount(20); + flush(); + setCount(30); + expect(latest(count)).toBe(20); // first-ever latest() read + flush(); + expect(latest(count)).toBe(30); + }); + + it("a companion created pre-flush on a node that was not held reads committed", () => { + const [s, setS] = createSignal(10); + flush(); + setS(30); + expect(latest(s)).toBe(10); // first-ever latest() read; the eager _pendingValue is 30 + flush(); + expect(latest(s)).toBe(30); + }); + + it("memo transparency: a getter and a memo wrapping latest() agree pre- and post-flush", async () => { + const { count, setCount } = await heldSetup(0); + const getter = () => latest(count); + let memo!: () => number; + createRoot(() => { + memo = createMemo(() => latest(count)); + }); + flush(); + expect([getter(), memo()]).toEqual([0, 0]); + setCount(1); + expect([getter(), memo()]).toEqual([0, 0]); // was [1, 0] + flush(); // held by the fetch; the latest lane is not held, so the memo commits + expect([getter(), memo()]).toEqual([1, 1]); + expect(count()).toBe(0); + }); + + it("a tracked latest() reader created after an unflushed write still updates at flush", () => { + const [s, setS] = createSignal(0); + flush(); + setS(1); + const seen: number[] = []; + createRoot(() => { + createEffect( + () => latest(s), + v => { + seen.push(v); + } + ); + }); + flush(); + expect(seen).toEqual([1]); + }); + + // latest() is an optimistic view: a derivation over it publishes the way an + // optimistic correction does (direct commit), whatever transaction the round + // entered. The shadow companion created by this first latest() read did not + // exist at the write, so the write-time sync missed it; it joins the + // flush-start re-sync instead (joinUnflushedResync) and is brought current + // by the optimistic write — not by a plain recompute, which would have + // staged it, and the memo over it, under the hold `data` re-enters (the memo + // then answered 20 until the fetch settled). + it("a tracked latest() reader created after an unflushed rewrite of a held node updates at flush", async () => { + // The reader links to the shadow while the rewrite is unflushed and reads + // the flushed value (20). It must be re-marked when the rewrite flushes — + // a reader that saw 20 and was never woken would be stale at 20 forever. + const { count, setCount } = await heldSetup(10); + setCount(20); + flush(); + setCount(30); + let memo!: () => number; + createRoot(() => { + memo = createMemo(() => latest(count)); + }); + expect(memo()).toBe(20); + flush(); + expect(memo()).toBe(30); + }); + + it("an effect over latest() created after an unflushed rewrite of a held node runs at flush (not parked with the hold)", async () => { + const { count, setCount } = await heldSetup(10); + setCount(20); + flush(); + setCount(30); + const seen: number[] = []; + createRoot(() => { + createEffect( + () => latest(count), + v => { + seen.push(v); + } + ); + }); + flush(); + expect(seen).toEqual([30]); + }); + + it("isPending() follows the same clock: false for an unflushed write, true once flushed and held", async () => { + const { count, setCount } = await heldSetup(10); + expect(isPending(count)).toBe(false); + setCount(20); + expect(isPending(count)).toBe(false); // nothing flushed to be pending + flush(); + expect(isPending(count)).toBe(true); // held by the fetch + }); + + it("functional updaters still compose across unflushed writes", () => { + const [s, setS] = createSignal(10); + flush(); + setS(v => v + 1); + setS(v => v + 1); + expect(latest(s)).toBe(10); + flush(); + expect(s()).toBe(12); + }); + + it("latest(memo) reads the flushed held derivation, not a mid-tick one", async () => { + const { count, setCount } = await heldSetup(10); + let double!: () => number; + createRoot(() => { + double = createMemo(() => count() * 2); + }); + flush(); + expect(latest(double)).toBe(20); + setCount(20); + expect(latest(double)).toBe(20); // 40 would be a mid-tick derivation + flush(); // held + expect(double()).toBe(20); + expect(latest(double)).toBe(40); // flushed staged world, coherent with latest(count) + expect(latest(count)).toBe(20); + }); +}); + +/** + * A28 consequence (3), one level down (#3336): a companion created lazily + * answers as if it had always existed — and so does a store node. Whether + * SOME reader had already created a companion (or materialized a key) before + * the hold must not be observable: A and B below differ only in that. + */ +describe("#3336: lazily created companions and store nodes carry the hold", () => { + const settle = async () => { + for (let i = 0; i < 4; i++) await Promise.resolve(); + flush(); + }; + + it("a latest() companion created under a foreign hold does not revert at the reader's flush", async () => { + const [a, setA] = createSignal(0); + const [b, setB] = createSignal(0); + const [show, setShow] = createSignal(false); + const runs: Array = []; + createRoot(() => { + const valueA = createMemo(a); + const valueB = createMemo(b); + createMemo(() => latest(valueB)); // B's companion exists before the hold; A's does not + createRenderEffect( + () => + show() ? [latest(valueA), latest(valueB), valueA(), valueB(), latest(a), a()] : null, + v => { + runs.push(v); + } + ); + }); + flush(); + let release!: () => void; + const update = action(function* () { + setA(1); + setB(1); + yield new Promise(r => (release = r)); + }); + update(); + await settle(); + setShow(true); + await settle(); + // One run: latest() reads the flushed staged world for both, plain reads + // stay committed for both. Before the fix the fresh companion's backfill + // registered in the render effect's ambient batch, reverted at that + // flush's end and re-ran the effect with [0, 1, 0, 0, 0, 0]. + expect(runs).toEqual([null, [1, 1, 0, 0, 1, 0]]); + release(); + await settle(); + expect(runs).toEqual([null, [1, 1, 0, 0, 1, 0], [1, 1, 1, 1, 1, 1]]); + }); + + it("an isPending() companion created under a foreign hold stays true until the hold lifts", async () => { + const [a, setA] = createSignal(0); + const [show, setShow] = createSignal(false); + const pendings: Array = []; + createRoot(() => { + const valueA = createMemo(a); + createRenderEffect( + () => (show() ? isPending(valueA) : null), + v => { + pendings.push(v); + } + ); + }); + flush(); + let release!: () => void; + const update = action(function* () { + setA(1); + yield new Promise(r => (release = r)); + }); + update(); + await settle(); + setShow(true); + await settle(); + expect(pendings).toEqual([null, true]); // no flip to false at the reader's flush end + release(); + await settle(); + expect(pendings).toEqual([null, true, false]); + }); + + it("a store key first read under a hold is born holding: plain reads committed, latest() staged", async () => { + const [a, setA] = createStore({ count: 0 }); + const [b, setB] = createStore({ count: 0 }); + const [show, setShow] = createSignal(false); + const runs: Array = []; + createRoot(() => { + createMemo(() => b.count); // b.count has a node before the hold; a.count does not + createRenderEffect( + () => (show() ? [a.count, b.count, latest(() => a.count), latest(() => b.count)] : null), + v => { + runs.push(v); + } + ); + }); + flush(); + let release!: () => void; + const update = action(function* () { + setA(s => { + s.count = 1; + }); + setB(s => { + s.count = 1; + }); + yield new Promise(r => (release = r)); + }); + update(); + await settle(); + setShow(true); + await settle(); + // Before the fix: [1, 0, 1, 0] — the held write leaked through the plain + // read of the key nothing had subscribed to, and B's fresh companion + // reverted at the flush end. + expect(runs).toEqual([null, [0, 0, 1, 1]]); + release(); + await settle(); + expect(runs).toEqual([null, [0, 0, 1, 1], [1, 1, 1, 1]]); + }); + + describe("every store read channel answers like core read() under a foreign hold", () => { + // Core: `(stale && el._transition !== null) → _value`. A render effect + // (stale) and a handler (no owner) see committed; latest()/isPending() + // see the hold. The channel — tracked, untracked, `in`, keys, + // deep()/snapshot() — and whether the key had a node before the hold + // must not change the answer. + async function heldStore() { + const [a, setA] = createStore<{ count: number; added?: number; list: number[] }>({ + count: 0, + list: [1] + }); + const [b, setB] = createStore({ count: 0 }); + createRoot(() => { + createMemo(() => b.count); // observed before the hold + }); + flush(); + const update = action(function* () { + setA(s => { + s.count = 1; + s.added = 5; + s.list.push(2); + }); + setB(s => { + s.count = 1; + }); + yield new Promise(() => {}); + }); + update(); + await settle(); + return { a, b }; + } + function inRenderEffect(fn: () => T): T[] { + const out: T[] = []; + createRoot(() => { + createRenderEffect(fn, v => { + out.push(v); + }); + }); + flush(); + return out; + } + + it("render effect: untracked reads, `in`, Object.keys, deep(), snapshot() all read committed", async () => { + const { a, b } = await heldStore(); + expect(inRenderEffect(() => untrack(() => [a.count, b.count]))).toEqual([[0, 0]]); + expect(inRenderEffect(() => "added" in a)).toEqual([false]); + expect(inRenderEffect(() => Object.keys(a).includes("added"))).toEqual([false]); + expect(inRenderEffect(() => a.list.length)).toEqual([1]); + expect(inRenderEffect(() => deep(a).count)).toEqual([0]); + expect(inRenderEffect(() => snapshot(a).count)).toEqual([0]); + }); + + it("render effect: latest() and isPending() see the hold on both keys", async () => { + const { a, b } = await heldStore(); + expect(inRenderEffect(() => [latest(() => a.count), latest(() => b.count)])).toEqual([ + [1, 1] + ]); + expect(inRenderEffect(() => [isPending(() => a.count), isPending(() => b.count)])).toEqual([ + [true, true] + ]); + }); + + it("handler: plain, deep(), snapshot(), `in`, keys read committed; latest()/isPending() see the hold", async () => { + const { a, b } = await heldStore(); + expect([a.count, b.count, deep(a).count, snapshot(a).count]).toEqual([0, 0, 0, 0]); + expect(["added" in a, Object.keys(a).includes("added")]).toEqual([false, false]); + expect([latest(() => a.count), latest(() => b.count)]).toEqual([1, 1]); + expect([isPending(() => a.count), isPending(() => b.count)]).toEqual([true, true]); + }); + + it("a memo created mainline during the hold is born held for both keys alike (A29)", async () => { + // Re-pinned for born-held (A29, creation-time form): a memo created from + // mainline while the transaction holds the keys it reads derives from + // the held world and is staged INTO the transaction — it has no + // committed value until the commit, so an untracked read throws + // NotReady rather than answer. Both keys, observed or not before the + // hold, get the same treatment: that is what #3336 asks of them. + const { a, b } = await heldStore(); + let m!: () => number[]; + const derived: number[][] = []; + createRoot(() => { + m = createMemo(() => { + const v = [a.count, b.count]; + derived.push(v); + return v; + }); + }); + flush(); + expect(derived).toEqual([[1, 1]]); // derives from the held world for both keys + expect(() => m()).toThrow(NotReadyError); // no committed value yet + }); + + it("a render effect recomputing inside the holding transaction sees the write on every channel", async () => { + // Core: `activeTransition !== el._transition` — the clause is for a + // FOREIGN hold. A render effect the transaction itself recomputes (its + // run is the transaction's to apply) reads `_pendingValue` through the + // tracked read; the untracked read, `in`, keys and deep() must agree, + // or the effect composes its view — and its subscriptions — from two + // worlds. + const [s, setS] = createStore<{ count: number; added?: number; list: number[] }>({ + count: 0, + list: [1] + }); + const frames: unknown[] = []; + createRoot(() => { + createRenderEffect( + () => + frames.push([ + s.count, + untrack(() => s.count), + "added" in s, + Object.keys(s).includes("added"), + untrack(() => s.list.length), + deep(s).count + ]), + () => {} + ); + }); + flush(); + frames.length = 0; + const update = action(function* () { + setS(d => { + d.count = 1; + d.added = 5; + d.list.push(2); + }); + yield new Promise(() => {}); + }); + update(); + await settle(); + // The recompute under the transaction saw one world. + expect(frames).toEqual([[1, 1, true, true, 2, 1]]); + // Outside it, the hold is foreign: committed. + expect([s.count, "added" in s, deep(s).count]).toEqual([0, false, 0]); + }); + + it("a same-tick plain write (no transaction) keeps the snapshot peek", () => { + const [s, setS] = createStore({ a: 1 }); + setS(d => { + d.a = 3; + }); + expect(snapshot(s).a).toBe(3); + expect(s.a).toBe(1); + flush(); + expect(s.a).toBe(3); + }); + }); +}); diff --git a/packages/signals/tests/latest-plain-write-purity.test.ts b/packages/signals/tests/latest-plain-write-purity.test.ts index 60678dd49..18e8195e8 100644 --- a/packages/signals/tests/latest-plain-write-purity.test.ts +++ b/packages/signals/tests/latest-plain-write-purity.test.ts @@ -8,8 +8,10 @@ * `_value` — leaking the queued plain write into committed reads before the * flush: `m()` returned the latest value instead of the committed one. * - * The fix demotes wake-only-lane recomputes pulled outside the flush to plain - * staged recomputes, so `latest()` stays a pure probe. + * Writes now become visible at flush for every channel, so `latest(m)` no + * longer pulls at all before the flush — the leak has no path. The purity + * invariant these tests pin is unchanged: probing `latest(m)` never changes + * what plain `m()` returns, and the flush commits and notifies normally. */ import { createEffect, createMemo, createRoot, createSignal, flush, latest } from "../src/index.js"; import { isPending } from "../src/index.js"; @@ -26,13 +28,16 @@ it("latest(m) does not change what plain m() returns before the flush (#3009)", setCount(4); expect(m()).toBe(0); // A - expect(latest(m)).toBe(8); // B - expect(m()).toBe(0); // C — was 8 before the fix - expect(latest(m)).toBe(8); // probe repeats stay consistent + expect(latest(m)).toBe(0); // B — the flushed world (was 8 under the pull) + expect(m()).toBe(0); // C + expect(latest(m)).toBe(0); // probe repeats stay consistent expect(m()).toBe(0); + flush(); + expect(m()).toBe(8); + expect(latest(m)).toBe(8); }); -it("the demoted recompute still commits at flush and notifies effects (#3009)", () => { +it("the flush commits and notifies effects after latest() probes (#3009)", () => { let m!: () => number; let setCount!: (v: number) => void; const effectLog: number[] = []; @@ -46,20 +51,20 @@ it("the demoted recompute still commits at flush and notifies effects (#3009)", expect(effectLog).toEqual([0]); setCount(4); - expect(latest(m)).toBe(8); + expect(latest(m)).toBe(0); flush(); expect(m()).toBe(8); expect(latest(m)).toBe(8); expect(effectLog).toEqual([0, 8]); - // Later writes keep flowing normally after the demoted pull. + // Later writes keep flowing normally. setCount(10); flush(); expect(m()).toBe(20); expect(effectLog).toEqual([0, 8, 20]); }); -it("chained memos over latest() stay pure through the pull (#3009)", () => { +it("chained memos over latest() stay pure (#3009)", () => { let m!: () => number; let m2!: () => number; let setCount!: (v: number) => void; @@ -73,14 +78,15 @@ it("chained memos over latest() stay pure through the pull (#3009)", () => { expect(m2()).toBe(3); setCount(5); - // Pulling through the chain must not commit any intermediate node. - expect(latest(m2)).toBe(11); + // Probing through the chain must not commit any intermediate node. + expect(latest(m2)).toBe(3); expect(m()).toBe(2); expect(m2()).toBe(3); flush(); expect(m()).toBe(10); expect(m2()).toBe(11); + expect(latest(m2)).toBe(11); }); it("an isPending probe on the same shape does not pollute committed reads (#3009)", () => { diff --git a/packages/signals/tests/latest-probe-order-independence.test.ts b/packages/signals/tests/latest-probe-order-independence.test.ts index 60091d576..9670847f9 100644 --- a/packages/signals/tests/latest-probe-order-independence.test.ts +++ b/packages/signals/tests/latest-probe-order-independence.test.ts @@ -52,18 +52,19 @@ describe("latest-mode probe order independence (#3104)", () => { // The heisenberg: these two used to disagree (false vs true). expect(withRead.reads.latestProbe).toBe(without.reads.latestProbe); - // The designed pre-flush answers for a plain staged write: the direct - // probe reports the held write; every latest-flavored probe saw the - // fresh value in the latest view, so it must not also report pending. + // The pre-flush answers for a plain write: writes become visible at + // flush, so an unflushed write is pending on no channel — the direct + // probe and every latest-flavored probe agree (false), and no read can + // have pulled the shadow current against it. expect(withRead.reads).toEqual({ m1Internal: false, // memo created pre-write, no flush seen yet (#3078) m1External: false, - direct: true, + direct: false, latestProbe: false }); expect(without.reads).toMatchObject({ m1Internal: false, - direct: true, + direct: false, latestProbe: false }); diff --git a/packages/signals/tests/latest-repeated-writes.test.ts b/packages/signals/tests/latest-repeated-writes.test.ts index ee58484a6..70067ec94 100644 --- a/packages/signals/tests/latest-repeated-writes.test.ts +++ b/packages/signals/tests/latest-repeated-writes.test.ts @@ -1,14 +1,23 @@ /** - * #2922: `latest()` only works once on memos. + * #2922 (superseded): `latest()` only works once on memos. * - * Interleaving plain signal writes with `latest(m)` reads (no flush in - * between): the first `latest(m)` correctly computes the in-flight value, but - * after a second write the shadow computed returned its cached speculative - * value instead of recomputing against the newest pending write. + * The original ruling had `latest(m)` pull the memo current against each + * unflushed write (`setS(1); latest(m) === 3`). That answer was per-node — a + * signal read new, a sync derivation read new, an async derivation read old — + * and depended on whether a companion already existed. Writes now become + * visible at flush for every read channel: `latest()` reads the flushed + * world, so nothing derives from a write before its flush, and after the + * flush every latest() read — signal, memo, chained memo — agrees. Read your + * own write by flushing first (`flush()` then `latest(m)`), which works the + * same whether or not the write is held. See latest-held-till-flush.test.ts. + * + * What this file still pins from #2922: the shadow does not STALL. Once a + * write flushes, `latest(m)` reflects it, for every write, whether or not an + * effect keeps the shadow subscribed between them. */ import { createEffect, createMemo, createRoot, createSignal, flush, latest } from "../src/index.js"; -it("latest(memo) recomputes after each unflushed write, not just the first (#2922)", () => { +it("latest(memo) reflects every flushed write, none of the unflushed ones", () => { const [s, setS] = createSignal(0); let m!: () => number; createRoot(() => { @@ -20,21 +29,24 @@ it("latest(memo) recomputes after each unflushed write, not just the first (#292 setS(1); expect(m()).toBe(1); + expect(latest(m)).toBe(1); // unflushed: not derived (was 3) + flush(); expect(latest(m)).toBe(3); - expect(m()).toBe(1); + expect(m()).toBe(3); setS(2); - expect(m()).toBe(1); + expect(latest(m)).toBe(3); + flush(); expect(latest(m)).toBe(5); setS(3); - expect(latest(m)).toBe(7); - + expect(latest(m)).toBe(5); flush(); + expect(latest(m)).toBe(7); expect(m()).toBe(7); }); -it("latest(memo) stays fresh across writes when an effect keeps the shadow subscribed (#2922)", () => { +it("latest(memo) stays fresh across flushed writes when an effect keeps the shadow subscribed", () => { const [s, setS] = createSignal(0); let m!: () => number; const effectLog: number[] = []; @@ -51,23 +63,46 @@ it("latest(memo) stays fresh across writes when an effect keeps the shadow subsc expect(effectLog).toEqual([1]); setS(1); + expect(latest(m)).toBe(1); + flush(); expect(latest(m)).toBe(3); setS(2); + expect(latest(m)).toBe(3); + flush(); expect(latest(m)).toBe(5); - flush(); expect(m()).toBe(5); + expect(effectLog).toEqual([1, 3, 5]); }); -it("latest(signal) tracks each unflushed write (#2922)", () => { +it("latest(signal) reflects each write once flushed", () => { const [s, setS] = createSignal(0); flush(); setS(1); + expect(latest(s)).toBe(0); + flush(); expect(latest(s)).toBe(1); setS(2); + expect(latest(s)).toBe(1); + flush(); expect(latest(s)).toBe(2); + expect(s()).toBe(2); +}); +it("two unflushed writes in one tick: latest() skips the intermediate, the flush lands the last", () => { + const [s, setS] = createSignal(0); + let m!: () => number; + createRoot(() => { + m = createMemo(() => s() * 2 + 1); + }); flush(); - expect(s()).toBe(2); + + setS(1); + setS(2); + expect(latest(s)).toBe(0); + expect(latest(m)).toBe(1); + flush(); + expect(latest(s)).toBe(2); + expect(latest(m)).toBe(5); }); diff --git a/packages/signals/tests/latest-unobserved-memo.test.ts b/packages/signals/tests/latest-unobserved-memo.test.ts index 2aa11700a..3ef6438db 100644 --- a/packages/signals/tests/latest-unobserved-memo.test.ts +++ b/packages/signals/tests/latest-unobserved-memo.test.ts @@ -52,8 +52,11 @@ it("latest(m); m(); write — recomputes on every click (#2927 case B)", () => { }); it("orderings stay consistent without explicit flushes (#2927)", () => { - // Reads happen before the write within each click, so the recompute for a - // write lands on the NEXT click's reads — a one-click lag, never a stall. + // Writes become visible at flush: with no flush between clicks, neither a + // plain read nor a latest() read derives from the queued writes, so the memo + // does not recompute at all — identically in both orderings. (Under the + // former mid-tick pull each click's latest() recomputed it once.) The + // flush at the end lands the writes and the memo runs once more. const a = setup(); const b = setup(); for (let i = 0; i < 4; i++) { @@ -66,6 +69,9 @@ it("orderings stay consistent without explicit flushes (#2927)", () => { b.write(); expect(a.runs()).toBe(b.runs()); - expect(a.runs()).toBe(Math.max(1, i + 1)); + expect(a.runs()).toBe(1); } + flush(); + expect(a.runs()).toBe(2); + expect(b.runs()).toBe(2); }); diff --git a/packages/signals/tests/optimistic-store-layer-scope.test.ts b/packages/signals/tests/optimistic-store-layer-scope.test.ts index 0f63b9bce..d22b88f7e 100644 --- a/packages/signals/tests/optimistic-store-layer-scope.test.ts +++ b/packages/signals/tests/optimistic-store-layer-scope.test.ts @@ -204,10 +204,13 @@ it("#2899: delete under a concurrent action survives the other action's settle", it("#2899: ambient write reverts at flush end without touching an in-flight action's keys", async () => { const gateA = deferred(); const [s, setS] = createOptimisticStore({ a: 1, c: 3 }); + const sums: number[] = []; createRoot(() => { createRenderEffect( () => s.a + s.c, - () => {} + v => { + sums.push(v); + } ); }); flush(); @@ -220,13 +223,16 @@ it("#2899: ambient write reverts at flush end without touching an in-flight acti })(); flush(); expect(s.a).toBe(10); + expect(sums).toEqual([4, 13]); - // Ambient optimistic write (no action): visible until its flush, then reverts. + // Ambient optimistic write (no action): unflushed until its flush (A28), + // shown by that flush, then reverted at its end. setS(d => { d.c = 30; }); - expect(s.c).toBe(30); + expect(s.c).toBe(3); flush(); + expect(sums).toEqual([4, 13, 40, 13]); expect(s.c).toBe(3); expect(s.a).toBe(10); // action A's override untouched diff --git a/packages/signals/tests/question-scoped-pending.test.ts b/packages/signals/tests/question-scoped-pending.test.ts index e84a96fc2..6e2c786c3 100644 --- a/packages/signals/tests/question-scoped-pending.test.ts +++ b/packages/signals/tests/question-scoped-pending.test.ts @@ -436,12 +436,25 @@ describe("new questions pend and cannot be silenced (the foos bug, fixed without describe("plain optimistic stores (no source)", () => { it("writes display and revert without ever pending", () => { const [state, setState] = createOptimisticStore({ count: 0 }); + const seen: number[] = []; + createRoot(() => + createRenderEffect( + () => state.count, + v => { + seen.push(v); + } + ) + ); + flush(); setState(s => { s.count++; }); - expect(state.count).toBe(1); + // A28: unflushed — readers see the flushed value, and nothing is pending + expect(state.count).toBe(0); expect(isPending(() => state.count)).toBe(false); flush(); + // The flush displayed the write, then reverted the ambient override + expect(seen).toEqual([0, 1, 0]); expect(state.count).toBe(0); expect(isPending(() => state.count)).toBe(false); }); @@ -594,8 +607,12 @@ describe("affects — the declaration verb", () => { const send = action(function* () { setState(s => { s.messages.push({ text: "new", status: "sending" }); + // A28: `state.messages[1]` is undefined until the flush carries the + // push, so the slot form names the row on the draft (a keyless + // `affects(state.messages)` would cover it — the walk is a writer + // channel — but here only `status` should pend). + affects(s.messages[1], "status"); }); - affects(state.messages[1], "status"); yield new Promise(r => (resolveSend = r)); }); @@ -898,6 +915,7 @@ describe("affects — captured proxies (#2882)", () => { const doneSecond = second(); flush(); const added = state.rows[2]; + expect(added.name).toBe("c"); expect(isPending(() => added.name)).toBe(true); // in the second declaration's scope resolveFirst(); @@ -918,15 +936,48 @@ describe("affects — captured proxies (#2882)", () => { expect(() => (affects as any)(state, "rows", "length")).toThrow(/single optional key/); }); - it("optimistically written records visible at declaration time are covered", async () => { + // A28(5): the optimistic write is not readable through `state` until the + // flush carries it, but the declaration walk is a writer channel — tagging + // the parent covers the whole record, the row this tick pushed included. + it("optimistically written records are covered by a same-tick affects(parent)", async () => { + const [state, setState] = createOptimisticStore<{ rows: Row[] }>({ rows: seedRows() }); + + let resolveIt!: () => void; + const act = action(function* () { + setState(s => { + s.rows.push({ name: "c", tags: { primary: "z" } }); + }); + expect(state.rows.length).toBe(2); // the push is not visible to readers yet… + affects(state); // …but the walk reads the tick's parked writes + yield new Promise(r => (resolveIt = r)); + }); + + const done = act(); + flush(); + const added = state.rows[2]; + expect(added.name).toBe("c"); + expect(isPending(() => state.rows[0].name)).toBe(true); + expect(isPending(() => added.name)).toBe(true); + expect(isPending(() => added.tags.primary)).toBe(true); + + resolveIt(); + await done; + flush(); + expect(isPending(() => state.rows[0].name)).toBe(false); + expect(isPending(() => added.name)).toBe(false); + }); + + // The slot form names a record: a row born in this tick's write is not + // readable through `state`, so it is named on the draft. + it("a record written in the same tick is covered when declared on the draft", async () => { const [state, setState] = createOptimisticStore<{ rows: Row[] }>({ rows: seedRows() }); let resolveIt!: () => void; const act = action(function* () { setState(s => { s.rows.push({ name: "c", tags: { primary: "z" } }); + affects(s.rows[2]); // the draft row: the same target the flush will serve }); - affects(state); // declared AFTER the write: the walk must read through overlays yield new Promise(r => (resolveIt = r)); }); @@ -935,6 +986,8 @@ describe("affects — captured proxies (#2882)", () => { const added = state.rows[2]; expect(added.name).toBe("c"); expect(isPending(() => added.name)).toBe(true); + expect(isPending(() => added.tags.primary)).toBe(true); + expect(isPending(() => state.rows[0].name)).toBe(false); // siblings stay crisp resolveIt(); await done; diff --git a/packages/signals/tests/snapshot-derived-store-rows.test.ts b/packages/signals/tests/snapshot-derived-store-rows.test.ts index d268a4620..3727106ca 100644 --- a/packages/signals/tests/snapshot-derived-store-rows.test.ts +++ b/packages/signals/tests/snapshot-derived-store-rows.test.ts @@ -11,6 +11,7 @@ */ import { types } from "node:util"; import { + action, createOptimisticStore, createRoot, createStore, @@ -52,18 +53,25 @@ it("per-row snapshot through the view matches the base row snapshot identity", ( }); }); -it("snapshot(view[i]) reflects an in-flight optimistic override", () => { - createRoot(() => { - const { base, view, setView } = setup(); +it("snapshot(view[i]) reflects an in-flight optimistic override", async () => { + let resolveIt!: () => void; + const { base, view, setView } = createRoot(setup); + const done = action(function* () { setView(d => { (d as any)[0].qty = 5; }); - const row = snapshot((view as any)[0]) as any; - expect(types.isProxy(row)).toBe(false); - expect(row.qty).toBe(5); - expect((base as any)[0].qty).toBe(1); // base untouched by the overlay - flush(); - }); + yield new Promise(r => (resolveIt = r)); + })(); + // A28: the override is in flight once a flush has carried the write + expect((snapshot((view as any)[0]) as any).qty).toBe(1); + flush(); + const row = snapshot((view as any)[0]) as any; + expect(types.isProxy(row)).toBe(false); + expect(row.qty).toBe(5); + expect((base as any)[0].qty).toBe(1); // base untouched by the overlay + resolveIt(); + await done; + flush(); }); it("unwraps chained derived stores (view over view)", () => { diff --git a/packages/signals/tests/store/createOptimisticStore.test.ts b/packages/signals/tests/store/createOptimisticStore.test.ts index d8010f367..9117b769e 100644 --- a/packages/signals/tests/store/createOptimisticStore.test.ts +++ b/packages/signals/tests/store/createOptimisticStore.test.ts @@ -31,52 +31,91 @@ describe("createOptimisticStore", () => { expect(state.age).toBe(30); }); + // A28: an optimistic write becomes visible at flush. An ambient one (no + // action in flight) is installed when the flush starts and reverted when + // it ends, so effects are the only channel that sees it; plain reads + // answer the flushed value on both sides of the flush. it("should update store via setter and revert on flush", () => { const [state, setState] = createOptimisticStore({ name: "John" }); + const values: string[] = []; + createRoot(() => + createRenderEffect( + () => state.name, + v => { + values.push(v); + } + ) + ); + flush(); + setState(s => { s.name = "Jake"; }); - // Optimistic update should be immediately visible - expect(state.name).toBe("Jake"); - // Without an active transition, flush reverts to original value + expect(state.name).toBe("John"); // unflushed — not visible yet flush(); + expect(values).toEqual(["John", "Jake", "John"]); // shown by the flush, then reverted expect(state.name).toBe("John"); }); it("should allow multiple optimistic updates before flush", () => { const [state, setState] = createOptimisticStore({ count: 1 }); + const values: number[] = []; + createRoot(() => + createRenderEffect( + () => state.count, + v => { + values.push(v); + } + ) + ); + flush(); + setState(s => { s.count = 2; }); - expect(state.count).toBe(2); setState(s => { s.count = 3; }); - expect(state.count).toBe(3); setState(s => { - s.count = s.count + 10; + s.count = s.count + 10; // the draft sees its own tick's writes (3) }); - expect(state.count).toBe(13); - // All revert on flush + expect(state.count).toBe(1); // readers do not flush(); + expect(values).toEqual([1, 13, 1]); expect(state.count).toBe(1); }); it("should handle multiple properties independently", () => { const [state, setState] = createOptimisticStore({ a: 1, b: 10 }); + const values: Array<{ a: number; b: number }> = []; + createRoot(() => + createRenderEffect( + () => ({ a: state.a, b: state.b }), + v => { + values.push(v); + } + ) + ); + flush(); + setState(s => { s.a = 2; }); - expect(state.a).toBe(2); + expect(state.a).toBe(1); expect(state.b).toBe(10); setState(s => { s.b = 20; }); - expect(state.a).toBe(2); - expect(state.b).toBe(20); + expect(state.a).toBe(1); + expect(state.b).toBe(10); flush(); + expect(values).toEqual([ + { a: 1, b: 10 }, + { a: 2, b: 20 }, + { a: 1, b: 10 } + ]); expect(state.a).toBe(1); expect(state.b).toBe(10); }); @@ -88,18 +127,29 @@ describe("createOptimisticStore", () => { user: { name: "John", address: { city: "NYC" } } }); + const values: string[] = []; + createRoot(() => + createRenderEffect( + () => `${state.user.name}/${state.user.address.city}`, + v => { + values.push(v); + } + ) + ); + flush(); + setState(s => { s.user.name = "Jake"; }); - expect(state.user.name).toBe("Jake"); - expect(state.user.address.city).toBe("NYC"); - setState(s => { s.user.address.city = "LA"; }); - expect(state.user.address.city).toBe("LA"); + // A28: unflushed until the flush carries them + expect(state.user.name).toBe("John"); + expect(state.user.address.city).toBe("NYC"); flush(); + expect(values).toEqual(["John/NYC", "Jake/LA", "John/NYC"]); expect(state.user.name).toBe("John"); expect(state.user.address.city).toBe("NYC"); }); @@ -108,13 +158,24 @@ describe("createOptimisticStore", () => { const [state, setState] = createOptimisticStore({ user: { name: "John" } }); + const values: string[] = []; + createRoot(() => + createRenderEffect( + () => state.user.name, + v => { + values.push(v); + } + ) + ); + flush(); setState(s => { s.user = { name: "Jake" }; }); - expect(state.user.name).toBe("Jake"); + expect(state.user.name).toBe("John"); flush(); + expect(values).toEqual(["John", "Jake", "John"]); expect(state.user.name).toBe("John"); }); }); @@ -128,13 +189,30 @@ describe("createOptimisticStore", () => { ] }); + const values: boolean[][] = []; + createRoot(() => + createRenderEffect( + () => state.todos.map(t => t.done), + v => { + values.push(v); + } + ) + ); + flush(); + setState(s => { s.todos[0].done = true; }); - expect(state.todos[0].done).toBe(true); + // A28: unflushed until the flush carries it + expect(state.todos[0].done).toBe(false); expect(state.todos[1].done).toBe(false); flush(); + expect(values).toEqual([ + [false, false], + [true, false], + [false, false] + ]); expect(state.todos[0].done).toBe(false); }); @@ -142,14 +220,29 @@ describe("createOptimisticStore", () => { const [state, setState] = createOptimisticStore({ items: [1, 2, 3] }); + const values: number[][] = []; + createRoot(() => + createRenderEffect( + () => [...state.items], + v => { + values.push(v); + } + ) + ); + flush(); setState(s => { s.items.push(4); }); - expect(state.items.length).toBe(4); - expect(state.items[3]).toBe(4); + expect(state.items.length).toBe(3); + expect(state.items[3]).toBeUndefined(); flush(); + expect(values).toEqual([ + [1, 2, 3], + [1, 2, 3, 4], + [1, 2, 3] + ]); expect(state.items.length).toBe(3); expect(state.items[3]).toBeUndefined(); }); @@ -158,13 +251,28 @@ describe("createOptimisticStore", () => { const [state, setState] = createOptimisticStore({ items: ["a", "b", "c"] }); + const values: string[][] = []; + createRoot(() => + createRenderEffect( + () => [...state.items], + v => { + values.push(v); + } + ) + ); + flush(); setState(s => { s.items.splice(1, 1); // remove "b" }); - expect(state.items).toEqual(["a", "c"]); + expect(state.items).toEqual(["a", "b", "c"]); flush(); + expect(values).toEqual([ + ["a", "b", "c"], + ["a", "c"], + ["a", "b", "c"] + ]); expect(state.items).toEqual(["a", "b", "c"]); }); @@ -173,18 +281,32 @@ describe("createOptimisticStore", () => { { id: 1, name: "First" }, { id: 2, name: "Second" } ]); + const values: string[][] = []; + createRoot(() => + createRenderEffect( + () => state.map(r => r.name), + v => { + values.push(v); + } + ) + ); + flush(); setState(s => { s[0].name = "Updated First"; }); - expect(state[0].name).toBe("Updated First"); - setState(s => { s.push({ id: 3, name: "Third" }); }); - expect(state.length).toBe(3); + expect(state[0].name).toBe("First"); + expect(state.length).toBe(2); flush(); + expect(values).toEqual([ + ["First", "Second"], + ["Updated First", "Second", "Third"], + ["First", "Second"] + ]); expect(state[0].name).toBe("First"); expect(state.length).toBe(2); }); @@ -373,17 +495,27 @@ describe("createOptimisticStore", () => { { value: 0 } ); + const values: number[] = []; + createRoot(() => + createRenderEffect( + () => state.value, + v => { + values.push(v); + } + ) + ); flush(); expect(state.value).toBe(2); - // Optimistic write + // Optimistic write — unflushed until the flush carries it (A28) setState(s => { s.value = 100; }); - expect(state.value).toBe(100); + expect(state.value).toBe(2); - // On flush, reverts to derived value + // The flush shows 100, then reverts the ambient write to the derived value flush(); + expect(values).toEqual([2, 100, 2]); expect(state.value).toBe(2); // Source change propagates through @@ -395,6 +527,15 @@ describe("createOptimisticStore", () => { it("should allow return value reconciliation and revert optimistic", () => { const [$x, setX] = createSignal(1); const [state, setState] = createOptimisticStore(() => ({ value: $x() * 2 }), { value: 0 }); + const values: number[] = []; + createRoot(() => + createRenderEffect( + () => state.value, + v => { + values.push(v); + } + ) + ); flush(); expect(state.value).toBe(2); @@ -402,9 +543,10 @@ describe("createOptimisticStore", () => { setState(s => { s.value = 50; }); - expect(state.value).toBe(50); + expect(state.value).toBe(2); flush(); + expect(values).toEqual([2, 50, 2]); expect(state.value).toBe(2); setX(10); @@ -442,7 +584,7 @@ describe("createOptimisticStore", () => { s.id = 99; s.name = "optimistic"; }); - expect(state.id).toBe(99); + expect(state.id).toBe(1); // unflushed (A28); the flush installs the overlay before the derive runs setId(2); flush(); @@ -487,10 +629,13 @@ describe("createOptimisticStore", () => { { value: 0 } ); + const values: number[] = []; createRoot(() => { createRenderEffect( () => state.value, - () => {} + v => { + values.push(v); + } ); }); @@ -498,15 +643,17 @@ describe("createOptimisticStore", () => { await Promise.resolve(); await Promise.resolve(); expect(state.value).toBe(2); + expect(values).toEqual([2]); - // Optimistic write + // Optimistic write — unflushed until the flush carries it (A28) setState(s => { s.value = 8; }); - expect(state.value).toBe(8); + expect(state.value).toBe(2); // Just flush without source update - this simpler case should still revert flush(); + expect(values).toEqual([2, 8, 2]); // After the async projection completes and transition ends, optimistic should revert await Promise.resolve(); await Promise.resolve(); @@ -802,7 +949,19 @@ describe("createOptimisticStore", () => { flush(); expect(lengths).toEqual([1]); - // Rapid successive pushes - each should see the updated length from previous + // Rapid successive pushes - each draft sees the length the previous one + // left (the draft is the writer's channel and composes on the tick's + // own writes); readers see nothing until the flush (A28) + const items: number[][] = []; + createRoot(() => { + createRenderEffect( + () => [...state.items], + v => { + items.push(v); + } + ); + }); + flush(); setState(s => { s.items.push(2); }); @@ -813,13 +972,13 @@ describe("createOptimisticStore", () => { s.items.push(4); }); - expect(state.items.length).toBe(4); - expect(state.items[1]).toBe(2); - expect(state.items[2]).toBe(3); - expect(state.items[3]).toBe(4); + expect(state.items.length).toBe(1); + expect(state.items[1]).toBeUndefined(); - // All revert on flush + // The flush shows all three pushes at once, then reverts the ambient writes flush(); + expect(lengths).toEqual([1, 4, 1]); + expect(items).toEqual([[1], [1, 2, 3, 4], [1]]); expect(state.items.length).toBe(1); expect(state.items[0]).toBe(1); }); @@ -846,17 +1005,35 @@ describe("createOptimisticStore", () => { flush(); expect(lengths).toEqual([4]); - // Rapid successive deletions - each filter returns a new array that gets applied + // Rapid successive deletions - each filter returns a new array that gets + // applied; the second draft filters the first one's result (the draft + // composes on the tick's own writes) while readers see nothing until + // the flush (A28) + const ids: number[][] = []; + createRoot(() => { + createRenderEffect( + () => [...state].map(i => i.id), + v => { + ids.push(v); + } + ); + }); + flush(); setState(s => s.filter(item => item.id !== 2)); - expect(state.length).toBe(3); + expect(state.length).toBe(4); setState(s => s.filter(item => item.id !== 4)); - expect(state.length).toBe(2); - - expect([...state].map(i => i.id)).toEqual([1, 3]); + expect(state.length).toBe(4); + expect([...state].map(i => i.id)).toEqual([1, 2, 3, 4]); - // All revert on flush + // The flush shows both deletions at once, then reverts the ambient writes flush(); + expect(lengths).toEqual([4, 2, 4]); + expect(ids).toEqual([ + [1, 2, 3, 4], + [1, 3], + [1, 2, 3, 4] + ]); expect(state.length).toBe(4); expect([...state].map(i => i.id)).toEqual([1, 2, 3, 4]); }); @@ -1575,11 +1752,17 @@ describe("createOptimisticStore", () => { setState(s => { s.value = 100; }); - expect(state.value).toBe(100); + // A28: the writer's own plain read does not see its unflushed write + expect(state.value).toBe(1); yield Promise.resolve(); }); doAsync(); + expect(state.value).toBe(1); + + // Once a flush has carried it, the override is readable outside any + // effect/memo for as long as the action holds it + flush(); expect(state.value).toBe(100); await Promise.resolve(); @@ -1615,13 +1798,22 @@ describe("createOptimisticStore", () => { // #2850: snapshot()/deep() must agree with every other reader — an active // optimistic overlay is THE value (A17), and snapshot's documented behavior // on regular stores is to read the pending-write overlay synchronously. The - // optimistic overlay is the same concept under a different key. + // optimistic overlay is the same concept under a different key. Under A28 + // the overlay becomes active at flush: before it, snapshot agrees with the + // plain read on the flushed value. describe("snapshot and deep see optimistic writes (#2850)", () => { - it("snapshot sees an optimistic write immediately", () => { + it("snapshot sees an optimistic write once the flush carries it", () => { const [state, setState] = createOptimisticStore({ name: "John" }); - setState(s => { - s.name = "Jake"; + const doAsync = action(function* () { + setState(s => { + s.name = "Jake"; + }); + yield Promise.resolve(); }); + doAsync(); + expect(state.name).toBe("John"); + expect(snapshot(state).name).toBe("John"); + flush(); expect(state.name).toBe("Jake"); expect(snapshot(state).name).toBe("Jake"); }); diff --git a/packages/signals/tests/store/projection-transition-isolation.test.ts b/packages/signals/tests/store/projection-transition-isolation.test.ts index 57ab876f1..4d770536a 100644 --- a/packages/signals/tests/store/projection-transition-isolation.test.ts +++ b/packages/signals/tests/store/projection-transition-isolation.test.ts @@ -117,7 +117,13 @@ describe("projection transition isolation", () => { expect(m()).toBe(0); expect(p.a).toBe(0); - // latest() sees the in-flight value everywhere — projections included + // writes become visible at flush: pre-flush latest() agrees with the + // plain reads everywhere — signal, memo, projection — and after the + // flush every channel has the value (nothing holds this write). + expect(latest(count)).toBe(0); + expect(latest(m)).toBe(0); + expect(latest(() => p.a)).toBe(0); + flush(); expect(latest(count)).toBe(5); expect(latest(m)).toBe(5); expect(latest(() => p.a)).toBe(5); diff --git a/packages/signals/tests/store/shallow.test.ts b/packages/signals/tests/store/shallow.test.ts index 9225e7af7..ae2b98e17 100644 --- a/packages/signals/tests/store/shallow.test.ts +++ b/packages/signals/tests/store/shallow.test.ts @@ -181,15 +181,27 @@ describe("createStore shallow", () => { // children served raw expect((state as any)[0]).toBe(rows[0]); const optimisticRow = { id: 0, count: 777, queries: [{ elapsed: 0 }] }; + const seen: any[] = []; + createRoot(() => { + createEffect( + () => (state as any)[0], + v => { + seen.push(v); + } + ); + }); + flush(); setState((s: any) => { s[0] = optimisticRow; }); - // staged: visible immediately (tentative) - expect((state as any)[0]).toBe(optimisticRow); + // A28: staged but unflushed — the flush carries it + expect((state as any)[0]).toBe(rows[0]); expect(rows[0].count).toBe(0); - // ambient (non-action) optimistic writes auto-revert at flush end, - // re-reading the untouched raw base row — the boundary contract holds. + // The flush shows the replacement (raw, tentative); ambient (non-action) + // optimistic writes auto-revert at flush end, re-reading the untouched + // raw base row — the boundary contract holds. flush(); + expect(seen).toEqual([rows[0], optimisticRow, rows[0]]); expect((state as any)[0]).toBe(rows[0]); expect(rows[0].count).toBe(0); }); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 685ae820d..257209004 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -308,7 +308,20 @@ describe("pay-for-use tree-shaking (#2883)", () => { // read()'s selection, commitPendingNode. Measured at 23,681 on top of // #3442; 23,753 rebased over #3443/#3444 (23,419 → 23,753, +334 — the // two land on the same notifyStatus/recompute seams). - expect(minifiedBytes).toBeLessThan(23_800); + // CONSCIOUS BUMP (2026-09-15): A28 — writes become visible at flush, as a + // READ-SIDE rule (supersedes #3337's deferred walk; +572 B vs its +387, + // with the plain write path untouched — no per-write list, no promotion + // pass). Core-retained pieces: `unflushedValue` (the structural test and + // its exemptions), the selection arms that serve the flushed value and + // latch the late linker, `_flushedStaged` for a rewrite of a held node, + // CONFIG_PROMOTED for writes inside a creation-time recompute, the + // companion re-sync at flush start, and the override arm's flush gate. + // The write-path arms are cold helpers gated on loads the write already + // pays (`_transition`, `context`) and the read sites test one module flag + // (`unflushedStaged`) instead of `_running`: inline, they cost ~140 B of + // setSignal bytecode and 10–20% on the write-loop benches (+156 B here). + // Measured at 24,478 rebased over #3464–#3471 (`next` 23,750 → 24,478). + expect(minifiedBytes).toBeLessThan(24_600); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { diff --git a/packages/signals/tests/visibility-oracle-store.test.ts b/packages/signals/tests/visibility-oracle-store.test.ts index cc3aa12af..b1f22c028 100644 --- a/packages/signals/tests/visibility-oracle-store.test.ts +++ b/packages/signals/tests/visibility-oracle-store.test.ts @@ -28,6 +28,7 @@ import { rule, runOracle, settle, + violation, type State } from "./visibility-oracle.harness.js"; @@ -66,21 +67,18 @@ const STATES: State[] = [ return { x, dispose() {} }; }, expect: { - untracked: observed( + untracked: rule( 0, - "pre-flush untracked store read serves the committed backing — same as the signal side; A28 (#3337) territory" + "A28: an untracked store read serves the committed backing until the flush" ), 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"), + preexisting: rule(HELD, "A28: nothing is visible before the flush"), 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 territory"), - isPending: observed( - true, - "pre-flush verdict flips on the unflushed write — same as the signal side; A28 territory" - ), - authoritative: observed(1, "pre-flush; A28 territory") + latest: rule(0, "A28: latest() reads the flushed staged world"), + isPending: rule(false, "A28 (2): false for an unflushed write"), + authoritative: rule(1, "A28 (4): the predicate runs in the carrying flush and sees the write") } }, { @@ -155,13 +153,16 @@ const STATES: State[] = [ return { x, dispose() {} }; }, expect: { - untracked: rule(5, "OL-R2: synchronously visible before any flush"), + untracked: rule( + 0, + "A28 (5): an optimistic store edit is a write — visible at the flush that carries it (supersedes OS-R1, CS-R34 visibility)" + ), derivesFrom: rule(0, "OL-R5: an ambient optimistic write reverts at the next flush"), published: rule(0, "OL-R5"), - preexisting: observed(HELD, "pre-flush: nothing has run yet"), + preexisting: rule(HELD, "A28: nothing is visible before the flush"), staleForeign: rule(0, "OL-R5"), childrenForbidden: rule(0, "OL-R5"), - latest: rule(5, "OL-R11 pre-flush"), + latest: rule(0, "A28 (5)"), isPending: rule(false, "A24 (3)"), authoritative: rule(0, "A17 carve-out") } diff --git a/packages/signals/tests/visibility-oracle.test.ts b/packages/signals/tests/visibility-oracle.test.ts index d8a06bd22..e8c831480 100644 --- a/packages/signals/tests/visibility-oracle.test.ts +++ b/packages/signals/tests/visibility-oracle.test.ts @@ -130,26 +130,26 @@ const STATES: State[] = [ }, expect: { // Readers that flush observe the write land — trivially 1. - untracked: observed( + untracked: rule( 0, - "pre-flush untracked read serves committed; A28 (#3337, unmerged) rules on this tick" + "A28: an unflushed write is not the committed value — an untracked read serves committed until the flush" ), 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"), + preexisting: rule(HELD, "A28: nothing is visible before the flush"), 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" + latest: rule( + 0, + 'A28: latest() reads the flushed staged world — the pre-write answer until the flush that carries the write ("nothing is ever 30 while its derivations are still 20-shaped")' ), - isPending: observed( - true, - "pre-flush verdict flips on the unflushed write; A28 (#3337) territory" + isPending: rule( + false, + "A28 (2): isPending is false for an unflushed write — nothing is observable yet to be pending from" ), - authoritative: observed( + authoritative: rule( 1, - "until() predicate pre-flush sees the unflushed write; A28 (#3337) territory" + "A28 (4): until()'s predicate is evaluated inside the flush that carries the write, where the write is promoted — it sees 1" ) } }, @@ -246,16 +246,22 @@ const STATES: State[] = [ return { x, dispose }; }, expect: { - untracked: rule(5, "OL-R2: synchronously visible before any flush"), + untracked: rule( + 0, + "A28 (5): an optimistic write is a write — it becomes the active override at the flush that carries it; until then no reader sees it (supersedes OL-R2)" + ), 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"), + preexisting: rule(HELD, "A28: nothing is visible before the flush"), staleForeign: rule(0, "OL-R5"), childrenForbidden: rule(0, "OL-R5"), - latest: rule(5, "OL-R11 pre-flush"), + latest: rule( + 0, + "A28 (5): not visible before the flush on any channel (supersedes OL-R11 pre-flush)" + ), isPending: rule(false, "A24 (3)"), authoritative: rule( 0, diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index f991f08d2..0790feacf 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -251,7 +251,14 @@ module.exports = [ // Superseded source keeps blocking (#3462, 2026-09-15): transitionComplete // judges a reporter's source by a non-empty `_pendingSources`, not the // self entry alone; -2 B minified (23,750), measured at 8855 B on top of #3464 (8851). - limit: "8.90 KB", + // A28 — writes visible at flush, read-side (2026-09-15, rebased over #3464–#3471): + // measured at 9,102 B against `next` (+204 B). `unflushedValue` and its exemptions, + // the flushed-value selection arms and late-linker latch, `_flushedStaged` for + // held rewrites, CONFIG_PROMOTED, the companion re-sync at flush start (lazy + // companions join it), the override arm's flush gate; the write-path arms are cold + // helpers and the read sites test one module flag, keeping the write loop at + // parity. +728 B minified in the in-package floor (23,752 -> 24,480). + limit: "9.15 KB", modifyEsbuildConfig }, { @@ -489,7 +496,9 @@ module.exports = [ // store/utils; #3455's verdict/optimistic changes measured 15780 against // the pre-#3454 `next`) — the union tipped the cap by 9 B after both // merged. - limit: "15.85 KB", + // A28 — writes visible at flush, read-side (2026-09-15): measured at 16,155 B + // against `next` (+305 B); the signals core delta, see the core floor note. + limit: "16.20 KB", modifyEsbuildConfig }, { @@ -615,7 +624,9 @@ module.exports = [ // verdict's body-end A18 (d) branch, and `uninitializedSource`'s owner // walk; all in the verdict/optimistic modules this scenario retains // (core floor 8820 -> 8832, +createStore 15743 -> 15780, both in cap). - limit: "11.30 KB", + // A28 — writes visible at flush, read-side (2026-09-15): measured at 11,639 B + // against `next` (+339 B); the signals core delta, see the core floor note. + limit: "11.70 KB", modifyEsbuildConfig }, { @@ -704,7 +715,9 @@ module.exports = [ // Born held (A29 creation-time form, #3451; 2026-09-15): 11.45 -> 11.65 KB, // measured at 11566 B against `next`'s 11407 (+159); the signals-core // bytes from the core floor note, nothing app-side. - limit: "11.65 KB", + // A28 — writes visible at flush, read-side (2026-09-15): measured at 11,829 B + // against `next` (+179 B); the signals core delta, see the core floor note. + limit: "11.90 KB", modifyEsbuildConfig }, { @@ -817,7 +830,9 @@ module.exports = [ // Born held (A29 creation-time form, #3451; 2026-09-15): 18.90 -> 19.10 KB, // measured at 19023 B against `next`'s 18899 (+124); the signals-core // bytes from the core floor note, nothing app-side. - limit: "19.10 KB", + // A28 — writes visible at flush, read-side (2026-09-15): measured at 19,367 B + // against `next` (+267 B); the signals core delta, see the core floor note. + limit: "19.40 KB", modifyEsbuildConfig }, { @@ -986,7 +1001,9 @@ module.exports = [ // other app scenarios BETTER (simple-app -33, hydrating -45, CSR -21); // this one drew the short straw, 1 B over a cap #3459 had just // consumed the room under. - limit: "28.95 KB", + // A28 — writes visible at flush, read-side (2026-09-15): measured at 29,280 B + // against `next` (+330 B); the signals core delta, see the core floor note. + limit: "29.35 KB", modifyEsbuildConfig }, { @@ -1073,7 +1090,9 @@ module.exports = [ // (`_holds` + the source harvest) in boundaries.ts, retained wherever // Loading is. Signals-only scenarios moved the other way (createStore // -62 B) — the new scheduler export shifts brotli layout, not a shrink. - limit: "14.5 KB", + // A28 — writes visible at flush, read-side (2026-09-15): measured at 14,747 B + // against `next` (+247 B); the signals core delta, see the core floor note. + limit: "14.80 KB", modifyEsbuildConfig }, { @@ -1160,7 +1179,9 @@ module.exports = [ // Born held (A29 creation-time form, #3451; 2026-09-15): 15.70 -> 15.90 KB, // measured at 15811 B against `next`'s 15679 (+132); the signals-core // bytes from the core floor note, nothing app-side. - limit: "15.90 KB", + // A28 — writes visible at flush, read-side (2026-09-15): measured at 16,133 B + // against `next` (+233 B); the signals core delta, see the core floor note. + limit: "16.20 KB", modifyEsbuildConfig: observeEsbuildConfig }, { @@ -1265,7 +1286,9 @@ module.exports = [ // Born held (A29 creation-time form, #3451; 2026-09-15): 27.25 -> 27.45 KB, // measured at 27350 B against `next`'s 27227 (+123); the signals-core // bytes from the core floor note, nothing app-side. - limit: "27.45 KB", + // A28 — writes visible at flush, read-side (2026-09-15): measured at 27,736 B + // against `next` (+286 B); the signals core delta, see the core floor note. + limit: "27.80 KB", modifyEsbuildConfig: observeEsbuildConfig }, {