diff --git a/.changeset/fix-lane-authority-store-twins.md b/.changeset/fix-lane-authority-store-twins.md new file mode 100644 index 000000000..c6625ac49 --- /dev/null +++ b/.changeset/fix-lane-authority-store-twins.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Store twins of the lane-authority fixes. An optimistic store's optimistic write after a `yield` now reveals on its lane with its derivations when the action's transaction already holds the same truth (#3330 store twin): adoptions under a live transaction hold on optimistic families too, a held adoption notifies its nodes at write time so the commit promotes silently instead of re-running every subscriber, and a tentative write is judged against the view readers see rather than the swapped-in backing. A derived optimistic store's own truth landing a different value over a tentative edit supersedes the override for the graph now, with action provenance (#3331 store twin): the authoritative landing reaches the engine's supersession, and a tracked reader of a superseded node reads the committed truth once the landing has committed ahead of the override's revert. Surfaced alongside and fixed in core: a reader that first links to a node while another transaction holds a staged write (an effect created during the hold, a store key first read under it) read the committed value but never learned of the commit — such readers now re-derive when the transaction reveals. Plain-store `reconcile` inside an action, and store keys first read under a held adoption, hold like every other write: handlers read committed, `latest()` the staged value, and the reveal comes with the transaction. diff --git a/.changeset/fix-lane-recompute-compares-published-slot.md b/.changeset/fix-lane-recompute-compares-published-slot.md new file mode 100644 index 000000000..e83526aee --- /dev/null +++ b/.changeset/fix-lane-recompute-compares-published-slot.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A memo deriving from an optimistic value now reveals together with the override when the override is written after its transaction already staged the same derived result (e.g. `setOptimistic` after an `await` inside an action whose earlier write produced the same value). The lane recompute compared its result against the transaction-held value instead of the value on screen, called it unchanged, and left the derivation stale until the action committed (#3330). diff --git a/.changeset/fix-merged-lane-hold-per-node.md b/.changeset/fix-merged-lane-hold-per-node.md new file mode 100644 index 000000000..fb683d778 --- /dev/null +++ b/.changeset/fix-merged-lane-hold-per-node.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix optimistic lanes merged through a shared reader releasing their reveal while one member's async is still in flight (#3335). A lane's hold is a property of each pending async node — looked up in whichever live transaction observed it — not of the merged root's transaction, which after a cross-transaction merge recorded only one member's observations. A memo reading two optimistic values now reveals with both, as it does for plain signals (A15). diff --git a/.changeset/fix-override-supersession-on-arrival.md b/.changeset/fix-override-supersession-on-arrival.md new file mode 100644 index 000000000..1eff9e28d --- /dev/null +++ b/.changeset/fix-override-supersession-on-arrival.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +An optimistic override is superseded the moment its source recomputes the node with a different value — its own async landing, or a sync recompute driven by an upstream change (`createOptimistic(() => asyncMemo())`): tracked derivations (memos, downstream async) recompute from the arrived truth immediately as held transaction work, instead of waiting for the override's own downstream flight to finish first — so the correction no longer takes two sequential round-trips (#3331). The override remains the displayed value for untracked reads and the applied frame until the transaction commits; `latest()` returns the arrived value and `isPending()` is `true` while they differ. An equal landing confirms silently. Only the override's own question or a newer one supersedes: when two rapid actions overlap on one node, the older action's late answer is held to the commit without moving the graph — a slow source does not leak back in over the user's latest intent. diff --git a/.changeset/fix-reveal-carve-out-input-visibility.md b/.changeset/fix-reveal-carve-out-input-visibility.md new file mode 100644 index 000000000..c7216ff72 --- /dev/null +++ b/.changeset/fix-reveal-carve-out-input-visibility.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +--- + +fix(signals): a reveal of a foreign-held flight shows the committed value unless the flight's inputs are visible; a same-value re-prediction renews the override's provenance + +The A15 reveal corollary is re-ruled (review on #3347): a stale (render) reader that lands on a node pending in another transaction shows the node's committed value, does not entangle the two transactions, and re-derives at that transaction's commit — parallel transactions, effects don't entangle. The reveal holds on the flight only when the committed value would tear against the frame: the flight's inputs were published while it was pending (`CONFIG_INPUTS_PUBLISHED`, set by a commit that leaves the node in the air, #3305), the node rides a live lane (optimistic / `latest`, #3334), or the node is uninitialized. An effect recorded for a transaction's commit replay that later recomputes under that transaction drops the stale recording (it is applied by the commit itself). GabbeV's "revealed reader never catches up" and "conditional reader stays hidden" shapes are pinned. + +A same-value optimistic write by a newer action now renews the override's provenance stamp on the fast path, so an older action's slow answer no longer supersedes a value the user just re-confirmed (#3331 follow-up). diff --git a/.changeset/fix-reveal-holds-on-lane-flight.md b/.changeset/fix-reveal-holds-on-lane-flight.md new file mode 100644 index 000000000..5cb2d336d --- /dev/null +++ b/.changeset/fix-reveal-holds-on-lane-flight.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +--- + +A reveal that discovers an async already in flight holds on the flight, whichever transaction the node is stamped with, and completes when the flight lands (#3334; A15 reveal corollary). + +- `read()` no longer serves a pending node's committed value to a stale reader just because the node is stamped by another transaction. The stamp is pending-node bookkeeping — the flight's inputs may already be on screen (committed with no observer, #3305; revealed through an optimistic lane, #3334; held only by another reveal waiting on the same flight) — so that value tears the frame. The reader throws, the reveal opens/joins a transition blocked on the flight, and settles as one unit with it. +- Landing a lane-routed async now re-enters the transaction _waiting_ on it (`waitingTransition`) rather than the transaction that owns the lane. Entering the owner made the waiting reveal's stamped recompute merge the owner's still-running action into the reveal, so a `Show` flipped during an optimistic action stayed hidden until the action finished instead of until the data landed. +- `laneHeld` looks the observation up through the same `waitingTransition` helper (#3335). diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index f4435edcf..dbf91e343 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -39,6 +39,45 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node it holds in `_pendingValue` and elevates to `_value` on its own transition's commit. Reverting is a pure drop of the override — `_value` is already correct. (Verdict: pending iff the held value _differs_ from the displayed override — a matching confirm reveals nothing; §5g.) +- `(held value, active value)` **+ `CONFIG_OVERRIDE_SUPERSEDED`** (#3331, A18 supersession) — the + held value came from the node's source — its own async landing, or a sync recompute driven by + an upstream change — and differs from the override. The pair is read two ways: tracked readers + (`read` with a computed observer) get the held value via `GlobalQueue._supersededRead` — the + graph derives from the truth — while untracked reads and the applied frame still get the + override. The bit is set by `supersedeOverride` (optimistic.ts), reached from every own-source + publish under an override: `asyncWrite`'s override branch and both of `recompute`'s + (differing value → the staged branch; equal value → the "unchanged" branch, where the hook also + owns the authoritative-observer wake, #3164/#3303). Cleared by a later equal arrival on the same + node (un-supersedes: the override is the graph's value again), by a fresh `optimisticWrite`, and + by `resolveOptimisticNodes` — which, seeing it, skips the revert-time `insertSubs(node, true)` + re-derive: the graph already moved. Setting it also demotes the node's lane cascade (DFS over + subs: clear `_optimisticLane`, drop from `_pendingAsync`) and notifies subs on the plain + channel, so the corrected derivations are transaction work held to the commit, not lane work + applied ahead of it; the override's own downstream flight, when it lands, finds no lane and no + observer and is inert. **Ordering** (`_overrideTime`, stamped with `clock` at the optimistic + write): a sync recompute in the same tick as the override derives from that batch's staged + inputs — truth that predates the override — and does not supersede; the async landing path has + no such case (a landing is always a later tick). **Provenance** (`_overrideStamp`, stamped with + the scheduler's `origin` at the optimistic write, and renewed to a newer `origin` by a same-value + write on the fast path — the newer action re-asks the question without writing a new override): `origin` is the invocation sequence of the + action whose ambient window is running — `action()` sets it for each slice (the first slice's + window runs to the scheduled flush; `flush()` clears it at the end of every drain) — and + `handleAsync` captures it per flight; `asyncWrite` re-arms the flight's capture for the landing's + synchronous propagation (including the flush it runs), so a sync wrapper recomputing under the + landing derives under the flight's provenance and flights it registers inherit it. A differing + arrival with `origin` older than `_overrideStamp` is a stale question (an earlier action's + refetch landing after a later action's override on the same node — the two transactions have + merged, so `_overrideOwner` cannot tell them apart) and returns before setting the bit: staged, + held to the commit, unobservable. `origin === 0` (mainline) is never stale. + **Store twin:** a derived optimistic store's truth lands on its nodes through `setSignal` + under `projectionWriteActive` (the authoritative posture); on an override-covered node that + dispatches to `GlobalQueue._landOnOverride` (optimistic.ts) — stage the truth whatever its + relation to the committed value (a landing equal to committed still differs from the override), + sync companions, then `supersedeOverride`. `supersededRead` serves the truth staged OR committed: + a mainline landing commits at the head of its flush, ahead of the heap run, and the override + drops only at the batch's end — in that window the graph must not fall back to the override it + has left (the store's async-derive landing reaches it; a node's `asyncWrite` rides its flight's + transaction and never does). Pinned in `tests/store/lane-authority-twins.test.ts`. ## 2. Lanes (`lanes.ts`) @@ -46,7 +85,9 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - `_parentLane`: companion nodes (`_pendingSignal`/`_latestValueComputed`) get _child_ lanes that intentionally do **not** merge with the parent (`assignOrMergeLane` parent/child carve-out) so `isPending` effects can flush before the parent's async settles. - Lane lifecycle: created on optimistic write → nodes join via `insertSubs(node, true)` → `assignOrMergeLane` → lane-routed effects run when the lane is not held (`runLaneEffects` → `laneHeld`) → cleaned up by `cleanupCompletedLanes` when the owning transition completes (or when orphaned, `_transition === null`). - `_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. -- Hold rule (`laneHeld`, #3289): a lane is held iff some `_pendingAsync` node is also in its transaction's `_asyncReporters` — i.e. a render effect observed it pending and no boundary consumed the status (INV-3, the one registration site). 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. +- 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. ## 3. Transitions (`scheduler.ts`) @@ -56,9 +97,11 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node - `_pendingNodes` — nodes whose `_pendingValue` commits when the transition completes (`commitPendingNodes` → `commitPendingNode`). - `_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. 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). -- 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. 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. +- `_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). +- 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 pending source and no active-override node is blocked on someone else's async. +- 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, and is recorded for that transaction's commit replay (`heldFromStale`). 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). ## 4. Write paths (all must stay equivalent) @@ -123,6 +166,17 @@ Confidence: **high** = implementation self-consistency, assert now. carried an `affects()` mark has `_affectsCount === 0` — every registration was released by exactly one settle/flush-end. A leaked count would latch a verdict `true` forever (the declared-motion analogue of the INV-9 latch). +- **INV-11 (high, structural — pinned, not asserted)** A recompute's equality + gate compares the new result against the slot it is about to publish to: + the override for an override-covered node, `_value` for a lane (OPT-dirty) + direct commit, `_pendingValue` for a transaction-staged run. "Unchanged" is + a statement about what the publishing view will show, so comparing against + a different view produces torn frames: #3330 compared a lane recompute + against a `_pendingValue` an earlier action write had staged, called the + identical result unchanged, and revealed the override without its + derivation. Pinned in `tests/spec-async-semantics.test.ts` (A17, #3330); + not a runtime assertion because the publishing slot is decided inside the + same branch that compares. Rejected for assertion (state space too dynamic, would need semantic rulings): whether `_optimisticLane` must always resolve to a live lane (stale lanes are diff --git a/packages/signals/docs/INTERNALS-STORE-STATE.md b/packages/signals/docs/INTERNALS-STORE-STATE.md index d8308e47c..af7ce1778 100644 --- a/packages/signals/docs/INTERNALS-STORE-STATE.md +++ b/packages/signals/docs/INTERNALS-STORE-STATE.md @@ -146,6 +146,32 @@ there is nothing to diff.) graph, which is what makes post-reconcile `snapshot` free. - Commit hook: lane settle folds the winning lane value into raw, then the node returns to passthrough (no pending state retained). +- **Adoption under a live transaction** (#3074, extended for the #3330 store + twin): `adoptPB` swaps the backing eagerly but records a hold (`ht` = the + transaction, `hv` = the pre-hold committed view) that committed-visibility + readers are served through `heldMaskView` — on optimistic families too (a + sync derive adopting truth under a transaction is held truth, not lane + business; unheld, handlers read the swapped-in backing early and an + optimistic write equal to it compared as a no-op, so no override and no + lane formed). A held adoption's nodes are notified at the outermost setter + exit (`stageHeldAdoptions`, the adoption twin of `notifyWrites`): staged + under the transaction's batch, transition-stamped, subscribers recompute in + that flush and park with the transaction, and the commit promotes silently + — the drain, which for a parked transaction IS the commit, would otherwise + deliver the adopted values as fresh writes and re-run every subscriber + against a frame the lane already published. `ab` moves to the adopted + backing (the view the nodes were last told, #3296) so the drain only + path-copies. `notifyOptimisticWrites` judges a tentative write against the + view readers see (`heldMaskView(t) ?? t.v`), not the backing slot. A plain + store's eager adoption (`reconcile` in a setter) under a live transaction + takes the same hold — its inline notify already staged the nodes; the + backing must not show handlers what the tracked read masks. A key first + read under a held adoption is **born holding** (`heldAdoptionTransition` / + `stageHeldKey` in `getNode`): committed value from `hv`, the adopted value + staged under the transaction — the adoption's notification ran before the + node existed and the drain has nothing left to say. (The #3336 PR adds the + same for setter-staged `pb` holds and makes the first tracked read serve + the node's value; the two compose.) ## 4. Identity rules diff --git a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md index 7daefa98b..d71d2c94c 100644 --- a/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md +++ b/packages/signals/docs/SPEC-ASYNC-SEMANTICS.md @@ -15,33 +15,33 @@ optimistic lanes. ## Tier A (ruled — pinned) -| # | Proposition | Ruling | Pinned by | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A1 | `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none). | #2839 ruling (2026-07-06) | `tests/effect-error-phases.test.ts` | -| A2 | Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | #2839 ruling | `tests/effect-error-phases.test.ts` | -| A3 | Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | #2837 | `tests/equals-comparator-errors.test.ts` | -| A4 | A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | #2837 follow-on | `tests/equals-comparator-errors.test.ts` (async case) | -| A5 | An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | #2761/#2762 | `tests/createErrorBoundary.test.ts`, `tests/errorHalt.test.ts` | -| A6 | `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. | #2822 | `tests/enforceLoadingBoundary.test.ts`, solid-web `test/dev-warning.spec.tsx` | -| A7 | After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. | #2829 (the `[false, undefined]` pins were a regression) | `tests/latest-async.test.ts`, `tests/createMemo.test.ts` | -| A8 | (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPending(x)`"; **amended 2026-07-13**: "own async in flight" is further filtered to _non-quiet_ flight — a re-ask of the same question is silent in the latest form too, and a live `affects()` mark on the owner pends it) **`isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel.** `latest` is an override the system writes for itself the moment a held value exists ("it is like an optimistic that sets itself to the value as soon as it is available to do so"). So the latest form reads `true` only while `x`'s own fetch for a _new question_ is in flight (showing the stale value) and turns `false` the instant that fetch resolves — even if the same update has other async still running and the commit is held. On a signal or sync computed the held value exists from the instant of the write, so their latest form is _never_ pending. The plain form keeps watching the committed channel (holds included). Pairing falls out: `[isPending(() => latest(x)), latest(x)]` never pairs `true` with the fresh value. | GabbeV/maintainer re-rule, 2026-07-07c; quiet-re-ask filter 2026-07-13 | `tests/createMemo.test.ts`, solid-web `test/latest-async.spec.tsx` | -| A9 | `isPending` on a store leaf behind a firewall reports the firewall's refetch like any async memo — in **both** forms (the latest-form filter of the old A20 is gone; an in-flight refetch supersedes both channels). (**Amended 2026-07-13**: "refetch" means a _new-question_ refetch — an input value change in flight. A quiet re-ask of the same question (`refresh(store)` with value-stable inputs) is silent in both forms; the declared reload `affects(store); refresh(store)` pends. The old exception — the store-wide mask (A21) silencing it — is deleted with A21.) | #2831 finding 1; both-forms re-ruled 2026-07-07c; question scoping 2026-07-13 | `tests/latest-isPending-consistency.test.ts`, V4 pin in `tests/spec-async-semantics.test.ts`, `tests/question-scoped-pending.test.ts` | -| A10 | `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | #2831 finding 2 | `tests/latest-isPending-consistency.test.ts` | -| A11 | Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is a write path like any other). | #2831 finding 3 | `tests/latest-isPending-consistency.test.ts` | -| A12 | A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optimistic write is not among them (a revert is not a refetch). (Phrasing updated with V1: "only the async-in-flight check" predated held-value pends, which resting nodes now report like any memo.) | #2799, #2806 | `tests/createOptimistic.test.ts` (#2806 cases) | -| A13 | (was B1) A resting optimistic node (no active override) is observationally identical to a plain async memo for `read`/`latest`/`isPending` at every checkpoint of a refetch cycle — both before any override was written and after a full override cycle reverted. (Pin re-checked 2026-07-13: both sides of the equivalence now read `false` through a bare `refresh` — the quiet re-ask, A24 — which preserves the identity.) | maintainer keep, 2026-07-06 | `tests/spec-async-semantics.test.ts` | -| A14 | (was B2) `isPending`/`latest` companion nodes get child lanes that do not merge with the owner's lane: an `isPending` effect (spinner) fires while the owner's async is still in flight. (Re-scoped 2026-07-13: the pin drives the spinner with a _question change_ — `setId` — which pends the slot even while an optimistic override displays over it; the override is verdict-inert, A24. The 2026-07-07c mask scoping is superseded.) | maintainer keep, 2026-07-06; re-scoped 2026-07-07c and 2026-07-13 | `tests/spec-async-semantics.test.ts` | -| A15 | (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. | maintainer keep, 2026-07-06 | `tests/spec-async-semantics.test.ts` | -| A16 | (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read uninitialized async sources yield `false`. Carve-out (B5a, pinned as current behavior): in _tracked_ contexts the `NotReadyError` of an uninitialized source propagates so the reader participates in loading boundaries. | maintainer keep, 2026-07-06 | `tests/spec-async-semantics.test.ts` | -| A17 | (was C4) An _active_ optimistic override is THE value for every **read** — ambient/untracked and tracked alike — regardless of transition entanglement. "It is the optimistic future value... it is both immediate and is the future until we know otherwise." "Knowing otherwise" is its own async source resolving (see A18); a transition whose optimistic node is still pending on its own fetch is not complete, so the override cannot be dropped early. **No-tearing is an effect-level concern, not a read-level one**: when async _derived from_ the optimistic value is in flight, the lane holds its render effects (the rendered view keeps the committed state as a unit) — but direct reads still return the override ("direct read shows optimistic, effect waits"). Do NOT mask the override from any read path to prevent tearing; that breaks the real-world optimistic-UI contract. | maintainer ruling, 2026-07-06/07 | `tests/spec-async-semantics.test.ts`; downstream-async lane holding: `tests/createOptimistic.test.ts` (CategoryDisplay/News-Finance real-world sections) | -| A18 | (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. 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. 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. 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. **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. | maintainer rulings, 2026-07-07 (original) and 2026-07-07b (re-rule: "the non-blocking aspect… only gates the reveal"; `_value` elevation at commit points) | `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) | -| A19 | (was C1 — **partially reverses an earlier decision**) **Definition: `isPending(x)` ≡ the value you can currently observe for `x` is not the final one.** Three causes of non-finality, each ending on its own terms: (i) a write held by a live transition — ends at commit; (ii) the node's own async in flight — ends at resolution; (iii) a fresh value that arrived but is held uncommitted by a transition it's entangled with — ends at that commit. A node is pending while _any_ cause holds it and final the moment none does — cascading async falls out of the definition rather than needing a rule ("once it can show its landed value it is no longer pending"). **Two exceptions.** (1) The initial NotReady: an uninitialized source is _loading_, not pending (A16/A12) — no observable value exists to be non-final — and its thrown `NotReadyError` must propagate to loading boundaries (A16/B5a) because SSR streaming and hydration reveal are driven by boundaries. (2) (re-amended 2026-07-13; was the 2026-07-07c decree) Question scoping: causes (i)–(iii) count only when the in-flight work answers a _new question_ (an input value change not yet revealed) — a re-ask of the same question (refresh/poll/confirm with value-stable inputs) is quiet, because the shown value still answers the question being asked (A24). An active optimistic override is the displayed value on its own slot (verdict-inert — pending only for a held correction that differs from it) but never exempts anything else; the old mask exemption is deleted. Everywhere else, boundaries and reporters never enter the definition: they decide what renders and what a transition waits for, not verdicts. The rejected earlier framing ("if it isn't read somewhere that reports to the transition, it isn't actually pending") was a proxy for cause (i) wrongly applied to causes (ii)/(iii), tying data verdicts to graph-topology accidents. Causes (ii)/(iii) were implemented by the #2838 shadow/companion redesign (2026-07-07) — see V3/V1 under Known violations (fixed). (A27 extends the question scoping to the commit-#0 loading window: a node born committed via `loadingValue` answers its first question by declaration, so its first flight is quiet.) | maintainer ruling, 2026-07-07 | cause (i) + boundary interplay: `tests/spec-async-semantics.test.ts`; causes (ii)/(iii): same file, "V1–V5" describe | -| A20 | (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the reasoning record. The surviving pieces: verdicts are per-channel (§2, now in A8), and action affordances belong in the data (co-written flags), not derived from verdicts.) (**re-ruled 2026-07-07c** — supersedes the 2026-07-07 "overrides are unsettled" ruling, which held for one day) **The mask: an optimistic override is certainty by decree; `isPending` follows the channel you read.** (1) An _active_ override reads `isPending === false` — uniformly, on every node kind, in **both** forms, for the override's whole lifetime (until its own source confirms it, A18, or the transition reverts it). Writing optimistically _declares_ the shown value the outcome; a decree cannot be superseded by work already in motion, because the writer just asserted it won't be. `isPending` is reserved for data being updated by machinery the reader did _not_ decree — refetches, transition-held commits — never for the provisional nature of an override ("isPending is about the data being in the process of being updated, not about an action being in progress"). Action-scoped affordances ("Saving…", per-row spinners) therefore belong **in the data**: a co-written flag (`todo.pending = true` — the repo's todos example) or a separate `createOptimistic(false)`. You are already writing the optimistic update; the flag rides along. The old no-extra-boolean idiom (`isPending(() => books.length)` as the "Adding…" label) is rejected — it derived an action's progress from a data verdict. (2) Verdicts are per-channel: the plain form watches the _committed_ channel — pending while its own fetch is in flight and while a resolved value is held uncommitted by a transition; the latest form watches the _fresh_ channel — `latest` is an override the system writes for itself the moment a held value exists (A8), and that self-override masks holds like any user override, leaving only actually-in-flight async as its pending cause. Pairing falls out for both forms: neither ever pairs `true` with the value that made it false. (3) Scope: the mask covers the primitive that was written — node-scoped for signals and computeds; store-scoped for derived optimistic stores (A21). (4) Non-derived optimistic signals/stores are never pending _from themselves_ — there is no source to confirm or refetch, the write is an instantly-visible decree — they pend only via a transition hold on the trigger like any plain signal. (5) No tension with A17/A18: the override is THE value (A17), its lifetime is transition-bound (A18), and the mask simply says the verdict agrees with the decree for exactly that lifetime — mask on at write, off at revert/confirm, in the same atomic settle. | GabbeV model adopted, maintainer re-rule 2026-07-07c (#2844/#2728 discussions) | A20 describe in `tests/spec-async-semantics.test.ts`; `tests/createOptimistic.test.ts` (mask + source-still-pends contrast); latest-channel: `tests/createMemo.test.ts`, solid-web `test/latest-async.spec.tsx`; INV-10 enforces the mask in dev | -| A21 | (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective-write gate (consequence 4) survives as transaction-entanglement hygiene: no-op optimistic writes still neither entangle nor decree anything.) **The store-wide mask: for a derived optimistic store, the store is the primitive — any active optimistic write masks `isPending` for the _entire_ store.** Written leaves, untouched siblings, structural reads (`length`, iteration), and the firewall's own refetch all read `false` while any override on the store is live, in both forms; the mask lifts when the store's optimistic state fully clears (same lane lifetime as A20). Rationale: a refetch pends the whole store because the authority's change set is unbounded (A9) — the decree that silences it must speak for the same unbounded scope, or `isPending(() => store.items.length)` would flip on a refresh the writer already declared the outcome of ("If I do `setOptimisticFormOptions(x => x.cities.push("London"))` then I expect the select to consider it settled" — same for `x.cities[i] = "London"`). Once you write optimistically you own the store's pending affordances (A20 §1: flags in the data). Consequences: (1) optimistic writes to the same store entangle — not just writes to the same property; (2) plain (non-derived) optimistic stores get this for free — with no source they were never pending from themselves (A20 §4); (3) A9 is the unmasked rule: with **no** active override, every leaf reports the firewall's refetch in both forms — the store-wide mask is an override-lifetime exception, not a repeal; (4) (added 2026-07-08) only **effective** writes arm the mask and entangle — the decree is about data actually asserted, so trap fires that change nothing (`s => s`, `s => ({ ...s })` replaying equal values, same-value property writes, deletes of absent properties) are no-ops with no decree, matching the signal path where an equal-value first optimistic write short-circuits before any override exists. A deliberate "silence this refresh" affordance is future explicit API (#2844 family), not an emergent no-op write. | GabbeV/maintainer, 2026-07-07c ("any optimistic write turns off isPending for the whole store"; "the store is the boundary"); effective-write gate ruled 2026-07-08 (brenelz/GabbeV probing `setOptStore(s => s)`) | "store-wide mask" pin in the A20 describe, `tests/spec-async-semantics.test.ts`; `tests/store/createOptimisticStore.test.ts` (refresh-pends → write-masks → lift contrasts); INV-10 store-mask arm | -| A22 | **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree that silences it (A21).** Every other A19 cause lives on the individual node — a transition-held write to a plain store pends exactly the touched leaves (untouched siblings and proxy-level reads stay settled: the writer's change set is known, unlike a refetching authority's unbounded one), manual projection writes pend the written leaf only, holds outliving a settled firewall stay leaf-local. Direction of flow: a node's verdict never inherits its _consumers'_ in-flight state — once a fetch commits, leaves show the landed value and read settled immediately, even while downstream async still holds the effect-level reveal (the commit is immediate at the data level; only the visual is lane-held, and `isPending` companions probe from their own lane, A14, so they are not fooled by the hold). | GabbeV plain-store demo + maintainer, 2026-07-08 ("probably not.. it's non optimistic and it isn't derived from an async source"; "this makes me want to keep things per property even more") | A22 describe in `tests/spec-async-semantics.test.ts` | -| A23 | **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `false` by design: keying any behavior off the returned value is inconsistent under the probe's expression semantics (`() => store && other()` doesn't return the store; a child component reading `props.options` never has the store to return — the props issue). Whole-store questions are asked through reads: any leaf reports the firewall's refetch (A9), spread/iteration reads report structure. The accepted ergonomic complement is the **direct-argument form** `isPending(store)`, mirroring `refresh(store)` — _argument_ inspection (a controlled API taking the store identity, no expression semantics), consulting the firewall: projections report their shared computation's refetch (question-scoped per A24; the original "A21-mask-aware" note died with the mask), plain stores read `false` (no firewall — consistent with A22 and with `refresh`, which is also only meaningful for derived stores). Accepted 2026-07-08; implementation post-2.0. | maintainer, 2026-07-08 (GabbeV ergonomics ask; "This isn't about returns.. the whole props issue again") | A23 describe in `tests/spec-async-semantics.test.ts` (reads-only half; direct form pinned when implemented) | -| A24 | (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#2728 threads) **Question-scoped pending: a read is pending iff a value change is in flight for it that has not yet revealed, or it carries a live `affects()` mark.** (1) **Same-question motion is silent.** Async whose tracked inputs are value-stable — `refresh()`, polling, an action's confirm refetch — is a _re-ask of the same question_: the shown value still answers it, so `isPending` stays `false` and the fresh value reveals silently. (2) **A new question pends monotonically.** Any tracked input value change in flight (a `setSignal`, an upstream memo's new value, an optimistic write feeding downstream async) pends every read under the source until its answer reveals; **nothing can silence it** — pendingness is additive-only. (3) **Optimistic writes are verdict-inert.** An active override is the displayed value on its own slot: not pending from itself (only a held authoritative _correction_ differing from the override re-opens the verdict; a matching confirm reveals nothing), and it masks nothing — an override displaying over an in-flight new question is the honest mixed state `{ value: guess, pending: true }`. To _downstream_ async the write is a real input change and pends those slots normally. Action affordances still belong in the data (co-written flags — the A20 §1 half that survives). (4) **`affects(target, key?)` is the sole declaration verb** (single optional key since 2026-07-14; the variadic form read as a 1.x path and was dropped). Additive pending on exactly the marked data (store record → every record reachable from it at declaration time, captured proxies included per #2882, siblings untouched; key → that leaf slot; accessor → that source); **store marks — keyed and keyless — cover by raw identity, not by proxy family** (amended 2026-07-17, #2904): a keyed mark registers an identity scope (owning record's raw, narrowed to the key), so reads through any other proxy sharing that backing record — e.g. a derived optimistic store whose projection landed the source store's value — witness the mark and inherit it on nodes born during the window, exactly as keyless scopes do **and on everything derived from it** (re-ruled 2026-07-14: a mark is a synthetic in-flight change on the normal status rails — `isPending(() => derived())` reads `true` during a mark window on `derived`'s inputs, exactly as it would over real in-flight async — while the marked values themselves stay readable; **mark-only pending is value-transparent through derivation too** (amended 2026-07-14, #2886): a read whose owner's pending sources are all mark sentinels never suspends — pendingness reaches readers only through verdicts, so optimistic writes under a whole-store mark keep rendering in live tracked readers; a mark's channel is never a re-ask, so a declared reload's own `refresh()` cannot silence it, and a mark never blocks its own transaction's settlement), live from declaration until its surrounding transaction settles or reverts (ambient marks release at flush end). Four corollaries pinned by the #2893 audit (2026-07-16): **(a)** derivation coverage is transitive and probe-stable — tracked reads of mark-pended owners re-establish the mark on the reader after any mid-window recompute (including the recompute an `isPending()` probe itself triggers), at every derivation depth, for graphs built before or during the window; **(b)** mark propagation is transaction-inert — pended subscribers are not queued as pending nodes, so plain writes to marked data (value-transparency) and to unmarked data sharing a downstream memo commit and render immediately, and concurrent actions don't merge into the marker's transaction through the pend; **(c)** a real error outranks a mark — a node holding `STATUS_ERROR` neither takes a sentinel on propagation nor re-applies collected marks after its recompute, so the user's error is never clobbered by a sentinel `NotReadyError`; **(d)** the pending-source container survives any number of overlapping sources (the singular→Set migration bug stranded mark sentinels forever on the third source — exactly the keyless-store-mark-over-`mapArray` shape). The declared-reload idiom `affects(x); refresh(x)` is how process knowledge enters the verdict when the graph can't see the change yet. Trade accepted knowingly: a re-ask that happens to return different data is silent until it reveals — honest silence over blanket alarm; whoever knows declares. | maintainer ruling 2026-07-13 (#2844/#2728 convergence; cause-scoped pending, per-path masking + UNCHANGED vouching, `background()`, and lane-bounded vouches each rejected on the way) | `tests/question-scoped-pending.test.ts` (scenario matrix: foos bug, list over-lighting, navigation-over-override, poll, reload, iMessage posture; cross-family raw sharing #2904); `tests/affects-propagation.test.ts` (marks through derivation: bare-mark windows on memos, late-mark wake, mid-mark landing hold, settle release, no settlement deadlock, store record/keyed marks reaching derived readers); `tests/affects-audit-2893.test.ts` (audit corollaries: container survival under 3+ sources, transaction-inert propagation, transitive/probe-stable re-establishment, error precedence); re-pinned A13/A14/A20-block in `tests/spec-async-semantics.test.ts`; `tests/createOptimistic.test.ts`, `tests/store/createOptimisticStore.test.ts`, `tests/store/createProjection.async.test.ts`, `tests/latest-isPending-consistency.test.ts`, `tests/createMemo.test.ts`, `tests/createLoadingBoundary.test.ts` (quiet-refresh + declared-reload re-pins); INV-10 (affects-count balance) | -| A25 | (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for the derive function — self reads while it works its draft are the point — but to every outside consumer the store is _uninitialized_ (A19 exception 1: loading, not pending) until the first resolution lands; for async-iterator derives, until the **first yield** lands (uninitialized only until then — each later yield is a revealed snapshot, readable between yields even while the generator is still running). During that window every outside consumer path throws: tracked reads suspend into loading boundaries via their node's `NotReadyError` as always, and the untracked fall-throughs — property reads, `in` checks, enumeration/spread — throw the same `NotReadyError` from the firewall (in dev strictRead scopes, i.e. component bodies, the more descriptive `PENDING_ASYNC_UNTRACKED_READ` error wins, matching async memos and preventing infinite loops). Returning the seed leaked a value the reader could never observe updating; returning `undefined` would break non-nullable types. Write-path reads (reconcile enumerating during the first landing) are exempt — they _are_ the initialization. This is safeguard parity: memos already behaved this way; store proxies bypassed `read()` and with it every guard. **Write-visibility corollary (ruled 2026-07-17, #2910 follow-up): the seed IS visible to write-path consumers.** A setter's function-form argument — the store setter's draft, `prev` in `set(prev => …)` — reads the raw current state: the seed for an uninitialized derived store, `undefined` for an uninitialized optimistic computed (it has no seed argument), the displayed value once initialized. Same exemption as the derive body: writes need a base, and because every read channel throws during the window, no consumer can _rely_ on the seed — visibility on the write path leaks nothing observable. Absolute writes were never gated. | maintainer rulings 2026-07-16 ("a seed… should never be visible under any case"; "we throw NotReady except in top-level component scope where we throw that other error"; "self reads are fine though — that's the point of seed, but outside isn't") and 2026-07-17 ("if the seed is visible in compute body it probably should be visible on write.. it throws on read so no consumer can rely on it") | `tests/strict-read-pending-store.test.ts` (untracked dev/prod matrix); `tests/store/createProjection.async.test.ts` (seed hidden until first resolution/first yield, supersession keeps it hidden, enumeration throws); `tests/uninitialized-visibility.test.ts` (write-path seed visibility, loading-vs-pending probe #2910) | +| # | Proposition | Ruling | Pinned by | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | `EffectBundle.error` intercepts compute-phase errors only; effect-phase throws escalate to the nearest error boundary (halt if none). | #2839 ruling (2026-07-06) | `tests/effect-error-phases.test.ts` | +| A2 | Compute-phase errors in _user_ effects without a handler are logged and the run is skipped; the system keeps running. | #2839 ruling | `tests/effect-error-phases.test.ts` | +| A3 | Errors thrown by a user `equals` comparator behave exactly like compute-phase errors (boundary-containable; loud halt without a boundary). | #2837 | `tests/equals-comparator-errors.test.ts` | +| A4 | A custom `equals` is never invoked with `undefined` previous value on a node's first commit. | #2837 follow-on | `tests/equals-comparator-errors.test.ts` (async case) | +| A5 | An error escaping every boundary permanently halts the system with `REACTIVITY_HALTED`; later writes log "Update ignored". | #2761/#2762 | `tests/createErrorBoundary.test.ts`, `tests/errorHalt.test.ts` | +| A6 | `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. | #2822 | `tests/enforceLoadingBoundary.test.ts`, solid-web `test/dev-warning.spec.tsx` | +| A7 | After an async memo resolves, `[isPending(x), latest(x)]` is `[false, resolvedValue]` — never `[false, undefined]`. | #2829 (the `[false, undefined]` pins were a regression) | `tests/latest-async.test.ts`, `tests/createMemo.test.ts` | +| A8 | (**re-ruled 2026-07-07c** — was "tracks the transition the same as `isPending(x)`"; **amended 2026-07-13**: "own async in flight" is further filtered to _non-quiet_ flight — a re-ask of the same question is silent in the latest form too, and a live `affects()` mark on the owner pends it) **`isPending(() => latest(x))` follows `x`'s own async only — verdicts are per-channel.** `latest` is an override the system writes for itself the moment a held value exists ("it is like an optimistic that sets itself to the value as soon as it is available to do so"). So the latest form reads `true` only while `x`'s own fetch for a _new question_ is in flight (showing the stale value) and turns `false` the instant that fetch resolves — even if the same update has other async still running and the commit is held. On a signal or sync computed the held value exists from the instant of the write, so their latest form is _never_ pending. The plain form keeps watching the committed channel (holds included). Pairing falls out: `[isPending(() => latest(x)), latest(x)]` never pairs `true` with the fresh value. | GabbeV/maintainer re-rule, 2026-07-07c; quiet-re-ask filter 2026-07-13 | `tests/createMemo.test.ts`, solid-web `test/latest-async.spec.tsx` | +| A9 | `isPending` on a store leaf behind a firewall reports the firewall's refetch like any async memo — in **both** forms (the latest-form filter of the old A20 is gone; an in-flight refetch supersedes both channels). (**Amended 2026-07-13**: "refetch" means a _new-question_ refetch — an input value change in flight. A quiet re-ask of the same question (`refresh(store)` with value-stable inputs) is silent in both forms; the declared reload `affects(store); refresh(store)` pends. The old exception — the store-wide mask (A21) silencing it — is deleted with A21.) | #2831 finding 1; both-forms re-ruled 2026-07-07c; question scoping 2026-07-13 | `tests/latest-isPending-consistency.test.ts`, V4 pin in `tests/spec-async-semantics.test.ts`, `tests/question-scoped-pending.test.ts` | +| A10 | `[isPending(x), x()]` read in one scope is atomic: a reader that observed the fresh value must not see `pending === true` for it. | #2831 finding 2 | `tests/latest-isPending-consistency.test.ts` | +| A11 | Sync derivations of transition-held sources are visible through `latest()`/`isPending()` (held sync recompute is a write path like any other). | #2831 finding 3 | `tests/latest-isPending-consistency.test.ts` | +| A12 | A resting optimistic node reports pending via exactly the causes a plain async memo does (A19) — a reverting optimistic write is not among them (a revert is not a refetch). (Phrasing updated with V1: "only the async-in-flight check" predated held-value pends, which resting nodes now report like any memo.) | #2799, #2806 | `tests/createOptimistic.test.ts` (#2806 cases) | +| A13 | (was B1) A resting optimistic node (no active override) is observationally identical to a plain async memo for `read`/`latest`/`isPending` at every checkpoint of a refetch cycle — both before any override was written and after a full override cycle reverted. (Pin re-checked 2026-07-13: both sides of the equivalence now read `false` through a bare `refresh` — the quiet re-ask, A24 — which preserves the identity.) | maintainer keep, 2026-07-06 | `tests/spec-async-semantics.test.ts` | +| A14 | (was B2) `isPending`/`latest` companion nodes get child lanes that do not merge with the owner's lane: an `isPending` effect (spinner) fires while the owner's async is still in flight. (Re-scoped 2026-07-13: the pin drives the spinner with a _question change_ — `setId` — which pends the slot even while an optimistic override displays over it; the override is verdict-inert, A24. The 2026-07-07c mask scoping is superseded.) | maintainer keep, 2026-07-06; re-scoped 2026-07-07c and 2026-07-13 | `tests/spec-async-semantics.test.ts` | +| A15 | (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`. | maintainer keep, 2026-07-06 | `tests/spec-async-semantics.test.ts` | +| A16 | (was B5) `isPending` never throws in untracked contexts — thunks that throw real errors or read uninitialized async sources yield `false`. Carve-out (B5a, pinned as current behavior): in _tracked_ contexts the `NotReadyError` of an uninitialized source propagates so the reader participates in loading boundaries. | maintainer keep, 2026-07-06 | `tests/spec-async-semantics.test.ts` | +| A17 | (was C4) An _active_ optimistic override is THE value for every **read** — ambient/untracked and tracked alike — regardless of transition entanglement. "It is the optimistic future value... it is both immediate and is the future until we know otherwise." "Knowing otherwise" is its own async source resolving (see A18); a transition whose optimistic node is still pending on its own fetch is not complete, so the override cannot be dropped early. **No-tearing is an effect-level concern, not a read-level one**: when async _derived from_ the optimistic value is in flight, the lane holds its render effects (the rendered view keeps the committed state as a unit) — but direct reads still return the override ("direct read shows optimistic, effect waits"). Do NOT mask the override from any read path to prevent tearing; that breaks the real-world optimistic-UI contract. **Amended 2026-09-09 (#3331):** "until we know otherwise" is the landing of the node's own async, and knowing otherwise splits the readers: from that landing on, the override is the value for the _display_ — untracked/ambient reads and the applied frame — until the transaction commits, while tracked derivations (memos, async drivers, lane recomputes) see the arrived truth (A18 supersession). `latest(x)` returns the arrived value; `isPending(x)` is `true` iff it differs from the override. | maintainer ruling, 2026-07-06/07 | `tests/spec-async-semantics.test.ts`; downstream-async lane holding: `tests/createOptimistic.test.ts` (CategoryDisplay/News-Finance real-world sections) | +| A18 | (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. 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. 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. 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. **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. **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. 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). | maintainer rulings, 2026-07-07 (original) and 2026-07-07b (re-rule: "the non-blocking aspect… only gates the reveal"; `_value` elevation at commit points) | `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) | +| A19 | (was C1 — **partially reverses an earlier decision**) **Definition: `isPending(x)` ≡ the value you can currently observe for `x` is not the final one.** Three causes of non-finality, each ending on its own terms: (i) a write held by a live transition — ends at commit; (ii) the node's own async in flight — ends at resolution; (iii) a fresh value that arrived but is held uncommitted by a transition it's entangled with — ends at that commit. A node is pending while _any_ cause holds it and final the moment none does — cascading async falls out of the definition rather than needing a rule ("once it can show its landed value it is no longer pending"). **Two exceptions.** (1) The initial NotReady: an uninitialized source is _loading_, not pending (A16/A12) — no observable value exists to be non-final — and its thrown `NotReadyError` must propagate to loading boundaries (A16/B5a) because SSR streaming and hydration reveal are driven by boundaries. (2) (re-amended 2026-07-13; was the 2026-07-07c decree) Question scoping: causes (i)–(iii) count only when the in-flight work answers a _new question_ (an input value change not yet revealed) — a re-ask of the same question (refresh/poll/confirm with value-stable inputs) is quiet, because the shown value still answers the question being asked (A24). An active optimistic override is the displayed value on its own slot (verdict-inert — pending only for a held correction that differs from it) but never exempts anything else; the old mask exemption is deleted. Everywhere else, boundaries and reporters never enter the definition: they decide what renders and what a transition waits for, not verdicts. The rejected earlier framing ("if it isn't read somewhere that reports to the transition, it isn't actually pending") was a proxy for cause (i) wrongly applied to causes (ii)/(iii), tying data verdicts to graph-topology accidents. Causes (ii)/(iii) were implemented by the #2838 shadow/companion redesign (2026-07-07) — see V3/V1 under Known violations (fixed). (A27 extends the question scoping to the commit-#0 loading window: a node born committed via `loadingValue` answers its first question by declaration, so its first flight is quiet.) | maintainer ruling, 2026-07-07 | cause (i) + boundary interplay: `tests/spec-async-semantics.test.ts`; causes (ii)/(iii): same file, "V1–V5" describe | +| A20 | (**SUPERSEDED 2026-07-13 by A24** — the mask is deleted; optimistic writes are verdict-inert. Kept for the reasoning record. The surviving pieces: verdicts are per-channel (§2, now in A8), and action affordances belong in the data (co-written flags), not derived from verdicts.) (**re-ruled 2026-07-07c** — supersedes the 2026-07-07 "overrides are unsettled" ruling, which held for one day) **The mask: an optimistic override is certainty by decree; `isPending` follows the channel you read.** (1) An _active_ override reads `isPending === false` — uniformly, on every node kind, in **both** forms, for the override's whole lifetime (until its own source confirms it, A18, or the transition reverts it). Writing optimistically _declares_ the shown value the outcome; a decree cannot be superseded by work already in motion, because the writer just asserted it won't be. `isPending` is reserved for data being updated by machinery the reader did _not_ decree — refetches, transition-held commits — never for the provisional nature of an override ("isPending is about the data being in the process of being updated, not about an action being in progress"). Action-scoped affordances ("Saving…", per-row spinners) therefore belong **in the data**: a co-written flag (`todo.pending = true` — the repo's todos example) or a separate `createOptimistic(false)`. You are already writing the optimistic update; the flag rides along. The old no-extra-boolean idiom (`isPending(() => books.length)` as the "Adding…" label) is rejected — it derived an action's progress from a data verdict. (2) Verdicts are per-channel: the plain form watches the _committed_ channel — pending while its own fetch is in flight and while a resolved value is held uncommitted by a transition; the latest form watches the _fresh_ channel — `latest` is an override the system writes for itself the moment a held value exists (A8), and that self-override masks holds like any user override, leaving only actually-in-flight async as its pending cause. Pairing falls out for both forms: neither ever pairs `true` with the value that made it false. (3) Scope: the mask covers the primitive that was written — node-scoped for signals and computeds; store-scoped for derived optimistic stores (A21). (4) Non-derived optimistic signals/stores are never pending _from themselves_ — there is no source to confirm or refetch, the write is an instantly-visible decree — they pend only via a transition hold on the trigger like any plain signal. (5) No tension with A17/A18: the override is THE value (A17), its lifetime is transition-bound (A18), and the mask simply says the verdict agrees with the decree for exactly that lifetime — mask on at write, off at revert/confirm, in the same atomic settle. | GabbeV model adopted, maintainer re-rule 2026-07-07c (#2844/#2728 discussions) | A20 describe in `tests/spec-async-semantics.test.ts`; `tests/createOptimistic.test.ts` (mask + source-still-pends contrast); latest-channel: `tests/createMemo.test.ts`, solid-web `test/latest-async.spec.tsx`; INV-10 enforces the mask in dev | +| A21 | (**SUPERSEDED 2026-07-13 by A24** — the store-wide mask is deleted with the mask model; nothing silences a new question. The effective-write gate (consequence 4) survives as transaction-entanglement hygiene: no-op optimistic writes still neither entangle nor decree anything.) **The store-wide mask: for a derived optimistic store, the store is the primitive — any active optimistic write masks `isPending` for the _entire_ store.** Written leaves, untouched siblings, structural reads (`length`, iteration), and the firewall's own refetch all read `false` while any override on the store is live, in both forms; the mask lifts when the store's optimistic state fully clears (same lane lifetime as A20). Rationale: a refetch pends the whole store because the authority's change set is unbounded (A9) — the decree that silences it must speak for the same unbounded scope, or `isPending(() => store.items.length)` would flip on a refresh the writer already declared the outcome of ("If I do `setOptimisticFormOptions(x => x.cities.push("London"))` then I expect the select to consider it settled" — same for `x.cities[i] = "London"`). Once you write optimistically you own the store's pending affordances (A20 §1: flags in the data). Consequences: (1) optimistic writes to the same store entangle — not just writes to the same property; (2) plain (non-derived) optimistic stores get this for free — with no source they were never pending from themselves (A20 §4); (3) A9 is the unmasked rule: with **no** active override, every leaf reports the firewall's refetch in both forms — the store-wide mask is an override-lifetime exception, not a repeal; (4) (added 2026-07-08) only **effective** writes arm the mask and entangle — the decree is about data actually asserted, so trap fires that change nothing (`s => s`, `s => ({ ...s })` replaying equal values, same-value property writes, deletes of absent properties) are no-ops with no decree, matching the signal path where an equal-value first optimistic write short-circuits before any override exists. A deliberate "silence this refresh" affordance is future explicit API (#2844 family), not an emergent no-op write. | GabbeV/maintainer, 2026-07-07c ("any optimistic write turns off isPending for the whole store"; "the store is the boundary"); effective-write gate ruled 2026-07-08 (brenelz/GabbeV probing `setOptStore(s => s)`) | "store-wide mask" pin in the A20 describe, `tests/spec-async-semantics.test.ts`; `tests/store/createOptimisticStore.test.ts` (refresh-pends → write-masks → lift contrasts); INV-10 store-mask arm | +| A22 | **Pending is per-node: store-wide verdicts exist only as the firewall's own in-flight work (A9) and the decree that silences it (A21).** Every other A19 cause lives on the individual node — a transition-held write to a plain store pends exactly the touched leaves (untouched siblings and proxy-level reads stay settled: the writer's change set is known, unlike a refetching authority's unbounded one), manual projection writes pend the written leaf only, holds outliving a settled firewall stay leaf-local. Direction of flow: a node's verdict never inherits its _consumers'_ in-flight state — once a fetch commits, leaves show the landed value and read settled immediately, even while downstream async still holds the effect-level reveal (the commit is immediate at the data level; only the visual is lane-held, and `isPending` companions probe from their own lane, A14, so they are not fooled by the hold). | GabbeV plain-store demo + maintainer, 2026-07-08 ("probably not.. it's non optimistic and it isn't derived from an async source"; "this makes me want to keep things per property even more") | A22 describe in `tests/spec-async-semantics.test.ts` | +| A23 | **The `isPending` probe is reads-only — the thunk's return value is never inspected.** `isPending(() => store)` reads nothing and reports `false` by design: keying any behavior off the returned value is inconsistent under the probe's expression semantics (`() => store && other()` doesn't return the store; a child component reading `props.options` never has the store to return — the props issue). Whole-store questions are asked through reads: any leaf reports the firewall's refetch (A9), spread/iteration reads report structure. The accepted ergonomic complement is the **direct-argument form** `isPending(store)`, mirroring `refresh(store)` — _argument_ inspection (a controlled API taking the store identity, no expression semantics), consulting the firewall: projections report their shared computation's refetch (question-scoped per A24; the original "A21-mask-aware" note died with the mask), plain stores read `false` (no firewall — consistent with A22 and with `refresh`, which is also only meaningful for derived stores). Accepted 2026-07-08; implementation post-2.0. | maintainer, 2026-07-08 (GabbeV ergonomics ask; "This isn't about returns.. the whole props issue again") | A23 describe in `tests/spec-async-semantics.test.ts` (reads-only half; direct form pinned when implemented) | +| A24 | (**ruled 2026-07-13** — supersedes A20/A21; the converged model from the #2844/#2728 threads) **Question-scoped pending: a read is pending iff a value change is in flight for it that has not yet revealed, or it carries a live `affects()` mark.** (1) **Same-question motion is silent.** Async whose tracked inputs are value-stable — `refresh()`, polling, an action's confirm refetch — is a _re-ask of the same question_: the shown value still answers it, so `isPending` stays `false` and the fresh value reveals silently. (2) **A new question pends monotonically.** Any tracked input value change in flight (a `setSignal`, an upstream memo's new value, an optimistic write feeding downstream async) pends every read under the source until its answer reveals; **nothing can silence it** — pendingness is additive-only. (3) **Optimistic writes are verdict-inert.** An active override is the displayed value on its own slot: not pending from itself (only a held authoritative _correction_ differing from the override re-opens the verdict; a matching confirm reveals nothing), and it masks nothing — an override displaying over an in-flight new question is the honest mixed state `{ value: guess, pending: true }`. To _downstream_ async the write is a real input change and pends those slots normally. Action affordances still belong in the data (co-written flags — the A20 §1 half that survives). (4) **`affects(target, key?)` is the sole declaration verb** (single optional key since 2026-07-14; the variadic form read as a 1.x path and was dropped). Additive pending on exactly the marked data (store record → every record reachable from it at declaration time, captured proxies included per #2882, siblings untouched; key → that leaf slot; accessor → that source); **store marks — keyed and keyless — cover by raw identity, not by proxy family** (amended 2026-07-17, #2904): a keyed mark registers an identity scope (owning record's raw, narrowed to the key), so reads through any other proxy sharing that backing record — e.g. a derived optimistic store whose projection landed the source store's value — witness the mark and inherit it on nodes born during the window, exactly as keyless scopes do **and on everything derived from it** (re-ruled 2026-07-14: a mark is a synthetic in-flight change on the normal status rails — `isPending(() => derived())` reads `true` during a mark window on `derived`'s inputs, exactly as it would over real in-flight async — while the marked values themselves stay readable; **mark-only pending is value-transparent through derivation too** (amended 2026-07-14, #2886): a read whose owner's pending sources are all mark sentinels never suspends — pendingness reaches readers only through verdicts, so optimistic writes under a whole-store mark keep rendering in live tracked readers; a mark's channel is never a re-ask, so a declared reload's own `refresh()` cannot silence it, and a mark never blocks its own transaction's settlement), live from declaration until its surrounding transaction settles or reverts (ambient marks release at flush end). Four corollaries pinned by the #2893 audit (2026-07-16): **(a)** derivation coverage is transitive and probe-stable — tracked reads of mark-pended owners re-establish the mark on the reader after any mid-window recompute (including the recompute an `isPending()` probe itself triggers), at every derivation depth, for graphs built before or during the window; **(b)** mark propagation is transaction-inert — pended subscribers are not queued as pending nodes, so plain writes to marked data (value-transparency) and to unmarked data sharing a downstream memo commit and render immediately, and concurrent actions don't merge into the marker's transaction through the pend; **(c)** a real error outranks a mark — a node holding `STATUS_ERROR` neither takes a sentinel on propagation nor re-applies collected marks after its recompute, so the user's error is never clobbered by a sentinel `NotReadyError`; **(d)** the pending-source container survives any number of overlapping sources (the singular→Set migration bug stranded mark sentinels forever on the third source — exactly the keyless-store-mark-over-`mapArray` shape). The declared-reload idiom `affects(x); refresh(x)` is how process knowledge enters the verdict when the graph can't see the change yet. Trade accepted knowingly: a re-ask that happens to return different data is silent until it reveals — honest silence over blanket alarm; whoever knows declares. | maintainer ruling 2026-07-13 (#2844/#2728 convergence; cause-scoped pending, per-path masking + UNCHANGED vouching, `background()`, and lane-bounded vouches each rejected on the way) | `tests/question-scoped-pending.test.ts` (scenario matrix: foos bug, list over-lighting, navigation-over-override, poll, reload, iMessage posture; cross-family raw sharing #2904); `tests/affects-propagation.test.ts` (marks through derivation: bare-mark windows on memos, late-mark wake, mid-mark landing hold, settle release, no settlement deadlock, store record/keyed marks reaching derived readers); `tests/affects-audit-2893.test.ts` (audit corollaries: container survival under 3+ sources, transaction-inert propagation, transitive/probe-stable re-establishment, error precedence); re-pinned A13/A14/A20-block in `tests/spec-async-semantics.test.ts`; `tests/createOptimistic.test.ts`, `tests/store/createOptimisticStore.test.ts`, `tests/store/createProjection.async.test.ts`, `tests/latest-isPending-consistency.test.ts`, `tests/createMemo.test.ts`, `tests/createLoadingBoundary.test.ts` (quiet-refresh + declared-reload re-pins); INV-10 (affects-count balance) | +| A25 | (**ruled 2026-07-16**, #2897) **A derived store's seed is a draft, never an observable value.** The seed exists for the derive function — self reads while it works its draft are the point — but to every outside consumer the store is _uninitialized_ (A19 exception 1: loading, not pending) until the first resolution lands; for async-iterator derives, until the **first yield** lands (uninitialized only until then — each later yield is a revealed snapshot, readable between yields even while the generator is still running). During that window every outside consumer path throws: tracked reads suspend into loading boundaries via their node's `NotReadyError` as always, and the untracked fall-throughs — property reads, `in` checks, enumeration/spread — throw the same `NotReadyError` from the firewall (in dev strictRead scopes, i.e. component bodies, the more descriptive `PENDING_ASYNC_UNTRACKED_READ` error wins, matching async memos and preventing infinite loops). Returning the seed leaked a value the reader could never observe updating; returning `undefined` would break non-nullable types. Write-path reads (reconcile enumerating during the first landing) are exempt — they _are_ the initialization. This is safeguard parity: memos already behaved this way; store proxies bypassed `read()` and with it every guard. **Write-visibility corollary (ruled 2026-07-17, #2910 follow-up): the seed IS visible to write-path consumers.** A setter's function-form argument — the store setter's draft, `prev` in `set(prev => …)` — reads the raw current state: the seed for an uninitialized derived store, `undefined` for an uninitialized optimistic computed (it has no seed argument), the displayed value once initialized. Same exemption as the derive body: writes need a base, and because every read channel throws during the window, no consumer can _rely_ on the seed — visibility on the write path leaks nothing observable. Absolute writes were never gated. | maintainer rulings 2026-07-16 ("a seed… should never be visible under any case"; "we throw NotReady except in top-level component scope where we throw that other error"; "self reads are fine though — that's the point of seed, but outside isn't") and 2026-07-17 ("if the seed is visible in compute body it probably should be visible on write.. it throws on read so no consumer can rely on it") | `tests/strict-read-pending-store.test.ts` (untracked dev/prod matrix); `tests/store/createProjection.async.test.ts` (seed hidden until first resolution/first yield, supersession keeps it hidden, enumeration throws); `tests/uninitialized-visibility.test.ts` (write-path seed visibility, loading-vs-pending probe #2910) | | A26 | (**ruled 2026-07-17**, #2913; **enforcement hardened 2026-08-31**, #3141 — parking is flush-driven, and a transaction opened with no writes before its first suspension scheduled no flush, so `activeTransition` stayed ambient across the await window and captured exactly the unrelated work this ruling rejects: an optimistic store's authoritative landing on a still-live lane was adopted and held until the stranger action settled. `initTransition` now guarantees a flush, so the ambient window closes one flush later regardless of whether the transaction wrote anything) **`yield` is an action's only transaction-safe suspension point; internal `await` continuations run outside the transaction by platform necessity.** The driver regains control exclusively at yield boundaries (`it.next()` resumes the body synchronously, so `restoreTransition` wraps the segment); a post-`await` continuation is a bare promise job the runtime cannot hook — JavaScript has no ambient async context (TC39 AsyncContext, unshipped), and holding `activeTransition` open across the await window was rejected as strictly worse: unrelated ambient writes interleaving during `await fetch()` (a user click, a timer) would be captured into the action's transaction and held until it settles. Consequences, all accepted: (1) a write to a **fresh** signal between an `await` and the next `yield` escapes and commits ambiently; (2) a signal **already written under the transaction** rejoins it even after an `await` (its `_transition` stamp routes the write back) — containment is write-history-dependent by design, not by accident; (3) the supported idiom is `await` for typed results, then a **bare `yield` before any writes** — re-entry is what `yield` is for, and TypeScript ergonomics are exactly why `await` stays welcome (yield results are untyped; awaited results are typed); (4) calling public `flush()` inside an action body is out of contract — it drains and stashes the transaction mid-step, stranding later same-segment writes; follow the idiom and there is nothing to flush for. The doc block on `action()` teaches the idiom. | maintainer ruling 2026-07-17 ("2913 is not addressable… it is a known thing and it isn't detectable, otherwise we'd have a different solution"; "one reason to not yield the promise is TypeScript — we are set up so you can await typed results and then yield nothing the next line"; flush-in-body ruled out of contract: "if they follow that they shouldn't be flushing there") | `tests/action-await-contract.test.ts` (documented escape, the await-then-bare-yield idiom, yield-the-promise alternative, pre-await stamp rejoin); `tests/store/optimistic-ambient-capture.test.ts` (#3141 — the ambient window closes in one flush even for a writeless transaction) | | A27 | (**ruled 2026-08-10**) **The commit-#0 loading window is loading-class and verdict-quiet.** A node born committed via `loadingValue` (memos: `createMemo` / `createSignal(fn)` / `createOptimistic(fn)`) or `seedLoadingValue` (projections: `createProjection` / `createStore(fn)` / `createOptimisticStore(fn)`) starts with the loading value as commit #0 of its lineage instead of `STATUS_UNINITIALIZED`. While its first real answer is in flight, the window is loading-class on every axis: (1) **reads** — every consumer path serves commit #0 (no `NotReadyError`, no Loading-boundary suspension; `latest()` and `resolve()` return it; it is the compute's first `prev`); (2) **transitions** — the window never initiates or extends one (matching boundary-fallback semantics): ambient writes concurrent with the window commit immediately rather than being held, and a loading node mounted inside a live transition does not add to what that transition waits for; (3) **verdict** — `isPending` reads false at the source, upstream, and downstream, in both forms. The quiet ruling is not an exception to A19 but its question scoping applied: commit #0 answers the question **by declaration**, so the first flight is re-ask-shaped — the shown answer still answers the question (A24 family). The alternative was rejected as structurally unavailable: genuine pending is chain-shaped (the shadow of a held commit — held write upstream, in-flight async at the node, propagated non-finality downstream), and the window has no held commit to shadow, so a true verdict could only exist as a point anomaly at the probed node while upstream and downstream read false — and making it propagate would reintroduce exactly the status machinery the window exists to silence (and re-open the server/client split: `isPending` is always false on the server). First-load affordances are therefore the **value channel**'s job — the author encodes provenance (`null`, a `skeleton` flag) into the loading value itself — and `data.skeleton || isPending(data)` covers the two disjoint states. The window closes at the first real landing on any path (sync return, sync-resolved thenable, first iterator yield, async settle); a real error answers reads but does not close it — a retry serves commit #0 again. After close, A19 applies unchanged: refetches are pending-class forever. A25 is unchanged for plain seeds: without `seedLoadingValue` a derived store's seed remains an unobservable draft; `seedLoadingValue` is precisely the author promoting the seed to commit #0 — observable by declaration. | maintainer ruling 2026-08-10 ("the reason I had skeleton or isPending is because isPending would be false in my mind"; "it was false both upstream and downstream") | `tests/loading-value.test.ts` | @@ -149,6 +149,95 @@ fixed it: The companion-vs-oracle census (`COMPANION_CENSUS=1`) reports **zero divergence fingerprints** across the suite post-redesign. +## Re-ruling log — 2026-09-09: lane authority (#3335, #3334, #3331, #3330) + +Four GitHub reports against `2.0.0-rc` lanes, all pre-existing (not +regressions from the held-till-flush change, #3337). Common thread: places +where a lane's or a transaction's _bookkeeping_ (a stamp, a root's +`_asyncReporters`, an override's mask) was consulted where the graph's +_state_ (is this async node in flight; is this value on screen) was the +question. + +- **A15 lanes corollary** added (#3335): a merged reveal's hold is per async + node, looked up in whichever live transaction recorded the observation — + not the root lane's transaction. +- **A15 reveal corollary** added (#3305, #3334): a reveal that discovers an + in-flight async joins the transition that flight blocks, whatever stamped + the node. Two implementation consequences: `read()` no longer serves a + pending node's committed value to a stale-stamped reader (the "carve-out"), + and a lane-routed landing re-enters the _waiting_ transaction, not the + lane owner's — an in-flight action holding the lane open does not hold a + reveal once the flight lands. Actions are not special here; the transition + they hook into is. + - **Re-ruled 2026-09-10 (review on #3347):** the carve-out was the right + semantics for the case it was written for — parallel transactions, + effects don't entangle — and wrong only where the flight's inputs were + already on screen. Removing it outright made a new reader of a memo whose + input write is _itself_ still held wait for the landing, though showing + the committed pair introduces no inconsistency (React 19.3 stopped + entangling the same shape). Restored, gated on input visibility: refused + when the node's inputs were published while it was pending + (`CONFIG_INPUTS_PUBLISHED`, set by the commit that left the flight in the + air) or when the node is routed through a live lane; otherwise the stale + reader shows committed, does not entangle, and is recorded for the commit + replay. The initialized-memo pin returns to its original expectation. +- **A17** amended, **A18** supersession block added (#3331): own-source + arrival supersedes the override for the graph at once; display and + untracked reads keep it until the (possibly merged) commit. Equal landing: + silent confirm. Differing landing: subscribers recompute from the truth on + the plain channel as transaction work, lane affinity dropped; the + override's own downstream flight is inert. The A18 entangled pin's old + expectation was a tear (committed `other = 2` while the screen showed + `mOther(1)`) and is re-expected to a single merged frame. +- **INV-11** added (#3330, no semantic change — a straight violation of + A17): a recompute's equality gate compares against the slot it publishes + to. A lane recompute publishes `_value`, so a transaction-held + `_pendingValue` that already equals the result does not make it + "unchanged"; the override's derivation reveals with the override (pinned + under A17). +- **Supersession scope ruled (2026-09-10):** the sync twin is in. A first + cut fired supersession only at the node's own async landing, which left + the most common real shape — a sync wrapper over an async memo — without + the fix; the maintainer: "if the source recomputes it doesn't matter if it + is async or not." Two shapes had made the sync twin look like an over-fire + and were re-examined: (1) a source write and an override in the _same + batch_ — resolved by the ordering rule (a new value postdates the + override; `_overrideTime` vs `clock`), so the override written over that + batch's truth stands; (2) an earlier action's answer landing under a later + action's override on a shared node — not sync-specific (the async twin has + the identical case). The agent first proposed accepting (2) as an honest + pending window and re-expected the two `createOptimistic.test.ts` pins + from the masked model that assert the opposite ("categoryData should NOT + recompute and isPending should NOT flicker"). Maintainer: "getting it not + to flicker was super important. a slow source shouldn't leak back in like + that. we spent many cycles getting the behavior." Resolved by + **provenance**: transactions merge, so the override's owner cannot say + which action asked; the scheduler carries the running action's invocation + sequence (`origin`) through each action slice and the flush that ends its + window, every flight captures it at registration, and a landing propagates + under its flight's provenance. The override stamps it at its write + (`_overrideStamp`); a differing arrival from an older action holds + silently. A same-value re-prediction by a newer action (the fast path, + which writes no new override) renews the stamp: the user re-asked, so the + older action's answer is stale to it too (review on #3347). The + no-double-flicker pin is restored verbatim; the "second + action" pin keeps its repair (its second action orphaned the first's + continuation, so its final-state assertions had been passing against a + transaction that never closed) and is expected to the same rule. +- **Replay gating** (found under #3330): `laneReadsCommitted` recorded a + lane reader for commit-time replay whenever the node it read had a staged + value; when that staged value equals the committed one (a lane recompute + already published it, INV-11) the replay re-ran effects against an + unchanged frame — the duplicate frame seen in the #3330 and #3334 pins. + Recorded only when the commit will change what the reader saw. +- Internals: `waitingTransition(node)` (replaces `asyncObserved`), + `CONFIG_OVERRIDE_SUPERSEDED`, `_overrideTime`, `_overrideStamp` / + scheduler `origin`, + `GlobalQueue._supersedeOverride` / `_supersededRead` hook slots (the + authoritative-observer wake now lives inside the former), lane demotion on + supersession, the `runEffect` owner-gate exception for lane-less lane + runners. See INTERNALS-ASYNC-STATE.md §1–§3. + ## Re-ruling log — 2026-07-13: question-scoped pending (#2844/#2728, supersedes the mask) The mask model held for six days. The #2844 thread kept producing cases where diff --git a/packages/signals/src/core/action.ts b/packages/signals/src/core/action.ts index 67e8a5f6b..ac05ceb79 100644 --- a/packages/signals/src/core/action.ts +++ b/packages/signals/src/core/action.ts @@ -7,6 +7,7 @@ import { flush, globalQueue, schedule, + setOrigin, type Transition } from "./scheduler.js"; import { isThenable } from "./async.js"; @@ -19,7 +20,14 @@ const ACTION_CALLED_IN_OWNED_SCOPE_MESSAGE = "[ACTION_CALLED_IN_OWNED_SCOPE] Calling an action inside an owned scope (component, computation) is not allowed. " + "Call it from an event handler or another imperative scope."; -function restoreTransition(transition: Transition, fn: () => T): T { +/** Invocation order across all actions — the provenance every slice of an + * action runs under (scheduler `origin`): the flights and overrides its + * ambient windows issue are stamped with it, so a later action's override + * can tell this action's late answer from its own (#3331). */ +let actionSeq = 0; + +function restoreTransition(seq: number, transition: Transition, fn: () => T): T { + const prevOrigin = setOrigin(seq); globalQueue.initTransition(transition); const result = fn(); // A nested action resuming synchronously (its body yielded a non-thenable) @@ -27,6 +35,7 @@ function restoreTransition(transition: Transition, fn: () => T): T { // shared transaction and detach the outer body's remaining writes (the // flush() rule, scheduler.ts). The outer step's own return drains. if (actionStepDepth === 0) flush(); + setOrigin(prevOrigin); return result; } @@ -121,6 +130,10 @@ export function action( } return new Promise((resolve, reject) => { const it = genFn(...args); + const seq = ++actionSeq; + // The first slice's window runs to the scheduled flush, which clears + // the provenance with the window — no restore here. + setOrigin(seq); globalQueue.initTransition(); let ctx = activeTransition!; ctx._actions.push(it); @@ -181,20 +194,20 @@ export function action( v => { if (settled) return; settled = true; - restoreTransition(ctx, () => step(v)); + restoreTransition(seq, ctx, () => step(v)); }, e => { if (settled) return; settled = true; - restoreTransition(ctx, () => step(e, true)); + restoreTransition(seq, ctx, () => step(e, true)); } ); } catch (e) { if (settled) return; settled = true; - return void restoreTransition(ctx, () => step(e, true)); + return void restoreTransition(seq, ctx, () => step(e, true)); } - restoreTransition(ctx, () => step(r.value)); + restoreTransition(seq, ctx, () => step(r.value)); }; step(); diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index edd2f2faf..f1722632e 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -1,7 +1,7 @@ import { - CONFIG_AUTHORITATIVE_OBSERVED, CONFIG_CHILD_COMPANIONS, CONFIG_AUTO_DISPOSE, + CONFIG_INPUTS_PUBLISHED, CONFIG_SYNC, EFFECT_TRACKED, EFFECT_USER, @@ -32,8 +32,11 @@ import { GlobalQueue, globalQueue, insertSubs, + origin, queuePendingNode, schedule, + setOrigin, + waitingTransition, zombieQueue } from "./scheduler.js"; import type { Computed, FirewallSignal, Link } from "./types.js"; @@ -347,6 +350,11 @@ export function handleAsync( // fired _flightTeardown. A future non-recompute registration path must // release it here before overwriting _inFlight. ext(el)._inFlight = result as PromiseLike | AsyncIterable; + // Provenance of the question this flight asks (#3331): the action whose + // window is registering it, or the flight whose landing is. Its landings + // propagate under it (asyncWrite) so an override downstream can tell a + // stale answer from its own. + const flightOrigin = origin; // Attribution hook: a new flight is registered. Fired here (not in the // branches below) so every flight shape — plain thenable, iterator, the // flattened combinations — is announced exactly once, while the recompute @@ -366,7 +374,15 @@ export function handleAsync( // keeps transition scheduling; initialized (value-holding) pending settles // are the transaction's reveal machinery and always re-enter. const settleTransition = () => { - const transition = resolveTransition(el as any); + let transition = resolveTransition(el as any); + // A lane-routed node's landing is revealed by its lane, ahead of the + // transaction that owns the lane (whose own commit is only the override's + // confirm/revert). Entering that owner here would fold every transaction + // 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. + if (el._x?._optimisticLane) transition = waitingTransition(el) ?? transition; if ( transition && el._statusFlags & STATUS_UNINITIALIZED && @@ -430,6 +446,9 @@ export function handleAsync( // skip this stale async result — the upcoming flush will recompute the node // with the new value, creating a fresh Promise that supersedes this one. if (el._flags & (REACTIVE_DIRTY | REACTIVE_OPTIMISTIC_DIRTY)) return; + // The landing propagates under the flight's provenance (#3331) — through + // the flush below, which clears it. + setOrigin(flightOrigin); settleTransition(); const wasUninitialized = !!(el._statusFlags & STATUS_UNINITIALIZED); // Captured before clearStatus wipes it: a quiet re-ask's landing may be @@ -481,7 +500,12 @@ export function handleAsync( // plain-memo landing through setSignal (markUnflushed); promotion // notifies subscribers only when the hold is visible to them (under an // active override every reader sees the override, A17 — the revert is - // the notification point). + // the notification point), and under an override hands the landing to + // the engine's supersedeOverride (A18 supersession, #3331): own-source + // truth that differs from the override ends the optimism for the graph + // now, a matching arrival wakes only an authoritative-view reader + // (#3164). The promotion runs at the top of the flush below, still + // under this flight's provenance. markUnflushed(el, prevStaged); schedule(); } else if (lane) { @@ -840,6 +864,10 @@ export function notifyStatus( if (!blockStatus) { if (status === STATUS_PENDING && pendingSource) { addPendingSource(el, pendingSource); + // A fresh flight from a settled state starts with its inputs unpublished + // (a replacement flight while still pending keeps the mark: the first + // flight's committed inputs are still the frame). + if (!(el._statusFlags & STATUS_PENDING)) el._config &= ~CONFIG_INPUTS_PUBLISHED; el._statusFlags = STATUS_PENDING | (el._statusFlags & STATUS_UNINITIALIZED); // Preserve the current source on this propagation so render-effect notification // can register every distinct pending source with the transition. diff --git a/packages/signals/src/core/constants.ts b/packages/signals/src/core/constants.ts index e96377e29..b079dc434 100644 --- a/packages/signals/src/core/constants.ts +++ b/packages/signals/src/core/constants.ts @@ -141,6 +141,28 @@ export const CONFIG_SLOT_NODE = 1 << 18; */ export const CONFIG_UNFLUSHED = 1 << 20; +/** Optimistic node whose own source arrived with a value DIFFERENT from its + * active override (A18 supersession, #3331). The override survives only as + * the displayed value — untracked reads and the applied frame keep it until + * the owning transaction commits — while the graph has already moved to the + * staged truth in `_pendingValue`: tracked readers see it and the corrected + * cascade is that transaction's held work. Set by the two own-source write + * paths (asyncWrite, transition-held recompute); cleared by a fresh optimistic + * write (a new override re-masks) and by the revert. */ +export const CONFIG_OVERRIDE_SUPERSEDED = 1 << 19; + +/** In-flight async node whose inputs were PUBLISHED while it was pending: a + * batch or transaction committed with the node still `STATUS_PENDING` (an + * unobserved flight, #3305), so the inputs are on screen and the node's + * committed `_value` is stale against them. Governs read()'s reveal + * carve-out: a stale (render) reader in some OTHER transaction may show a + * foreign-held pending node's committed value — parallel transactions, no + * entanglement — only while that value is coherent with the visible frame, + * i.e. while the flight's inputs are themselves held (unpublished) and not + * lane-revealed. Set by `commitPendingNodes`; cleared when the node next + * enters pending fresh (a new flight from a settled state). */ +export const CONFIG_INPUTS_PUBLISHED = 1 << 21; + export const STATUS_NONE = 0; export const STATUS_PENDING = 1 << 0; export const STATUS_ERROR = 1 << 1; diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 1a676c2ae..8b50e9481 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -21,8 +21,10 @@ import { CONFIG_HAS_COMPANIONS, CONFIG_HAS_LANE, CONFIG_HAS_SNAPSHOT, + CONFIG_INPUTS_PUBLISHED, CONFIG_NO_SNAPSHOT, CONFIG_OPTIMISTIC, + CONFIG_OVERRIDE_SUPERSEDED, CONFIG_OWNED_WRITE, CONFIG_SLOT_NODE, CONFIG_SYNC, @@ -323,6 +325,14 @@ export function recompute(el: Computed, create: boolean = false): void { const isStaleEffect = isEffect && isEffect !== EFFECT_USER; const prevStale = stale; if (isStaleEffect) stale = true; + // An effect recorded for this transaction's commit replay (it once read a + // node the transaction held and showed the committed value) and now + // recomputing UNDER the transaction sees its staged view: the value this + // run produces is applied by the commit itself, and the stale recording + // would publish the frame a second time. Drop it; the reads below re-record + // if they are served the committed view again (a lane's committed read). + if (isEffect && activeTransition !== null && activeTransition._gatedSubs.size) + activeTransition._gatedSubs.delete(el); // Writes this run issues (a firewall staging its leaves, a boundary's // status signal) are part of this pass: promoted at the tail below, in // this run's posture (context, lane, RECOMPUTING_DEPS still set). Local @@ -424,9 +434,15 @@ export function recompute(el: Computed, create: boolean = false): void { for (let d = el._deps; d !== null; d = d._nextDep) fanIn++; if (fanIn >= GRAPH_SIZE_WARN_AT) noteFanIn(el, fanIn); } + // INV-11 (#3330): the equality gate compares against the slot this run + // publishes to. An override-covered node publishes the override; a lane + // recompute (OPT-dirty) direct-commits `_value` — the lane's own reveal + // schedule — so a transaction-held `_pendingValue` that already equals + // the new result is not "unchanged": the screen still shows `_value`. + // Only a transaction-staged run compares against `_pendingValue`. const compareValue = hasOverride ? unwrapOverride(el._x?._overrideValue) - : el._pendingValue === NOT_PENDING + : isOptimisticDirty || el._pendingValue === NOT_PENDING ? el._value : el._pendingValue; let valueChanged = false; @@ -550,6 +566,19 @@ export function recompute(el: Computed, create: boolean = false): void { (!hasOverride || isOptimisticDirty || el._x?._overrideValue !== prevVisible) ) insertSubs(el, isOptimisticDirty || hasOverride); + // A18 supersession, sync twin of asyncWrite's override branch (#3331): + // this pass published truth that differs from the override (the gate + // compared against it) into the transaction-held slot. Whether the + // source is this node's own async or an upstream node it derives from + // synchronously makes no difference — "the source recomputed". The + // override stays displayed until the commit; the graph moves to the + // staged truth now (plain channel, lane demoted). Ordering: "a new + // value from the source" postdates the override — an override written + // in this same tick (optimisticWrite stamps `_overrideTime`) is the + // newer intent over whatever this pass derives from the batch's staged + // inputs, and is not superseded by it. + else if (hasOverride && !isOptimisticDirty && el._x!._overrideTime !== clock) + GlobalQueue._supersedeOverride!(el, value); } else if (hasOverride) { // Unchanged value (equals the override) recomputed while the override // is active: _value may still be stale, so hold the authoritative value @@ -558,13 +587,14 @@ export function recompute(el: Computed, create: boolean = false): void { el._pendingValue = value; if (__DEV__) devTrackHeldPending(el); if (wasLoading) el._loading = true; // see the held branch above (#2990) - // An authoritative-view reader (until()'s predicate, refresh()'s waiter) - // observed this node past its override — and "authoritative arrival - // equal to the override" is exactly the acknowledgment it waits for. - // Wake those readers only; A17 silence holds for every ordinary - // subscriber. (Hook installed by both setters of the gating bit, #3303.) - if (el._config & CONFIG_AUTHORITATIVE_OBSERVED) - GlobalQueue._notifyAuthoritativeObservers!(el); + // A confirmation after a supersession restores the override as the + // graph's value and notifies; a plain confirmation wakes only an + // authoritative-view reader (until()'s predicate, refresh()'s waiter) + // that observed this node past its override — "authoritative arrival + // equal to the override" is exactly the acknowledgment it waits for; + // A17 silence holds for every ordinary subscriber. Both live in the + // engine's supersedeOverride. + GlobalQueue._supersedeOverride!(el, value); } else if (el._height != oldHeight) { for (let s = el._subs; s !== null; s = s._nextSub) { insertIntoHeapHeight(s._sub, queueFor(s._sub)); @@ -773,6 +803,8 @@ export function ext(el: { _x: NodeExtension | null }): NodeExtension { return (el._x ??= { _overrideValue: undefined, _overrideOwner: undefined, + _overrideTime: 0, + _overrideStamp: 0, _optimisticLane: undefined, _pendingSignal: undefined, _latestValueComputed: undefined, @@ -1314,6 +1346,26 @@ function unflushedView(el: Signal | Computed): T { return (staged !== undefined && staged !== NOT_PENDING ? staged : el._value) as T; } +/** + * Stale-reader term of the value selections below: a render effect reading a + * node some OTHER live transaction has staged sees the committed value. The + * commit is silent — the staging walk was the notification — so a reader + * that linked AFTER that walk (an effect created during the hold, a store + * key first read under it) would show the old value past the reveal: record + * it for the transaction's commit replay (the `_gatedSubs` contract lanes + * already use). An effect the transaction itself computed re-derives at its + * commit on its own (parked run, or the contested re-derive, #3322) and is + * not recorded — replaying it too would publish the frame twice. + */ +function heldFromStale(el: Signal | Computed, c: Computed): boolean { + const t = el._transition; + if (t === null || t === activeTransition) return false; + const txn = currentTransition(t); + const vt: Transition | null | undefined = (c as any)._valueTransition; + if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c); + return true; +} + export function readNodeFast(el: Signal): T | typeof READ_SLOW { if ( latestReadActive || @@ -1345,7 +1397,7 @@ export function readNodeFast(el: Signal): T | typeof READ_SLOW { !c || el._pendingValue === NOT_PENDING || c._config & CONFIG_CHILDREN_FORBIDDEN || - (stale && el._transition !== null) + (stale && heldFromStale(el, c as Computed)) ? el._value : el._config & CONFIG_UNFLUSHED ? unflushedView(el) @@ -1392,7 +1444,7 @@ export function read(el: Signal | Computed): T { !c || el._pendingValue === NOT_PENDING || c._config & CONFIG_CHILDREN_FORBIDDEN || - (stale && el._transition !== null) + (stale && heldFromStale(el, c as Computed)) ? el._value : el._config & CONFIG_UNFLUSHED ? unflushedView(el) @@ -1437,7 +1489,32 @@ export function read(el: Signal | Computed): T { } if (owner._statusFlags & STATUS_PENDING) { - if (c && !(stale && owner._transition && activeTransition !== owner._transition)) { + // A reader landing on a pending node throws — the reveal that discovered + // the flight holds on it (A15: observed async settles as one unit) — with + // one carve-out: a stale (render) reader of a node pending in some OTHER + // transaction keeps showing the node's committed value, no entanglement + // (parallel transactions; the reader is recorded for that transaction's + // commit replay, `heldFromStale`). The carve-out is sound only while the + // committed value is coherent with the visible frame, i.e. while the + // flight's inputs are themselves unpublished: the stamp alone does not + // say so (it is pending-node bookkeeping), so it is refused when the + // inputs are on screen — committed by a batch that left the flight in + // the air (CONFIG_INPUTS_PUBLISHED, #3305) or revealed through a lane + // (optimistic / latest, #3334) — and the reader holds instead. An + // UNINITIALIZED node has no committed value to show and holds too + // (firewall-backed store reads always did; plain memos since the #3043 + // port): falling through served `undefined` as if settled and stranded + // the reader outside both transactions, so it never re-ran. + if ( + c && + !( + stale && + !(owner._statusFlags & STATUS_UNINITIALIZED) && + !(owner._config & CONFIG_INPUTS_PUBLISHED) && + !(owner._config & CONFIG_HAS_LANE && GlobalQueue._laneLive!(owner as Computed)) && + heldFromStale(owner, c as Computed) + ) + ) { if (__DEV__ && c && c._config & CONFIG_CHILDREN_FORBIDDEN) { const message = "[PENDING_ASYNC_FORBIDDEN_SCOPE] Reading a pending async value inside createTrackedEffect or onSettled will throw. " + @@ -1466,19 +1543,6 @@ export function read(el: Signal | Computed): T { if (!tracking && el !== c) link(el, c as Computed); throw owner._x?._error; } - } else if (c && owner._statusFlags & STATUS_UNINITIALIZED) { - // A stale (render) reader of a node held pending in ANOTHER transition - // normally keeps showing the node's committed value instead of - // entangling the two transactions — but an uninitialized node has no - // committed value to show. Suspend on it (firewall-backed store reads - // always took this branch; plain memos now do too): the reader - // registers as a reporter of that source, and its pending-node stamp - // ties it to the active transaction, so the two transactions merge - // when the source settles. Falling through served `undefined` as if - // settled and stranded the reader outside both transactions, so it - // never re-ran when either landed (#3043 port). - if (!tracking && el !== c) link(el, c as Computed); - throw owner._x?._error; } else if (!c && owner._statusFlags & STATUS_UNINITIALIZED) { throw owner._x?._error; } @@ -1526,8 +1590,16 @@ export function read(el: Signal | Computed): T { // authoritative — optimism never lives there); the sticky mark makes the // A17-silent "landing equals override" paths notify this node's subs so // the reader re-runs when truth arrives. - if (!(c && c._config & CONFIG_AUTHORITATIVE_READ)) + if (!(c && c._config & CONFIG_AUTHORITATIVE_READ)) { + // A18 supersession (#3331): the node's own source answered with a + // DIFFERENT value. The optimism is over for the graph — a tracked + // reader sees the staged truth — while the override remains the + // DISPLAYED value for untracked reads (and for a stale reader of some + // other transaction). The selection lives with the engine. + if (c && el._config & CONFIG_OVERRIDE_SUPERSEDED) + return GlobalQueue._supersededRead!(el) as T; return unwrapOverride(el._x?._overrideValue); + } el._config |= CONFIG_AUTHORITATIVE_OBSERVED; } @@ -1559,7 +1631,7 @@ export function read(el: Signal | Computed): T { GlobalQueue._laneReadsCommitted!(el, owner, c as Computed)) || el._pendingValue === NOT_PENDING || c._config & CONFIG_CHILDREN_FORBIDDEN || - (stale && el._transition && activeTransition !== el._transition) || + (stale && heldFromStale(el, c as Computed)) || // A17 for HELD truth (#3164, see CONFIG_HELD_TRUTH): staged confirming // truth — fold-staged onto an armed family, or entangle-stolen by an // awaited until() — is masked from ordinary readers until its @@ -1674,15 +1746,22 @@ export function promoteUnflushed(from: number = 0): void { // finalize's commitPendingNodes ran) — the walk is still owed. if (node._config & CONFIG_HAS_COMPANIONS && sync !== null) sync(node, node._pendingValue === NOT_PENDING ? node._value : node._pendingValue); - // Same wake rule as the eager landing (asyncWrite): under an active - // override every reader sees the override (A17), so the hold is not - // visible to them and the revert is their notification — only an - // authoritative-view reader (until()'s predicate) waiting on the staged - // truth is woken (#3164). Only CONFIG_OPTIMISTIC nodes carry an - // override slot (see constants.ts): plain nodes skip the probe. + // Under an active override every reader sees the override (A17), so the + // hold is not visible to them and the revert is their notification. The + // engine decides what the arrival means for the override (A18 + // supersession, #3331): own-source truth that differs ends the optimism + // for the graph now (plain channel, lane demoted); a matching arrival is + // silent except to an authoritative-view reader (until()'s predicate) + // waiting on exactly this staged truth (#3164). The hook is installed + // with the engine, which an active override implies. Only + // CONFIG_OPTIMISTIC nodes carry an override slot (see constants.ts): + // plain nodes skip the probe. if (!(node._config & CONFIG_OPTIMISTIC) || !hasActiveOverride(node)) insertSubs(node); - else if (node._config & CONFIG_AUTHORITATIVE_OBSERVED) - GlobalQueue._notifyAuthoritativeObservers?.(node); + else + GlobalQueue._supersedeOverride!( + node, + node._pendingValue === NOT_PENDING ? node._value : node._pendingValue + ); } unflushedNodes.length = from; } @@ -1750,8 +1829,13 @@ export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T // _overrideValue slot (flagged by CONFIG_OPTIMISTIC — a masked read of the // always-present config instead of a missing-property probe), and every // module that installs one installs the engine first. - if (el._config & CONFIG_OPTIMISTIC && !projectionWriteActive) - return GlobalQueue._optimisticWrite!(el, v); + if (el._config & CONFIG_OPTIMISTIC) { + if (!projectionWriteActive) return GlobalQueue._optimisticWrite!(el, v); + // An authoritative store landing on an override-covered node: the store + // twin of asyncWrite's override branch, decided by the engine (#3331). + const o = el._x?._overrideValue; + if (o !== undefined && o !== NOT_PENDING) return GlobalQueue._landOnOverride!(el, v); + } const currentValue = el._pendingValue === NOT_PENDING ? el._value : (el._pendingValue as T); diff --git a/packages/signals/src/core/effect.ts b/packages/signals/src/core/effect.ts index 3acd513a4..727d58ab0 100644 --- a/packages/signals/src/core/effect.ts +++ b/packages/signals/src/core/effect.ts @@ -160,11 +160,18 @@ function runEffect(node: Effect, type: number): void { // Mainline-owned runs (null) apply now. Lanes are exempt by design (they // apply their own effects ahead of their transaction — the optimistic view) // and mark their runs with LANE_RUN. + // + // Lane exemption has one exception (#3331): a lane runner for an effect that + // no longer rides a lane — its optimistic source was superseded, so the lane + // has no optimistic view left to apply, and the value this effect now + // carries (or will, once its plain recompute lands) belongs to the still-held + // transaction. Hand the run to the regular queue, where the transaction's + // gate stashes it with the owner. Lane-less runners with no live owner + // (reverts, wake-only lanes) apply now. if ( - activeTransition !== null && - !(type & LANE_RUN) && node._valueTransition !== null && - !currentTransition(node._valueTransition)._done + !currentTransition(node._valueTransition)._done && + (type & LANE_RUN ? !node._x?._optimisticLane : activeTransition !== null) ) { node._queue.enqueue(node._type, node._boundRunEffect!); return; diff --git a/packages/signals/src/core/lanes.ts b/packages/signals/src/core/lanes.ts index 83144f5ae..5aa03742a 100644 --- a/packages/signals/src/core/lanes.ts +++ b/packages/signals/src/core/lanes.ts @@ -3,6 +3,7 @@ import { ext } from "./core.js"; import { activeTransition, currentTransition, + waitingTransition, type QueueCallback, type Transition } from "./scheduler.js"; @@ -90,18 +91,23 @@ export function findLane(lane: OptimisticLane): OptimisticLane { /** * Is the lane held? `_pendingAsync` records the async the lane OWNS (derived - * under it); the transaction's reporter map records the async a render effect + * under it); a transaction's reporter map records the async a render effect * OBSERVED pending with no boundary taking it (INV-3, the one registration * site). A hold needs both — the same rule the transaction itself uses, so a * memo nobody renders, or one a fallback-showing boundary caught, cannot tear * a frame and holds nothing (#3289). An orphan lane has no observation record * and never holds. + * + * The observation is looked up per NODE, in whichever live transaction + * recorded it — not in this lane's transaction. Lanes merge across + * transactions (#2912: ownership never travels through lanes), so after a + * merge the root's transaction holds the observations of only one member; + * the async the other member's transaction observed must hold the merged + * reveal just the same (A15 for lanes, #3335). */ export function laneHeld(lane: OptimisticLane): boolean { - const t = lane._transition; - if (t) - for (const node of lane._pendingAsync) - if (currentTransition(t)._asyncReporters.has(node)) return true; + if (!lane._transition) return false; + for (const node of lane._pendingAsync) if (waitingTransition(node) !== null) return true; return false; } diff --git a/packages/signals/src/core/optimistic.ts b/packages/signals/src/core/optimistic.ts index d304692a4..4a03239cf 100644 --- a/packages/signals/src/core/optimistic.ts +++ b/packages/signals/src/core/optimistic.ts @@ -24,11 +24,14 @@ import { REACTIVE_OPTIMISTIC_DIRTY, STATUS_PENDING, STATUS_UNINITIALIZED, - CONFIG_HAS_LANE + CONFIG_AUTHORITATIVE_OBSERVED, + CONFIG_HAS_LANE, + CONFIG_OVERRIDE_SUPERSEDED } from "./constants.js"; +import { attrHooks } from "./attribution-hooks.js"; import { currentOptimisticLane, latestReadActive, stale, ext, markUnflushed } from "./core.js"; import { NotReadyError } from "./error.js"; -import { devCheckMergedLaneEmpty, devTrackOptimistic } from "./invariants.js"; +import { devCheckMergedLaneEmpty, devTrackHeldPending, devTrackOptimistic } from "./invariants.js"; import { activeLanes, assignOrMergeLane, @@ -47,6 +50,8 @@ import { GlobalQueue, globalQueue, insertSubs, + origin, + queuePendingNode, schedule, type QueueCallback, type Transition @@ -90,10 +95,15 @@ function optimisticWrite(el: Signal | Computed, v: T | ((prev: T) => T) !el._equals(currentValue, v); if (!valueChanged) { // Same-value write with an active override still entangles the current - // action's transition — the hold must outlast all overlapping actions. + // action's transition — the hold must outlast all overlapping actions — + // and renews the override's PROVENANCE: the newer action re-asks the + // question, so an older action's answer arriving later is stale to it + // too (#3331; a same-value re-prediction otherwise let the first + // action's slow source supersede and restart the downstream flight). if (hasOverride) { const transition = resolveTransition(el as any); if (transition && activeTransition !== transition) globalQueue.initTransition(transition); + if (origin > el._x!._overrideStamp) el._x!._overrideStamp = origin; } return v; } @@ -108,10 +118,16 @@ function optimisticWrite(el: Signal | Computed, v: T | ((prev: T) => T) // joint root). resolveTransition prefers this over the lane's _transition, // which a shared subscriber can merge across transactions (#2912). ext(el)._overrideOwner = activeTransition; + ext(el)._overrideTime = clock; + // Provenance: the action asking. An answer an OLDER action's flight brings + // back is a stale question and holds silently to commit (#3331). + ext(el)._overrideStamp = origin; const lane = getOrCreateLane(el as Signal); ext(el)._optimisticLane = lane; - el._config |= CONFIG_HAS_LANE; + // A fresh override re-masks: whatever truth is staged, this write is the + // value for the graph again until the source answers it (#3331). + el._config = (el._config | CONFIG_HAS_LANE) & ~CONFIG_OVERRIDE_SUPERSEDED; // Literal undefined must not land raw: the slot doubles as the optimistic // brand, and erasing it makes the write invisible and routes follow-up @@ -200,7 +216,12 @@ function resolveOptimisticNodes(nodes: OptimisticNode[]): void { (node as any)._statusFlags &= ~STATUS_UNINITIALIZED; const prevOverride = node._x?._overrideValue; ext(node)._overrideValue = NOT_PENDING; - if (prevOverride !== NOT_PENDING && node._value !== unwrapOverride(prevOverride)) + // A superseded override's subscribers already re-derived from the truth + // when it arrived (#3331) — the drop changes nothing they read. Everyone + // else learns of the correction here: this drop IS their notification. + const superseded = (node._config & CONFIG_OVERRIDE_SUPERSEDED) !== 0; + node._config &= ~CONFIG_OVERRIDE_SUPERSEDED; + if (!superseded && prevOverride !== NOT_PENDING && node._value !== unwrapOverride(prevOverride)) insertSubs(node, true); node._transition = null; if (node._x !== null) node._x._overrideOwner = null; @@ -219,6 +240,111 @@ function resolveOptimisticNodes(nodes: OptimisticNode[]): void { nodes.splice(0, len); } +/** + * A18 supersession (#3331): the node's own source arrived with a value that + * differs from its active override. "Knowing otherwise" ends the optimism for + * the graph at once: the override stays only as the DISPLAYED value (untracked + * reads, the applied frame) until the owning transaction commits, while + * tracked readers see the staged truth and re-derive from it as that + * transaction's held work — so async downstream restarts now, not at the + * revert (no waterfall). + * + * The lane's job for this node is over: a lane applies an optimistic view + * ahead of its transaction, and there is no optimistic view left — the + * corrected cascade is plain transaction-held work (staged memos, effect runs + * in the stashable queues). Demote the node and every cascade member that + * rides this lane; the caller then notifies on the plain channel. Runners the + * lane still holds for demoted effects (the optimistic frame that never got to + * apply) defer to the regular queue in runEffect. In a lane merged with a + * still-optimistic source, members shared with that source lose their lane + * too and simply wait for the transaction — less optimistic, never torn. + * + * A later arrival EQUAL to the override (an earlier action's answer superseded + * this one's; now this one's answer confirms it) ends the supersession: the + * graph re-derives from the override, which is the truth again. Notifies the + * subscribers in both cases. A plain matching confirmation (no supersession + * in force) is A17-silent for ordinary subscribers and wakes only an + * authoritative-view reader (until()'s predicate, refresh()'s waiter) that + * observed this node past its override — "authoritative arrival equal to the + * override" is exactly the acknowledgment it waits for (#3164, #3303). The + * wake hook is installed by the setters of that bit; optional here because + * the bit only implies the optimistic engine was consulted. + */ +function supersedeOverride(el: OptimisticNode, value: unknown): void { + const differs = !el._equals || !el._equals(value, unwrapOverride(el._x!._overrideValue)); + if (!differs) { + if (!(el._config & CONFIG_OVERRIDE_SUPERSEDED)) { + if (el._config & CONFIG_AUTHORITATIVE_OBSERVED) + GlobalQueue._notifyAuthoritativeObservers?.(el); + return; + } + el._config &= ~CONFIG_OVERRIDE_SUPERSEDED; + } else { + // Provenance (#3331): an answer brought back by an OLDER action than the + // one that wrote this override answers a question the user has since + // changed. It is staged like any other landing and reveals if it is + // still the truth when the transaction commits, but it does not move the + // graph now — a slow source must not leak back in over a newer intent. + // 0 is mainline (no action): always the current question. + if (origin && origin < el._x!._overrideStamp) return; + el._config |= CONFIG_OVERRIDE_SUPERSEDED; + const lane = el._x?._optimisticLane; + if (lane) { + const root = findLane(lane); + const stack: OptimisticNode[] = [el]; + while (stack.length) { + const n = stack.pop()!; + const l = n._x?._optimisticLane; + if (!l || findLane(l) !== root) continue; + n._x!._optimisticLane = undefined; + root._pendingAsync.delete(n as Computed); + for (let s = n._subs; s !== null; s = s._nextSub) stack.push(s._sub); + } + } + } + if (__OBSERVE__ && attrHooks !== null) + attrHooks.asyncEnd(el as Computed, undefined, value, true); + insertSubs(el); +} + +/** 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 + * batch's end; in between the graph must not fall back to the override it + * has left) — or the displayed override for a stale (render) reader of some + * OTHER transaction, the same visibility a foreign transaction's staged + * write has. */ +function supersededRead(el: OptimisticNode): unknown { + if (stale && el._transition && activeTransition !== el._transition) + return unwrapOverride(el._x?._overrideValue); + return el._pendingValue !== NOT_PENDING ? el._pendingValue : el._value; +} + +/** + * An authoritative store landing on an override-covered node — a derived + * optimistic store's own truth arriving over a tentative edit through the + * projection-write channel (setSignal under projectionWriteActive). The + * store twin of asyncWrite's override branch: the truth stages for its + * transaction's commit whatever its relation to the committed value (a + * landing equal to committed still differs from the override), companions + * learn of it, and supersedeOverride decides the rest — A18 supersession with + * action provenance, or an A17-silent confirmation (#3331). Before this the + * landing took setSignal's plain path: a differing truth staged silently + * under the override and the graph never moved until the commit. + */ +function landOnOverride(el: Signal | Computed, v: T | ((prev: T) => T)): T { + const currentValue = el._pendingValue === NOT_PENDING ? el._value : (el._pendingValue as T); + if (typeof v === "function") v = (v as (prev: T) => T)(currentValue); + if (__OBSERVE__ && attrHooks !== null) attrHooks.write(el, currentValue, v); + if (el._pendingValue === NOT_PENDING) queuePendingNode(el); + el._pendingValue = v; + if (__DEV__) devTrackHeldPending(el); + GlobalQueue._syncCompanions?.(el, v); + supersedeOverride(el as OptimisticNode, v); + schedule(); + return v; +} + function runQueue(queue: QueueCallback[], type: number): void { for (let i = 0; i < queue.length; i++) queue[i](type | LANE_RUN); } @@ -280,6 +406,19 @@ function laneSuspends(owner: OptimisticNode): boolean { return findLane(pendingLane) === findLane(currentOptimisticLane!) && !hasActiveOverride(owner); } +/** + * read()'s reveal carve-out asks whether a pending node is routed through a + * LIVE lane: a lane-derived flight's inputs are already revealed through the + * lane (the override, or latest()'s fresh value), so a stale reader of another + * transaction must hold on the flight rather than show the node's committed + * value beside them (#3334). Exact, not sticky: `resolveLane` clears a lane + * reference the engine has since retired, so a node that was once lane-routed + * and is now pending under a plain hold is judged by that hold alone. + */ +function laneLive(el: Computed): boolean { + return resolveLane(el) !== undefined; +} + /** * read()'s entanglement gate: a reader recomputing under an optimistic lane * that reads a pending mid-transition write sees the committed value; the sub @@ -315,8 +454,14 @@ function laneReadsCommitted(el: OptimisticNode, owner: OptimisticNode, c: Comput // already settled (laneAsyncSettled keeps _optimisticLane) served its // committed value to a reader that never re-ran after the landing, so a // pending-gated branch stayed one value behind permanently (#3041 - // follow-up). Record the reader under the same replay contract. - if (el._pendingValue !== NOT_PENDING) + // follow-up). Record the reader under the same replay contract — when + // the commit will actually change what it read: a staged value equal to + // the committed one (a lane recompute already published it, INV-11) + // promotes to the same view, and a replay would only re-run effects + // against an unchanged frame (#3330). An override-covered node's revert + // notifies its own subscribers when the truth differs (resolveOptimistic + // Nodes), so the reader is recorded only for the staged-vs-committed gap. + if (el._pendingValue !== NOT_PENDING && el._pendingValue !== el._value) (activeTransition ?? globalQueue._batch)._gatedSubs.add(c); return true; } @@ -423,8 +568,12 @@ export function installOptimisticEngine(): void { GlobalQueue._transitionBlocked = transitionBlocked; GlobalQueue._cleanupLanes = cleanupCompletedLanes; GlobalQueue._runLaneEffects = runLaneEffects; + GlobalQueue._supersedeOverride = supersedeOverride; + GlobalQueue._supersededRead = supersededRead; + GlobalQueue._landOnOverride = landOnOverride; GlobalQueue._gatedRead = gatedRead; GlobalQueue._laneSuspends = laneSuspends; + GlobalQueue._laneLive = laneLive; GlobalQueue._laneReadsCommitted = laneReadsCommitted; GlobalQueue._recomputeLane = recomputeLane; GlobalQueue._laneAsyncPending = laneAsyncPending; diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 1efd146eb..09a2f6238 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -13,6 +13,7 @@ import { CONFIG_HAS_COMPANIONS, CONFIG_HAS_LANE, CONFIG_HAS_SNAPSHOT, + CONFIG_INPUTS_PUBLISHED, CONFIG_SLOT_NODE, REACTIVE_IN_HEAP_HEIGHT, REACTIVE_MANUAL_WRITE, @@ -610,6 +611,11 @@ export class GlobalQueue extends Queue { | ((el: Signal, owner: OptimisticNode, c: Computed) => boolean) | null = null; static _laneSuspends: ((owner: OptimisticNode) => boolean) | null = null; + /** Is the node routed through a LIVE lane (`resolveLane`)? read()'s reveal + * carve-out asks before showing a foreign-held pending node's committed + * value: a lane-derived flight's inputs are already revealed through the + * lane (#3334). Gated on CONFIG_HAS_LANE, which only the engine sets. */ + static _laneLive: ((el: Computed) => boolean) | null = null; static _laneReadsCommitted: | ((el: OptimisticNode, owner: OptimisticNode, c: Computed) => boolean) | null = null; @@ -623,6 +629,28 @@ export class GlobalQueue extends Queue { * the gate holds (#3303). */ static _notifyAuthoritativeObservers: ((el: Signal | Computed) => void) | null = null; static _laneAsyncSettled: ((el: Computed) => void) | null = null; + /** A18 supersession (#3331): own-source truth `value` landed under an active + * override. The engine decides whether the graph re-derives — the value + * differs from the override and is not a stale (older-action) answer (mark + * the node, demote its lane cascade, notify), or returns to it after an + * earlier differing arrival (clear the mark, notify) — and owns the + * authoritative-observer wake for a silent confirm. Installed with the + * optimistic engine; only reachable on a node that has an override. */ + static _supersedeOverride: ((el: Signal | Computed, value: unknown) => void) | 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 + * transaction's committed value over its staged write. */ + static _supersededRead: ((el: Signal | Computed) => unknown) | null = null; + /** setSignal's authoritative (projection-write) landing on an override- + * covered node (#3331 store twin): stage the truth for its transaction's + * commit whatever its relation to the committed value — a landing equal to + * committed still differs from the override — then _supersedeOverride + * decides. Installed with the optimistic engine; only reachable on a node + * that has an override. */ + static _landOnOverride: ((el: Signal | Computed, v: T | ((prev: T) => T)) => T) | null = + null; static _trackOptimisticStore: ((store: any) => void) | null = null; flush() { if (this._running) return; @@ -926,6 +954,26 @@ export function armReaskClear(): void { reaskArmed = true; } +/** Provenance of the work currently running (A18 supersession, #3331): the + * invocation sequence of the action whose ambient window this is — set by + * action() for each slice; the flush that ends the window clears it — or, + * inside an async landing, the sequence captured when that flight was + * registered (asyncWrite sets it for the landing's synchronous propagation, + * so a sync recompute downstream of the landing — an optimistic wrapper over + * the async source — derives under the flight's provenance, and flights it + * registers inherit it). 0 is mainline: no action, always the current + * question. An override stamps this at its write (`_overrideStamp`); an + * answer whose flight an OLDER action issued is a stale question the user + * has since changed — it holds silently to commit instead of superseding. A + * slow source must not leak back in over a newer intent. Transactions merge, + * so the transition object cannot say WHICH action asked; this can. */ +export let origin = 0; +export function setOrigin(seq: number): number { + const prev = origin; + origin = seq; + return prev; +} + export function insertSubs(node: Signal | Computed, optimistic: boolean = false): void { // Get source lane: prefer node's own lane over current context // This is important for isPending signals which need their own lane to flush immediately @@ -1008,6 +1056,10 @@ function commitPendingNode(n: Signal): void { c._loading = false; c._flags! &= ~REACTIVE_MANUAL_WRITE; if (!(c._statusFlags! & STATUS_PENDING)) c._statusFlags! &= ~STATUS_UNINITIALIZED; + // A flight this commit leaves in the air (unobserved, or observed only by + // a boundary) now has PUBLISHED inputs: its committed value is stale + // against the frame. read()'s reveal carve-out keys on the mark (#3305). + else n._config |= CONFIG_INPUTS_PUBLISHED; if (c._x != null && (c._x._pendingFirstChild !== null || c._x._pendingDisposal !== null)) GlobalQueue._dispose(c as Computed, false, true); if (n._config & CONFIG_HAS_COMPANIONS) GlobalQueue._snapCompanions!(n); @@ -1084,9 +1136,16 @@ export function finalizePureQueue( // the recompute and the effect phase land in this same pass and the value // the other transaction wrote into the slot is never published. // (No clear: a completed transition is never finalized again.) - if (completingTransition?._contested) - for (const el of completingTransition._contested) - if (!(el._flags & REACTIVE_DISPOSED)) enqueueSub(el); + // A transaction whose settle reverts optimism re-derives them post-revert + // instead (below, with the gated replay): between commitPendingNodes and + // _resolveOptimistic the truth is committed but the overrides still + // display, and a re-derive here would compose the two — the #3164 tear, + // one window later. The slot meanwhile holds the frame that is on screen. + const contested = completingTransition?._contested; + const revertsOptimism = + resolvePending && (completingTransition ?? finalizingBatch)._optimisticNodes.length !== 0; + if (contested && !revertsOptimism) + for (const el of contested) if (!(el._flags & REACTIVE_DISPOSED)) enqueueSub(el); // The commit hooks' and the boundary sweep's writes are the world this // heap run computes (a boundary re-enabled by the sweep reveals in the // same cycle its source settled in). @@ -1110,6 +1169,10 @@ export function finalizePureQueue( // Optimistic reversion: a non-empty batch means _optimisticWrite ran, // which installed the engine's hooks. if (batch._optimisticNodes.length) GlobalQueue._resolveOptimistic!(batch._optimisticNodes); + if (contested && revertsOptimism) { + for (const el of contested) if (!(el._flags & REACTIVE_DISPOSED)) enqueueSub(el); + schedule(); + } // Replay entanglement: subs recorded by the read-time gate get rescheduled // so they re-run with the now-committed values visible. The ambient batch // replays too — laneReadsCommitted records readers whose committed-view @@ -1318,6 +1381,9 @@ export function flush(fn?: () => T): T | void { globalQueue.flush(); if (__OBSERVE__) drained = true; } + // Provenance ends with the drain: every ambient window (an action's first + // slice, a landing's propagation) runs to this flush. + origin = 0; // Outside every try in this function (see the rule in attribution-hooks.ts): // the drain loop above is the one place all scheduled work funnels through, // so this is the "committed and effects ran, or parked" instant for @@ -1399,6 +1465,20 @@ export function currentTransition(transition: Transition) { return transition; } +/** + * The live transition blocked on `source` — the one whose render reader + * observed it pending (INV-3 records the observation in whichever transaction + * was active when the reader was notified). The observation is a fact about + * 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. + */ +export function waitingTransition(source: Computed): Transition | null { + for (const t of transitions) if (t._asyncReporters.has(source)) return t; + return null; +} + export function setActiveTransition(transition: Transition | null) { activeTransition = transition; } diff --git a/packages/signals/src/core/types.ts b/packages/signals/src/core/types.ts index 97a88cd7f..14fea4e1b 100644 --- a/packages/signals/src/core/types.ts +++ b/packages/signals/src/core/types.ts @@ -75,6 +75,16 @@ export interface NodeExtension { * layer's STORE_OPTIMISTIC_OWNERS stamps (#2899). `null` = ambient write. */ _overrideOwner: Transition | null | undefined; + /** `clock` at the active override's write. A sync recompute in the same + * tick derives from inputs that predate the override and does not + * supersede it (A18 supersession ordering, #3331). */ + _overrideTime: number; + /** Provenance of the active override's write: the scheduler's `origin` (the + * asking action's invocation sequence; 0 = mainline). An arriving answer + * whose flight an older action issued asked a question the override has + * since changed: it holds to commit instead of superseding (A18 + * supersession provenance, #3331). */ + _overrideStamp: number; _optimisticLane: OptimisticLane | undefined; _pendingSignal: Signal | undefined; // Lazy signal for isPending() _latestValueComputed: Computed | undefined; // Lazy computed for latest() diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 78e04da8f..08957b583 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -69,6 +69,7 @@ import { getKeySetNode, getNode, hasActiveOverride, + heldMaskView, runAuthoritative, stagedTruthPB, storeSetterNext, @@ -551,7 +552,15 @@ export function notifyOptimisticWrites(t: StoreNextTarget, pb: Record | undefined = nodes[key]; if (node === undefined) { - // A node born under a held fold is born as if it had always existed - // (#3336): committed value, the held write staged (see stageHeldKey). - const held = heldFoldTransition(target); - if (held !== null) current = (target.v as any)[key]; + // Born holding (#3336, and its #3330 store twin): a key first read while + // a live transaction holds this target is created as if it had existed + // when the hold was notified — committed value, the held value staged + // under the transaction (stageHeldKey). Two kinds of hold, one rule: + // - a held FOLD (pb): a setter's write to an unobserved key landed only + // in the pending backing; committed is `v[key]`, staged is `pb[key]` + // (undefined for a deleted key); + // - a held ADOPTION (ht, adoptPB): the adopted value already swapped + // into `v`; committed is the held view `hv[key]`, staged is `v[key]`. + // Its held-adoption notification (stageHeldAdoptions) ran before the + // node existed and the drain has nothing left to say. + // Without this the node was born from whichever view its first reader + // saw and never learned the other. + const fold = heldFoldTransition(target); + let held = heldAdoptionTransition(target); + if (held !== null) current = (target.hv as any)[key]; + else if ((held = fold) !== null) current = (target.v as any)[key]; // Create-floor diet: slotSignal bakes the whole node into one literal — // no options object, no equals/unobserved closures, no NodeExtension, // no post-construction expandos (acc + the wrap cache px/pxv are @@ -312,7 +325,16 @@ export function getNode( // A node born inside a live mark's identity scope inherits the mark // (the declaration walk could only cover nodes existing then). if (key !== $AFFECTS && affectsScopesLive()) inheritAffectsMarks(created, target.v, key); - if (held !== null) stageHeldKey(created, target, key, held); + if (held !== null) + stageHeldKey( + created, + fold !== null + ? target.del !== null && target.del.has(key) + ? undefined + : (target.pb as any)[key] + : (target.v as any)[key], + held + ); nodes[key] = node; target.nc++; markDescendants(target); @@ -378,6 +400,14 @@ function foreignHold(txn: Transition): boolean { ); } +/** The live transaction holding an adoption on `target` (adoptPB's `ht`; + * a latest()-pull PLAIN_HOLD is not a transaction), else null. */ +function heldAdoptionTransition(target: StoreNextTarget): Transition | null { + if (target.ht === null || target.ht === PLAIN_HOLD || heldMaskView(target) === null) return null; + const txn = currentTransition(target.ht); + return txn._done === false ? txn : null; +} + /** * Materialization under a hold (#3336). A setter's write to an UNOBSERVED * key lands only in the pending backing — there is no node to stage, and the @@ -391,19 +421,13 @@ function foreignHold(txn: Transition): boolean { * key before the hold: the leak in #3336's store variant — the render effect * read the held write on the key nothing had subscribed to (`pb` served * straight to an owner-context reader) and committed on the key a memo had. - * Stage the pending backing's value as the holding transaction's write — - * directly, not through setSignal: it is not a new write (it flushed with - * the setter's batch, A28) and it walks no subscriber (the node has none - * yet). Transition-stamped now, as `runFolded` does — no parked-flush pass - * will stamp it. + * Stage `nv` (the held value for the key — see getNode for which view it + * comes from) as the holding transaction's write — directly, not through + * setSignal: it is not a new write (it flushed with the setter's batch, + * A28) and it walks no subscriber (the node has none yet). Transition- + * stamped now, as `runFolded` does — no parked-flush pass will stamp it. */ -function stageHeldKey( - node: Signal, - target: StoreNextTarget, - key: PropertyKey, - txn: Transition -): void { - const nv = target.del !== null && target.del.has(key) ? undefined : (target.pb as any)[key]; +function stageHeldKey(node: Signal, nv: any, txn: Transition): void { if (slotNodeEquals.call(node, node._value, nv)) return; node._pendingValue = nv; node._transition = txn; @@ -714,7 +738,7 @@ let latestPullActive = false; * while the hold is live, and lazily clears a hold whose transition has * committed (transitions merge — resolve through currentTransition, same as * foldHeld's node stamps). */ -function heldMaskView(t: StoreNextTarget): Record | null { +export function heldMaskView(t: StoreNextTarget): Record | null { const ht = t.ht; if (ht === null) return null; if (ht !== PLAIN_HOLD && currentTransition(ht)?._done === true) return (t.ht = t.hv = null); @@ -751,20 +775,26 @@ export function adoptPB( if (target.ovl) materializePB(target); target.ab = target.pb; } else target.ab ??= foldOlds.get(target)!; - // #3074/#3075: a projection recompute deriving from uncommitted inputs - // swaps the backing SPECULATIVELY — committed-visibility readers must - // keep the pre-hold view until the hold resolves (a source held by a - // live transition, or a latest()-pull ahead of the flush). Post-await - // landings (write-override) stay immediately visible — landed truth — - // and clear any hold; optimistic families ride the lane machinery. - if (target.fam?.opt !== true) { - if (getWriteOverride()) { - target.ht = target.hv = null; - } else if (activeTransition !== null || latestPullActive) { - if (heldMaskView(target) === null) target.hv = target.v; - target.ht = activeTransition ?? PLAIN_HOLD; - } - } + } + // #3074/#3075: a projection recompute deriving from uncommitted inputs + // swaps the backing SPECULATIVELY — committed-visibility readers must + // keep the pre-hold view until the hold resolves (a source held by a + // live transition, or a latest()-pull ahead of the flush). Post-await + // landings (write-override) stay immediately visible — landed truth — + // and clear any hold. Optimistic families hold too (#3330 store twin): + // their tentative edits ride the lane machinery, but a sync derive + // adopting under a transaction is held TRUTH like any projection's — + // unheld, handlers read it early and an optimistic write equal to it + // compared as a no-op against the swapped-in backing. A plain store's + // reconcile inside an action holds the same way: its nodes stage under the + // transaction (the inline notify), and the backing must not show handlers + // and stale readers what the tracked read masks (signal parity, #3336). + if (getWriteOverride()) { + target.ht = target.hv = null; + } else if (activeTransition !== null || (!eager && latestPullActive)) { + if (heldMaskView(target) === null) target.hv = target.v; + target.ht = activeTransition ?? PLAIN_HOLD; + if (!eager && activeTransition !== null) heldAdoptions.add(target); } target.pb = null; // Overlay and accessor-scan state describe the OUTGOING backing — a @@ -1524,6 +1554,36 @@ function draftServe(target: StoreNextTarget, proxy: any): any { /** Targets written during the current (outermost) setter — notified at exit. */ const pendingNotify = new Set(); +/** Targets adopted under a live transaction this setter (adoptPB set a + * transaction hold) — their nodes are notified at the outermost exit. */ +const heldAdoptions = new Set(); + +/** + * Write-time notification for a transaction-held adoption (#3330 store twin; + * the setter path's notifyWrites twin for adoptions). A projection's fold + * normally notifies its nodes at the drain — and the drain of a batch parked + * in a live transaction is the transaction's COMMIT, so the nodes took the + * adopted values as fresh writes at commit time: every subscriber was + * re-marked and re-ran against a frame the lane had already published (a + * third `v=1 d=2`), where a signal's write had staged at write time and + * promoted silently. Staging here, inside the transaction's batch, makes the + * two paths one: the nodes carry transition-stamped `_pendingValue`s, their + * subscribers recompute in this flush and park with the transaction, and the + * commit promotes without re-notifying. `ab` moves to the adopted backing — + * the view the nodes were last told (#3296) — so the drain has nothing left + * to say and only path-copies. + */ +function stageHeldAdoptions(): void { + const staged = [...heldAdoptions]; + heldAdoptions.clear(); + for (const t of staged) { + const base = t.ab; + if (base === null || base === t.v) continue; + notifyFold(t, base, t.v); + t.ab = t.v; + } +} + const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]); /** Mirror of core read()'s context rule: the OWNER context (not the tracking @@ -2292,6 +2352,7 @@ export function storeSetterNext(proxy: T, fn: (draft: T) => T | void, guard = adoptPB(target, unwrapValue(result)); } } + if (writing === 0 && heldAdoptions.size) stageHeldAdoptions(); } // Affects integration: the legacy affects machinery reads next targets diff --git a/packages/signals/tests/createOptimistic.test.ts b/packages/signals/tests/createOptimistic.test.ts index c0cba25ca..e9cfe20ba 100644 --- a/packages/signals/tests/createOptimistic.test.ts +++ b/packages/signals/tests/createOptimistic.test.ts @@ -3156,10 +3156,15 @@ describe("createOptimistic", () => { expect(pendingVals.at(-1)).toBe(false); // ACTION 1: News -> Finance + // One resolver per action: the second click must not orphan the first + // action's continuation (an unresumed action holds the merged + // transaction open forever, and every "final state" assertion below + // would pass vacuously against the frozen override). + const resolveUpdates: Array<() => void> = []; const handleSelect = action(function* (cat: string) { setOptimistic(cat); yield new Promise(r => { - resolveUpdate = r; + resolveUpdates.push(r); }); refresh(userCategory); }); @@ -3202,24 +3207,32 @@ describe("createOptimistic", () => { // Now complete action 1's background work dbCategory = "Finance"; - resolveUpdate!(); + resolveUpdates[0](); await Promise.resolve(); flush(); resolveCategory!("Finance"); await Promise.resolve(); flush(); - resolveDetails!(items["Finance"]); - await Promise.resolve(); - flush(); - - // After action 1 completes, should still show Sports (action 2 override) + // A18 supersession provenance (#3331): the source answered "Finance" ≠ + // the displayed "Sports", but the flight that brought it was action 1's + // — an OLDER question than the "Sports" override. A slow source does + // not leak back in over a newer intent: the answer is staged for the + // commit and nothing moves. details keeps Sports, no refetch, no + // pending flip. expect(optimistic()).toBe("Sports"); + expect(isPending(details)).toBe(false); + expect(pendingRuns.at(-1)).toBe(false); + expect(selectedVals.at(-1)).toBe("Sports"); + expect(detailVals.at(-1)).toEqual(["Live Scores"]); // Complete action 2's background work dbCategory = "Sports"; - resolveUpdate!(); + resolveUpdates[1](); await Promise.resolve(); flush(); + // Action 2's own answer: "Sports" confirms the override and the + // transaction can commit. (details never refetched — the resolver is + // the already-settled Sports fetch.) resolveCategory!("Sports"); await Promise.resolve(); flush(); @@ -3229,7 +3242,12 @@ describe("createOptimistic", () => { // Final state: Sports expect(optimistic()).toBe("Sports"); + expect(latest(optimistic)).toBe("Sports"); expect(selectedVals.at(-1)).toBe("Sports"); + expect(detailVals.at(-1)).toEqual(["Live Scores"]); + // Finance details were shown once (action 1's own reveal) and never + // refetched: the stale answer never moved the graph. + expect(detailVals.filter(v => v[0] === "Stock Ticker")).toHaveLength(1); expect(pendingRuns.at(-1)).toBe(false); }); }); diff --git a/packages/signals/tests/lane-hold-on-observation.test.ts b/packages/signals/tests/lane-hold-on-observation.test.ts index ea3d4983b..e18bf5405 100644 --- a/packages/signals/tests/lane-hold-on-observation.test.ts +++ b/packages/signals/tests/lane-hold-on-observation.test.ts @@ -291,4 +291,119 @@ describe("lane async holds on observation, like a transaction (#3289)", () => { dispose(); }); }); + + /** + * #3335 — a hold is a property of the async node, not of the root lane's + * transaction. Two optimistic writes from two transactions merge through a + * shared reader (A15 for lanes: one reveal unit). Each lane's observed + * async was recorded in ITS transaction; after the merge the root belongs + * to one of them. The merged lane is held while ANY member's observed + * async is in flight — the check must follow each node to the transaction + * that observed it, never the root's. + */ + describe("merged lanes across transactions hold on every member's async (#3335)", () => { + function setup() { + const [a, setAInner] = createOptimistic(0); + const [b, setBInner] = createOptimistic(0); + const gateA = deferred(); + const gateB = deferred(); + const cells = { A: 0, B: 0, pair: "0:0" }; + const frames: string[] = []; + const snap = () => `A=${cells.A} B=${cells.B} pair=${cells.pair}`; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const asyncA = createMemo(async () => { + const v = a(); + if (v !== 0) await gateA.promise; + return v; + }); + const asyncB = createMemo(async () => { + const v = b(); + if (v !== 0) await gateB.promise; + return v; + }); + const pair = createMemo(() => `${a()}:${b()}`); + // Per-cell readers, as compiled JSX produces: the pair memo is the + // only shared reader, and it is what merges the two lanes. + createRenderEffect(asyncA, v => { + cells.A = v; + frames.push(snap()); + }); + createRenderEffect(asyncB, v => { + cells.B = v; + frames.push(snap()); + }); + createRenderEffect(pair, v => { + cells.pair = v; + frames.push(snap()); + }); + }); + flush(); + const holdA = deferred(); + const holdB = deferred(); + const actA = action(function* (v: number) { + setAInner(v); + yield holdA.promise; + }); + const actB = action(function* (v: number) { + setBInner(v); + yield holdB.promise; + }); + return { actA, actB, gateA, gateB, holdA, holdB, frames, dispose }; + } + + it("the later lane's flight landing first does not release the merged reveal", async () => { + const { actA, actB, gateA, gateB, holdA, holdB, frames, dispose } = setup(); + await tick(); + frames.length = 0; + const doneA = actA(1); + await tick(); + expect(frames).toEqual([]); // A's async observed pending: lane A held + const doneB = actB(1); + await tick(); + expect(frames).toEqual([]); // merged: still held on A's flight + gateB.resolve(); + await tick(); + // B landed, but A — observed in A's transaction — is still in flight. + expect(frames).toEqual([]); + gateA.resolve(); + await tick(); + // One reveal unit: nothing ran before this round (asserted above) and + // all three cells land in it. Effects apply sequentially inside the + // round; the frame the screen shows is its end state. + expect(frames).toHaveLength(3); + expect(frames.at(-1)).toBe("A=1 B=1 pair=1:1"); + holdA.resolve(); + holdB.resolve(); + await doneA; + await doneB; + await tick(); + dispose(); + }); + + it("the earlier lane's flight landing first does not release the merged reveal", async () => { + const { actA, actB, gateA, gateB, holdA, holdB, frames, dispose } = setup(); + await tick(); + frames.length = 0; + const doneA = actA(1); + await tick(); + const doneB = actB(1); + await tick(); + gateA.resolve(); + await tick(); + // A landed; B — the root's own transaction — is still in flight. + expect(frames).toEqual([]); + gateB.resolve(); + await tick(); + expect(frames).toHaveLength(3); + expect(frames.at(-1)).toBe("A=1 B=1 pair=1:1"); + holdA.resolve(); + holdB.resolve(); + await doneA; + await doneB; + await tick(); + dispose(); + }); + }); }); diff --git a/packages/signals/tests/reveal-carve-out.test.ts b/packages/signals/tests/reveal-carve-out.test.ts new file mode 100644 index 000000000..a37d5c3df --- /dev/null +++ b/packages/signals/tests/reveal-carve-out.test.ts @@ -0,0 +1,336 @@ +// A15 reveal corollary, re-ruled 2026-09-10 (#3305, #3334; review on #3347). +// +// A stale (render) reader that lands on a node pending in some OTHER +// transaction shows the node's committed value, does not entangle the two +// transactions, and re-derives at that transaction's commit — parallel +// transactions, effects don't entangle. The carve-out is refused (the reveal +// holds on the flight) only when the committed value would tear against the +// visible frame: the flight's inputs were PUBLISHED while it was pending +// (CONFIG_INPUTS_PUBLISHED — a batch committed with the node still in the +// air, #3305) or the node rides a live lane (optimistic / latest, #3334). +// Those refusals are pinned in spec-async-semantics; this file pins the +// carve-out itself, the replay that finishes it, and the exactness of the +// two refusals (neither is sticky). +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + flush +} from "../src/index.js"; + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise(r => (resolve = r)); + return { promise, resolve }; +} +async function settle() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flush(); +} +type Gate = { v: number; d: ReturnType> }; + +describe("reveal carve-out: a stale reader of a foreign-held flight (A15)", () => { + // GabbeV's case A (#3347 review): B is revealed while `input`'s write is + // held by the flight `slow` blocks. It shows the coherent committed pair + // `0:0` (input is 0 on screen; `fast` settled on 0) and must catch up when + // the hold commits — the recording in `_gatedSubs` is the notification, + // since the commit itself is silent. + it("a reader revealed during a held update shows the committed pair and catches up at the commit", async () => { + const rendered: Record = {}; + const gates: Gate[] = []; + let setInput!: (v: number) => void; + let setShow!: (v: boolean) => void; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [input, si] = createSignal(0); + const [show, ss] = createSignal(false); + setInput = si; + setShow = ss; + const shared = createMemo(() => input()); + const slow = createMemo(() => { + const v = shared(); + const d = deferred(); + gates.push({ v, d }); + return d.promise; + }); + const fast = createMemo(async () => shared()); + const observe = (name: string, read: () => unknown) => + createRenderEffect(read, v => void (rendered[name] = v)); + observe("input", input); + observe("show", show); + observe("A", () => `${input()}:${slow()}`); + observe("B", () => (show() ? `${input()}:${fast()}` : "hidden")); + }); + try { + flush(); + gates.shift()!.d.resolve(0); + await settle(); + expect(rendered).toEqual({ input: 0, show: false, A: "0:0", B: "hidden" }); + + setInput(2); + await settle(); + expect(rendered).toEqual({ input: 0, show: false, A: "0:0", B: "hidden" }); + + // The reveal: `show` commits (parallel transaction), B shows committed. + setShow(true); + await settle(); + expect(rendered).toEqual({ input: 0, show: true, A: "0:0", B: "0:0" }); + + const g = gates.shift()!; + g.d.resolve(g.v); + await settle(); + await settle(); + expect(rendered).toEqual({ input: 2, show: true, A: "2:2", B: "2:2" }); + } finally { + dispose(); + } + }); + + // GabbeV's case B: two conditional readers hide, are asked to show again + // while `details` is pending, and the input is changed back. Every reader + // must publish the final world once it settles. + it("a conditional reader hidden and re-shown around a pending flight publishes the settled world", async () => { + const rendered: Record = {}; + const gates: Gate[] = []; + let setInput!: (v: number) => void; + let setShow!: (v: boolean) => void; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [input, si] = createSignal(0); + const [show, ss] = createSignal(true); + setInput = si; + setShow = ss; + const details = createMemo(() => { + const v = input(); + const d = deferred(); + gates.push({ v, d }); + return d.promise; + }); + const observe = (name: string, read: () => unknown) => + createRenderEffect(read, v => void (rendered[name] = v)); + observe("input", input); + observe("show", show); + observe("A", () => (show() ? input() : "hidden")); + observe("B", () => (show() ? details() : "hidden")); + }); + try { + flush(); + gates.shift()!.d.resolve(0); + await settle(); + expect(rendered).toEqual({ input: 0, show: true, A: 0, B: 0 }); + + // The hide is queued ahead of the flush the write schedules. + Promise.resolve().then(() => setShow(false)); + setInput(3); + await settle(); + setShow(true); + await settle(); + setInput(0); + await settle(); + for (const g of gates.splice(0)) g.d.resolve(g.v); + await settle(); + await settle(); + for (const g of gates.splice(0)) g.d.resolve(g.v); + await settle(); + await settle(); + expect(rendered).toEqual({ input: 0, show: true, A: 0, B: 0 }); + } finally { + dispose(); + } + }); + + // The published-inputs mark outlives the flight that earned it for as long + // as the node stays pending — and no longer. `details`' first flight is + // unobserved, so its write commits beneath it: `input` is 1 on screen while + // the node's committed value still derives from 0. The next write is HELD + // (a sibling flight, `twin`, is observed and opens T1), `details` replaces + // its flight under T1 and is stamped with it. A reveal in another + // transaction now finds a foreign-stamped pending node whose committed + // value would tear against the published 1: it must hold (#3305), not carve + // out. Once the node lands and later goes pending fresh under a hold, the + // mark is gone and the carve-out applies again. + it("a replacement flight over published inputs refuses the carve-out; a fresh flight after landing carves out again", async () => { + const gates: Array = []; + const out: unknown[] = []; + let setInput!: (v: number) => void; + let setPick!: (v: number) => void; + let input!: () => number; + let details!: () => number; + let dispose!: () => void; + const land = (who: string) => { + const i = gates.findIndex(g => g.who === who); + expect(i).not.toBe(-1); + const [g] = gates.splice(i, 1); + g.d.resolve(g.v); + }; + createRoot(d => { + dispose = d; + const [input_, si] = createSignal(0); + const [pick, sp] = createSignal(0); + input = input_; + setInput = si; + setPick = sp; + const fetch = (who: string) => { + const v = input_(); + const d = deferred(); + gates.push({ who, v, d }); + return d.promise; + }; + details = createMemo(() => fetch("details")); + const twin = createMemo(() => fetch("twin")); + createRenderEffect( + () => (pick() ? details() : "other"), + v => void out.push(v) + ); + createRenderEffect(twin, () => {}); + createRenderEffect(input_, () => {}); + }); + try { + flush(); + land("details"); + land("twin"); + await settle(); + setPick(1); // initialize details through the reader + flush(); + expect(out).toEqual(["other", 0]); + setPick(0); + flush(); + out.length = 0; + + // Flight 1: `details` is unobserved — the write commits beneath it + // (published); `twin` is observed and holds nothing else. + setInput(1); + flush(); + land("twin"); + await settle(); + await settle(); + expect([input(), gates.map(g => g.who)]).toEqual([1, ["details"]]); + + // The held write: `twin` re-reports, T1 opens holding `input = 2` and + // stamps both flights; `details` replaces its flight under the hold. + setInput(2); + flush(); + expect([input(), gates.map(g => g.who)]).toEqual([1, ["details", "details", "twin"]]); + + // The reveal: committed value derives from 0, the screen shows 1 — hold. + setPick(1); + flush(); + expect(out).toEqual([]); + land("details"); // the superseded flight's promise — ignored + land("details"); + land("twin"); + await settle(); + await settle(); + expect([input(), out]).toEqual([2, [2]]); + setPick(0); + flush(); + out.length = 0; + + // A fresh flight from a settled state, held in T1 — unmarked: the + // reveal shows committed 2, then the landing. + createRoot(() => + createRenderEffect( + () => details(), + () => {} + ) + ); + flush(); + setInput(3); + flush(); + expect([input(), gates.map(g => g.who)]).toEqual([2, ["details", "twin"]]); + setPick(1); + flush(); + expect(out).toEqual([2]); + land("details"); + land("twin"); + await settle(); + await settle(); + expect([input(), out]).toEqual([3, [2, 3]]); + } finally { + dispose(); + } + }); + + // Lane routing is judged live (`resolveLane`): a node that was once derived + // under an optimistic lane, whose lane has since retired, is a plain held + // flight to a later reveal. + it("a retired lane does not make a later plain-held flight refuse the carve-out", async () => { + const gates: Gate[] = []; + const out: unknown[] = []; + let setSrc!: (v: number) => void; + let setPick!: (v: number) => void; + let write!: (v: number) => Promise; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [src, ss] = createSignal(0); + const [b, setB] = createOptimistic(src); + const [pick, sp] = createSignal(0); + setSrc = ss; + setPick = sp; + const details = createMemo(() => { + const v = b(); + const d = deferred(); + gates.push({ v, d }); + return d.promise; + }); + const act = action(function* (v: number) { + setB(v); + setSrc(v); + yield Promise.resolve(); + }); + write = v => act(v); + createRenderEffect(b, () => {}); + createRenderEffect( + () => details(), + () => {} + ); + createRenderEffect( + () => (pick() ? details() : "other"), + v => void out.push(v) + ); + }); + try { + flush(); + gates.shift()!.d.resolve(0); + await settle(); + await settle(); + + // A lane-routed flight: details derives under b's lane, lands, and the + // action completes — the lane retires. + const done = write(1); + await settle(); + let g = gates.shift()!; + g.d.resolve(g.v); + await done; + await settle(); + await settle(); + out.length = 0; + + // A plain held write: the observed flight holds src's write in T1. + setSrc(2); + flush(); + expect(gates.length).toBe(1); + // A reveal in another transaction: no live lane, inputs unpublished — + // committed 1 now, the landing later. + setPick(1); + flush(); + expect(out).toEqual([1]); + g = gates.shift()!; + g.d.resolve(g.v); + await settle(); + await settle(); + expect(out).toEqual([1, 2]); + } finally { + dispose(); + } + }); +}); diff --git a/packages/signals/tests/spec-async-semantics.test.ts b/packages/signals/tests/spec-async-semantics.test.ts index 149bfb4e0..0e4c18ac9 100644 --- a/packages/signals/tests/spec-async-semantics.test.ts +++ b/packages/signals/tests/spec-async-semantics.test.ts @@ -283,6 +283,188 @@ describe("A15 (was B3): overlapping transitions settle as one unit", () => { } ); + // #3334 — the flight the reveal discovers is lane-owned: the optimistic (or + // latest) value revealed through its lane ahead of the transaction that + // owns it, and that transaction stays open (a live action). The reveal + // joins the transition whose blocker the flight is and completes when the + // flight lands — it must not fold into the lane's owning transaction, + // whose commit is only the override's confirm/revert. Same timing as the + // plain-signal case above; the owning transaction's lifetime is irrelevant. + it.each(["optimistic", "latest"] as const)( + "holds a reveal of an existing LANE flight until the flight lands, not until the lane's transaction completes (%s, #3334)", + async kind => { + const [show, setShow] = createSignal(false); + const fetcher = deferredFetcher(v => v); + let release!: () => void; + const hold = new Promise(r => (release = r)); + let get!: () => number; + let write!: (v: number) => Promise; + let valueLine: number | undefined; + let showLine: readonly [boolean, boolean] | undefined; + let slot: number | string | undefined; + let dispose!: () => void; + + createRoot(d => { + dispose = d; + if (kind === "optimistic") { + const [b, setB] = createOptimistic(0); + get = b; + const act = action(function* (v: number) { + setB(v); + yield hold; + }); + write = v => act(v); + } else { + const [c, setC] = createSignal(0); + get = () => latest(c); + const act = action(function* (v: number) { + setC(v); + yield hold; + }); + write = v => act(v); + } + const details = createMemo(() => fetcher.fetch(get())); + createRenderEffect(get, v => { + valueLine = v; + }); + createRenderEffect( + () => [show(), isPending(show)] as const, + v => { + showLine = v; + } + ); + createRenderEffect( + () => (show() ? details() : "hidden"), + v => { + slot = v; + } + ); + }); + + try { + flush(); + fetcher.resolveAll(); + await settle(); + expect([valueLine, showLine, slot]).toEqual([0, [false, false], "hidden"]); + + // The lane reveals the value at once; its flight (details) is in the + // air but unobserved, so nothing holds. + const done = write(1); + await settle(); + expect([valueLine, showLine, slot]).toEqual([1, [false, false], "hidden"]); + + // The reveal discovers the flight started in the earlier flush: held, + // exactly as for a plain signal. + setShow(true); + flush(); + expect([valueLine, showLine, slot]).toEqual([1, [false, true], "hidden"]); + expect(show()).toBe(false); + + // The flight lands. The lane's owning transaction is still open (the + // action has not finished) — the reveal does not wait for it. + fetcher.resolveAll(); + await settle(); + expect([valueLine, showLine, slot]).toEqual([1, [true, false], 1]); + expect(show()).toBe(true); + + release(); + await done; + await settle(); + } finally { + dispose(); + } + } + ); + + // Two reveals, in separate flushes, discovering the same flight: each holds + // on it and both complete at the landing (they settle as one unit). The + // second reveal finds the flight already observed — and its source already + // stamped by the first reveal's transaction — which must not turn it into a + // "show the committed value" read (#3305 with a plain signal, #3334 with a + // lane-owned flight). + it.each(["signal", "optimistic"] as const)( + "holds every reveal that discovers the same flight; all complete at the landing (%s)", + async kind => { + const [show1, setShow1] = createSignal(false); + const [show2, setShow2] = createSignal(false); + const fetcher = deferredFetcher(v => v); + let release!: () => void; + const hold = new Promise(r => (release = r)); + let get!: () => number; + let write!: (v: number) => Promise | void; + const frames: unknown[] = []; + let dispose!: () => void; + + createRoot(d => { + dispose = d; + if (kind === "optimistic") { + const [b, setB] = createOptimistic(0); + get = b; + const act = action(function* (v: number) { + setB(v); + yield hold; + }); + write = v => act(v); + } else { + const [c, setC] = createSignal(0); + get = c; + write = v => setC(v); + } + const details = createMemo(() => fetcher.fetch(get())); + createRenderEffect( + () => (show1() ? details() : "h1"), + v => void frames.push(["1", v, isPending(show1)]) + ); + createRenderEffect( + () => (show2() ? details() : "h2"), + v => void frames.push(["2", v, isPending(show2)]) + ); + createRenderEffect(get, v => void frames.push(["value", v])); + }); + + try { + flush(); + fetcher.resolveAll(); + await settle(); + frames.length = 0; + + const done = write(1); + await settle(); + expect(frames).toEqual([["value", 1]]); + frames.length = 0; + + setShow1(true); + flush(); + setShow2(true); + flush(); + expect(frames).toEqual([]); + expect([show1(), show2()]).toEqual([false, false]); + + fetcher.resolveAll(); + await settle(); + expect([show1(), show2()]).toEqual([true, true]); + // No frame ever shows a revealed panel with the pre-flight value 0. + // (With a lane-owned flight, the lane's own landing pass first + // re-applies each panel under the lane's committed view — `show` still + // false there, so the same "hidden" value it already showed — before + // the reveal commits; `laneReadsCommitted` records it for replay at + // commit. Redundant, consistent, and not what this pin is about.) + const revealed = frames.filter(f => typeof (f as unknown[])[1] === "number"); + expect(revealed).toEqual([ + ["1", 1, false], + ["2", 1, false] + ]); + expect(frames.filter(f => (f as unknown[])[1] === 0)).toEqual([]); + + release(); + await done; + await settle(); + } finally { + dispose(); + } + } + ); + it.each(["new", "reset"])( "lets a %s loading boundary catch an existing flight without holding the reveal", async mode => { @@ -590,6 +772,117 @@ describe("A17 (was C4): an active override is THE value — every reader, until expect(derivedLog).toEqual(["derived(99)", "derived(20)"]); }); + // #3330 (INV-11): a lane recompute publishes to `_value` — the lane's own + // reveal — so its change detection compares against `_value`, not against a + // `_pendingValue` some transaction staged earlier. Here the action stages + // `serverValue = 1` first (so `doubled` already HOLDS 2 for the commit) and + // only later writes the override; the lane's recompute of `doubled` also + // yields 2, which "equals" the held value but not the screen's 0. Before + // the fix the lane called it unchanged and revealed `value = 1` beside + // `doubled = 0` — a torn frame — until the action's commit caught up. + it("a derivation of the override reveals with it even when the transaction already holds the same result (#3330)", async () => { + const tick = () => new Promise(r => setTimeout(r, 0)); + const drain = async () => { + for (let i = 0; i < 6; i++) await tick(); + flush(); + }; + const [serverValue, setServerValue] = createSignal(0); + const log: string[] = []; + let release!: () => void; + let releaseEnd!: () => void; + let value!: SourceAccessor; + let setOptimistic!: (v: number) => void; + createRoot(() => { + [value, setOptimistic] = createOptimistic(serverValue); + const doubled = createMemo(() => value() * 2); + createRenderEffect( + () => `v=${value()} d=${doubled()}`, + s => { + log.push(s); + } + ); + }); + flush(); + expect(log).toEqual(["v=0 d=0"]); + + const run = action(function* () { + setServerValue(1); + yield new Promise(r => (release = r)); + setOptimistic(1); + yield new Promise(r => (releaseEnd = r)); + }); + const done = run(); + await drain(); + // Transaction held: the staged truth (and its staged derivation) is + // invisible. + expect(log).toEqual(["v=0 d=0"]); + + release(); + await drain(); + // The override reveals on its lane WITH its derivation — one frame. + expect(log).toEqual(["v=0 d=0", "v=1 d=2"]); + + releaseEnd(); + await done; + await drain(); + // The commit changes nothing the effect read (the lane already published + // `doubled`'s value, so its staged copy promotes to the same view) and so + // does not replay it (laneReadsCommitted records only a real + // staged-vs-committed gap). One frame per change, never a torn one. + expect(log).toEqual(["v=0 d=0", "v=1 d=2"]); + expect(value()).toBe(1); + expect(isPending(value)).toBe(false); + }); + + // Companion to the above: skipping the replay is per value. When the lane + // later publishes a DIFFERENT frame, the commit still restores and applies + // the transaction's frame over the optimistic one. + it("a later lane frame differs: the commit re-applies the transaction's frame", async () => { + const tick = () => new Promise(r => setTimeout(r, 0)); + const drain = async () => { + for (let i = 0; i < 6; i++) await tick(); + flush(); + }; + const [serverValue, setServerValue] = createSignal(0); + const log: string[] = []; + let release!: () => void; + let value!: SourceAccessor; + let setOptimistic!: (v: number) => void; + createRoot(() => { + [value, setOptimistic] = createOptimistic(serverValue); + const doubled = createMemo(() => value() * 2); + createRenderEffect( + () => `v=${value()} d=${doubled()}`, + s => { + log.push(s); + } + ); + }); + flush(); + log.length = 0; + + const run = action(function* () { + setServerValue(1); + yield new Promise(r => (release = r)); + setOptimistic(1); // lane applies the frame the transaction already holds + yield Promise.resolve(); + setOptimistic(5); // lane applies a different frame; the mark clears + yield new Promise(r => (release = r)); + }); + const done = run(); + await drain(); + release(); + await drain(); + expect(log).toEqual(["v=1 d=2", "v=5 d=10"]); + + release(); + await done; + await drain(); + // Commit: the override reverts and the transaction's frame is back. + expect(value()).toBe(1); + expect(log.at(-1)).toBe("v=1 d=2"); + }); + it("simple graph: override visible ambiently until its own fetch settles", async () => { const [id, setId] = createSignal(1); const fetcher = deferredFetcher((t: number) => t * 10); @@ -623,7 +916,17 @@ describe("A18 (was B4): an override's lifetime is bound to its own async source, // needs correction and triggers further async." The authoritative value // wins the moment it arrives; the correction cascade (and any async it // triggers) must not wait for strangers in a merged transition. - it("entangled: own-source resolution clears the override while an unrelated fetch is still pending", async () => { + // Re-ruled 2026-07-07b and again 2026-09-09 (#3331): own-source resolution + // ends the optimism for the GRAPH at once — tracked readers derive from the + // arrived truth, and that corrected work belongs to the transaction — while + // the override remains the DISPLAYED value (untracked reads, the applied + // frame) until the transaction commits. Here `joined` re-derives from the + // truth and, doing so, observes the unrelated fetch still pending: the + // transaction holds for it (A15), and the correction reveals with it — one + // frame, "20|200", never "20|100". (Before, the lane read `mOther`'s + // committed value, so the transaction closed at the landing and committed + // `other = 2` while the screen still showed mOther(1).) + it("entangled: own-source resolution supersedes the override for the graph; display and untracked reads keep it until the merged commit", async () => { const [id, setId] = createSignal(1); const [other, setOther] = createSignal(1); const dataFetch = deferredFetcher((t: number) => t * 10); @@ -652,17 +955,24 @@ describe("A18 (was B4): an override's lifetime is bound to its own async source, setData(99); flush(); expect(data()).toBe(99); // override active while own fetch is in flight (A17) + expect(log).toEqual(["99|100"]); - // Own source resolves: the fresh value wins NOW — the override must not be - // held hostage by the still-pending unrelated fetch in the merged transition. + // Own source resolves with a DIFFERENT value: the graph moves to it now + // (latest() sees it; the verdict says the displayed value is not final), + // while the displayed frame and untracked reads keep the override until + // the transaction — now holding for the re-derived `joined` — commits. dataFetch.resolveAll(); await settle(); - expect(data()).toBe(20); + expect(latest(data)).toBe(20); + expect(isPending(data)).toBe(true); + expect(data()).toBe(99); + expect(log).toEqual(["99|100"]); otherFetch.resolveAll(); await settle(); expect(data()).toBe(20); - expect(log[log.length - 1]).toBe("20|200"); + expect(isPending(data)).toBe(false); + expect(log).toEqual(["99|100", "20|200"]); }); // In the simple (unentangled) graph, own-source resolution and transition @@ -701,6 +1011,426 @@ describe("A18 (was B4): an override's lifetime is bound to its own async source, expect(data()).toBe(20); expect(valueLog).toEqual([99, 20]); }); + + // #3331 (ruled 2026-09-09): "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." The arrival + // is authoritative for the GRAPH the moment it lands — downstream async + // re-derives from it now, so no waterfall forms behind the override's own + // downstream flight — while the override stays the DISPLAYED value (the + // applied frame, untracked reads) until the transaction commits: "when the + // optimism drops we might not see it until end of transition because it + // folds into the parent's transition." + describe("#3331: own-source arrival supersedes the override on landing", () => { + // The reporter's shape: an optimistic node whose own async derives from a + // signal, with a further async memo downstream. Click = signal change + + // override; the override's downstream flight and the source refetch + // overlap. Before the fix the graph kept deriving from the override until + // the override's downstream flight landed and only THEN started deriving + // from the arrived truth — two sequential flights, ~double the delay. + function reporterGraph() { + const [value, setValue] = createSignal(0); + const doubleFetch = deferredFetcher((v: number) => v * 2); + const flights: Array<{ n: number; resolve: () => void }> = []; + const log: string[] = []; + let double!: SourceAccessor; + let setDouble!: (v: number) => void; + createRoot(() => { + [double, setDouble] = createOptimistic(() => doubleFetch.fetch(value())); + const asyncMemo = createMemo(() => { + const n = double(); + return new Promise(resolve => + flights.push({ n, resolve: () => resolve(`${n} async`) }) + ); + }); + const b = createLoadingBoundary( + () => `double=${double()} async=${asyncMemo()}`, + () => "loading" + ); + createRenderEffect(b, s => { + log.push(s); + }); + }); + return { setValue, doubleFetch, flights, log, double, setDouble }; + } + + async function primed() { + const g = reporterGraph(); + flush(); + g.doubleFetch.resolveAll(); + await settle(); + g.flights.shift()!.resolve(); + await settle(); + expect(g.log.at(-1)).toBe("double=0 async=0 async"); + g.log.length = 0; + return g; + } + + it("differing arrival: downstream async restarts from the truth immediately; screen and untracked reads keep the override until commit", async () => { + const g = await primed(); + + g.setValue(1); + g.setDouble(3); + flush(); + // The override's downstream flight (3) is in the air; the screen holds + // for it (A17: lane effects wait for their downstream async). + expect(g.flights.map(f => f.n)).toEqual([3]); + expect(g.log).toEqual([]); + expect(g.double()).toBe(3); + + // The source lands with 2 ≠ 3. The graph moves to 2 NOW: a flight for 2 + // starts without waiting for the 3-flight. + g.doubleFetch.resolveAll(); + await settle(); + expect(g.flights.map(f => f.n)).toEqual([3, 2]); + expect(latest(g.double)).toBe(2); // the arrived truth + expect(g.double()).toBe(3); // untracked read: still the displayed override + expect(isPending(g.double)).toBe(true); // displayed ≠ final + expect(g.log).toEqual([]); + + // The superseded 3-flight landing changes nothing — it is not the truth. + g.flights.shift()!.resolve(); + await settle(); + expect(g.log).toEqual([]); + expect(g.double()).toBe(3); + + // The 2-flight lands: the transaction commits, the optimism is gone. + g.flights.shift()!.resolve(); + await settle(); + expect(g.log).toEqual(["double=2 async=2 async"]); + expect(g.double()).toBe(2); + expect(isPending(g.double)).toBe(false); + }); + + it("equal arrival: confirms silently — no new work, the lane's flight completes the frame", async () => { + const g = await primed(); + + g.setValue(1); + g.setDouble(2); // the user guessed right + flush(); + expect(g.flights.map(f => f.n)).toEqual([2]); + + g.doubleFetch.resolveAll(); + await settle(); + // Nothing restarted; the value is simply confirmed. + expect(g.flights.map(f => f.n)).toEqual([2]); + expect(latest(g.double)).toBe(2); + expect(g.double()).toBe(2); + expect(g.log).toEqual([]); // still held for the downstream flight + + g.flights.shift()!.resolve(); + await settle(); + expect(g.log).toEqual(["double=2 async=2 async"]); + expect(isPending(g.double)).toBe(false); + }); + + // The source need not be the node's own async. The common real-world + // shape wraps an async memo synchronously — `createOptimistic(() => + // userCategory())` — and the truth reaches the optimistic node as a SYNC + // recompute when the upstream memo lands. "If the source recomputes it + // doesn't matter if it is async or not" (maintainer, 2026-09-10). + it("sync wrapper over an async source: the upstream landing supersedes just the same", async () => { + const [value, setValue] = createSignal(0); + const doubleFetch = deferredFetcher((v: number) => v * 2); + const flights: Array<{ n: number; resolve: () => void }> = []; + const log: string[] = []; + let double!: SourceAccessor; + let setDouble!: (v: number) => void; + createRoot(() => { + const upstream = createMemo(() => doubleFetch.fetch(value())); + [double, setDouble] = createOptimistic(() => upstream()); + const asyncMemo = createMemo(() => { + const n = double(); + return new Promise(resolve => + flights.push({ n, resolve: () => resolve(`${n} async`) }) + ); + }); + const b = createLoadingBoundary( + () => `double=${double()} async=${asyncMemo()}`, + () => "loading" + ); + createRenderEffect(b, s => { + log.push(s); + }); + }); + flush(); + doubleFetch.resolveAll(); + await settle(); + flights.shift()!.resolve(); + await settle(); + expect(log.at(-1)).toBe("double=0 async=0 async"); + log.length = 0; + + setValue(1); + setDouble(3); + flush(); + expect(flights.map(f => f.n)).toEqual([3]); + + doubleFetch.resolveAll(); + await settle(); + expect(flights.map(f => f.n)).toEqual([3, 2]); + expect(latest(double)).toBe(2); + expect(double()).toBe(3); + expect(isPending(double)).toBe(true); + expect(log).toEqual([]); + + flights.shift()!.resolve(); + await settle(); + expect(log).toEqual([]); + flights.shift()!.resolve(); + await settle(); + expect(log).toEqual(["double=2 async=2 async"]); + expect(double()).toBe(2); + expect(isPending(double)).toBe(false); + }); + + // Ordering: "a new value from the source" 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. + it("same-batch source write and override: the override is the newer intent, no supersession", async () => { + const [sig, setSig] = createSignal(1); + const log: number[] = []; + let node!: SourceAccessor; + let setNode!: (v: number) => void; + createRoot(() => { + [node, setNode] = createOptimistic(() => sig() * 2); + const derived = createMemo(() => node() + 1); + createRenderEffect(derived, v => { + log.push(v); + }); + }); + flush(); + expect(log).toEqual([3]); + + let release!: () => void; + const run = action(function* () { + setSig(2); // truth: 4 + setNode(9); // override written over it, same batch + yield new Promise(r => (release = r)); + }); + const done = run(); + await settle(); + expect(node()).toBe(9); + expect(latest(node)).toBe(9); // not superseded: the override is the graph's value + expect(isPending(node)).toBe(true); // ...though the held 4 differs (A24) + expect(log).toEqual([3, 10]); // derivation follows the override + + release(); + await done; + await settle(); + await settle(); + expect(node()).toBe(4); + expect(log.at(-1)).toBe(5); + }); + + // Provenance: "the source" means 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 — a slow + // source leaking back in over the user's latest intent. That answer is + // stale: it is staged for the commit like any landing, but it does not + // move the graph (no downstream refetch, no pending flip on downstream + // readers). The newer action's own answer supersedes as usual. Mainline + // (no action) is always the current question. + it("provenance: an older action's answer arriving over a newer action's override holds silently — no leak-back", async () => { + const resolveUp: Array<(v: number) => void> = []; + const flights: Array<{ n: number; resolve: () => void }> = []; + const log: string[] = []; + let upstream!: SourceAccessor; + let double!: SourceAccessor; + let setDouble!: (v: number) => void; + createRoot(() => { + upstream = createMemo(() => new Promise(r => resolveUp.push(r))); + [double, setDouble] = createOptimistic(() => upstream()); + const asyncMemo = createMemo(() => { + const n = double(); + return new Promise(resolve => + flights.push({ n, resolve: () => resolve(`${n} async`) }) + ); + }); + const b = createLoadingBoundary( + () => `double=${double()} async=${asyncMemo()}`, + () => "loading" + ); + createRenderEffect(b, s => { + log.push(s); + }); + }); + flush(); + resolveUp.shift()!(0); + await settle(); + flights.shift()!.resolve(); + await settle(); + expect(log.at(-1)).toBe("double=0 async=0 async"); + log.length = 0; + + const releases: Array<() => void> = []; + const select = action(function* (guess: number) { + setDouble(guess); + yield new Promise(r => releases.push(r)); + refresh(upstream); + }); + + const a = select(3); + flush(); + const b = select(5); + flush(); + expect(flights.map(f => f.n)).toEqual([3, 5]); + expect(double()).toBe(5); + flights.shift()!.resolve(); + flights.shift()!.resolve(); + await settle(); + expect(log.at(-1)).toBe("double=5 async=5 async"); + log.length = 0; + + // Action A (older) completes: its refetch answers 2 ≠ the displayed 5. + releases[0](); + await settle(); + expect(resolveUp).toHaveLength(1); + resolveUp.shift()!(2); + await settle(); + // Stale question: held for the commit, nothing moves. + expect(flights).toEqual([]); // no downstream refetch for 2 + expect(latest(double)).toBe(5); // not superseded + expect(double()).toBe(5); + expect(isPending(double)).toBe(true); // ...though the held 2 differs (A24) + expect(log).toEqual([]); + + // Action B (the override's own) completes: its answer 6 ≠ 5 supersedes. + releases[1](); + await settle(); + expect(resolveUp).toHaveLength(1); + resolveUp.shift()!(6); + await settle(); + expect(flights.map(f => f.n)).toEqual([6]); + expect(latest(double)).toBe(6); + expect(double()).toBe(5); + expect(log).toEqual([]); + + flights.shift()!.resolve(); + await a; + await b; + await settle(); + expect(log).toEqual(["double=6 async=6 async"]); + expect(double()).toBe(6); + expect(isPending(double)).toBe(false); + }); + + // Same-value twin (review on #3347): the newer action predicts the SAME + // value as the older one. The write takes the same-value fast path — no + // new override, the transaction entangles — and must still renew the + // override's provenance: the user re-asked the question, so the older + // action's answer is stale to it exactly as with a differing guess. It + // used to keep the older stamp, and A's slow source then superseded a + // 5 the user had just re-confirmed: a corrective downstream refetch and a + // pending flip for nothing. + it("provenance: a same-value re-prediction by a newer action renews the override's provenance", async () => { + const resolveUp: Array<(v: number) => void> = []; + const flights: Array<{ n: number; resolve: () => void }> = []; + const pendingLog: boolean[] = []; + let upstream!: SourceAccessor; + let double!: SourceAccessor; + let asyncMemo!: SourceAccessor; + let setDouble!: (v: number) => void; + createRoot(() => { + upstream = createMemo(() => new Promise(r => resolveUp.push(r))); + [double, setDouble] = createOptimistic(() => upstream()); + asyncMemo = createMemo(() => { + const n = double(); + return new Promise(resolve => + flights.push({ n, resolve: () => resolve(`${n} async`) }) + ); + }); + createRenderEffect( + () => [double(), asyncMemo(), isPending(asyncMemo)] as const, + () => {} + ); + createRenderEffect( + () => isPending(asyncMemo), + p => void pendingLog.push(p) + ); + }); + flush(); + resolveUp.shift()!(0); + await settle(); + flights.shift()!.resolve(); + await settle(); + pendingLog.length = 0; + + const releases: Array<() => void> = []; + const select = action(function* (guess: number) { + setDouble(guess); + yield new Promise(r => releases.push(r)); + refresh(upstream); + }); + + const a = select(5); + flush(); + const b = select(5); // same value: fast path + flush(); + expect(flights.map(f => f.n)).toEqual([5]); + flights.shift()!.resolve(); + await settle(); + expect([double(), isPending(asyncMemo)]).toEqual([5, false]); + pendingLog.length = 0; + + // Action A (older) completes: its refetch answers 2 ≠ the displayed 5. + // B re-asked for 5 — A's answer is a stale question: held silently. + releases[0](); + await settle(); + expect(resolveUp).toHaveLength(1); + resolveUp.shift()!(2); + await settle(); + expect(flights).toEqual([]); // no downstream refetch for 2 + expect(latest(double)).toBe(5); // not superseded + expect(isPending(asyncMemo)).toBe(false); + expect(pendingLog).toEqual([]); + + // Action B (the override's own) completes: its answer supersedes. + releases[1](); + await settle(); + expect(resolveUp).toHaveLength(1); + resolveUp.shift()!(6); + await settle(); + expect(flights.map(f => f.n)).toEqual([6]); + expect(latest(double)).toBe(6); + flights.shift()!.resolve(); + await a; + await b; + await settle(); + expect([double(), isPending(asyncMemo)]).toEqual([6, false]); + }); + + it("no downstream async: a differing arrival corrects at the landing (simple graph, unchanged)", async () => { + const [value, setValue] = createSignal(0); + const doubleFetch = deferredFetcher((v: number) => v * 2); + const log: number[] = []; + let double!: SourceAccessor; + let setDouble!: (v: number) => void; + createRoot(() => { + [double, setDouble] = createOptimistic(() => doubleFetch.fetch(value())); + createRenderEffect(double, v => { + log.push(v); + }); + }); + flush(); + doubleFetch.resolveAll(); + await settle(); + log.length = 0; + + setValue(1); + setDouble(3); + flush(); + expect(log).toEqual([3]); + + doubleFetch.resolveAll(); + await settle(); + // Nothing else is held: the correction is the commit. + expect(log).toEqual([3, 2]); + expect(double()).toBe(2); + expect(isPending(double)).toBe(false); + }); + }); }); describe("A19 (was C1): isPending(x) = the observable value of x is not final", () => { @@ -1432,3 +2162,92 @@ describe("V1–V5: verdicts in and after the blocked-merged window (fixed 2026-0 expect(isPending(data)).toBe(false); }); }); + +// A transaction's commit is silent: the staging walk was the notification, and +// every subscriber it marked recomputed under the transaction and parked. A +// reader that links to a held node AFTER that walk — an effect created during +// the hold, a memo first pulled under it — is served the committed value +// (stale-reader rule) and would never hear of the reveal. read() records such +// a reader for the transaction's commit replay (the `_gatedSubs` contract +// lanes use); the transaction's own effects are not recorded — they re-derive +// on their own (parked run / contested re-derive, #3322) and a replay would +// publish the frame twice. Surfaced by the #3330 store twin (a store key first +// read under a held adoption), but a plain signal shows it as well. +describe("a reader that links to a held node during the hold re-derives at the commit", () => { + const tick = () => new Promise(r => setTimeout(r, 0)); + const drain = async () => { + for (let i = 0; i < 6; i++) await tick(); + flush(); + }; + + it("a render effect created while an action holds a signal write: committed now, the truth at the commit", async () => { + const [s, setS] = createSignal(0); + const early: number[] = []; + createRoot(() => { + createRenderEffect( + () => s(), + v => { + early.push(v); + } + ); + }); + flush(); + let release!: () => void; + const run = action(function* () { + setS(1); + yield new Promise(r => (release = r)); + }); + const done = run(); + await drain(); + const late: number[] = []; + createRoot(() => { + createRenderEffect( + () => s(), + v => { + late.push(v); + } + ); + }); + flush(); + expect(early).toEqual([0]); + expect(late).toEqual([0]); + release(); + await done; + await drain(); + expect(early).toEqual([0, 1]); + expect(late).toEqual([0, 1]); + expect(s()).toBe(1); + }); + + it("the transaction's own effect is not replayed: one frame per reveal", async () => { + const [a, setA] = createSignal(0); + const [b, setB] = createSignal(0); + const log: string[] = []; + createRoot(() => { + createRenderEffect( + () => `${a()}:${b()}`, + v => { + log.push(v); + } + ); + }); + flush(); + let release!: () => void; + const run = action(function* () { + setA(1); + yield new Promise(r => (release = r)); + }); + const done = run(); + await drain(); + // A mainline write mid-hold re-runs the effect as a stale reader of the + // held `a` (masked to 0) — it is the transaction's own effect and + // re-derives at the commit through the contested path, not a replay. + setB(1); + flush(); + expect(log).toEqual(["0:0", "0:1"]); + release(); + await done; + await drain(); + expect(log).toEqual(["0:0", "0:1", "1:1"]); + }); +}); diff --git a/packages/signals/tests/stale-read-uninitialized-cross-transition.test.ts b/packages/signals/tests/stale-read-uninitialized-cross-transition.test.ts index 7b779498c..534af919d 100644 --- a/packages/signals/tests/stale-read-uninitialized-cross-transition.test.ts +++ b/packages/signals/tests/stale-read-uninitialized-cross-transition.test.ts @@ -1,8 +1,10 @@ -// A stale (render) reader that lands on an async memo which is pending in a -// DIFFERENT transition normally keeps showing that memo's committed value -// (no entanglement). An UNINITIALIZED memo has no committed value to show: -// falling through served `undefined` as if settled and left the reader -// stamped into neither transaction, so it never re-ran when either landed. +// A (render) reader that lands on an async memo which is pending in a +// DIFFERENT transition suspends on it and its reveal settles with that +// flight (A15; #3305, #3334). An UNINITIALIZED memo was the first shape +// where the old "show the committed value, no entanglement" carve-out broke: +// it has no committed value to show, so falling through served `undefined` +// as if settled and left the reader stamped into neither transaction, so it +// never re-ran when either landed. // // effA: a -> memo("foo" + a) setA(1) opens T1 (memo foo1 in flight) // effB: b -> memo("foo" + b) setB(1) opens T2; effB now reads foo1, @@ -95,9 +97,15 @@ describe("stale reader of an uninitialized memo held by another transition", () expect(outC).toEqual(["bar0", "bar1"]); }); - it("still shows the committed value (no entanglement) when the held memo is initialized", async () => { - // Control: the reader's new dependency already has a committed value, so - // the stale-read rule applies and the transitions stay independent. + it("shows the committed value (no entanglement) when the held memo is initialized and its inputs are unpublished", async () => { + // Control: the reader's new dependency already has a committed value, + // and that value is coherent with the frame — the flight's input (`a`) + // is itself held in T1, still 0 on screen. Parallel transactions: the + // reader shows "v0", the transactions stay independent, and the reader + // re-derives at T1's commit (`heldFromStale` records it). The carve-out + // is refused only when the flight's inputs are already visible — committed + // by a batch that left the flight in the air (#3305) or lane-revealed + // (#3334) — see spec-async-semantics for those pins. const [a, setA] = createSignal(0); const [pick, setPick] = createSignal(0); const gate = deferred(); @@ -137,6 +145,7 @@ describe("stale reader of an uninitialized memo held by another transition", () setPick(1); // T2: reader switches onto shared — shows committed "v0", no suspend. flush(); expect(out).toEqual(["other", "v0"]); + expect(pick()).toBe(1); gate.resolve(); await settle(); diff --git a/packages/signals/tests/store/lane-authority-twins.test.ts b/packages/signals/tests/store/lane-authority-twins.test.ts new file mode 100644 index 000000000..63ea3fa44 --- /dev/null +++ b/packages/signals/tests/store/lane-authority-twins.test.ts @@ -0,0 +1,371 @@ +// Store twins of the lane-authority fixes (#3335, #3334, #3330, #3331). Every +// rule pinned for signals in spec-async-semantics.test.ts is pinned here +// against the equivalent store shape: an optimistic store leaf reached +// through the proxy — not a createOptimistic node — must answer the same +// way, or the fix bounces straight back as a store issue. +// +// #3330 store twin needed two store-side rules the node path already had: +// an adoption under a live transaction HOLDS on optimistic families too (a +// sync derive adopting truth is held truth, not lane business — unheld, the +// optimistic write compared equal to it and wrote no override), and a held +// adoption notifies its nodes at write time (stageHeldAdoptions) so the +// commit promotes silently instead of re-marking every subscriber. +// #3331 store twin needed the authoritative landing on an override-covered +// node to reach the engine (setSignal → _landOnOverride → supersedeOverride) +// and supersededRead to serve the committed truth once the landing has +// committed ahead of the override's revert (mainline flush ordering). +import { describe, expect, it } from "vitest"; +import { + action, + createLoadingBoundary, + createMemo, + createOptimisticStore, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + isPending, + latest, + reconcile +} from "../../src/index.js"; + +const tick = () => new Promise(r => setTimeout(r, 0)); +const settle = async () => { + for (let i = 0; i < 6; i++) await tick(); + flush(); +}; +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise(r => (resolve = r)); + return { promise, resolve }; +} + +describe("store twins of the lane-authority fixes (#3335/#3334/#3330/#3331)", () => { + it("#3335 twin: two optimistic STORE writes read in one memo entangle; the later lane's landing does not release the merged reveal", async () => { + const [a, setA] = createOptimisticStore({ v: 0 }); + const [b, setB] = createOptimisticStore({ v: 0 }); + const gateA = deferred(); + const gateB = deferred(); + const cells = { A: 0, B: 0, pair: "0:0" }; + const frames: string[] = []; + const snap = () => `A=${cells.A} B=${cells.B} pair=${cells.pair}`; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const asyncA = createMemo(async () => { + const v = a.v; + if (v !== 0) await gateA.promise; + return v; + }); + const asyncB = createMemo(async () => { + const v = b.v; + if (v !== 0) await gateB.promise; + return v; + }); + const pair = createMemo(() => `${a.v}:${b.v}`); + createRenderEffect(asyncA, v => { + cells.A = v; + frames.push(snap()); + }); + createRenderEffect(asyncB, v => { + cells.B = v; + frames.push(snap()); + }); + createRenderEffect(pair, v => { + cells.pair = v; + frames.push(snap()); + }); + }); + flush(); + await tick(); + frames.length = 0; + const holdA = deferred(); + const holdB = deferred(); + const actA = action(function* (v: number) { + setA(s => { + s.v = v; + }); + yield holdA.promise; + }); + const actB = action(function* (v: number) { + setB(s => { + s.v = v; + }); + yield holdB.promise; + }); + const doneA = actA(1); + await tick(); + expect(frames).toEqual([]); + const doneB = actB(1); + await tick(); + expect(frames).toEqual([]); + gateB.resolve(); + await tick(); + expect(frames).toEqual([]); // B landed; A still in flight — merged reveal held + gateA.resolve(); + await tick(); + expect(frames).toHaveLength(3); + expect(frames.at(-1)).toBe("A=1 B=1 pair=1:1"); + holdA.resolve(); + holdB.resolve(); + await doneA; + await doneB; + }); + + it("#3330 twin: a derivation of an optimistic STORE override reveals with it when the transaction already holds the same result", async () => { + const [serverValue, setServerValue] = createSignal(0); + const log: string[] = []; + let release!: () => void; + let releaseEnd!: () => void; + let state!: { v: number }; + let setState!: any; + createRoot(() => { + [state, setState] = createOptimisticStore(() => ({ v: serverValue() }), { v: 0 }); + const doubled = createMemo(() => state.v * 2); + createRenderEffect( + () => `v=${state.v} d=${doubled()}`, + s => { + log.push(s); + } + ); + }); + flush(); + expect(log).toEqual(["v=0 d=0"]); + const run = action(function* () { + setServerValue(1); + yield new Promise(r => (release = r)); + setState((s: any) => { + s.v = 1; + }); + yield new Promise(r => (releaseEnd = r)); + }); + const done = run(); + await settle(); + expect(log).toEqual(["v=0 d=0"]); + // The derive's truth is held by the action's transaction: handlers read + // committed, latest() the hold — the optimistic family holds like any + // projection (unheld, the swapped-in backing leaked to handlers and + // inverted latest()). + expect(state.v).toBe(0); + expect(latest(() => state.v)).toBe(1); + release(); + await settle(); + const probeLog = JSON.stringify(log); + releaseEnd(); + await done; + await settle(); + expect(probeLog).toBe(JSON.stringify(["v=0 d=0", "v=1 d=2"])); + expect(log).toEqual(["v=0 d=0", "v=1 d=2"]); + expect(state.v).toBe(1); + expect(isPending(() => state.v)).toBe(false); + }); + + it("#3331 twin: the derived optimistic STORE's own source landing a different value supersedes the override now", async () => { + const [value, setValue] = createSignal(0); + const pending: Array<{ v: number; resolve: () => void }> = []; + const fetch = (v: number) => + new Promise(resolve => pending.push({ v, resolve: () => resolve(v * 2) })); + const resolveAll = () => { + const p = pending.splice(0); + p.forEach(x => x.resolve()); + }; + const flights: Array<{ n: number; resolve: () => void }> = []; + const log: string[] = []; + let state!: { d: number }; + let setState!: any; + createRoot(() => { + [state, setState] = createOptimisticStore(async () => ({ d: await fetch(value()) }), { + d: 0 + }); + const asyncMemo = createMemo(() => { + const n = state.d; + return new Promise(resolve => + flights.push({ n, resolve: () => resolve(`${n} async`) }) + ); + }); + const b = createLoadingBoundary( + () => `double=${state.d} async=${asyncMemo()}`, + () => "loading" + ); + createRenderEffect(b, s => { + log.push(s); + }); + }); + flush(); + resolveAll(); + await settle(); + flights.shift()!.resolve(); + await settle(); + expect(log.at(-1)).toBe("double=0 async=0 async"); + log.length = 0; + + setValue(1); + setState((s: any) => { + s.d = 3; + }); + flush(); + expect(flights.map(f => f.n)).toEqual([3]); + expect(log).toEqual([]); + expect(state.d).toBe(3); + + resolveAll(); // truth: 2 ≠ 3 + await settle(); + expect(flights.map(f => f.n)).toEqual([3, 2]); // graph moves to 2 NOW + expect(latest(() => state.d)).toBe(2); + expect(state.d).toBe(3); + expect(isPending(() => state.d)).toBe(true); + expect(log).toEqual([]); + + flights.shift()!.resolve(); // superseded 3-flight: nothing + await settle(); + expect(log).toEqual([]); + expect(state.d).toBe(3); + + flights.shift()!.resolve(); + await settle(); + expect(log).toEqual(["double=2 async=2 async"]); + expect(state.d).toBe(2); + expect(isPending(() => state.d)).toBe(false); + }); + + it("#3334 twin: a reader switching onto an in-flight derived STORE leaf whose inputs are unpublished shows the committed value, then the landing", async () => { + // Twin of stale-read-uninitialized-cross-transition's initialized-memo + // control: the derive's input (`a`) is held in T1, so the leaf's committed + // "v0" is coherent with the frame — the reader shows it without + // entangling and re-derives at T1's commit. The reveal HOLDS only when the + // flight's inputs are already visible (spec-async-semantics, #3305/#3334). + const [a, setA] = createSignal(0); + const [pick, setPick] = createSignal(0); + const gate = deferred(); + let resolveNow = true; + const [shared] = createStore( + async () => { + const v = a(); + if (!resolveNow) await gate.promise; + return { v: "v" + v }; + }, + { v: "" } + ); + const other = createMemo(() => "other"); + const out: unknown[] = []; + createRoot(() => { + createLoadingBoundary( + () => + createRenderEffect( + () => (pick() ? shared.v : other()), + v => void out.push(v) + ), + () => "fallback" + ); + }); + flush(); + expect(out).toEqual(["other"]); + createRoot(() => + createRenderEffect( + () => shared.v, + () => {} + ) + ); + await settle(); + resolveNow = false; + setA(1); + flush(); + setPick(1); + flush(); + expect(out).toEqual(["other", "v0"]); + expect(pick()).toBe(1); + gate.resolve(); + await settle(); + expect(out).toEqual(["other", "v0", "v1"]); + }); + + describe("adoptions under a live transaction hold — plain, derived, optimistic", () => { + function observe(fn: () => T): T[] { + const out: T[] = []; + createRoot(() => { + createRenderEffect(fn, v => { + out.push(v); + }); + }); + flush(); + return out; + } + + it("a key first read under a held adoption is born holding: handler committed, latest() the hold, the truth at the commit (derived store)", async () => { + const [sv, setSv] = createSignal(0); + let state!: { v: number; w: number }; + createRoot(() => { + [state] = createStore(() => ({ v: sv(), w: sv() * 10 }), { v: 0, w: 0 }); + }); + flush(); + let release!: () => void; + const run = action(function* () { + setSv(1); + yield new Promise(r => (release = r)); + }); + const done = run(); + await settle(); + // No node existed for `w` before the hold. + const late = observe(() => state.w); + let m!: () => number; + createRoot(() => { + m = createMemo(() => state.w); + }); + flush(); + expect(state.w).toBe(0); + expect(latest(() => state.w)).toBe(10); + expect(isPending(() => state.w)).toBe(true); + release(); + await done; + await settle(); + expect(late.at(-1)).toBe(10); + expect(m()).toBe(10); + expect(state.w).toBe(10); + expect(isPending(() => state.w)).toBe(false); + }); + + it("same on an optimistic derived store", async () => { + const [sv, setSv] = createSignal(0); + let state!: { v: number; w: number }; + createRoot(() => { + [state] = createOptimisticStore(() => ({ v: sv(), w: sv() * 10 }), { v: 0, w: 0 }); + }); + flush(); + let release!: () => void; + const run = action(function* () { + setSv(1); + yield new Promise(r => (release = r)); + }); + const done = run(); + await settle(); + const late = observe(() => state.w); + expect([state.w, latest(() => state.w), isPending(() => state.w)]).toEqual([0, 10, true]); + release(); + await done; + await settle(); + expect(late.at(-1)).toBe(10); + expect(state.w).toBe(10); + }); + + it("a plain store's reconcile inside an action holds: handlers read committed until the commit", async () => { + const [s, setS] = createStore({ v: 0 }); + const log = observe(() => s.v); + let release!: () => void; + const run = action(function* () { + setS(reconcile({ v: 1 })); + yield new Promise(r => (release = r)); + }); + const done = run(); + await settle(); + expect(log).toEqual([0]); + expect(s.v).toBe(0); + expect(latest(() => s.v)).toBe(1); + release(); + await done; + await settle(); + expect(log).toEqual([0, 1]); + expect(s.v).toBe(1); + }); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 1538b5a32..4c4d01c42 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -218,7 +218,68 @@ describe("pay-for-use tree-shaking (#2883)", () => { // changed in the rebase beyond taking `next`'s heap.ts line, so the // difference is how the merged core minifies). Measured at 22,381; // budget 22,400 -> 22,450 for headroom. - expect(minifiedBytes).toBeLessThan(22_450); + // + // CONSCIOUS BUMP (2026-09-10): +~160B for the lane-authority fixes + // (#3335, #3334, #3331, #3330). Reveal-hold: read()'s pending branch + // drops the stale/foreign-transaction carve-out (-), asyncWrite's + // settleTransition routes a lane-owned landing to the waiting transaction + // (waitingTransition, which laneHeld shares). Override supersession: the + // landing branch and recompute's two override branches each collapse to + // one engine hook call (the authoritative-observer wake moved into the + // hook), read()'s override arm gains a bit test plus a hook call for + // tracked readers of a superseded node, runEffect's owner gate learns + // that a lane runner for a lane-less effect belongs to the still-held + // transaction, and the ext literal gains `_overrideTime` and + // `_overrideStamp`. Two GlobalQueue hook slots. INV-11 adds one term to + // recompute's compare-slot select. Supersession provenance: the scheduler + // carries the running action's sequence (`origin` + setter, cleared at + // the end of flush()), handleAsync captures it per flight and asyncWrite + // re-arms it for the landing's propagation. On the A28 write path the + // landing's supersession decision runs at the write's promotion + // (promoteUnflushed's override arm hands the node to the hook, still + // under the flight's provenance — the landing flushes synchronously). + // Core-retained by necessity: read visibility, the landing branch, the + // effect gate, and the provenance carrier are the seams themselves; the + // decision logic (equality, ordering, provenance comparison, lane + // demotion, value selection, replay gating) lives in optimistic.ts and + // shakes out. Store twins (#3330/#3331): setSignal's + // CONFIG_OPTIMISTIC dispatch gains the authoritative-write case — an + // override test plus one engine hook call (`_landOnOverride`, one + // GlobalQueue slot); the landing itself (staging, companions, + // supersession) lives in optimistic.ts and shakes out (+15 B). The + // stale-reader term of read()'s three value selections becomes + // `heldFromStale`: a reader served the committed value of a node another + // live transaction staged is recorded for that transaction's commit + // replay unless the transaction computed it — the commit is silent, and + // a reader that linked after the staging walk otherwise never learns of + // the reveal (+111 B; the record is core-retained because the read + // visibility seam is). A settle that reverts optimism re-derives its + // contested effects (#3322) after the revert, not ahead of the heap run + // (~+40 B, finalizePureQueue): between commitPendingNodes and + // _resolveOptimistic the truth is committed but the overrides still + // display, and a re-derive there composed the two (the #3164 tear — + // surfaced by deep() over an optimistic store whose held adoption was + // eagerly visible to the committing transaction's own readers). Measured + // at 22,638. + // CONSCIOUS BUMP (2026-09-10, review on #3347): +99 B. The reveal + // carve-out returns, gated on input visibility (A15 reveal corollary, + // re-ruled): read()'s pending branch tests three node bits + // (uninitialized, CONFIG_INPUTS_PUBLISHED, CONFIG_HAS_LANE → one engine + // hook call, `_laneLive`) before `heldFromStale` serves the committed + // value and records the reader; commitPendingNode's computed branch marks + // a still-pending node's inputs published, notifyStatus clears the mark + // on a fresh flight; recompute drops an effect's stale replay recording + // when it recomputes under the recording transaction (one Set.delete). + // The lane predicate itself (`resolveLane`) shakes out. Measured at + // 22,737; 13 bytes of headroom. + // Rebased on #3337's hot-path fix (+27 B) and A28 for optimistic writes + // (+52 B) — the two bumps noted above, arriving from the base branch. + // Measured at 22,816. + // + // NOTE (2026-09-11, rebased on #3337 @ 451f0879, on `next` @ 6bf2bf85): + // +50 B, `next`'s #3350/#3351 (see #3337's note above). Measured at + // 22,866; budget 22,900 -> 22,950 for headroom. + expect(minifiedBytes).toBeLessThan(22_950); }); 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 34eb6e1d5..1267b1002 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -194,7 +194,27 @@ module.exports = [ // GlobalQueue._promoteOverride); the floor pays the slot's initializer, // the arm and the hook slot — +52 B raw (22,279 -> 22,331). The install // itself (promoteOverride/installOverride) rides the optimistic module. - limit: "8.32 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 8.29 -> + // 8.51 KB, measured at 8482 B. Core-retained seams of the lane fixes: + // read()'s override arm (superseded-node selection hook), the reveal + // carve-out gated on input visibility (three bit tests, one lane hook, + // `heldFromStale` recording late readers for the commit replay), + // commitPendingNode marking a still-pending node's inputs published, + // recompute's stale-recording drop, runEffect's lane-less owner gate, + // the provenance carrier (`origin`) captured per flight and re-armed at + // the landing, INV-11's compare-slot term, setSignal's authoritative + // store-landing dispatch, the contested re-derive deferred past a + // reverting settle. The decision logic (supersession, provenance + // comparison, lane demotion, replay gating, the landing) lives in + // optimistic.ts and shakes out of this floor. In-package floor 22,252 -> + // 22,737. + // + // Rebased on #3337 @ 50225d04 (2026-09-10): 8.51 -> 8.54 KB, measured at + // 8531 B. The base branch's promotion hot-path fix (+27 B raw) and A28 + // for optimistic writes (+52 B raw) arriving under the lane-authority + // seams; see those notes on #3337. + limit: "8.54 KB", modifyEsbuildConfig }, { @@ -427,7 +447,29 @@ module.exports = [ // ownership stamp (#3367/#3368: scan grade, spread arm, overlay gate, // `$OWNER` lookups and trap guards) — `next` moved 14.83 -> 15.06 KB on // the same; this branch's delta over `next` is unchanged (~370 B). - limit: "15.42 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 14.91 -> + // 15.29 KB, measured at 15262 B. The core seams (see the core floor note) + // plus the store twins: held adoption under a live transaction on + // optimistic families (`heldMaskView`, `stageHeldAdoptions`), + // `notifyOptimisticWrites` judging against the view readers see, and + // the authoritative landing on an override-covered node dispatching to + // the engine. + // + // Rebased on #3337 @ 50225d04 (2026-09-10): 15.29 -> 15.34 KB, measured at + // 15335 B. The base branch's promotion hot-path fix (+27 B raw) and A28 + // for optimistic writes (+52 B raw) arriving under the lane-authority + // seams; see those notes on #3337. + // + // Rebased on #3337 @ 451f0879 (on `next` @ 6bf2bf85, 2026-09-11): 15.34 -> + // 15.45 KB, measured at 15401 B (was 15335). `next`'s #3351/#3352 store + // bytes (see #3337's note) under the lane-authority store twins. + // + // Rebased on #3337 @ 027fda24 (on `next` @ 4935c7dd, 2026-09-11): 15.45 -> + // 15.72 KB, measured at 15678 B (was 15401). `next`'s #3367/#3368 + // narrow-store write floor and `$OWNER` stamp (see #3337's note) under + // the store twins. + limit: "15.72 KB", modifyEsbuildConfig }, { @@ -526,7 +568,25 @@ module.exports = [ // Rebased on `next` @ 6bf2bf85 (2026-09-11): 10.47 -> 10.50 KB, measured at // 10484 B (was 10462). `next`'s #3350 in-place heap marking and #3351 // `_prevChild` on the core literals; `next` moved 10.27 -> 10.32 KB. - limit: "10.50 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 10.40 -> + // 10.85 KB, measured at 10822 B. The core seams (see the core floor note) + // plus the engine they dispatch to, which this scenario retains: + // override supersession with action provenance (`supersedeOverride`, + // `supersededRead`, the same-value stamp renewal), the authoritative + // store landing (`landOnOverride`), the per-node merged-lane hold + // (`laneHeld` over `waitingTransition`), `laneLive`, and the + // lane-routed settle entering the waiting transaction. + // + // Rebased on #3337 @ 50225d04 (2026-09-10): 10.85 -> 10.93 KB, measured at + // 10926 B. The base branch's promotion hot-path fix (+27 B raw) and A28 + // for optimistic writes (+52 B raw) arriving under the lane-authority + // seams; see those notes on #3337. + // + // Rebased on #3337 @ 451f0879 (on `next` @ 6bf2bf85, 2026-09-11): 10.93 -> + // 10.98 KB, measured at 10949 B (was 10926). `next`'s #3350/#3351 core + // bytes (see #3337's note) under the lane engine this scenario retains. + limit: "10.98 KB", modifyEsbuildConfig }, { @@ -605,7 +665,15 @@ module.exports = [ // Rebased on `next` @ 6bf2bf85 (2026-09-11): 11.06 -> 11.10 KB, measured at // 11062 B (was 11058). Brotli noise from `next`'s #3350/#3351 core bytes; // `next` moved 10.92 -> 10.96 KB. - limit: "11.10 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 11.05 -> + // 11.27 KB, measured at 11242 B — the core seams (see the core floor note) + // and, where the app retains lanes, the engine they dispatch to. + // + // Rebased on #3337 @ 451f0879 (on `next` @ 6bf2bf85, 2026-09-11): 11.27 -> + // 11.32 KB, measured at 11292 B (was 11242). `next`'s #3350/#3351 core + // bytes (see #3337's note). + limit: "11.32 KB", modifyEsbuildConfig }, { @@ -699,7 +767,16 @@ module.exports = [ // Rebased on `next` @ 4935c7dd (2026-09-11): 18.48 -> 18.52 KB, measured at // 18484 B (was 18451). No stores in this scenario; brotli layout across // the shared core after `next`'s #3367/#3368 (the CSR twin moved +7 B). - limit: "18.52 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 18.42 -> + // 18.65 KB, measured at 18628 B — the core seams (see the core floor note) + // and, where the app retains lanes, the engine they dispatch to. + // + // Rebased on #3337 @ 50225d04 (2026-09-10): 18.65 -> 18.71 KB, measured at + // 18702 B. The base branch's promotion hot-path fix (+27 B raw) and A28 + // for optimistic writes (+52 B raw) arriving under the lane-authority + // seams; see those notes on #3337. + limit: "18.71 KB", modifyEsbuildConfig }, { @@ -847,7 +924,34 @@ module.exports = [ // Rebased on `next` @ 4935c7dd (2026-09-11): 27.90 -> 28.15 KB, measured at // 28112 B (was 27894). `next`'s #3367/#3368 createStore arm (see that // note); `next` moved 27.48 -> 27.66 KB on the same. - limit: "28.15 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 27.56 -> + // 28.16 KB, measured at 28138 B. The core seams (see the core floor note) + // plus the store twins: held adoption under a live transaction on + // optimistic families (`heldMaskView`, `stageHeldAdoptions`), + // `notifyOptimisticWrites` judging against the view readers see, and + // the authoritative landing on an override-covered node dispatching to + // the engine. + // + // Rebased on #3337 @ 50225d04 (2026-09-10): 28.16 -> 28.30 KB, measured at + // 28299 B. The base branch's promotion hot-path fix (+27 B raw) and A28 + // for optimistic writes (+52 B raw) arriving under the lane-authority + // seams; see those notes on #3337. + // + // Rebased on #3337 @ 05bcc711 (2026-09-10): 28.30 -> 28.35 KB, measured at + // 28344 B. The affects() declaration walk composing the tick's optimistic + // writes (`optimisticView(t, raw, true)`) — a one-argument change whose + // brotli fallout lands here, on the branch already carrying the store + // twins that touch the same seam. + // + // Rebased on #3337 @ 451f0879 (on `next` @ 6bf2bf85, 2026-09-11): 28.35 -> + // 28.50 KB, measured at 28444 B (was 28344). `next`'s #3351/#3352 store + // bytes and #3350 core bytes (see #3337's note) under the store twins. + // + // Rebased on #3337 @ 027fda24 (on `next` @ 4935c7dd, 2026-09-11): 28.50 -> + // 28.66 KB, measured at 28620 B (was 28444). `next`'s #3367/#3368 + // createStore arm (see that note). + limit: "28.66 KB", modifyEsbuildConfig }, { @@ -911,7 +1015,20 @@ module.exports = [ // truncating `length =` when nothing is queued, and plain nodes skip the // override probe. +27 B raw in the floor; the rest is brotli reordering // from the code motion. - limit: "13.87 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 13.84 -> + // 14.03 KB, measured at 14009 B — the core seams (see the core floor note) + // and, where the app retains lanes, the engine they dispatch to. + // + // Rebased on #3337 @ 50225d04 (2026-09-10): 14.03 -> 14.09 KB, measured at + // 14087 B. The base branch's promotion hot-path fix (+27 B raw) and A28 + // for optimistic writes (+52 B raw) arriving under the lane-authority + // seams; see those notes on #3337. + // + // Rebased on #3337 @ 451f0879 (on `next` @ 6bf2bf85, 2026-09-11): 14.09 -> + // 14.15 KB, measured at 14118 B (was 14087). `next`'s #3350/#3351 core + // bytes (see #3337's note); #3337 itself stayed under its cap here. + limit: "14.15 KB", modifyEsbuildConfig }, { @@ -963,7 +1080,11 @@ module.exports = [ // // Writes visible at flush (A28, #3337; #3336, 2026-09-10): 15.08 -> 15.23 KB, // measured at 15205 B — the core write-path change (see the core floor note). - limit: "15.23 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 15.23 -> + // 15.49 KB, measured at 15461 B — the core seams (see the core floor note) + // and, where the app retains lanes, the engine they dispatch to. + limit: "15.49 KB", modifyEsbuildConfig: observeEsbuildConfig }, { @@ -1039,7 +1160,11 @@ module.exports = [ // Rebased on `next` @ 6bf2bf85 (2026-09-11): 26.75 -> 26.80 KB, measured at // 26772 B (was 26734). `next`'s #3350 in-place heap marking under the // attribution build; `next` moved 26.65 -> 26.68 KB. - limit: "26.80 KB", + // + // Lane authority (#3335, #3334, #3330, #3331; #3347, 2026-09-10): 26.75 -> + // 26.99 KB, measured at 26968 B — the core seams (see the core floor note) + // and, where the app retains lanes, the engine they dispatch to. + limit: "26.99 KB", modifyEsbuildConfig: observeEsbuildConfig }, {