From d89a3c8c22b1d2b514a128b6ad07cbbdc4de81f4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 14 Sep 2026 23:05:48 -0700 Subject: [PATCH 1/3] fix(signals): two flights through one memo settle as one unit; lane-dirtied zombies run (#3443, #3444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flight, entangles the two at the propagation. The second flight only propagated pending onto the memo (no recompute — its inputs' values were unchanged), so the memo's stamped re-entry never ran and the first transaction never learned it was waiting on the second: it revealed `A: 1` beside the committed `Sum: 0`, and `Sum: 2` arrived with `B: 1`. `notifyStatus`'s dependent walk now enters the held memo's transaction (A15: a shared derivation of both). Effects are skipped — an effect entangles nothing by itself (A15 shared-hole corollary). The write that starts the second flight is held with the first when its async work flows into a held memo; the #3375 boundary-reset pin publishes `Sum: 2` once at the reset instead of a two-pass `Sum: 1 | Sum: 2`. zombie recomputes its staged writes queued (a world the zombie never displays). A zombie dirtied through the lane channel (OPTIMISTIC_DIRTY — an override or a `latest()` companion) displays exactly that world mainline until the commit disposes it, so it runs instead: `latest(count)` inside a branch a held `Show` is removing now follows the value outside. Docs: A15 mechanism completed (#3443) and pinned by tests/overlapping-flights.test.ts; INTERNALS bullets for both; rules index regenerated. Floor 23,365 -> 23,419 (+54 B, conscious bump to 23,450); three brotli caps ratcheted 0.05 KB with notes. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/fix-overlapping-flights.md | 8 + .../signals/docs/INTERNALS-ASYNC-STATE.md | 3 +- packages/signals/docs/RULES-INDEX.md | 88 +++++----- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 8 +- packages/signals/src/core/async.ts | 14 +- packages/signals/src/core/scheduler.ts | 10 +- .../tests/async-chain-supersession.test.ts | 14 +- ...nested-render-effect-async-cleanup.test.ts | 50 ++++++ .../signals/tests/overlapping-flights.test.ts | 160 ++++++++++++++++++ packages/signals/tests/treeshake.test.ts | 9 +- scripts/size/.size-limit.js | 23 ++- 11 files changed, 326 insertions(+), 61 deletions(-) create mode 100644 .changeset/fix-overlapping-flights.md create mode 100644 packages/signals/tests/overlapping-flights.test.ts diff --git a/.changeset/fix-overlapping-flights.md b/.changeset/fix-overlapping-flights.md new file mode 100644 index 000000000..166e28c30 --- /dev/null +++ b/.changeset/fix-overlapping-flights.md @@ -0,0 +1,8 @@ +--- +"@solidjs/signals": patch +--- + +Two flights through one memo settle as one unit, and a branch a held `Show` is removing keeps following `latest()` (#3443, #3444). + +- A memo another live transaction holds, made pending by a second flight, entangles the two at the propagation (#3443). The second flight only propagated pending onto the memo — no recompute, its inputs' values were unchanged — so the memo's stamped re-entry never ran and the first transaction never learned it was waiting: it revealed `A: 1` beside the committed `Sum: 0`, and `Sum: 2` arrived with `B: 1`. Now one reveal when both have landed (A15). A render effect reading both plainly stays parallel, as before; a write whose async work flows into a held memo is held with it. +- A zombie dirtied through the lane channel runs instead of being cancelled (#3444). When the parking batch is the transaction, queued zombie recomputes are cancelled as a world the zombie never displays — but overrides and `latest()` companions are the mainline frame, and the still-visible branch showed `latest(count)` at 0 beside the same read outside at 1. diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index 232fb2480..ed5243aa7 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -98,7 +98,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - `initTransition` ends by scheduling a flush: the ambient window is one flush by definition, but parking is flush-driven, so a transaction opened with no writes (an action that only awaits) would otherwise leave `activeTransition` and the adopted batch armed across the async gap, capturing the next unrelated work to arrive — the A26-rejected behavior (#3141). - `_asyncReporters: Map>` — which computeds are blocked on which async sources. **Entries open only from `GlobalQueue.notify` during render-effect status notification** `[ruled — async-registration-invariants rule]`. One reporter may join an entry that already exists from elsewhere: a stale reader served a pending node's committed value by the reveal carve-out (`heldFromStale`, §3 below) joins that node's entry (#3374) — it observes the flight, so it holds the transaction on it the way the reader that opened the entry did, and dies with disposal the same way (`reporterBlocksSource`: the read linked the node as a dep). - `_pendingNodes` — nodes whose `_pendingValue` commits when the transition completes (`commitPendingNodes` → `commitPendingNode`). -- Held children (#3404): a node's owned children (nested effects, memos, `onCleanup` registrations) belong to the frame that committed them. `recompute` defers the previous pass's children as zombies (`_pendingFirstChild` / `_pendingDisposal`, rendering mainline until `commitPendingNode` disposes them) unless `CONFIG_HELD_CHILDREN` is set — the pass that built them never committed (a staged value, a pending window, a run under a held transaction), so no frame ever showed them and they die on the spot. Set at recompute's tail whenever the pass's result waits on a commit, cleared by `commitPendingNode`. A `_transition` stamp alone says nothing about the children: status propagation stamps a parked dependent without recomputing it, so its children are still the committed frame's, and disposing them when the source lands ran their cleanups mid-hold. Exception: a transaction-owned effect recomputed mainline (contested, #3322) publishes directly, so that pass's children are the frame's and it releases its zombies itself — its commit rides the transaction, not the flush. +- Held children (#3404): a node's owned children (nested effects, memos, `onCleanup` registrations) belong to the frame that committed them. `recompute` defers the previous pass's children as zombies (`_pendingFirstChild` / `_pendingDisposal`, rendering mainline until `commitPendingNode` disposes them) unless `CONFIG_HELD_CHILDREN` is set — the pass that built them never committed (a staged value, a pending window, a run under a held transaction), so no frame ever showed them and they die on the spot. Set at recompute's tail whenever the pass's result waits on a commit, cleared by `commitPendingNode`. A `_transition` stamp alone says nothing about the children: status propagation stamps a parked dependent without recomputing it, so its children are still the committed frame's, and disposing them when the source lands ran their cleanups mid-hold. Exception: a transaction-owned effect recomputed mainline (contested, #3322) publishes directly, so that pass's children are the frame's and it releases its zombies itself — its commit rides the transaction, not the flush. Zombies and the lane channel (#3444): when the parking batch _is_ the transaction, `flush` cancels the zombie recomputes its staged writes queued (`cancelZombieRecompute`) — a zombie renders mainline until the commit disposes it, and the staged world is one it never displays. A zombie dirtied through the lane channel (`REACTIVE_OPTIMISTIC_DIRTY`: an override, or a `latest()` companion the write synced) is the exception and runs: the lane's values _are_ the mainline frame, so the still-visible branch a held `Show` was removing showed `latest(count)` at 0 beside the same read outside at 1. Its pass runs under the lane and its run lands on the lane's effect queue, so a held lane defers it exactly as it defers every other reader's; the commit disposes it as before. Pinned: `tests/nested-render-effect-async-cleanup.test.ts`. - `_optimisticNodes` — nodes whose override reverts at completion (`resolveOptimisticNodes`). - Incomplete-transition flush stashes queues (`stashQueues`) and continues with a fresh view; completion restores them, commits pending, reverts optimistic, replays `_gatedSubs`, cleans lanes. - `_contested` — effects whose single value slot was written under this transaction and then overwritten by another live transaction or by mainline (#3322). Effects are not shared state, so a shared effect never merges transactions (memos do, via their `_transition` stamp; `recompute`'s stamp re-entry is memo-only since #3407 — an effect's pass belongs to whatever dirtied it); instead `Effect._valueTransition` records which view produced `_value`, `recompute`, when that owner changes, registers the effect on every owed live transaction, and `finalizePureQueue` re-dirties them **before** its heap run so the re-derive and the effect phase land in the same pass — the other view's value is never published. Exception: a settle that reverts optimism (a non-empty `_optimisticNodes`) re-dirties them **after** `_resolveOptimistic`, with the gated replay — between `commitPendingNodes` and the revert the truth is committed but the overrides still display, and a re-derive there composes the two (the #3164 tear; the reveal wake sits post-revert for the same reason). The slot meanwhile holds the frame already on screen, so nothing new is published early. Rules that fall out: a stale (render) reader with no transaction active is mainline and sees a foreign transaction's staged signal as committed (`read`'s fast path and `readNodeFast` apply `stale && el._transition !== null`, matching the slow path); a value computed mainline needs no protection (mainline publishes what it computes, and a transaction whose writes never touched the effect finds it still correct at commit). @@ -111,6 +111,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - Dependencies are the committed frame's (A30, #3410): `recompute`'s tail trims the previous pass's dependency tail only for a pass that published or changed nothing (`_pendingValue === NOT_PENDING` and no `_error`); a staged pass leaves it linked and `commitPendingNode` trims after a clean pass (`_error == null` — a set `_error` means the last pass threw, kept its full list, and `_depsTail` marks where it stopped); an effect pass that direct-committed but still owes a run (`_modified`, #3438 — the flush may stash that run into a transaction it opens later) leaves it for `runEffect` to trim once the run applies. `__OBSERVE__` fan-in counting walks the validated prefix only, so a held tail does not inflate distinct-source counts. Why commit-time and not the pass, and the `Selected: 0` beside `Count: 1` shape: the spec. - Reveal-hold and its carve-out (#3305, #3334, re-ruled 2026-09-10): a reader landing on a node with `STATUS_PENDING` throws — the throw reaches `GlobalQueue.notify`, which opens a transaction for the reveal if none is active (#3305) and records the source as its reporter (INV-3); the reveal completes when the flight lands. One carve-out, the staged-value rule's twin for flights: a **stale** (render) reader of a node pending in some **other** transaction shows the node's committed value, does not entangle (its own writes stay outside that transaction), is recorded for that transaction's commit replay (`heldFromStale`), and joins the transaction's reporters for the node when it has an entry (#3374) — the reader displays the pre-flight value, so the transaction cannot commit the flight's inputs ahead of its answer just because the reader that opened the entry was disposed (a keyed remount). It is refused — the reader holds — when the committed value would tear against the frame: the node carries `CONFIG_INPUTS_PUBLISHED` (a batch or transaction committed with the node still pending, `commitPendingNode`'s computed branch: the flight's inputs are on screen; cleared when the node next enters pending from a settled state, `notifyStatus`), or the node is routed through a live lane (`GlobalQueue._laneLive` → `resolveLane`, exact rather than sticky: lane-revealed inputs, optimistic or `latest`), or the node is uninitialized (nothing committed to show). The stamp itself is pending-node bookkeeping and decides nothing. Replay hygiene: an effect recorded in `_gatedSubs` that later recomputes _under_ the transaction sees its staged view and is applied by the commit (ownership) — `recompute` drops the stale recording at its start (`activeTransition._gatedSubs.delete`), and a lane's committed-view read re-records during the run, so the lane replay (`laneReadsCommitted`) is untouched. - Settle-time re-entry, lane-routed nodes (#3334): `handleAsync`'s `settleTransition` re-enters `resolveTransition(el)` — for a lane-routed node the transaction that _owns_ the lane. That owner's commit is only the override's confirm/revert; the landing itself is revealed by the lane. If a transaction is _waiting_ on the node (`waitingTransition(el)`), the settle enters that one instead: entering the owner would fold a reveal that only waits on the flight into the owner's action (A18 node corollary, #2912). Every other transaction waiting on the flight then folds in explicitly (`enterWaiting`, #3407 — see the next bullet): each reveal that discovered the flight completes at its landing (A15). +- Pending propagation onto a held memo entangles (#3443): `notifyStatus`'s dependent walk, on a pending propagation reaching a memo stamped by another live transaction (`sub._transition` set, not the active one, not behind a boundary), enters that transaction — `initTransition(sub._transition)`, merging it into the active one or, with none active, entering it and adopting the ambient batch (the write that started this flight becomes its). A memo the first transaction holds and the second flight now feeds cannot reveal before the second lands (A15: a shared derivation of both), and propagation is the one moment that is known: it marks the memo pending without recomputing it (its inputs' _values_ are unchanged), so the memo's stamped re-entry — the entanglement's usual site — never ran, the first flight landed, its transaction's verdict saw only its own reporters (the memo's reader had registered for the second flight in the _second_ transaction, INV-3 keyed by transaction), and it revealed `A: 1` beside the memo's committed `Sum: 0`. Effects are skipped (`_type`): an effect entangles nothing by itself (A15 shared-hole corollary) — its reader registers with the flight's transaction at queue notification and the landing folds waiters in (`enterWaiting`). Consequence pinned alongside: a write whose async work flows into a held memo is held with it (`page=1` beside `count=1` while `details` re-asks — before, the ambient page=1 committed a pass ahead), which is also why the #3375 boundary-reset pin now publishes `Sum: 2` once at the reset instead of `Sum: 1 | Sum: 2`. Pinned: `tests/overlapping-flights.test.ts`. - Pass provenance for effects (#3407): a render effect's pass belongs to whatever dirtied it. `recompute` re-enters a stamped node's transaction only for memos (their value _is_ that transaction's work); an effect stamped by a transaction — it observed that transaction's flight — and dirtied by another transaction's write, or by mainline, runs that writer's pass, reads the held flight as a stale reader (committed value, `heldFromStale`) and publishes with the writer. The pass entangles only if it _observes_ a pending flight (the carve-out refused: inputs published, lane-live, uninitialized) — the throw reaches `GlobalQueue.notify`, which registers the writer's transaction as a reporter, and the flight's landing folds it in. Before, `recompute` re-entered an effect's stamp whenever _any_ other transaction was active: a sync `action` write to a signal that merely shared a hole with a held async (`{b()}:{detailsA()}`) merged into the async's transaction and waited (`0:0 → 2:1`, no `1:0`), while the same write made plainly passed through; two independent flights read in one hole settled as one unit. Now both writers publish on their own (`1:0` at the write, `1:1` at the landing; two flights land at their own times). The re-entry's other job — delivering a landing to the transactions waiting on it — moves to the landing itself: `settleTransition` enters every parked transaction whose reporters still observe the node (`enterWaiting`, over `sourceObserved`), including the waiter of a stampless node (a flight started under a batch that committed beneath it, #3305), whose landing used to open a fresh batch that the stamped reader's re-entry folded into the waiter. Pinned: `tests/shared-effect-no-entangle.test.ts`; the reveal-completion pins (`spec-async-semantics` A15, `reveal-carve-out`, `stale-read-uninitialized-cross-transition`) are the regression net for the fold. - The action body's end starts the correction (#3427, `endOptimism`, called from `flush` after the heap and before the verdict): with the bodies over (`_acted` and no live `_actions`) and nothing _authoritative_ in flight — no override node's own source (`transitionBlocked`), no held flight that does not derive from an override (`sourceObserved` and not `resolveLane`) — each override's truth (the staged value an A17-silent landing left, else the committed value) supersedes it now, as an arriving differing truth does (A18): the graph re-derives from the truth as the transaction's held work, and the transaction settles when _that_ lands. The lane-derived flights were questions about the guess; nobody reads their answer. Before, the settle waited for the obsolete flight, revealed the obsolete optimistic frame when it landed, then reverted and re-asked — a waterfall with a flash. A co-written plain load the action asked for (`setSaving(true); setPage(2)`) is authoritative and keeps the optimistic world up until it lands. Optimistic **store** edits opt the whole transaction out (their truth is the base layer under an overlay with no tracked/displayed split); companions (`_parentSource`) answer for their owner and snap at settlement, not here. Pinned: `tests/optimistic-lane-release.test.ts`. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 4758295c2..542edcb6b 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -43,39 +43,39 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul ## A — spec propositions -| id | status | defined | cited in src | cited in tests | statement (at definition) | -| --- | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:229` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | -| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:237` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | -| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | -| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:253` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | -| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:261` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | -| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:269` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | -| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:109` | verdict.ts×1 | spec-async-semantics.test.ts×2 visibility-oracle.test.ts×2 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. \*\*Amende… | -| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | — | createMemo.test.ts×1 visibility-oracle.test.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | -| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | — | spec-async-semantics.test.ts×3 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | -| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | invariants.ts×1 | createMemo.test.ts×1 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | -| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:51` | — | latest-isPending-consistency.test.ts×1 visibility-oracle.test.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | -| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:141` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | -| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:149` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | -| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | -| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:191` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 treeshake.test.ts×3 visibility-oracle.test.ts×7 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | -| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×3 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×1 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle.test.ts×16 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | -| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 visibility-oracle.test.ts×12 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | -| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:93` | async.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×10 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | -| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:281` | invariants.ts×1 | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | -| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:288` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | -| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | -| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | -| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle.test.ts×4 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | -| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:219` | — | uninitialized-visibility.test.ts×3 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | -| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle.test.ts×2 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | -| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:211` | — | loading-value.test.ts×2 visibility-oracle.test.ts×10 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | -| A29 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 | held-conditional-memo.test.ts×1 visibility-oracle.test.ts×3 | [ruled 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a live transaction h… | -| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | -| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×1 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | -| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:83` | — | visibility-oracle.test.ts×7 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | +| id | status | defined | cited in src | cited in tests | statement (at definition) | +| --- | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:229` | — | onCleanup.test.ts×2 transitionEntanglement.test.ts×4 | [ruled 2026-07-06] Effect error interception is compute-phase only — `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none… | +| A2 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:237` | — | onCleanup.test.ts×2 | [ruled] Unhandled compute-phase errors in user effects are logged and skipped — Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | +| A3 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:245` | — | equals-comparator-errors.test.ts×1 | [ruled] Comparator throws are compute-phase errors — Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | +| A4 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:253` | — | equals-comparator-errors.test.ts×1 | [ruled] A custom `equals` never sees `undefined` prev on first commit — A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | +| A5 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:261` | — | errorHalt.test.ts×1 | [ruled] An error escaping every boundary halts the system — An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | +| A6 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:269` | — | enforceLoadingBoundary.test.ts×1 | [ruled] `ASYNC_OUTSIDE_LOADING_BOUNDARY` is warn-only — `ASYNC_OUTSIDE_LOADING_BOUNDARY` is a warn-only diagnostic; an `Errored` above must not swallow it and must not show its fallback for a pending. | +| A7 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:109` | verdict.ts×1 | spec-async-semantics.test.ts×2 visibility-oracle.test.ts×2 | [ruled, amended in place] Resolved async never reads `[false, undefined]` — After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. \*\*Amende… | +| A8 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | — | createMemo.test.ts×1 visibility-oracle.test.ts×2 | [ruled, amended in place 2026-07-07] `isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel — (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPendin… | +| A9 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:125` | — | spec-async-semantics.test.ts×3 | [ruled, amended in place 2026-07-07] Store leaves behind a firewall report the firewall's new-question refetch — `isPending` on a store leaf behind a firewall reports the firewall's refetch like any a… | +| A10 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:133` | invariants.ts×1 | createMemo.test.ts×1 latest-isPending-consistency.test.ts×1 | [ruled] `[isPending(x), x()]` is atomic within one scope — `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | +| A11 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:51` | — | latest-isPending-consistency.test.ts×1 visibility-oracle.test.ts×1 | [ruled] Sync derivations of held sources are visible through `latest()`/`isPending()` — Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is… | +| A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:141` | — | createOptimistic.test.ts×2 spec-async-semantics.test.ts×1 | [ruled, amended in place] Resting optimistic nodes report pending like a plain memo — A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optim… | +| A13 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:149` | async.ts×1 | spec-async-semantics.test.ts×7 | [ruled 2026-07-06 (promoted from B1)] Resting optimistic ≡ plain async memo at every checkpoint — (was B1) A resting optimistic node (no active override) is observationally identical to a plain async … | +| A14 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:157` | — | spec-async-semantics.test.ts×2 | [ruled, amended in place 2026-07-06 (promoted from B2)] Companion nodes get child lanes that do not merge with the owner — (was B2) `isPending`/`latest` companion nodes get child lanes that do not mer… | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:191` | async.ts×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 treeshake.test.ts×4 visibility-oracle.test.ts×7 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | +| A16 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:165` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×3 | [ruled, amended in place 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×1 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 visibility-oracle.test.ts×16 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — \*\*Statement (curre… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 visibility-oracle.test.ts×12 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | +| A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:93` | async.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×3 uninitialized-visibility.test.ts×1 visibility-oracle.test.ts×10 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) \*\*Definition: `isPending(x)` ≡ the value… | +| A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:281` | invariants.ts×1 | question-scoped-pending.test.ts×2 spec-async-semantics.test.ts×3 createOptimisticStore.test.ts×1 | [superseded 2026-07-13 by A24] (superseded) Optimistic writes announce a store-wide pending — (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the… | +| A21 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:288` | — | question-scoped-pending.test.ts×3 spec-async-semantics.test.ts×3 | [superseded 2026-07-13 by A24] (superseded) The store-wide mask — (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective… | +| A22 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:173` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] Pending is per-node; store-wide only for the firewall's own work — \*\*Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree tha… | +| A23 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:181` | — | spec-async-semantics.test.ts×1 | [ruled 2026-07-08] The `isPending` probe is reads-only — **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `fals… | +| A24 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:101` | — | optimistic-undefined-override.test.ts×1 reveal-gating-contract.test.ts×1 spec-async-semantics.test.ts×2 visibility-oracle.test.ts×4 | [ruled 2026-07-13] Question-scoped pending: pending iff a value change is in flight or an `affects()` mark is live — (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#272… | +| A25 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:219` | — | uninitialized-visibility.test.ts×3 | [ruled 2026-07-16] A derived store's seed is a draft, never an observable value — (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for th… | +| A26 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:59` | scheduler.ts×1 | action-await-contract.test.ts×2 visibility-oracle.test.ts×2 | [ruled 2026-07-17] An ambient transaction window is one flush; parking is flush-driven — (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a trans… | +| A27 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:211` | — | loading-value.test.ts×2 visibility-oracle.test.ts×10 | [ruled 2026-08-10] The commit-#0 loading window is loading-class and verdict-quiet — (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed v… | +| A29 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:67` | core.ts×1 | held-conditional-memo.test.ts×1 visibility-oracle.test.ts×3 | [ruled 2026-09-13 (#3408)] A tracked read served a live transaction's staged value enters that transaction — A tracked computation served a node's staged `_pendingValue` — a value a live transaction h… | +| A30 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:199` | attribution.ts×1 core.ts×1 effect.ts×1 scheduler.ts×1 | held-conditional-effect.test.ts×1 held-conditional-memo.test.ts×1 treeshake.test.ts×1 | [ruled 2026-09-13 (#3410)] A memo's dependencies are the committed frame's until the frame is replaced — A pass that _staged_ its value has not replaced the committed frame, so the committed value sti… | +| A31 | live | `docs/SPEC-ASYNC-SEMANTICS.md:75` | core.ts×1 | ispending-combined-atomic-3442.test.ts×1 | [live 2026-09-14 (#3442)] A memo computes under its own lane posture, never its puller's — A memo's value is one shared slot every reader sees, so its pass runs under the lane posture the memo itself … | +| A32 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:83` | — | visibility-oracle.test.ts×7 | [ruled 2026-09-14] Children-forbidden readers see the frame, not the graph — `createTrackedEffect` and `onSettled` callbacks are effect-phase code that runs after the frame is decided. They read the f… | ## V — fixed violations @@ -110,17 +110,17 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | id | status | defined | cited in src | cited in tests | statement (at definition) | | ------ | ------- | ----------------------------------- | --------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | -| INV-1 | live | `docs/INTERNALS-ASYNC-STATE.md:144` | invariants.ts×2 | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call | -| INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:146` | invariants.ts×2 | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is | -| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:150` | core.ts×1 invariants.ts×2 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside | -| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:157` | invariants.ts×3 | — | - **INV-4 (medium)** After any of the three write paths completes for node `el` | -| INV-5 | live | `docs/INTERNALS-ASYNC-STATE.md:161` | invariants.ts×2 lanes.ts×1 | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` | -| INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:167` | invariants.ts×2 | — | - **INV-6 (medium)** At the end of a completing-transition flush: every node in | -| INV-7 | live | `docs/INTERNALS-ASYNC-STATE.md:170` | core.ts×1 invariants.ts×2 | action-completion-race.test.ts×2 | - **INV-7 (medium)** `_pendingValue !== NOT_PENDING` on a non-optimistic node | -| INV-8 | retired | `docs/INTERNALS-ASYNC-STATE.md:173` | invariants.ts×1 | rules-index.test.ts×1 | - **INV-8 (RETIRED 2026-07-07b, §5e)** Hold-provenance: a `_pendingValue` on an | -| INV-9 | live | `docs/INTERNALS-ASYNC-STATE.md:184` | invariants.ts×1 owner.ts×1 | — | - **INV-9 (high)** An `isPending` companion of a DISPOSED owner reads `false` | -| INV-10 | live | `docs/INTERNALS-ASYNC-STATE.md:189` | invariants.ts×2 | action-done-window.test.ts×1 | - **INV-10 (high)** Affects-count balance (question-scoped model, 2026-07-13; | -| INV-11 | live | `docs/INTERNALS-ASYNC-STATE.md:194` | core.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×1 treeshake.test.ts×1 | - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality | +| INV-1 | live | `docs/INTERNALS-ASYNC-STATE.md:145` | invariants.ts×2 | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call | +| INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:147` | invariants.ts×2 | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is | +| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:151` | core.ts×1 invariants.ts×2 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 | - **INV-3 (high)** `_asyncReporters` gains entries only inside | +| INV-4 | live | `docs/INTERNALS-ASYNC-STATE.md:158` | invariants.ts×3 | — | - **INV-4 (medium)** After any of the three write paths completes for node `el` | +| INV-5 | live | `docs/INTERNALS-ASYNC-STATE.md:162` | invariants.ts×2 lanes.ts×1 | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` | +| INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:168` | invariants.ts×2 | — | - **INV-6 (medium)** At the end of a completing-transition flush: every node in | +| INV-7 | live | `docs/INTERNALS-ASYNC-STATE.md:171` | core.ts×1 invariants.ts×2 | action-completion-race.test.ts×2 | - **INV-7 (medium)** `_pendingValue !== NOT_PENDING` on a non-optimistic node | +| INV-8 | retired | `docs/INTERNALS-ASYNC-STATE.md:174` | invariants.ts×1 | rules-index.test.ts×1 | - **INV-8 (RETIRED 2026-07-07b, §5e)** Hold-provenance: a `_pendingValue` on an | +| INV-9 | live | `docs/INTERNALS-ASYNC-STATE.md:185` | invariants.ts×1 owner.ts×1 | — | - **INV-9 (high)** An `isPending` companion of a DISPOSED owner reads `false` | +| INV-10 | live | `docs/INTERNALS-ASYNC-STATE.md:190` | invariants.ts×2 | action-done-window.test.ts×1 | - **INV-10 (high)** Affects-count balance (question-scoped model, 2026-07-13; | +| INV-11 | live | `docs/INTERNALS-ASYNC-STATE.md:195` | core.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×1 treeshake.test.ts×1 | - **INV-11 (high, structural — pinned, not asserted)** A recompute's equality | ## RUL — store rulings diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 0f750a789..b555a10e5 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -190,11 +190,11 @@ A resting optimistic node reports pending via exactly the causes a plain async m ### A15. Transition entanglement is graph-driven; lanes settle as one reveal -**Status:** **ruled, amended in place** 2026-07-06 (promoted from B3) — maintainer keep, 2026-07-06; amended 2026-09-14 (#3407: a shared render effect entangles nothing by itself — see the shared-hole corollary) -**Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407) -**Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335); `sourceObserved` (the live-reporter test, shared by the verdict, the lane hold and the landing, #3426); `recompute`'s stamp re-entry is memo-only and `settleTransition` → `enterWaiting` folds every waiter in at the landing (#3407). +**Status:** **ruled, amended in place** 2026-07-06 (promoted from B3) — maintainer keep, 2026-07-06; amended 2026-09-14 (#3407: a shared render effect entangles nothing by itself — see the shared-hole corollary); mechanism completed 2026-09-14 (#3443: a held memo made pending by another flight entangles at the propagation, not at its next pass) +**Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407); `tests/overlapping-flights.test.ts` (#3443: two flights through one memo reveal once; the effect arm stays parallel; the second flight's own write is held with the first) +**Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335); `sourceObserved` (the live-reporter test, shared by the verdict, the lane hold and the landing, #3426); `recompute`'s stamp re-entry is memo-only and `settleTransition` → `enterWaiting` folds every waiter in at the landing (#3407); `notifyStatus`'s pending propagation onto a memo stamped by another live transaction enters it (`initTransition(sub._transition)`, #3443). -(was B3) Transition entanglement is graph-driven: writes whose async work is observed by a shared reader settle as one unit (no tearing — nothing commits until all entangled async resolves); writes on fully disjoint graphs keep independent transitions and settle independently. **Shared-hole corollary (amended 2026-09-14, #3407):** "observed by a shared reader" is a reader's _pass_ observing the flight pending — not the reader's mere existence. A render effect groups whatever bindings the compiler put in one hole, and a pass belongs to whoever dirtied it: a stamped effect (it observed one transaction's flight) dirtied by another transaction's write — a sync `action`, or a second flight's landing — runs that writer's pass, reads the held flight as a stale reader (its committed value, coherent with the flight's inputs which are also committed) and publishes with the writer; the two transactions stay parallel. Only a pass that _observes_ a pending flight — the reveal carve-out refused, next paragraph — joins that flight's transaction, and every transaction waiting on a flight completes at its landing. Maintainer: "we do want unrelated sync updates to pass through render effects… it makes no sense to the end user that separate bindings would hold"; "splitting a render effect per [binding] is a non-starter… the grouping cannot change." Consequence: `{b()}:{detailsA()}` publishes `1:0` when `b` is written (plainly or in an action) and `1:1` when `detailsA` lands; two independent flights read in one hole land at their own times. Memos keep the stamped re-entry (a memo's value _is_ its transaction's work), so entanglement through a user derivation of both stands. **Lanes corollary (clarified 2026-09-09, #3335):** for optimistic writes the unit that settles is the _reveal_ — lanes merge through the shared reader (their effect queues become one) while transaction ownership stays put (A18 node corollary, #2912). The merged reveal is held while **any** member's observed async is in flight: a hold is a property of the async node — observed pending by a render reader in whichever live transaction recorded it (INV-3) — never of the root lane's transaction, which after a cross-transaction merge knows only one member's observations. Pinned: `tests/lane-hold-on-observation.test.ts` (#3335). **Reveal corollary (clarified 2026-09-09, re-ruled 2026-09-10; #3305, #3334):** a reveal that _discovers_ an async already in flight — a write that makes a render reader read a pending node for the first time — is that shared-reader observation: the reveal holds and joins the transition the flight blocks, settling as one unit with it, **whenever the flight's inputs are already visible** — committed by a batch that left the flight in the air with no observer (#3305), or revealed through an optimistic / `latest` lane (#3334). Showing the node's pre-flight (committed) value beside those inputs would tear the frame, and which transaction stamped the node says nothing about it. When the flight's inputs are themselves still held (unpublished, in some _other_ transaction), the reveal is a stale reader of a parallel transaction and follows the effects rule: it shows the node's committed value — coherent with the frame, whose inputs are also committed — does **not** entangle the two transactions, and re-derives at that transaction's commit (the reader is recorded for the commit replay). Corollary of the A18 node corollary: when the flight is lane-routed, the reveal waits on the _flight_, not on the transaction that owns the lane — an in-flight action holding that lane open does not hold the reveal once the flight lands. Pinned: `tests/spec-async-semantics.test.ts` (#3334, optimistic and `latest` sources; #3305 second reveal), `tests/stale-read-uninitialized-cross-transition.test.ts` (unpublished inputs: show committed, no entanglement), `tests/reveal-carve-out.test.ts`. +(was B3) Transition entanglement is graph-driven: writes whose async work is observed by a shared reader settle as one unit (no tearing — nothing commits until all entangled async resolves); writes on fully disjoint graphs keep independent transitions and settle independently. **Shared-hole corollary (amended 2026-09-14, #3407):** "observed by a shared reader" is a reader's _pass_ observing the flight pending — not the reader's mere existence. A render effect groups whatever bindings the compiler put in one hole, and a pass belongs to whoever dirtied it: a stamped effect (it observed one transaction's flight) dirtied by another transaction's write — a sync `action`, or a second flight's landing — runs that writer's pass, reads the held flight as a stale reader (its committed value, coherent with the flight's inputs which are also committed) and publishes with the writer; the two transactions stay parallel. Only a pass that _observes_ a pending flight — the reveal carve-out refused, next paragraph — joins that flight's transaction, and every transaction waiting on a flight completes at its landing. Maintainer: "we do want unrelated sync updates to pass through render effects… it makes no sense to the end user that separate bindings would hold"; "splitting a render effect per [binding] is a non-starter… the grouping cannot change." Consequence: `{b()}:{detailsA()}` publishes `1:0` when `b` is written (plainly or in an action) and `1:1` when `detailsA` lands; two independent flights read in one hole land at their own times. Memos keep the stamped re-entry (a memo's value _is_ its transaction's work), so entanglement through a user derivation of both stands — and it stands from the moment the second flight reaches the memo (#3443): pending _propagates_ onto a held memo without recomputing it (its inputs' values are unchanged), so the propagation itself enters the memo's transaction; waiting for the memo's next pass let the first flight land, reveal its inputs (`A: 1`) beside the memo's committed value (`Sum: 0`), and left `Sum: 2` to arrive with `B: 1`. Consequence: the write that started the second flight is held with the first when its async work flows into a memo the first holds (`page=1` waits with `count=1` while `details` re-asks), even where a plain binding of the same write would have passed through — the async work, not the binding, is what is shared. **Lanes corollary (clarified 2026-09-09, #3335):** for optimistic writes the unit that settles is the _reveal_ — lanes merge through the shared reader (their effect queues become one) while transaction ownership stays put (A18 node corollary, #2912). The merged reveal is held while **any** member's observed async is in flight: a hold is a property of the async node — observed pending by a render reader in whichever live transaction recorded it (INV-3) — never of the root lane's transaction, which after a cross-transaction merge knows only one member's observations. Pinned: `tests/lane-hold-on-observation.test.ts` (#3335). **Reveal corollary (clarified 2026-09-09, re-ruled 2026-09-10; #3305, #3334):** a reveal that _discovers_ an async already in flight — a write that makes a render reader read a pending node for the first time — is that shared-reader observation: the reveal holds and joins the transition the flight blocks, settling as one unit with it, **whenever the flight's inputs are already visible** — committed by a batch that left the flight in the air with no observer (#3305), or revealed through an optimistic / `latest` lane (#3334). Showing the node's pre-flight (committed) value beside those inputs would tear the frame, and which transaction stamped the node says nothing about it. When the flight's inputs are themselves still held (unpublished, in some _other_ transaction), the reveal is a stale reader of a parallel transaction and follows the effects rule: it shows the node's committed value — coherent with the frame, whose inputs are also committed — does **not** entangle the two transactions, and re-derives at that transaction's commit (the reader is recorded for the commit replay). Corollary of the A18 node corollary: when the flight is lane-routed, the reveal waits on the _flight_, not on the transaction that owns the lane — an in-flight action holding that lane open does not hold the reveal once the flight lands. Pinned: `tests/spec-async-semantics.test.ts` (#3334, optimistic and `latest` sources; #3305 second reveal), `tests/stale-read-uninitialized-cross-transition.test.ts` (unpublished inputs: show committed, no entanglement), `tests/reveal-carve-out.test.ts`. ### A30. A memo's dependencies are the committed frame's until the frame is replaced diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 778302918..0f0a43feb 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -992,7 +992,19 @@ export function notifyStatus( schedule(); return; } - if (!downstreamBlockStatus && !sub._transition) queuePendingNode(sub); + // A memo another live transaction holds, made pending by THIS flight, + // cannot reveal before the flight lands: the two settle as one unit + // (A15, a shared derivation of both — #3443). Propagation marks the + // held memo without recomputing it (its inputs' values are unchanged), + // so this is the one moment the entanglement is known; the memo's + // stamped re-entry at its next pass came too late — the holder's own + // flight landed first and revealed the inputs beside the stale sum. + // Effects entangle nothing (A15 shared-hole corollary): their reader + // registers with the flight's transaction at queue notification. + if (!downstreamBlockStatus) + sub._transition + ? pendingSource && !(sub as any)._type && globalQueue.initTransition(sub._transition) + : queuePendingNode(sub); notifyStatus(sub, status, error, downstreamBlockStatus, downstreamLane); } }); diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 674b8b039..f0c1a0aef 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -71,8 +71,16 @@ export const zombieQueue: Heap = { /** runHeap callback that discards a queued zombie recompute instead of running * it: unlink pure recompute entries; strip just the recompute bit from dirtied - * height-adjust entries so their height work still happens. */ + * height-adjust entries so their height work still happens. A zombie dirtied + * through the lane channel (OPTIMISTIC_DIRTY — an override or a `latest()` + * companion) runs instead (#3444): a zombie renders mainline until the commit + * disposes it, and the lane's values ARE the mainline frame — the still-visible + * branch a held `Show` is removing showed the old `latest(count)` beside the + * new one outside. Its pass runs under the lane and its run lands on the + * lane's effect queue, so a held lane defers it exactly as it defers every + * other reader's. */ function cancelZombieRecompute(el: Computed): void { + if (el._flags & REACTIVE_OPTIMISTIC_DIRTY) return GlobalQueue._update(el); if (el._flags & REACTIVE_IN_HEAP_HEIGHT) el._flags &= ~(REACTIVE_IN_HEAP | REACTIVE_DIRTY | REACTIVE_CHECK); else { diff --git a/packages/signals/tests/async-chain-supersession.test.ts b/packages/signals/tests/async-chain-supersession.test.ts index a53bad2d2..100eda573 100644 --- a/packages/signals/tests/async-chain-supersession.test.ts +++ b/packages/signals/tests/async-chain-supersession.test.ts @@ -169,11 +169,13 @@ describe("a second write while an async chain is in flight", () => { setCount(1); await settle(); await advanceTo(4000); - // page=1 resets the boundary (`on`): fallback, pageData1 due 5000. The only - // reader of details is now behind the fallback, so the hold on count=1 is - // over (ruled 2026-09-12): the reset wakes the parked transaction and it - // commits in the idle pass that follows — same drain, one pass after the - // ambient page=1 commit, hence two Sum publishes at 4000. + // page=1 resets the boundary (`on`): fallback, pageData1 due 5000. Its + // flight makes `details` — a memo the count=1 transaction holds — pending, + // so page=1 joins that transaction (A15 shared derivation, #3443). The only + // reader of details is now behind the fallback, so the hold is over (ruled + // 2026-09-12): the reset wakes the parked transaction and both writes + // commit together — one Sum publish at 4000 (before #3443 the ambient + // page=1 committed a pass ahead: `Sum: 1 | Sum: 2`). setPage(1); await settle(); await advanceTo(5500); @@ -186,7 +188,7 @@ describe("a second write while an async chain is in flight", () => { expect(frames(log, when)).toEqual([ "0: Boundary: Loading... | Sum: 0", "3000: Boundary: content | Details: 0", - "4000: Boundary: Loading... | Sum: 1 | Sum: 2", + "4000: Boundary: Loading... | Sum: 2", "5500: Sum: 3", "8500: Boundary: content | Details: 3" ]); diff --git a/packages/signals/tests/nested-render-effect-async-cleanup.test.ts b/packages/signals/tests/nested-render-effect-async-cleanup.test.ts index c35e3e5a4..9935a6e4e 100644 --- a/packages/signals/tests/nested-render-effect-async-cleanup.test.ts +++ b/packages/signals/tests/nested-render-effect-async-cleanup.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "vitest"; import { + action, createLoadingBoundary, createMemo, createRenderEffect, createRoot, createSignal, flush, + latest, onCleanup } from "../src/index.js"; @@ -199,4 +201,52 @@ describe("#3404 nested render effect reading a downstream async value", () => { flush(); expect(log).toEqual(["run 1", "run 2", "cleanup 1"]); }); + + // #3444: a held `Show` removing its branch marks the branch's effects + // zombies — they render mainline until the commit disposes them. Their + // queued recomputes are cancelled when the parking batch IS the transaction + // (the staged writes are a world a zombie never displays), but a zombie + // dirtied through the lane channel displays exactly that: `latest(count)` + // inside the branch stayed 0 while the same read outside showed 1. + it("#3444 latest() inside a branch a held Show is removing follows the value outside", async () => { + reset(); + const log: string[] = []; + let save!: () => unknown; + createRoot(() => { + const [count, setCount] = createSignal(0); + save = action(function* () { + setCount(1); + yield delay(1000); + }); + createRenderEffect( + () => `Outside: ${latest(count)}`, + v => { + log.push(`${v}@${now}`); + } + ); + // Show: the branch's effects are owned by the run that built them. + createRenderEffect( + () => { + if (count() === 0) { + createRenderEffect( + () => `Inside: ${latest(count)}`, + v => { + log.push(`${v}@${now}`); + } + ); + onCleanup(() => log.push(`Inside gone@${now}`)); + } + }, + () => {} + ); + }); + flush(); + await advanceTo(500); + expect(log).toEqual(["Outside: 0@0", "Inside: 0@0"]); + save(); + await settle(); + expect(log).toEqual(["Outside: 0@0", "Inside: 0@0", "Outside: 1@500", "Inside: 1@500"]); + await advanceTo(2000); + expect(log.slice(4)).toEqual(["Inside gone@1500"]); + }); }); diff --git a/packages/signals/tests/overlapping-flights.test.ts b/packages/signals/tests/overlapping-flights.test.ts new file mode 100644 index 000000000..639afbaa2 --- /dev/null +++ b/packages/signals/tests/overlapping-flights.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { createMemo, createRenderEffect, createRoot, createSignal, flush } from "../src/index.js"; + +// Manual clock (see async-chain-supersession.test.ts). +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + timers.sort((a, b) => a.at - b.at); + const next = timers[0]; + if (!next || next.at > t) break; + timers.shift(); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function reset() { + now = 0; + timers = []; +} +function frames(log: string[], when: number[]): string[] { + const byTime = new Map(); + log.forEach((v, i) => (byTime.get(when[i]) ?? byTime.set(when[i], []).get(when[i])!).push(v)); + return [...byTime].map(([t, vs]) => `${t}: ${vs.sort().join(" | ")}`); +} +function text(fn: () => string, log: string[], when: number[]) { + let last: string | undefined; + createRenderEffect(fn, v => { + if (v !== last) { + last = v; + log.push(v); + when.push(now); + } + }); +} + +// Two flights in one graph, in flight at once (A15: async work observed by a +// shared reader settles as one unit). The #3442 arm — a probe's pull must not +// break the slower flight's hold — is pinned in ispending-combined-atomic-3442. +describe("overlapping flights", () => { + // #3443: `setA` opens T1 (a in flight, `sum` held pending on it); `setB` 500ms + // later starts b's flight, whose pending PROPAGATES onto `sum` — no recompute + // (its inputs' values are unchanged), so the memo's stamped re-entry never + // ran and T1 never learned it was waiting on b. a landed first, T1 revealed + // `A: 1` beside the committed `Sum: 0`, and `Sum: 2` arrived with `B: 1`. + // A held memo made pending by another flight entangles the two at the + // propagation (A15): one reveal, when both have landed. + it("#3443 a shared memo entangles two flights that overlap", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setA!: (v: number) => void, setB!: (v: number) => void; + createRoot(() => { + const [inputA, sa] = createSignal(0); + const [inputB, sb] = createSignal(0); + setA = sa; + setB = sb; + const a = createMemo(() => delay(1000, inputA())); + const b = createMemo(() => delay(1000, inputB())); + const sum = createMemo(() => a() + b()); + text(() => `A: ${a()}`, log, when); + text(() => `B: ${b()}`, log, when); + text(() => `Sum: ${sum()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(2000); + setA(1); + await settle(); + await advanceTo(2500); + setB(1); + await settle(); + await advanceTo(6000); + expect(frames(log, when)).toEqual(["1000: A: 0 | B: 0 | Sum: 0", "3500: A: 1 | B: 1 | Sum: 2"]); + }); + + // The effect arm stays parallel (A15 shared-hole corollary, #3407): the same + // sum written as a plain expression in the hole is a stale reader of b's + // flight, served the committed 0, and publishes `Sum: 1` with a's landing. + it("a shared render effect stays parallel: each flight reveals at its own landing", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setA!: (v: number) => void, setB!: (v: number) => void; + createRoot(() => { + const [inputA, sa] = createSignal(0); + const [inputB, sb] = createSignal(0); + setA = sa; + setB = sb; + const a = createMemo(() => delay(1000, inputA())); + const b = createMemo(() => delay(1000, inputB())); + text(() => `A: ${a()}`, log, when); + text(() => `B: ${b()}`, log, when); + text(() => `Sum: ${a() + b()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(2000); + setA(1); + await settle(); + await advanceTo(2500); + setB(1); + await settle(); + await advanceTo(6000); + expect(frames(log, when)).toEqual([ + "1000: A: 0 | B: 0 | Sum: 0", + "3000: A: 1 | Sum: 1", + "3500: B: 1 | Sum: 2" + ]); + }); + + // The write that starts the second flight is held with the first (its async + // work flows into the held memo): `page` is read plainly by `Sum`, but + // `details` — held pending on count=1's flight — now depends on `pageData`'s. + // Before, page=1 committed ambient (`Sum: 1`) and details was re-asked under + // the hold once pageData landed. + it("#3443 the second flight's own write is held with the first", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void, setPage!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + const [page, sp] = createSignal(0); + setCount = sc; + setPage = sp; + const pageData = createMemo(() => delay(1000, page())); + const details = createMemo(() => delay(2000, pageData() + count())); + text(() => `Sum: ${page() + count()}`, log, when); + text(() => `Details: ${details()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3500); + setCount(1); + await settle(); + await advanceTo(4000); + setPage(1); + await settle(); + await advanceTo(12000); + expect(frames(log, when)).toEqual([ + "0: Sum: 0", + "3000: Details: 0", + // pageData1 lands at 5000 and re-asks details (due 7000) under the hold. + "7000: Details: 2 | Sum: 2" + ]); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 229a63528..2387586cc 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -286,7 +286,14 @@ describe("pay-for-use tree-shaking (#2883)", () => { // NOTE (2026-09-14, no bump): +12 B for memo lane posture (#3442) — one // assignment at recompute's head runs a memo plain unless it owns or // adopts a lane. Measured at 23,365 on top of #3438 (23,353 → 23,365). - expect(minifiedBytes).toBeLessThan(23_400); + // CONSCIOUS BUMP (2026-09-14): +54 B for two overlapping-flight rules — + // pending propagation onto a memo another live transaction holds enters + // that transaction (A15 shared derivation, #3443; one ternary at the + // propagation site), and a zombie dirtied through the lane channel runs + // instead of being cancelled when the parking batch is the transaction + // (#3444; one guard in cancelZombieRecompute). Measured at 23,419 on top + // of #3442 (23,365 → 23,419). + expect(minifiedBytes).toBeLessThan(23_450); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 9c3bebf34..fd2b77fb0 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -220,6 +220,11 @@ module.exports = [ // Held-input rules (#3408, #3410; 2026-09-14, on top of #3434): 8.65 -> // 8.70 KB, measured at 8685 B against `next`'s 8628 (+57) — `enterStagedRead` on read()'s value // selections and the deferred dependency trim; see the core floor note. + // Overlapping flights (#3443, #3444; 2026-09-14, on top of #3442): no bump, + // measured at 8690 B — pending propagation onto a held memo enters its + // transaction (`initTransition` at notifyStatus's dependent walk), and a + // lane-dirtied zombie runs instead of being cancelled; +54 B minified in + // the in-package floor (23,365 -> 23,419), brotli absorbs most of it. limit: "8.70 KB", modifyEsbuildConfig }, @@ -446,7 +451,11 @@ module.exports = [ // Memo lane posture (#3442, 2026-09-14): no bump, measured at 15624 B on // the rebase over #3438 (+12 B minified — one assignment in recompute's // head; brotli noise absorbs it; see the core floor note). - limit: "15.65 KB", + // Overlapping flights (#3443, #3444; 2026-09-14, on top of #3442): 15.65 -> + // 15.70 KB, measured at 15679 B — pending propagation onto a held memo enters + // its transaction, and a lane-dirtied zombie runs instead of being + // cancelled (+54 B minified in the in-package floor); see the core floor note. + limit: "15.70 KB", modifyEsbuildConfig }, { @@ -558,7 +567,11 @@ module.exports = [ // #3426/#3427/#3407 (2026-09-14): 10.90 -> 11.05 KB, measured at 11018 B // against `next`'s 10897 (+121 — the core seams plus `endOptimism` in the // optimistic module this scenario loads); see the core floor note. - limit: "11.05 KB", + // Overlapping flights (#3443, #3444; 2026-09-14, on top of #3442): 11.05 -> + // 11.10 KB, measured at 11066 B — pending propagation onto a held memo enters + // its transaction, and a lane-dirtied zombie runs instead of being + // cancelled (+54 B minified in the in-package floor); see the core floor note. + limit: "11.10 KB", modifyEsbuildConfig }, { @@ -1065,7 +1078,11 @@ module.exports = [ // Held-input rules (#3408, #3410; 2026-09-14, on top of #3434): 15.60 -> // 15.65 KB, measured at 15630 B against `next` (#3434 head) — `enterStagedRead` on read()'s value // selections and the deferred dependency trim; see the core floor note. - limit: "15.65 KB", + // Overlapping flights (#3443, #3444; 2026-09-14, on top of #3442): 15.65 -> + // 15.70 KB, measured at 15681 B — pending propagation onto a held memo enters + // its transaction, and a lane-dirtied zombie runs instead of being + // cancelled (+54 B minified in the in-package floor); see the core floor note. + limit: "15.70 KB", modifyEsbuildConfig: observeEsbuildConfig }, { From 42632d57c3b393d10b0bb2c7c05400829c634967 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 14 Sep 2026 23:36:04 -0700 Subject: [PATCH 2/3] fix(signals): entangle on what the transaction holds, not the stamp (#3443 carve-out) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A memo stamped by another live transaction but holding nothing of it — status clear, nothing staged — is not entangled when a second flight propagates pending onto it. The stamp alone decides nothing (#3334): a Dynamic switched twice mid-flight dragged the superseded first call's gate into the live second call's reveal (web call-driven-lifecycle args-switch-gate: expected Two, got Zero). Keyed on STATUS_PENDING or a staged _pendingValue instead; #3443's shapes are unchanged. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .../signals/docs/INTERNALS-ASYNC-STATE.md | 2 +- packages/signals/docs/SPEC-ASYNC-SEMANTICS.md | 4 +-- packages/signals/src/core/async.ts | 27 ++++++++++++------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index ed5243aa7..6815cae48 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -111,7 +111,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - Dependencies are the committed frame's (A30, #3410): `recompute`'s tail trims the previous pass's dependency tail only for a pass that published or changed nothing (`_pendingValue === NOT_PENDING` and no `_error`); a staged pass leaves it linked and `commitPendingNode` trims after a clean pass (`_error == null` — a set `_error` means the last pass threw, kept its full list, and `_depsTail` marks where it stopped); an effect pass that direct-committed but still owes a run (`_modified`, #3438 — the flush may stash that run into a transaction it opens later) leaves it for `runEffect` to trim once the run applies. `__OBSERVE__` fan-in counting walks the validated prefix only, so a held tail does not inflate distinct-source counts. Why commit-time and not the pass, and the `Selected: 0` beside `Count: 1` shape: the spec. - Reveal-hold and its carve-out (#3305, #3334, re-ruled 2026-09-10): a reader landing on a node with `STATUS_PENDING` throws — the throw reaches `GlobalQueue.notify`, which opens a transaction for the reveal if none is active (#3305) and records the source as its reporter (INV-3); the reveal completes when the flight lands. One carve-out, the staged-value rule's twin for flights: a **stale** (render) reader of a node pending in some **other** transaction shows the node's committed value, does not entangle (its own writes stay outside that transaction), is recorded for that transaction's commit replay (`heldFromStale`), and joins the transaction's reporters for the node when it has an entry (#3374) — the reader displays the pre-flight value, so the transaction cannot commit the flight's inputs ahead of its answer just because the reader that opened the entry was disposed (a keyed remount). It is refused — the reader holds — when the committed value would tear against the frame: the node carries `CONFIG_INPUTS_PUBLISHED` (a batch or transaction committed with the node still pending, `commitPendingNode`'s computed branch: the flight's inputs are on screen; cleared when the node next enters pending from a settled state, `notifyStatus`), or the node is routed through a live lane (`GlobalQueue._laneLive` → `resolveLane`, exact rather than sticky: lane-revealed inputs, optimistic or `latest`), or the node is uninitialized (nothing committed to show). The stamp itself is pending-node bookkeeping and decides nothing. Replay hygiene: an effect recorded in `_gatedSubs` that later recomputes _under_ the transaction sees its staged view and is applied by the commit (ownership) — `recompute` drops the stale recording at its start (`activeTransition._gatedSubs.delete`), and a lane's committed-view read re-records during the run, so the lane replay (`laneReadsCommitted`) is untouched. - Settle-time re-entry, lane-routed nodes (#3334): `handleAsync`'s `settleTransition` re-enters `resolveTransition(el)` — for a lane-routed node the transaction that _owns_ the lane. That owner's commit is only the override's confirm/revert; the landing itself is revealed by the lane. If a transaction is _waiting_ on the node (`waitingTransition(el)`), the settle enters that one instead: entering the owner would fold a reveal that only waits on the flight into the owner's action (A18 node corollary, #2912). Every other transaction waiting on the flight then folds in explicitly (`enterWaiting`, #3407 — see the next bullet): each reveal that discovered the flight completes at its landing (A15). -- Pending propagation onto a held memo entangles (#3443): `notifyStatus`'s dependent walk, on a pending propagation reaching a memo stamped by another live transaction (`sub._transition` set, not the active one, not behind a boundary), enters that transaction — `initTransition(sub._transition)`, merging it into the active one or, with none active, entering it and adopting the ambient batch (the write that started this flight becomes its). A memo the first transaction holds and the second flight now feeds cannot reveal before the second lands (A15: a shared derivation of both), and propagation is the one moment that is known: it marks the memo pending without recomputing it (its inputs' _values_ are unchanged), so the memo's stamped re-entry — the entanglement's usual site — never ran, the first flight landed, its transaction's verdict saw only its own reporters (the memo's reader had registered for the second flight in the _second_ transaction, INV-3 keyed by transaction), and it revealed `A: 1` beside the memo's committed `Sum: 0`. Effects are skipped (`_type`): an effect entangles nothing by itself (A15 shared-hole corollary) — its reader registers with the flight's transaction at queue notification and the landing folds waiters in (`enterWaiting`). Consequence pinned alongside: a write whose async work flows into a held memo is held with it (`page=1` beside `count=1` while `details` re-asks — before, the ambient page=1 committed a pass ahead), which is also why the #3375 boundary-reset pin now publishes `Sum: 2` once at the reset instead of `Sum: 1 | Sum: 2`. Pinned: `tests/overlapping-flights.test.ts`. +- Pending propagation onto a held memo entangles (#3443): `notifyStatus`'s dependent walk, on a pending propagation reaching a memo another live transaction _holds_ — stamped by it AND pending on its work or carrying its staged `_pendingValue`; not behind a boundary — enters that transaction — `initTransition(sub._transition)`, merging it into the active one or, with none active, entering it and adopting the ambient batch (the write that started this flight becomes its). A memo the first transaction holds and the second flight now feeds cannot reveal before the second lands (A15: a shared derivation of both), and propagation is the one moment that is known: it marks the memo pending without recomputing it (its inputs' _values_ are unchanged), so the memo's stamped re-entry — the entanglement's usual site — never ran, the first flight landed, its transaction's verdict saw only its own reporters (the memo's reader had registered for the second flight in the _second_ transaction, INV-3 keyed by transaction), and it revealed `A: 1` beside the memo's committed `Sum: 0`. The stamp alone decides nothing (#3334): a memo the transaction once queued but holds nothing of — status clear, nothing staged — is not entangled, or a `Dynamic` switched twice mid-flight would drag the superseded first call's gate into the live second call's reveal (`call-driven-lifecycle` args-switch-gate: the second write supersedes the first through the shared output memo, and only the live answer settles it). Effects are skipped (`_type`): an effect entangles nothing by itself (A15 shared-hole corollary) — its reader registers with the flight's transaction at queue notification and the landing folds waiters in (`enterWaiting`). Consequence pinned alongside: a write whose async work flows into a held memo is held with it (`page=1` beside `count=1` while `details` re-asks — before, the ambient page=1 committed a pass ahead), which is also why the #3375 boundary-reset pin now publishes `Sum: 2` once at the reset instead of `Sum: 1 | Sum: 2`. Pinned: `tests/overlapping-flights.test.ts`. - Pass provenance for effects (#3407): a render effect's pass belongs to whatever dirtied it. `recompute` re-enters a stamped node's transaction only for memos (their value _is_ that transaction's work); an effect stamped by a transaction — it observed that transaction's flight — and dirtied by another transaction's write, or by mainline, runs that writer's pass, reads the held flight as a stale reader (committed value, `heldFromStale`) and publishes with the writer. The pass entangles only if it _observes_ a pending flight (the carve-out refused: inputs published, lane-live, uninitialized) — the throw reaches `GlobalQueue.notify`, which registers the writer's transaction as a reporter, and the flight's landing folds it in. Before, `recompute` re-entered an effect's stamp whenever _any_ other transaction was active: a sync `action` write to a signal that merely shared a hole with a held async (`{b()}:{detailsA()}`) merged into the async's transaction and waited (`0:0 → 2:1`, no `1:0`), while the same write made plainly passed through; two independent flights read in one hole settled as one unit. Now both writers publish on their own (`1:0` at the write, `1:1` at the landing; two flights land at their own times). The re-entry's other job — delivering a landing to the transactions waiting on it — moves to the landing itself: `settleTransition` enters every parked transaction whose reporters still observe the node (`enterWaiting`, over `sourceObserved`), including the waiter of a stampless node (a flight started under a batch that committed beneath it, #3305), whose landing used to open a fresh batch that the stamped reader's re-entry folded into the waiter. Pinned: `tests/shared-effect-no-entangle.test.ts`; the reveal-completion pins (`spec-async-semantics` A15, `reveal-carve-out`, `stale-read-uninitialized-cross-transition`) are the regression net for the fold. - The action body's end starts the correction (#3427, `endOptimism`, called from `flush` after the heap and before the verdict): with the bodies over (`_acted` and no live `_actions`) and nothing _authoritative_ in flight — no override node's own source (`transitionBlocked`), no held flight that does not derive from an override (`sourceObserved` and not `resolveLane`) — each override's truth (the staged value an A17-silent landing left, else the committed value) supersedes it now, as an arriving differing truth does (A18): the graph re-derives from the truth as the transaction's held work, and the transaction settles when _that_ lands. The lane-derived flights were questions about the guess; nobody reads their answer. Before, the settle waited for the obsolete flight, revealed the obsolete optimistic frame when it landed, then reverted and re-asked — a waterfall with a flash. A co-written plain load the action asked for (`setSaving(true); setPage(2)`) is authoritative and keeps the optimistic world up until it lands. Optimistic **store** edits opt the whole transaction out (their truth is the base layer under an overlay with no tracked/displayed split); companions (`_parentSource`) answer for their owner and snap at settlement, not here. Pinned: `tests/optimistic-lane-release.test.ts`. diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index b555a10e5..84e4d2cdc 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -192,9 +192,9 @@ A resting optimistic node reports pending via exactly the causes a plain async m **Status:** **ruled, amended in place** 2026-07-06 (promoted from B3) — maintainer keep, 2026-07-06; amended 2026-09-14 (#3407: a shared render effect entangles nothing by itself — see the shared-hole corollary); mechanism completed 2026-09-14 (#3443: a held memo made pending by another flight entangles at the propagation, not at its next pass) **Pinned by:** `tests/spec-async-semantics.test.ts`; `tests/shared-effect-no-entangle.test.ts` (#3407); `tests/overlapping-flights.test.ts` (#3443: two flights through one memo reveal once; the effect arm stays parallel; the second flight's own write is held with the first) -**Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335); `sourceObserved` (the live-reporter test, shared by the verdict, the lane hold and the landing, #3426); `recompute`'s stamp re-entry is memo-only and `settleTransition` → `enterWaiting` folds every waiter in at the landing (#3407); `notifyStatus`'s pending propagation onto a memo stamped by another live transaction enters it (`initTransition(sub._transition)`, #3443). +**Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335); `sourceObserved` (the live-reporter test, shared by the verdict, the lane hold and the landing, #3426); `recompute`'s stamp re-entry is memo-only and `settleTransition` → `enterWaiting` folds every waiter in at the landing (#3407); `notifyStatus`'s pending propagation onto a memo another live transaction holds (stamped, and pending or staged) enters it (`initTransition(sub._transition)`, #3443). -(was B3) Transition entanglement is graph-driven: writes whose async work is observed by a shared reader settle as one unit (no tearing — nothing commits until all entangled async resolves); writes on fully disjoint graphs keep independent transitions and settle independently. **Shared-hole corollary (amended 2026-09-14, #3407):** "observed by a shared reader" is a reader's _pass_ observing the flight pending — not the reader's mere existence. A render effect groups whatever bindings the compiler put in one hole, and a pass belongs to whoever dirtied it: a stamped effect (it observed one transaction's flight) dirtied by another transaction's write — a sync `action`, or a second flight's landing — runs that writer's pass, reads the held flight as a stale reader (its committed value, coherent with the flight's inputs which are also committed) and publishes with the writer; the two transactions stay parallel. Only a pass that _observes_ a pending flight — the reveal carve-out refused, next paragraph — joins that flight's transaction, and every transaction waiting on a flight completes at its landing. Maintainer: "we do want unrelated sync updates to pass through render effects… it makes no sense to the end user that separate bindings would hold"; "splitting a render effect per [binding] is a non-starter… the grouping cannot change." Consequence: `{b()}:{detailsA()}` publishes `1:0` when `b` is written (plainly or in an action) and `1:1` when `detailsA` lands; two independent flights read in one hole land at their own times. Memos keep the stamped re-entry (a memo's value _is_ its transaction's work), so entanglement through a user derivation of both stands — and it stands from the moment the second flight reaches the memo (#3443): pending _propagates_ onto a held memo without recomputing it (its inputs' values are unchanged), so the propagation itself enters the memo's transaction; waiting for the memo's next pass let the first flight land, reveal its inputs (`A: 1`) beside the memo's committed value (`Sum: 0`), and left `Sum: 2` to arrive with `B: 1`. Consequence: the write that started the second flight is held with the first when its async work flows into a memo the first holds (`page=1` waits with `count=1` while `details` re-asks), even where a plain binding of the same write would have passed through — the async work, not the binding, is what is shared. **Lanes corollary (clarified 2026-09-09, #3335):** for optimistic writes the unit that settles is the _reveal_ — lanes merge through the shared reader (their effect queues become one) while transaction ownership stays put (A18 node corollary, #2912). The merged reveal is held while **any** member's observed async is in flight: a hold is a property of the async node — observed pending by a render reader in whichever live transaction recorded it (INV-3) — never of the root lane's transaction, which after a cross-transaction merge knows only one member's observations. Pinned: `tests/lane-hold-on-observation.test.ts` (#3335). **Reveal corollary (clarified 2026-09-09, re-ruled 2026-09-10; #3305, #3334):** a reveal that _discovers_ an async already in flight — a write that makes a render reader read a pending node for the first time — is that shared-reader observation: the reveal holds and joins the transition the flight blocks, settling as one unit with it, **whenever the flight's inputs are already visible** — committed by a batch that left the flight in the air with no observer (#3305), or revealed through an optimistic / `latest` lane (#3334). Showing the node's pre-flight (committed) value beside those inputs would tear the frame, and which transaction stamped the node says nothing about it. When the flight's inputs are themselves still held (unpublished, in some _other_ transaction), the reveal is a stale reader of a parallel transaction and follows the effects rule: it shows the node's committed value — coherent with the frame, whose inputs are also committed — does **not** entangle the two transactions, and re-derives at that transaction's commit (the reader is recorded for the commit replay). Corollary of the A18 node corollary: when the flight is lane-routed, the reveal waits on the _flight_, not on the transaction that owns the lane — an in-flight action holding that lane open does not hold the reveal once the flight lands. Pinned: `tests/spec-async-semantics.test.ts` (#3334, optimistic and `latest` sources; #3305 second reveal), `tests/stale-read-uninitialized-cross-transition.test.ts` (unpublished inputs: show committed, no entanglement), `tests/reveal-carve-out.test.ts`. +(was B3) Transition entanglement is graph-driven: writes whose async work is observed by a shared reader settle as one unit (no tearing — nothing commits until all entangled async resolves); writes on fully disjoint graphs keep independent transitions and settle independently. **Shared-hole corollary (amended 2026-09-14, #3407):** "observed by a shared reader" is a reader's _pass_ observing the flight pending — not the reader's mere existence. A render effect groups whatever bindings the compiler put in one hole, and a pass belongs to whoever dirtied it: a stamped effect (it observed one transaction's flight) dirtied by another transaction's write — a sync `action`, or a second flight's landing — runs that writer's pass, reads the held flight as a stale reader (its committed value, coherent with the flight's inputs which are also committed) and publishes with the writer; the two transactions stay parallel. Only a pass that _observes_ a pending flight — the reveal carve-out refused, next paragraph — joins that flight's transaction, and every transaction waiting on a flight completes at its landing. Maintainer: "we do want unrelated sync updates to pass through render effects… it makes no sense to the end user that separate bindings would hold"; "splitting a render effect per [binding] is a non-starter… the grouping cannot change." Consequence: `{b()}:{detailsA()}` publishes `1:0` when `b` is written (plainly or in an action) and `1:1` when `detailsA` lands; two independent flights read in one hole land at their own times. Memos keep the stamped re-entry (a memo's value _is_ its transaction's work), so entanglement through a user derivation of both stands — and it stands from the moment the second flight reaches the memo (#3443): pending _propagates_ onto a held memo without recomputing it (its inputs' values are unchanged), so the propagation itself enters the memo's transaction — when the memo is genuinely _held_ (pending on that transaction's work, or staged by it); a stamp alone decides nothing (#3334), so a second write that supersedes the first through a shared output memo does not drag the superseded flight into the live reveal; waiting for the memo's next pass let the first flight land, reveal its inputs (`A: 1`) beside the memo's committed value (`Sum: 0`), and left `Sum: 2` to arrive with `B: 1`. Consequence: the write that started the second flight is held with the first when its async work flows into a memo the first holds (`page=1` waits with `count=1` while `details` re-asks), even where a plain binding of the same write would have passed through — the async work, not the binding, is what is shared. **Lanes corollary (clarified 2026-09-09, #3335):** for optimistic writes the unit that settles is the _reveal_ — lanes merge through the shared reader (their effect queues become one) while transaction ownership stays put (A18 node corollary, #2912). The merged reveal is held while **any** member's observed async is in flight: a hold is a property of the async node — observed pending by a render reader in whichever live transaction recorded it (INV-3) — never of the root lane's transaction, which after a cross-transaction merge knows only one member's observations. Pinned: `tests/lane-hold-on-observation.test.ts` (#3335). **Reveal corollary (clarified 2026-09-09, re-ruled 2026-09-10; #3305, #3334):** a reveal that _discovers_ an async already in flight — a write that makes a render reader read a pending node for the first time — is that shared-reader observation: the reveal holds and joins the transition the flight blocks, settling as one unit with it, **whenever the flight's inputs are already visible** — committed by a batch that left the flight in the air with no observer (#3305), or revealed through an optimistic / `latest` lane (#3334). Showing the node's pre-flight (committed) value beside those inputs would tear the frame, and which transaction stamped the node says nothing about it. When the flight's inputs are themselves still held (unpublished, in some _other_ transaction), the reveal is a stale reader of a parallel transaction and follows the effects rule: it shows the node's committed value — coherent with the frame, whose inputs are also committed — does **not** entangle the two transactions, and re-derives at that transaction's commit (the reader is recorded for the commit replay). Corollary of the A18 node corollary: when the flight is lane-routed, the reveal waits on the _flight_, not on the transaction that owns the lane — an in-flight action holding that lane open does not hold the reveal once the flight lands. Pinned: `tests/spec-async-semantics.test.ts` (#3334, optimistic and `latest` sources; #3305 second reveal), `tests/stale-read-uninitialized-cross-transition.test.ts` (unpublished inputs: show committed, no entanglement), `tests/reveal-carve-out.test.ts`. ### A30. A memo's dependencies are the committed frame's until the frame is replaced diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 0f0a43feb..f39871a57 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -992,18 +992,25 @@ export function notifyStatus( schedule(); return; } - // A memo another live transaction holds, made pending by THIS flight, - // cannot reveal before the flight lands: the two settle as one unit - // (A15, a shared derivation of both — #3443). Propagation marks the - // held memo without recomputing it (its inputs' values are unchanged), - // so this is the one moment the entanglement is known; the memo's - // stamped re-entry at its next pass came too late — the holder's own - // flight landed first and revealed the inputs beside the stale sum. - // Effects entangle nothing (A15 shared-hole corollary): their reader - // registers with the flight's transaction at queue notification. + // A memo another live transaction HOLDS — pending on its work, or + // staged by it — made pending by THIS flight cannot reveal before the + // flight lands: the two settle as one unit (A15, a shared derivation of + // both — #3443). Propagation marks the held memo without recomputing it + // (its inputs' values are unchanged), so this is the one moment the + // entanglement is known; the memo's stamped re-entry at its next pass + // came too late — the holder's own flight landed first and revealed the + // inputs beside the stale sum. The stamp alone decides nothing (#3334): + // a node the transaction once queued but holds nothing of — a switch's + // shared output whose first flight the second write superseded — must + // not drag the older flight into the newer reveal. Effects entangle + // nothing (A15 shared-hole corollary): their reader registers with the + // flight's transaction at queue notification. if (!downstreamBlockStatus) sub._transition - ? pendingSource && !(sub as any)._type && globalQueue.initTransition(sub._transition) + ? pendingSource && + !(sub as any)._type && + (sub._statusFlags & STATUS_PENDING || sub._pendingValue !== NOT_PENDING) && + globalQueue.initTransition(sub._transition) : queuePendingNode(sub); notifyStatus(sub, status, error, downstreamBlockStatus, downstreamLane); } From aa2cfccb6524034eb805d1b45a141137c4a35cca Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 14 Sep 2026 23:49:21 -0700 Subject: [PATCH 3/3] chore(size): core floor 8.70 -> 8.75 KB for the #3443 holds carve-out Measured at 8718 B against next's 8691 (+27). The stamp-only first cut fit under 8.70 at 8690; keying entanglement on STATUS_PENDING / a staged value is what tips the cap. Every other scenario unchanged and within cap. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- scripts/size/.size-limit.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index fd2b77fb0..28ebbf7da 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -220,12 +220,15 @@ module.exports = [ // Held-input rules (#3408, #3410; 2026-09-14, on top of #3434): 8.65 -> // 8.70 KB, measured at 8685 B against `next`'s 8628 (+57) — `enterStagedRead` on read()'s value // selections and the deferred dependency trim; see the core floor note. - // Overlapping flights (#3443, #3444; 2026-09-14, on top of #3442): no bump, - // measured at 8690 B — pending propagation onto a held memo enters its - // transaction (`initTransition` at notifyStatus's dependent walk), and a + // Overlapping flights (#3443, #3444; 2026-09-14, on top of #3442): 8.70 -> + // 8.75 KB, measured at 8718 B against `next`'s 8691 (+27) — pending + // propagation onto a memo another transaction HOLDS enters its + // transaction (`initTransition` at notifyStatus's dependent walk, keyed on + // STATUS_PENDING / a staged value, not the stamp alone), and a // lane-dirtied zombie runs instead of being cancelled; +54 B minified in - // the in-package floor (23,365 -> 23,419), brotli absorbs most of it. - limit: "8.70 KB", + // the in-package floor (23,365 -> 23,419). The first cut (stamp-only + // entanglement) fit at 8690; the holds carve-out is what tips the cap. + limit: "8.75 KB", modifyEsbuildConfig }, {