diff --git a/.changeset/fix-optimistic-lane-release.md b/.changeset/fix-optimistic-lane-release.md new file mode 100644 index 000000000..04313bff7 --- /dev/null +++ b/.changeset/fix-optimistic-lane-release.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +--- + +Optimistic frames release when nothing authoritative is left to wait on, and a shared render effect no longer entangles unrelated updates (#3426, #3427, #3407). + +- The last async reader of an optimistic value unmounting mid-action releases the frame (#3426): the lane's hold check prunes dead reporters itself (`sourceObserved`, shared with the settle verdict), instead of waiting for a flight nobody observes to land. +- The action body ending starts the correction (#3427): with the bodies over and no authoritative work in flight — no override node's own source, no held plain load the action asked for — each override's truth supersedes it now and the graph re-derives from it as the transaction's held work, settling when that lands. Before, the settle waited for the obsolete lane-derived flight, flashed the obsolete optimistic frame, then reverted and re-asked. A co-written flag stays through a plain load; optimistic store edits keep the settle-then-revert order. +- A render effect's pass belongs to whatever dirtied it (#3407): a sync `action` write to a signal that shared a hole with a held async (`{b()}:{detailsA()}`) merged into the async's transaction and waited (`0:0 → 2:1`) while the same plain write passed through. `recompute` re-enters a stamped node's transaction for memos only; the landing of a flight enters every transaction waiting on it (`enterWaiting`), which is where the effect re-entry's one legitimate job — completing the reveals that discovered the flight — now lives. Two independent flights read in one hole land at their own times. diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index 999878314..da60545e2 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -90,7 +90,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - `_pendingAsync` add/delete sites: added in `recompute`'s async catch under a lane (core.ts ~264), removed on async resolution (`asyncWrite`, async.ts ~214) and on lane-corrected recompute (core.ts ~254). The set records the async the lane _owns_, not what holds it. - Replay gating (#3330): `laneReadsCommitted` hands a lane reader the committed `_value` of a staged node and records the reader in the batch's `_gatedSubs` for a re-run at commit — only when `_pendingValue !== _value`. A lane recompute that already published the value (INV-11) leaves the two equal; recording the reader anyway replayed its effects against an unchanged frame. - Late readers of a transaction hold (#3330 store twin → general): the stale-reader term of `read()`'s value selections (`heldFromStale`, core.ts — the fast paths and the slow path) serves a render effect the committed `_value` of a node another live transaction staged, and records the reader in that transaction's `_gatedSubs`. The commit is silent (the staging walk was the notification), so a reader that linked after the walk — an effect created during the hold, a store key first read under it — would otherwise show the old value past the reveal. An effect the transaction itself computed (`_valueTransition` resolves to it) is not recorded: it re-derives at the commit through its parked run or the contested re-derive (#3322), and a replay would publish the frame twice. Pinned in `tests/spec-async-semantics.test.ts` ("a reader that links to a held node during the hold"). -- Hold rule (`laneHeld`, #3289; per-node lookup #3335): a lane is held iff some `_pendingAsync` node is in the `_asyncReporters` of **any live transaction** (`waitingTransition(node) !== null`) — i.e. a render effect observed it pending and no boundary consumed the status (INV-3, the one registration site). Not "its transaction's": lanes merge across transactions (#2912) and the merged root's transaction recorded only one member's observations. Same rule as `transitionComplete`: unrendered async and fallback-caught async hold nothing. The two facts arrive in either order (a node created by the lane's own reveal is observed first and stamped on a later re-ask), which is why the hold is a predicate over both records rather than a registration. +- Hold rule (`laneHeld`, #3289; per-node lookup #3335): a lane is held iff some `_pendingAsync` node is in the `_asyncReporters` of **any live transaction** (`waitingTransition(node) !== null`) — i.e. a render effect observed it pending and no boundary consumed the status (INV-3, the one registration site). Not "its transaction's": lanes merge across transactions (#2912) and the merged root's transaction recorded only one member's observations. Same rule as `transitionComplete`: unrendered async and fallback-caught async hold nothing. The two facts arrive in either order (a node created by the lane's own reveal is observed first and stamped on a later re-ask), which is why the hold is a predicate over both records rather than a registration. The predicate prunes as it reads (#3426): `waitingTransition` asks `sourceObserved`, the same live-reporter test `transitionComplete` runs, so a reporter that died (disposed, behind a fallback, no longer reading the node) stops holding the lane at the next check. A live action parks its transaction without a verdict, so this is the only prune an optimistic frame whose last async reader unmounted mid-action ever gets — the lane used to hold on the dead registration until the flight nobody observed landed. ## 3. Transitions (`scheduler.ts`) @@ -101,14 +101,16 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - 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. - `_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); 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). +- `_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). - `onSettled` and the revert re-derive (#3411): the settle drops the overrides in the commit pass and only _enqueues_ their subscribers (the revert's `insertSubs`, the contested and gated replays, the store clears); the pass after re-derives them, and reads do not pull (`prepareComputed(el, false)`). An unowned `onSettled` callback is a one-shot in the commit pass's user phase, so it read the optimistic source already reverted next to a sync memo of it still holding the optimistic value. The fire waits for the heap to drain instead — while `dirtyQueue` has work it re-enqueues itself (`run` swaps the queue, so the re-enqueue lands in the next pass; `enqueue` keeps the drain alive) — which is what settled means: no derivation outstanding. Forcing the re-derive into the commit pass is not an option: an optimistic write completing in its own flush shows its lane frame (applied by `cleanupCompletedLanes`) before the truth, and the store clears compose a torn shape when their readers re-derive inside finalize (#3164). - Finalize re-entry (#3319): `finalizePureQueue` can _enter_ a held transaction partway through — a store commit hook (`bumpDeep` on a node the transaction owns), a boundary `_checkSources` write, or a stamped recompute in its heap — and `initTransition` then adopts the batch being finalized. Two rules keep that consistent. **State:** finalize captures the batch it started with and, if `currentBatch` changed, commits/reverts nothing batch-derived (the entered transaction owns it now); a _completing_ transaction whose ambient batch was separate (the #2916 shape) still settles its own containers, since adoption never touched them. **Effects:** ownership. A run applies with the commit of the transaction that computed its value (`Effect._valueTransition`). The ordinary effect phase runs with `activeTransition` set only in a flush whose finalize entered one, so `runEffect` leaves runs owned by a still-held transaction queued — `_modified` stays set — for the next gate to stash with the owner, while everything computed mainline (the write that caused the flush) applies now. Lanes are exempt by construction: they apply their own effects ahead of their transaction (the optimistic view) and their runner ORs `LANE_RUN` into the `type` it passes; the creation-time immediate run in `effect()` passes it too. The exemption is keyed on the _effect_ still having a lane, not on the runner: after a supersession demotes the cascade (#3331, §1), a `LANE_RUN` runner reaching a now lane-less effect whose value was computed under a still-held transaction leaves it queued like any owned run — otherwise the lane would apply the corrected derivation ahead of the commit that is supposed to reveal it. Known residue: writes staged by a hook _before_ the entry are adopted (held) and, because finalize's heap runs after its hooks, their dependents recompute owner-stamped and park with them; an entry that happens _inside_ that heap can leave an earlier mainline-computed effect applied over an adopted source — narrow, and inherited from adoption rather than from this rule. - `transitionComplete`: prunes dead reporters (`reporterBlocksSource`), transition is done when no live reporter still blocks a source whose **own flight is up** and no active-override node is blocked on someone else's async. "Own flight is up" is the source's self entry in its `_pendingSources` (#3375) — not `_error.source`, which a later-pending input overwrites on propagation while the flight is still in the air. - Fallback-caught async holds nothing — in both orders (ruled 2026-09-12, #3375). A collecting boundary consumes the notification, so a reader under a fallback never registers. A reader registered while its boundary showed content (forwarded) stays registered when the boundary's `on` changes and it flips to the fallback; `reporterBlocksSource` therefore walks the reporter's `_queue._parent` chain and treats a reporter behind a collecting pending-type boundary (`_collectionType & STATUS_PENDING && !_initialized`) as not live. If nothing outside the boundary consumes the flight, the hold is over; a reader outside it still holds. The reset itself calls `wakeParked()` so the re-judgement happens in the same drain. - Wake of parked transactions (`wokenTransitions`): the flush judges only the _active_ transaction; a parked one is re-entered by a stamped node's landing (`settleTransition`), a stamped recompute, or an action resuming. A reporter that stops counting for another reason — its boundary reset (above), or its disposal by ambient work (#3372: `disposeChildren(self)` on a node with `_transition` and `STATUS_PENDING`; a pending reader is always queued as a pending node, so the stamp is reliable) — is none of those: `reporterBlocksSource` would prune it at the next check, but no check comes, and the writes held with it stay staged. Such sites record the transaction (deduped) and schedule; the flush re-enters a woken transaction from the `finally` of a full pass — reached from the park exit and the normal exit alike — and only when idle: no `activeTransition` and `!scheduled`, which at that point means an empty dirty heap, no write since the heap ran (every write re-arms it) and, the finalize having reverted them, no optimistic ambient nodes. Entering adopts the ambient batch, and ambient work present at that instant would be held behind flights it never read; a wake in a pass with work just falls to the next. Entries are popped in a loop until one enters: a wake whose transaction completed by other means is a bare return (`initTransition` on `_done`) and must not strand the ones behind it. The fast drain defers to the full path while a wake is outstanding so such dead entries are still consumed. A wake with other live reporters re-parks; the idle pass is its only cost. Known shape: the ambient write that triggered the reset commits in its own pass and the released hold in the idle pass after it — two effect runs in one synchronous drain (`Sum: 1`, `Sum: 2` at the same clock time in the #3375 pin), never a visible tear. - 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 make the waiter's stamped recompute merge the owner into it (`recompute` → `initTransition`), folding a reveal that only waits on the flight into the owner's action (A18 node corollary, #2912). Several waiters on one flight still merge with each other through their stamped readers at the landing (A15). +- 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). +- 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`. ## 4. Write paths (all must stay equivalent) @@ -167,8 +169,8 @@ Confidence: **high** = implementation self-consistency, assert now. implies the node is queued (`_pendingNode`/`_pendingNodes`) or held by a transition — a pending value with no committer is a leak (the #2827 class). - **INV-8 (RETIRED 2026-07-07b, §5e)** Hold-provenance: a `_pendingValue` on an - optimistic node was tracked as either a *revert target* (the pre-override - value stashed for the revert) or a *held authoritative value*, and the + optimistic node was tracked as either a _revert target_ (the pre-override + value stashed for the revert) or a _held authoritative value_, and the invariant asserted a resting node never carried a revert target. That provenance proved a held value on a resting node is always a refetch / transition hold — pending like a plain memo (V1, §5d) — and then the A18 @@ -427,7 +429,6 @@ The findings that used to follow here (§5a–§5h, 2026-07-06 → 2026-07-16) a - #2838 (tracked) — `latest()` shadow should become write-driven post-release; the probe-based design is acknowledged overcomplication. - ## History — dated findings (§5a–§5h) Verbatim, in original order. See "Dated findings — where they went" under §5 for which still govern. diff --git a/packages/signals/docs/RULES-INDEX.md b/packages/signals/docs/RULES-INDEX.md index 816056e74..8c36d090e 100644 --- a/packages/signals/docs/RULES-INDEX.md +++ b/packages/signals/docs/RULES-INDEX.md @@ -59,10 +59,10 @@ Status legend: **live** stated and standing · **ruled** carries an explicit rul | A12 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:117` | — | 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:125` | 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:133` | — | 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 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:167` | core.ts×2 lanes.ts×1 scheduler.ts×1 | lane-hold-on-observation.test.ts×1 reveal-carve-out.test.ts×2 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 treeshake.test.ts×3 | [ruled 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 is observed by a s… | +| A15 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:167` | async.ts×1 core.ts×3 lanes.ts×1 scheduler.ts×2 | lane-hold-on-observation.test.ts×1 reveal-carve-out.test.ts×2 shared-effect-no-entangle.test.ts×1 spec-async-semantics.test.ts×2 stale-read-uninitialized-cross-transition.test.ts×1 treeshake.test.ts×3 | [ruled, amended in place 2026-07-06 (promoted from B3)] Transition entanglement is graph-driven; lanes settle as one reveal — (was B3) Transition entanglement is graph-driven: writes whose async work … | | A16 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:141` | verdict.ts×1 | spec-async-semantics.test.ts×1 strict-read-pending-store.test.ts×2 uninitialized-visibility.test.ts×1 | [ruled 2026-07-06 (promoted from B5)] `isPending` never throws in untracked contexts — (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read uninitialized asy… | -| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×3 scheduler.ts×2 verdict.ts×1 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | -| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×3 scheduler.ts×2 types.ts×2 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | +| A17 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:31` | async.ts×3 constants.ts×2 core.ts×7 invariants.ts×3 optimistic.ts×5 scheduler.ts×2 verdict.ts×1 signals.ts×2 optimistic.ts×1 store.ts×3 | optimistic-undefined-override.test.ts×1 refresh-await.test.ts×1 reveal-gating-contract.test.ts×3 spec-async-semantics.test.ts×10 createOptimisticStore.test.ts×1 treeshake.test.ts×1 until.test.ts×1 | [ruled, amended in place 2026-07-06 (promoted from C4)] An active override is the displayed value until its transaction commits, and the graph's value until its own source answers — **Statement (curre… | +| A18 | amended | `docs/SPEC-ASYNC-SEMANTICS.md:41` | async.ts×2 constants.ts×1 core.ts×4 optimistic.ts×4 scheduler.ts×2 types.ts×2 optimistic.ts×1 | createOptimistic.test.ts×1 spec-async-semantics.test.ts×3 flight-owned-transaction.test.ts×1 | [ruled, amended in place 2026-07-07 (promoted from B4)] An override lives exactly as long as its own transaction; a newer truth from the source supersedes it in the graph immediately, on screen at com… | | A19 | ruled | `docs/SPEC-ASYNC-SEMANTICS.md:69` | async.ts×1 optimistic.ts×1 | spec-async-semantics.test.ts×3 uninitialized-visibility.test.ts×1 | [ruled 2026-07-07 (promoted from C1)] `isPending(x)` ≡ the observable value is not final (three causes) — (was C1 — **partially reverses an earlier decision**) **Definition: `isPending(x)` ≡ the value… | | A20 | superseded | `docs/SPEC-ASYNC-SEMANTICS.md:247` | 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:254` | — | 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… | @@ -102,17 +102,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:140` | invariants.ts×2 | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call | -| INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:142` | invariants.ts×2 | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is | -| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:146` | 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:153` | 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:157` | invariants.ts×2 lanes.ts×1 | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` | -| INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:163` | 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:166` | 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:169` | 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:180` | 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:185` | 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:190` | 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:142` | invariants.ts×2 | — | - **INV-1 (high)** `pendingProbe` is non-null only inside an `isPending()` call | +| INV-2 | live | `docs/INTERNALS-ASYNC-STATE.md:144` | invariants.ts×2 | — | - **INV-2 (high)** A node with an _active_ override (`hasActiveOverride`) is | +| INV-3 | live | `docs/INTERNALS-ASYNC-STATE.md:148` | 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:155` | 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:159` | invariants.ts×2 lanes.ts×1 | — | - **INV-5 (medium)** A lane in `activeLanes` has `_mergedInto === null` | +| INV-6 | live | `docs/INTERNALS-ASYNC-STATE.md:165` | 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:168` | 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:171` | 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:182` | 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:187` | 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:192` | 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 | id | status | defined | cited in src | cited in tests | statement (at definition) | diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 836615448..c0176cf10 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -40,11 +40,11 @@ The former Tier A table is these sections. Tier B/C, the fixed violations, and t ### A18. 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 commit -**Status:** **ruled, amended in place** 2026-07-07 (promoted from B4) — maintainer rulings, 2026-07-07 (original) and 2026-07-07b (re-rule: "the non-blocking aspect… only gates the reveal"); mechanism re-ruled 2026-09-09 (#3331, supersession: "a new value from the source should remove the optimism immediately") with scope and provenance ruled 2026-09-10; store/node ownership corollaries 2026-07-17/18 (#2899, #2912) -**Pinned by:** `tests/spec-async-semantics.test.ts` (same pins — behavior coincides in unmerged graphs); `tests/optimistic-store-layer-scope.test.ts` (store corollary: disjoint-key independence, nested rows, same-key entanglement, delete survival, ambient flush-end); `tests/optimistic-lane-transaction-ownership.test.ts` (node corollary: shared-subscriber lane merge with swapped write order, three-action signal hijack) -**Mechanism (index, 2026-09-14):** `CONFIG_OVERRIDE_SUPERSEDED` set by `supersedeOverride` on a differing, postdating source landing; `supersededRead` serves the arrived value to tracked readers while untracked reads keep `_overrideValue` until commit (#3331); `_overrideTime` / `_overrideStamp` decide "postdates" and provenance (2026-09-10); `_overrideOwner` answers ownership instead of the lane (#2912); the arrival itself is staged in `_pendingValue` and elevated by `commitPendingNode` like any transition write. +**Status:** **ruled, amended in place** 2026-07-07 (promoted from B4) — maintainer rulings, 2026-07-07 (original) and 2026-07-07b (re-rule: "the non-blocking aspect… only gates the reveal"); mechanism re-ruled 2026-09-09 (#3331, supersession: "a new value from the source should remove the optimism immediately") with scope and provenance ruled 2026-09-10; store/node ownership corollaries 2026-07-17/18 (#2899, #2912); body-end corollary 2026-09-14 (#3427: the action body ending supersedes the overrides still in force when nothing authoritative is in flight) +**Pinned by:** `tests/spec-async-semantics.test.ts` (same pins — behavior coincides in unmerged graphs); `tests/optimistic-store-layer-scope.test.ts` (store corollary: disjoint-key independence, nested rows, same-key entanglement, delete survival, ambient flush-end); `tests/optimistic-lane-transaction-ownership.test.ts` (node corollary: shared-subscriber lane merge with swapped write order, three-action signal hijack); `tests/optimistic-lane-release.test.ts` (body-end corollary, and #3426: the last async reader unmounting releases the frame) +**Mechanism (index, 2026-09-14):** `CONFIG_OVERRIDE_SUPERSEDED` set by `supersedeOverride` on a differing, postdating source landing; `supersededRead` serves the arrived value to tracked readers while untracked reads keep `_overrideValue` until commit (#3331); `_overrideTime` / `_overrideStamp` decide "postdates" and provenance (2026-09-10); `_overrideOwner` answers ownership instead of the lane (#2912); the arrival itself is staged in `_pendingValue` and elevated by `commitPendingNode` like any transition write; `endOptimism` (`GlobalQueue._endOptimism`, from `flush` after the heap, before the verdict) supersedes with the node's own truth once `_acted` and no `_actions` remain and nothing authoritative blocks (#3427). -**Statement (current).** (was B4; **refined by re-rule 2026-07-07b**) An override's lifetime is bound to **its own transition** — which, because lanes keep their transitions separate from unrelated work, contains exactly the override's own async cascade. **Supersession (re-ruled 2026-09-09, #3331): own-source arrival removes the optimism from the graph immediately; the display keeps it until the transaction commits.** Maintainer: "a new value from the source should remove the optimism immediately.. if it matches then no more work, if it doesn't match then that work gets folded into the parent transition"; "when the optimism drops we might not see it until end of transition because it folds into the parent's transition." This replaces the mechanical sentence above ("elevate to `_value` only at their transition's commit; the elevation is unobservable under the override mask") — that model let the override's own downstream flight serialize ahead of the truth's, doubling the delay the reporter saw. Now: (a) a landing that _equals_ the override confirms silently — nothing re-runs, the lane's in-flight work completes the frame; (b) a landing that _differs_ marks the node superseded: its subscribers recompute from the arrived value on the plain channel (their lane affinity is dropped, so this is held transaction work, not lane work), downstream async restarts from the truth _now_, and the override's own downstream flight is inert when it lands; (c) untracked reads and the applied screen keep the override until the transaction — holding for whatever the corrected derivations observe (A15) — commits and clears the override; (d) `latest` returns the arrived value, `isPending` reads `true` iff the arrival differs (consequence (3) unchanged in statement, now true in mechanism). A later landing on the same node that equals the override un-supersedes it (the override is again the graph's value). **Scope (ruled 2026-09-10): "the source" is whatever recomputes the node** — its own async landing, or a synchronous recompute driven by an upstream change (`createOptimistic(() => userCategory())` over an async memo is the common real-world shape): "if the source recomputes it doesn't matter if it is async or not." **Ordering:** a new value from the source is one that _postdates_ the override — a source write and an override in the same batch derive nothing new (the override is written over that batch's truth knowingly and stays the graph's value until the commit reveals it). **Provenance (ruled 2026-09-10):** "a new value from the source" answers the override's _own_ question or a newer one. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override; that answer is a question the user has since changed — it is staged for the commit like any landing (and reveals then iff it is still the truth) but does **not** supersede: no downstream re-derivation, no pending flip on downstream readers. "A slow source shouldn't leak back in like that." Only the override's own action, a later action, or mainline (no action — a fresh question by definition) supersedes. Consequences: (1) in unmerged graphs, own-source resolution IS the lane-transition's completion, so the correction reveals on arrival — the original A18 pins hold unchanged; (2) matching confirmations collapse silently (revert sees value == override, nobody re-runs); (3) when the override's transition genuinely merges with unrelated async, the correction reveals atomically with that merged completion — verdict during the window per A24 (amended 2026-07-13; was "false throughout" under the A20 mask): a held correction that _differs_ from the displayed override reads pending; a matching confirm stays quiet — corrections still _propagate_ internally on arrival (fresh readers/async drivers see the hold), so downstream refetches start immediately and no waterfalls form; only the reveal is gated. **Store corollary (2026-07-17, #2899): the optimistic layer obeys the same per-transaction lifetime.** `createOptimisticStore`'s override layer is one record per store target, but each entry is owned by the transaction that wrote it (`STORE_OPTIMISTIC_OWNERS` stamps, merge chains resolved): a settling action consumes only its own keys, so concurrent actions on disjoint keys revert independently — first-settling no longer wipes the other's live overrides. Same-key writes still entangle through the shared node (one joint settle); ambient (transaction-less) entries clear at plain flush end; a derived store's projection landing still consumes the whole layer (fresh authority supersedes every tentative write). **Node corollary (2026-07-18, #2912): ownership never travels through lanes.** Lanes are scheduling affinity — a shared subscriber (one effect reading keys touched by two actions) merges them correctly for flushing, but the merged root's `_transition` must not answer "which transaction owns this override": that let one action's settle revert another's live override, and same-key follow-up writes entangle with the wrong transaction. Every optimistic write stamps `_overrideOwner` on the node (post-merge, so entangled writers share the joint root; cleared at settle); `resolveTransition` prefers a live owner stamp over the lane, falling back to lane `_transition` for nodes without overrides (async routing) exactly as before. Pinned: `tests/spec-async-semantics.test.ts` ("#3331" describe: own-async, sync-wrapper, same-batch, provenance, simple graph; A18 entangled pin re-expected: the merged correction reveals as one frame, never the committed-behind-the-mask tear); `tests/createOptimistic.test.ts` (CategoryDisplay no-double-flicker pin, unchanged: the older action's answer never moves the graph; "second action while first still in flight" pin, resolver repaired and re-expected to the same rule). +**Statement (current).** (was B4; **refined by re-rule 2026-07-07b**) An override's lifetime is bound to **its own transition** — which, because lanes keep their transitions separate from unrelated work, contains exactly the override's own async cascade. **Supersession (re-ruled 2026-09-09, #3331): own-source arrival removes the optimism from the graph immediately; the display keeps it until the transaction commits.** Maintainer: "a new value from the source should remove the optimism immediately.. if it matches then no more work, if it doesn't match then that work gets folded into the parent transition"; "when the optimism drops we might not see it until end of transition because it folds into the parent's transition." This replaces the mechanical sentence above ("elevate to `_value` only at their transition's commit; the elevation is unobservable under the override mask") — that model let the override's own downstream flight serialize ahead of the truth's, doubling the delay the reporter saw. Now: (a) a landing that _equals_ the override confirms silently — nothing re-runs, the lane's in-flight work completes the frame; (b) a landing that _differs_ marks the node superseded: its subscribers recompute from the arrived value on the plain channel (their lane affinity is dropped, so this is held transaction work, not lane work), downstream async restarts from the truth _now_, and the override's own downstream flight is inert when it lands; (c) untracked reads and the applied screen keep the override until the transaction — holding for whatever the corrected derivations observe (A15) — commits and clears the override; (d) `latest` returns the arrived value, `isPending` reads `true` iff the arrival differs (consequence (3) unchanged in statement, now true in mechanism). A later landing on the same node that equals the override un-supersedes it (the override is again the graph's value). **Scope (ruled 2026-09-10): "the source" is whatever recomputes the node** — its own async landing, or a synchronous recompute driven by an upstream change (`createOptimistic(() => userCategory())` over an async memo is the common real-world shape): "if the source recomputes it doesn't matter if it is async or not." **Ordering:** a new value from the source is one that _postdates_ the override — a source write and an override in the same batch derive nothing new (the override is written over that batch's truth knowingly and stays the graph's value until the commit reveals it). **Provenance (ruled 2026-09-10):** "a new value from the source" answers the override's _own_ question or a newer one. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override; that answer is a question the user has since changed — it is staged for the commit like any landing (and reveals then iff it is still the truth) but does **not** supersede: no downstream re-derivation, no pending flip on downstream readers. "A slow source shouldn't leak back in like that." Only the override's own action, a later action, or mainline (no action — a fresh question by definition) supersedes. **Body-end corollary (2026-09-14, #3427):** the action bodies were the optimism's justification; once they have all ended, an override still in force is superseded by the truth already at hand — the staged value an equal (A17-silent) landing left, else the committed value — exactly as an arriving differing truth would be, _unless_ the transaction is still waiting on authoritative work: an override node's own source in flight (that answer confirms or supersedes on arrival), or a held flight that does not derive from an override (a plain load the action asked for; a co-written `saving` flag stays through it — the optimistic world is one, A17). Flights that derive from an override are questions about a guess that is about to revert, and nobody reads their answer: the graph re-derives from the truth as the transaction's held work and settles when _that_ lands, so the correction starts at the body's end instead of after the obsolete flight (which used to reveal the obsolete optimistic frame first, then revert and re-ask — a waterfall with a flash). Display is unchanged: the override stays on screen until the commit (c). Optimistic store edits keep the settle-then-revert order (their truth is a base layer under an overlay, with no tracked/displayed split), and companions snap at settlement. Consequences: (1) in unmerged graphs, own-source resolution IS the lane-transition's completion, so the correction reveals on arrival — the original A18 pins hold unchanged; (2) matching confirmations collapse silently (revert sees value == override, nobody re-runs); (3) when the override's transition genuinely merges with unrelated async, the correction reveals atomically with that merged completion — verdict during the window per A24 (amended 2026-07-13; was "false throughout" under the A20 mask): a held correction that _differs_ from the displayed override reads pending; a matching confirm stays quiet — corrections still _propagate_ internally on arrival (fresh readers/async drivers see the hold), so downstream refetches start immediately and no waterfalls form; only the reveal is gated. **Store corollary (2026-07-17, #2899): the optimistic layer obeys the same per-transaction lifetime.** `createOptimisticStore`'s override layer is one record per store target, but each entry is owned by the transaction that wrote it (`STORE_OPTIMISTIC_OWNERS` stamps, merge chains resolved): a settling action consumes only its own keys, so concurrent actions on disjoint keys revert independently — first-settling no longer wipes the other's live overrides. Same-key writes still entangle through the shared node (one joint settle); ambient (transaction-less) entries clear at plain flush end; a derived store's projection landing still consumes the whole layer (fresh authority supersedes every tentative write). **Node corollary (2026-07-18, #2912): ownership never travels through lanes.** Lanes are scheduling affinity — a shared subscriber (one effect reading keys touched by two actions) merges them correctly for flushing, but the merged root's `_transition` must not answer "which transaction owns this override": that let one action's settle revert another's live override, and same-key follow-up writes entangle with the wrong transaction. Every optimistic write stamps `_overrideOwner` on the node (post-merge, so entangled writers share the joint root; cleared at settle); `resolveTransition` prefers a live owner stamp over the lane, falling back to lane `_transition` for nodes without overrides (async routing) exactly as before. Pinned: `tests/spec-async-semantics.test.ts` ("#3331" describe: own-async, sync-wrapper, same-batch, provenance, simple graph; A18 entangled pin re-expected: the merged correction reveals as one frame, never the committed-behind-the-mask tear); `tests/createOptimistic.test.ts` (CategoryDisplay no-double-flicker pin, unchanged: the older action's answer never moves the graph; "second action while first still in flight" pin, resolver repaired and re-expected to the same rule). **History (superseded mechanism, kept verbatim).** The 2026-07-07b mechanical model — replaced by the 2026-09-09 supersession, which the statement now leads with; take the mechanism from the statement, not from here: Mechanically: authoritative values arriving under an active override hold in `_pendingValue` like any other transition write and **elevate to `_value` only at their transition's commit** (`_value` changes at commit points, period); the elevation is unobservable under the override mask (A17); reverting is a pure drop — there is no revert target and reverts commit nothing. This supersedes the earlier "bound to its own async source, not its transition" formulation, which was implemented by escaping the transition commit (revert-target commit at revert) and allowed a mid-flight arrival to reveal before its own transition completed. @@ -166,11 +166,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** 2026-07-06 (promoted from B3) — maintainer keep, 2026-07-06 -**Pinned by:** `tests/spec-async-semantics.test.ts` -**Mechanism (index, 2026-09-14):** `_asyncReporters`, `mergeTransitionState`, `laneHeld` / `waitingTransition` (#3335). +**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). -(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. **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. **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`. ## Loading window and seeds diff --git a/packages/signals/src/core/action.ts b/packages/signals/src/core/action.ts index ac05ceb79..b43fcf668 100644 --- a/packages/signals/src/core/action.ts +++ b/packages/signals/src/core/action.ts @@ -137,6 +137,7 @@ export function action( globalQueue.initTransition(); let ctx = activeTransition!; ctx._actions.push(it); + ctx._acted = true; const done = (v?: R, e?: any, failed = false) => { ctx = currentTransition(ctx); diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index 235a4b709..778302918 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -28,6 +28,7 @@ import { clock, currentTransition, dirtyQueue, + enterWaiting, flush, GlobalQueue, globalQueue, @@ -400,7 +401,12 @@ export function handleAsync( // waiting on this flight into it at the landing — a reveal that // discovered the flight (#3305) would then wait on the owner's action // instead of on the flight (#3334). Enter the waiter: the transaction - // whose blocker this landing clears. + // whose blocker this landing clears. Then every transaction waiting on + // the flight folds in (enterWaiting): a reveal that discovered it + // completes at its landing (A15) — a stampless node's fresh batch + // included (its flight started under a batch that committed beneath it, + // #3305). The fold used to happen as each stamped reader recomputed, + // which effects no longer do (#3407). if (el._x?._optimisticLane) transition = waitingTransition(el) ?? transition; if ( transition && @@ -413,6 +419,7 @@ export function handleAsync( return; } globalQueue.initTransition(transition); + enterWaiting(el); }; const handleError = (error: any) => { diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index e22aa75ac..1f0ce37bf 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -229,7 +229,11 @@ export function recompute(el: Computed, create: boolean = false): void { let devChanged = false; if (__OBSERVE__ && attrHooks !== null) attrHooks.recomputeStart(el, create); if (!create) { - if (el._transition && (!isEffect || activeTransition) && activeTransition !== el._transition) + // A stamped memo re-enters its hold: its value is that transaction's work. + // An effect's pass belongs to whatever dirtied it (A15 corollary: effects + // don't entangle parallel transactions); it joins its stamp only when the + // pass observes the held flight — queue notification (#3407). + if (el._transition && !isEffect && activeTransition !== el._transition) globalQueue.initTransition(el._transition); deleteFromHeap(el, queueFor(el)); if (el._x !== null) { diff --git a/packages/signals/src/core/optimistic.ts b/packages/signals/src/core/optimistic.ts index 2c47bf3a4..a0f1b941b 100644 --- a/packages/signals/src/core/optimistic.ts +++ b/packages/signals/src/core/optimistic.ts @@ -53,6 +53,7 @@ import { origin, queuePendingNode, schedule, + sourceObserved, type QueueCallback, type Transition } from "./scheduler.js"; @@ -258,6 +259,77 @@ function supersedeOverride(el: OptimisticNode, value: unknown): void { insertSubs(el); } +/** + * The flush's pre-verdict step once the action bodies have ended (#3427). + * The bodies were the optimism's justification; with them over, the + * overrides still in force revert at the settle — unless the transaction is + * still waiting on AUTHORITATIVE work: an override node's own source in + * flight (`transitionBlocked` — that answer supersedes or confirms on + * arrival), or a held flight that does not derive from an override (a plain + * write's load the action asked for). Through that window the optimistic + * world stands: a co-written "saving" flag stays rendered until the page it + * covers lands (A17 — the optimistic world is one). + * + * The flights that DO derive from an override — routed through a live lane — + * are obsolete: their input is the guess that is about to revert, and nobody + * will read their answer. With nothing authoritative left, each override's + * truth is already here (the staged value an A17-silent landing left, else + * the committed value) and supersedes it now, exactly as an arriving + * differing truth does (A18, #3331): the graph re-derives from it as this + * transaction's held work — a lane-derived memo re-asks with the truth, its + * other changed inputs included — and the transaction settles when THAT + * lands. Before this the settle first waited for the obsolete flight, + * revealed the obsolete optimistic frame when it landed, and only then + * started the correction — a waterfall with a flash in the middle. Returns + * whether it superseded anything (the caller re-runs the heap). + * + * The optimistic world is one, so it ends early only when all of it can. An + * optimistic STORE edit cannot yet: its truth is the base layer under an + * overlay that `_clearOptimisticStores` folds off at settlement, with no + * tracked/displayed split — superseding its tracking signals alone would + * re-derive readers against a still-displayed overlay (and a memo reading + * both a signal and the store would ask a mixed question). A transaction + * holding one keeps the settle-then-revert order throughout. Companions + * (`_parentSource` set) are optimistic nodes too — a verdict written through + * the optimistic path so it flushes ahead of the hold — but they answer for + * their owner and snap at settlement (`_snapCompanions`), not here. + */ +function endOptimism(transition: Transition): boolean { + if ( + !transition._acted || + transition._actions.length || + !transition._optimisticNodes.length || + transition._optimisticStores.size || + transitionBlocked(transition) + ) + return false; + for (const source of transition._asyncReporters.keys()) + if ( + sourceObserved(transition, source) && + source._x?._pendingSources?.has(source) && + !resolveLane(source) + ) + return false; + let superseded = false; + for (const node of transition._optimisticNodes) { + if ( + !hasActiveOverride(node) || + node._x!._parentSource || + node._config & CONFIG_OVERRIDE_SUPERSEDED || + (node as Computed)._statusFlags & STATUS_UNINITIALIZED + ) + continue; + const truth = node._pendingValue !== NOT_PENDING ? node._pendingValue : node._value; + if (!node._equals || !node._equals(truth, unwrapOverride(node._x!._overrideValue))) { + supersedeOverride(node, truth); + // Judged by the mark, not the call: a provenance refusal (an older + // action's window is still open) leaves the node for a later pass. + if (node._config & CONFIG_OVERRIDE_SUPERSEDED) superseded = true; + } + } + return superseded; +} + /** read()'s value for a tracked reader of a superseded node: the truth — * staged, or already committed (a mainline landing commits at the head of * its flush, ahead of the heap run, and the override drops only at the @@ -519,6 +591,7 @@ export function installOptimisticEngine(): void { GlobalQueue._cleanupLanes = cleanupCompletedLanes; GlobalQueue._runLaneEffects = runLaneEffects; GlobalQueue._supersedeOverride = supersedeOverride; + GlobalQueue._endOptimism = endOptimism; GlobalQueue._supersededRead = supersededRead; GlobalQueue._landOnOverride = landOnOverride; GlobalQueue._gatedRead = gatedRead; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index d47d0d3ae..1562d89f3 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -198,6 +198,10 @@ export interface Transition { _affectsNodes: OptimisticNode[]; _optimisticStores: Set; _actions: Array | AsyncGenerator>; + /** An action ran in this transaction (#3427, set by action()): once + * `_actions` drains, its bodies are OVER — as opposed to a transaction that + * never had one, whose bare optimistic writes live until it settles. */ + _acted?: boolean; _queueStash: QueueStub; _done: boolean | Transition; // Subscribers that, while recomputing under an optimistic lane, read a plain @@ -240,6 +244,7 @@ function mergeTransitionState(target: Transition, outgoing: Transition): void { if (__OBSERVE__ && attrHooks !== null) attrHooks.transitionMerged(target, outgoing); outgoing._done = target; target._actions.push(...outgoing._actions); + target._acted ||= outgoing._acted; for (const lane of activeLanes) if (lane._transition === outgoing) lane._transition = target; if (outgoing._optimisticNodes.length) { // Move (don't copy): the global queue's batch may still be the outgoing @@ -662,6 +667,15 @@ export class GlobalQueue extends Queue { * optimistic engine; only reachable on a node that has an override. */ static _supersedeOverride: ((el: Signal | Computed, value: unknown) => void) | null = null; + /** The flush's pre-verdict step (#3427): once the transaction's action + * bodies have all ended and nothing authoritative is left in flight, the + * engine supersedes every override still in force with the truth it + * reverts to, so the graph re-derives from it now, as the transaction's + * held work, instead of after the flights the overrides fed have landed. + * True when it superseded something: the caller re-runs the heap ahead of + * the verdict. The engine owns every gate (acted, actions drained, has + * overrides, no store edits, no authoritative flight); null without it. */ + static _endOptimism: ((transition: Transition) => boolean) | null = null; /** read()'s value for a TRACKED reader of a superseded node (#3331): the * staged truth, unless the reader is a stale (render) reader of another * transaction — then the displayed override, as it keeps a foreign @@ -720,6 +734,13 @@ export class GlobalQueue extends Queue { sweepDormant(); runHeap(dirtyQueue, GlobalQueue._update); if (activeTransition) { + // The action bodies are over: the overrides they leave in force + // revert at this settle, and the correction is this transaction's + // held work — re-derived here, under it, ahead of the verdict — not a + // waterfall after the flights the overrides fed (#3427). After the + // heap, not before: a synchronous body's own writes (a refresh that + // puts an override node's source in flight) are judged applied. + if (GlobalQueue._endOptimism?.(activeTransition)) runHeap(dirtyQueue, GlobalQueue._update); const isComplete = transitionComplete(activeTransition); if (!isComplete) { const stashedTransition = activeTransition!; @@ -1448,6 +1469,25 @@ function reporterBlocksSource(reporter: Computed, source: Computed): b ); } +/** + * Does a live reporter of `transition` still observe `source` pending? Dead + * reporters (disposed, behind a fallback, no longer reading the source) are + * pruned as they are found, and the source's entry with them. Shared by the + * settle verdict and the lane's hold check (`waitingTransition`): a live + * action parks its transaction without a verdict, so this prune is the only + * one an optimistic lane whose last async reader unmounted mid-action ever + * gets — without it the lane held on the dead reporter's registration until + * the flight it no longer observed landed (#3426). + */ +export function sourceObserved(transition: Transition, source: Computed): boolean { + const reporters = transition._asyncReporters.get(source); + for (const reporter of reporters ?? []) { + if (reporterBlocksSource(reporter, source)) return true; + reporters!.delete(reporter); + } + return transition._asyncReporters.delete(source) && false; +} + function transitionComplete(transition: Transition): boolean { if (transition._done) return true; if (transition._actions.length) { @@ -1456,16 +1496,7 @@ function transitionComplete(transition: Transition): boolean { return false; } let done = true; - for (const [source, reporters] of transition._asyncReporters) { - let hasLive = false; - for (const reporter of reporters) { - if (reporterBlocksSource(reporter, source)) { - hasLive = true; - break; - } - reporters.delete(reporter); - } - if (!hasLive) transition._asyncReporters.delete(source); + for (const source of transition._asyncReporters.keys()) { // The source blocks while its OWN flight is up — the self entry in its // pending sources (added by notifyStatus's source path, with status; // retired by the landing and the supersede sweep, so it implies @@ -1474,7 +1505,7 @@ function transitionComplete(transition: Transition): boolean { // boundary-consumed load re-asked under a held derivation), and the // still-flying source read as settled, committing the writes it was // asked with ahead of its answer. - else if (source._x?._pendingSources?.has(source)) { + if (sourceObserved(transition, source) && source._x?._pendingSources?.has(source)) { done = false; break; } @@ -1513,13 +1544,26 @@ export function currentTransition(transition: Transition) { * the node, so a hold check must not assume it was recorded in the transaction * it happens to hold — lanes merge across transactions (#2912), and a merged * root's transaction knows nothing of the async its members' transactions - * observed (#3335). Null when nobody is waiting. + * observed (#3335). Null when nobody is waiting — a registration whose every + * reporter has since died is nobody (#3426). */ export function waitingTransition(source: Computed): Transition | null { - for (const t of transitions) if (t._asyncReporters.has(source)) return t; + for (const t of transitions) if (sourceObserved(t, source)) return t; return null; } +/** A landing enters EVERY parked transaction still waiting on `source`, folding + * them into the active one (A15: each reveal that discovered the flight + * completes at its landing). The fold used to happen as the waiters' stamped + * readers recomputed under the landing — recompute re-entering an effect's + * stamp — which also folded in writes those readers merely shared a hole + * with (#3407); effects no longer re-enter, so the landing folds explicitly. + * Live iteration is safe: a merge deletes the outgoing (active) entry and + * re-adds the visited one. */ +export function enterWaiting(source: Computed): void { + for (const t of transitions) if (sourceObserved(t, source)) globalQueue.initTransition(t); +} + export function setActiveTransition(transition: Transition | null) { activeTransition = transition; } diff --git a/packages/signals/tests/optimistic-lane-release.test.ts b/packages/signals/tests/optimistic-lane-release.test.ts new file mode 100644 index 000000000..e2f9d877b --- /dev/null +++ b/packages/signals/tests/optimistic-lane-release.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + onSettled +} 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); + } + }); +} + +describe("an optimistic lane releases when nothing authoritative is left to wait on", () => { + it("#3426 the last async reader unmounting mid-action releases the optimistic frame", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let run!: () => Promise; + let setShow!: (v: boolean) => void; + createRoot(() => { + const [value, setValue] = createOptimistic(0); + const [show, sS] = createSignal(true); + setShow = sS; + const details = createMemo(() => delay(1500, value())); + run = action(function* () { + setValue(1); + yield delay(3000); + }); + text(() => `Value: ${value()}`, log, when); + createRenderEffect( + () => show(), + s => { + if (!s) return; + const dispose = createRoot(d => { + text(() => `Details: ${details()}`, log, when); + return d; + }); + return () => { + dispose(); + log.push("Details: "); + when.push(now); + }; + } + ); + }); + flush(); + await settle(); + await advanceTo(2000); + run(); + await settle(); + await advanceTo(2500); + setShow(false); + await settle(); + await advanceTo(8000); + // The lane held Value: 1 on the Details reader observing details pending. + // A live action parks its transaction without a settle verdict, so that + // reader's registration was never pruned when it died (2500): the lane + // went on waiting for the flight nobody observed (3500). Now the hold + // check prunes dead reporters itself: the frame reveals at the unmount + // and reverts when the action ends, like a plain write would. + expect(frames(log, when)).toEqual([ + "0: Value: 0", + "1500: Details: 0", + "2500: Details: | Value: 1", + "5000: Value: 0" + ]); + }); + + it("#3427 the action body ending starts the correction; the obsolete optimistic flight is not waited on", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let run!: () => Promise; + let setOther!: (v: number) => void; + createRoot(() => { + const [value, setValue] = createOptimistic(0); + const [other, sO] = createSignal(0); + setOther = sO; + const details = createMemo(() => delay(1000, value())); + run = action(function* () { + setValue(1); + yield delay(500); + }); + text(() => `Value: ${value()}`, log, when); + text(() => `Details: ${details()}`, log, when); + text(() => `Pending: ${isPending(details)}`, log, when); + text(() => `Other: ${other()}`, log, when); + onSettled(() => { + log.push("settled"); + when.push(now); + }); + }); + flush(); + await settle(); + await advanceTo(2000); + run().then(() => { + log.push("action resolved"); + when.push(now); + }); + await settle(); + await advanceTo(2600); + setOther(1); + await settle(); + await advanceTo(8000); + // details(1) is a question about the guess; the body ending (2500) with + // no authoritative flight up makes the guess obsolete. The truth (0) + // supersedes the override then and details re-asks with it as the + // transaction's held work — landing 3500, nothing visible changes. Before, + // the settle waited for details(1) (3000: Value 1 | Details 1 flashed), + // then reverted and re-asked (4000: 0 | 0) — a waterfall with a flash. + // The transaction is over at 3500 (Pending false); unrelated writes + // (Other, 2600) were never entangled. + expect(frames(log, when)).toEqual([ + "0: Other: 0 | Value: 0", + "1000: Details: 0 | Pending: false | settled", + "2000: Pending: true", + "2500: action resolved", + "2600: Other: 1", + "3500: Pending: false" + ]); + }); + + it("#3427 a lane-derived memo re-asks with the truth and its held plain inputs, in one frame", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let run!: () => Promise; + createRoot(() => { + const [value, setValue] = createOptimistic(0); + const [page, setPage] = createSignal(1); + const details = createMemo(() => delay(1000, `${value()}/${page()}`)); + run = action(function* () { + setValue(1); + setPage(2); + yield delay(500); + }); + text(() => `Value: ${value()}`, log, when); + text(() => `Page: ${page()}`, log, when); + text(() => `Details: ${details()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(2000); + run(); + await settle(); + await advanceTo(8000); + // The optimistic pass asked details(1, page=1) — the held page write is + // gated for a lane recompute — and before the fix that answer flashed + // beside Page: 2 | Value: 1 (3000) ahead of the correction (4000). The + // correction now re-asks details(0, 2) at the body's end and the page + // lands with its answer. + expect(frames(log, when)).toEqual([ + "0: Page: 1 | Value: 0", + "1000: Details: 0/1", + "3500: Details: 0/2 | Page: 2" + ]); + }); + + it("a co-written flag stays through a held plain load the action asked for (authoritative work)", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let run!: () => Promise; + createRoot(() => { + const [saving, setSaving] = createOptimistic(false); + const [page, setPage] = createSignal(1); + const posts = createMemo(() => delay(1000, `p${page()}`)); + run = action(function* () { + setSaving(true); + setPage(2); + yield; + }); + text(() => `Saving: ${saving()}`, log, when); + text(() => `Posts: ${posts()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(2000); + run(); + await settle(); + await advanceTo(8000); + // posts' flight does not derive from the override: it is the page load + // the action asked for, and the optimistic world stands until it lands. + // The body ending at once (yield) does not revert the flag early. + expect(frames(log, when)).toEqual([ + "0: Saving: false", + "1000: Posts: p1", + "2000: Saving: true", + "3000: Posts: p2 | Saving: false" + ]); + }); +}); diff --git a/packages/signals/tests/shared-effect-no-entangle.test.ts b/packages/signals/tests/shared-effect-no-entangle.test.ts new file mode 100644 index 000000000..e2a41a454 --- /dev/null +++ b/packages/signals/tests/shared-effect-no-entangle.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { + action, + 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); + } + }); +} + +// A15 reveal corollary: parallel transactions, effects don't entangle. A +// render effect groups whatever bindings the compiler put in one hole; a +// write that dirties it belongs to whoever made the write, and only a pass +// that actually OBSERVES another transaction's flight (reads it pending) joins +// that transaction. A pass whose stale input is served committed (the reveal +// carve-out) observes nothing and publishes on its own. +describe("a shared render effect does not entangle an unrelated sync write with a held flight", () => { + // #3407: the counter increments through a held effect whether the write is + // plain or made inside an action. Before, the action's transaction was + // merged into the effect's stamp at the head of recompute — before the pass + // ran, so before anything could have been observed — and both increments + // waited on the flight (0:0 → 2:1). + it.each(["plain", "action"])( + "#3407 %s write publishes at once beside the pending binding", + async kind => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setA!: (v: number) => void; + let bump!: () => unknown; + createRoot(() => { + const [a, sA] = createSignal(0); + const [b, setB] = createSignal(0); + setA = sA; + const detailsA = createMemo(() => (a() ? delay(2000, a()) : 0)); + const inc = () => setB(p => p + 1); + bump = + kind === "action" + ? action(function* () { + inc(); + }) + : inc; + text(() => `${b()}:${detailsA()}`, log, when); + }); + flush(); + await settle(); + setA(1); + await settle(); + await advanceTo(500); + bump(); + await settle(); + await advanceTo(1000); + bump(); + await settle(); + await advanceTo(4000); + expect(frames(log, when)).toEqual(["0: 0:0", "500: 1:0", "1000: 2:0", "2000: 2:1"]); + } + ); + + // The carve-out itself, plain or in an action: a reveal that lands on the + // foreign-held flight shows its committed value (coherent with the held + // input's committed value) and catches up at the landing. + it.each(["plain", "action"])( + "a %s write that reveals the pending binding carves out and catches up", + async kind => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setA!: (v: number) => void; + let reveal!: () => unknown; + createRoot(() => { + const [a, sA] = createSignal(0); + const [show, setShow] = createSignal(false); + setA = sA; + const detailsA = createMemo(() => delay(2000, a())); + const on = () => setShow(true); + reveal = + kind === "action" + ? action(function* () { + on(); + }) + : on; + text(() => `A: ${detailsA()}`, log, when); + text(() => `Show: ${show()}`, log, when); + text(() => (show() ? `Details: ${detailsA()}` : "hidden"), log, when); + }); + flush(); + await settle(); + await advanceTo(2500); + setA(1); + await settle(); + await advanceTo(3000); + reveal(); + await settle(); + await advanceTo(6000); + // The flight was started by the earlier write (2500); the reveal (3000) + // shows the committed 0 and re-derives at its landing. + expect(frames(log, when)).toEqual([ + "0: Show: false | hidden", + "2000: A: 0", + "3000: Details: 0 | Show: true", + "4500: A: 1 | Details: 1" + ]); + } + ); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index bbc612437..731e9c8e3 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -262,7 +262,16 @@ describe("pay-for-use tree-shaking (#2883)", () => { // lane to its root like any other (the stale-lane shortcut that skipped // the parent/child check is gone), -33 B (23,091 → 23,058); the // unowned `onSettled` fire's heap-drain wait shakes out with onSettled. - expect(minifiedBytes).toBeLessThan(23_150); + // Lane release (#3426, #3427): the hold check prunes dead reporters + // itself (`sourceObserved`, shared with completion), and the action + // body's end starts the correction (`_acted`, the `_endOptimism` hook + // from flush — the engine's own gating rides the optimistic module). + // +78 B (23,058 → 23,136). + // Shared hole (#3407): an effect's recompute never re-enters its stamp + // (the pass belongs to whoever dirtied it), so a landing folds in every + // transaction waiting on the flight itself (`enterWaiting`) — a + // stampless node's fresh batch included. +59 B (23,136 → 23,195). + expect(minifiedBytes).toBeLessThan(23_300); }); 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 4d86f3ae8..072fbb970 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -209,7 +209,15 @@ module.exports = [ // #3377; 2026-09-12): 8.55 -> 8.60 KB, measured at 8556 B against 8507 // (+49) — the disposal wake site and the lane hold drop. +58 B minified // in the in-package floor (22,915 -> 22,973). - limit: "8.60 KB", + // Lane release + shared hole (#3426, #3427, #3407; 2026-09-14): 8.60 -> + // 8.65 KB, measured at 8628 B against `next`'s 8572 (+56) — the hold + // check prunes dead reporters itself (`sourceObserved`, shared with the + // settle verdict and the landing), the action body's end starts the + // correction (`_acted`, the `_endOptimism` hook from flush), and an + // effect's recompute no longer re-enters its stamp: a landing folds in + // every transaction waiting on the flight (`enterWaiting`) instead. + // +137 B minified in the in-package floor (23,058 -> 23,195). + limit: "8.65 KB", modifyEsbuildConfig }, { @@ -537,7 +545,10 @@ module.exports = [ // measured at 10820 B against 10784 (+36); see the core floor note. // #3372/#3377 (2026-09-12): 10.85 -> 10.90 KB, measured at 10857 B against 10820 // (+37); see the core floor note. - limit: "10.90 KB", + // #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", modifyEsbuildConfig }, { @@ -618,7 +629,9 @@ module.exports = [ // measured at 11243 B against 11212 (+31); see the core floor note. // #3372/#3377 (2026-09-12): 11.30 -> 11.35 KB, measured at 11295 B against 11243 // (+52); see the core floor note. - limit: "11.35 KB", + // #3426/#3427/#3407 (2026-09-14): 11.35 -> 11.40 KB, measured at 11368 B + // against `next`'s 11322 (+46); see the core floor note. + limit: "11.40 KB", modifyEsbuildConfig }, { @@ -721,7 +734,9 @@ module.exports = [ // measured at 18721 B against 18678 (+43); see the core floor note. // #3372/#3377 (2026-09-12): 18.75 -> 18.80 KB, measured at 18749 B against 18721 // (+28); see the core floor note. - limit: "18.80 KB", + // #3426/#3427/#3407 (2026-09-14): 18.80 -> 18.85 KB, measured at 18811 B + // against `next`'s 18780 (+31); see the core floor note. + limit: "18.85 KB", modifyEsbuildConfig }, { @@ -868,7 +883,10 @@ module.exports = [ // recompute, on top of #3413's companion-gate change. // Held children (#3404, 2026-09-13): cap held at 28.45 KB; see the // createStore note. - limit: "28.45 KB", + // #3426/#3427/#3407 (2026-09-14): 28.45 -> 28.65 KB, measured at 28602 B + // against `next`'s 28430 (+172 — the core seams plus `endOptimism` in + // the optimistic module); see the core floor note. + limit: "28.65 KB", modifyEsbuildConfig }, { @@ -1117,7 +1135,10 @@ module.exports = [ // are excluded subjects like signals — that part lives in store.ts and // no observe scenario bundles stores; the observe CSR scenario above did // not move. - limit: "27.16 KB", + // #3426/#3427/#3407 (2026-09-14): 27.16 -> 27.25 KB, measured at 27203 B + // against `next`'s 27109 (+94 — the core seams on the observe artifacts); + // see the core floor note. + limit: "27.25 KB", modifyEsbuildConfig: observeEsbuildConfig }, {