Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fix-overlapping-flights.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@solidjs/signals": patch
---

Two flights through one memo settle as one unit, and a branch a held `Show` is removing keeps following `latest()` (#3443, #3444).

- A memo another live transaction holds, made pending by a second flight, entangles the two at the propagation (#3443). The second flight only propagated pending onto the memo — no recompute, its inputs' values were unchanged — so the memo's stamped re-entry never ran and the first transaction never learned it was waiting: it revealed `A: 1` beside the committed `Sum: 0`, and `Sum: 2` arrived with `B: 1`. Now one reveal when both have landed (A15). A render effect reading both plainly stays parallel, as before; a write whose async work flows into a held memo is held with it.
- A zombie dirtied through the lane channel runs instead of being cancelled (#3444). When the parking batch is the transaction, queued zombie recomputes are cancelled as a world the zombie never displays — but overrides and `latest()` companions are the mainline frame, and the still-visible branch showed `latest(count)` at 0 beside the same read outside at 1.
3 changes: 2 additions & 1 deletion packages/signals/docs/INTERNALS-ASYNC-STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node
- `initTransition` ends by scheduling a flush: the ambient window is one flush by definition, but parking is flush-driven, so a transaction opened with no writes (an action that only awaits) would otherwise leave `activeTransition` and the adopted batch armed across the async gap, capturing the next unrelated work to arrive — the A26-rejected behavior (#3141).
- `_asyncReporters: Map<source, Set<reporter>>` — which computeds are blocked on which async sources. **Entries open only from `GlobalQueue.notify` during render-effect status notification** `[ruled — async-registration-invariants rule]`. One reporter may join an entry that already exists from elsewhere: a stale reader served a pending node's committed value by the reveal carve-out (`heldFromStale`, §3 below) joins that node's entry (#3374) — it observes the flight, so it holds the transaction on it the way the reader that opened the entry did, and dies with disposal the same way (`reporterBlocksSource`: the read linked the node as a dep).
- `_pendingNodes` — nodes whose `_pendingValue` commits when the transition completes (`commitPendingNodes` → `commitPendingNode`).
- Held children (#3404): a node's owned children (nested effects, memos, `onCleanup` registrations) belong to the frame that committed them. `recompute` defers the previous pass's children as zombies (`_pendingFirstChild` / `_pendingDisposal`, rendering mainline until `commitPendingNode` disposes them) unless `CONFIG_HELD_CHILDREN` is set — the pass that built them never committed (a staged value, a pending window, a run under a held transaction), so no frame ever showed them and they die on the spot. Set at recompute's tail whenever the pass's result waits on a commit, cleared by `commitPendingNode`. A `_transition` stamp alone says nothing about the children: status propagation stamps a parked dependent without recomputing it, so its children are still the committed frame's, and disposing them when the source lands ran their cleanups mid-hold. Exception: a transaction-owned effect recomputed mainline (contested, #3322) publishes directly, so that pass's children are the frame's and it releases its zombies itself — its commit rides the transaction, not the flush.
- Held children (#3404): a node's owned children (nested effects, memos, `onCleanup` registrations) belong to the frame that committed them. `recompute` defers the previous pass's children as zombies (`_pendingFirstChild` / `_pendingDisposal`, rendering mainline until `commitPendingNode` disposes them) unless `CONFIG_HELD_CHILDREN` is set — the pass that built them never committed (a staged value, a pending window, a run under a held transaction), so no frame ever showed them and they die on the spot. Set at recompute's tail whenever the pass's result waits on a commit, cleared by `commitPendingNode`. A `_transition` stamp alone says nothing about the children: status propagation stamps a parked dependent without recomputing it, so its children are still the committed frame's, and disposing them when the source lands ran their cleanups mid-hold. Exception: a transaction-owned effect recomputed mainline (contested, #3322) publishes directly, so that pass's children are the frame's and it releases its zombies itself — its commit rides the transaction, not the flush. Zombies and the lane channel (#3444): when the parking batch _is_ the transaction, `flush` cancels the zombie recomputes its staged writes queued (`cancelZombieRecompute`) — a zombie renders mainline until the commit disposes it, and the staged world is one it never displays. A zombie dirtied through the lane channel (`REACTIVE_OPTIMISTIC_DIRTY`: an override, or a `latest()` companion the write synced) is the exception and runs: the lane's values _are_ the mainline frame, so the still-visible branch a held `Show` was removing showed `latest(count)` at 0 beside the same read outside at 1. Its pass runs under the lane and its run lands on the lane's effect queue, so a held lane defers it exactly as it defers every other reader's; the commit disposes it as before. Pinned: `tests/nested-render-effect-async-cleanup.test.ts`.
- `_optimisticNodes` — nodes whose override reverts at completion (`resolveOptimisticNodes`).
- Incomplete-transition flush stashes queues (`stashQueues`) and continues with a fresh view; completion restores them, commits pending, reverts optimistic, replays `_gatedSubs`, cleans lanes.
- `_contested` — effects whose single value slot was written under this transaction and then overwritten by another live transaction or by mainline (#3322). Effects are not shared state, so a shared effect never merges transactions (memos do, via their `_transition` stamp; `recompute`'s stamp re-entry is memo-only since #3407 — an effect's pass belongs to whatever dirtied it); instead `Effect._valueTransition` records which view produced `_value`, `recompute`, when that owner changes, registers the effect on every owed live transaction, and `finalizePureQueue` re-dirties them **before** its heap run so the re-derive and the effect phase land in the same pass — the other view's value is never published. Exception: a settle that reverts optimism (a non-empty `_optimisticNodes`) re-dirties them **after** `_resolveOptimistic`, with the gated replay — between `commitPendingNodes` and the revert the truth is committed but the overrides still display, and a re-derive there composes the two (the #3164 tear; the reveal wake sits post-revert for the same reason). The slot meanwhile holds the frame already on screen, so nothing new is published early. Rules that fall out: a stale (render) reader with no transaction active is mainline and sees a foreign transaction's staged signal as committed (`read`'s fast path and `readNodeFast` apply `stale && el._transition !== null`, matching the slow path); a value computed mainline needs no protection (mainline publishes what it computes, and a transaction whose writes never touched the effect finds it still correct at commit).
Expand All @@ -111,6 +111,7 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node
- Dependencies are the committed frame's (A30, #3410): `recompute`'s tail trims the previous pass's dependency tail only for a pass that published or changed nothing (`_pendingValue === NOT_PENDING` and no `_error`); a staged pass leaves it linked and `commitPendingNode` trims after a clean pass (`_error == null` — a set `_error` means the last pass threw, kept its full list, and `_depsTail` marks where it stopped); an effect pass that direct-committed but still owes a run (`_modified`, #3438 — the flush may stash that run into a transaction it opens later) leaves it for `runEffect` to trim once the run applies. `__OBSERVE__` fan-in counting walks the validated prefix only, so a held tail does not inflate distinct-source counts. Why commit-time and not the pass, and the `Selected: 0` beside `Count: 1` shape: the spec.
- Reveal-hold and its carve-out (#3305, #3334, re-ruled 2026-09-10): a reader landing on a node with `STATUS_PENDING` throws — the throw reaches `GlobalQueue.notify`, which opens a transaction for the reveal if none is active (#3305) and records the source as its reporter (INV-3); the reveal completes when the flight lands. One carve-out, the staged-value rule's twin for flights: a **stale** (render) reader of a node pending in some **other** transaction shows the node's committed value, does not entangle (its own writes stay outside that transaction), is recorded for that transaction's commit replay (`heldFromStale`), and joins the transaction's reporters for the node when it has an entry (#3374) — the reader displays the pre-flight value, so the transaction cannot commit the flight's inputs ahead of its answer just because the reader that opened the entry was disposed (a keyed remount). It is refused — the reader holds — when the committed value would tear against the frame: the node carries `CONFIG_INPUTS_PUBLISHED` (a batch or transaction committed with the node still pending, `commitPendingNode`'s computed branch: the flight's inputs are on screen; cleared when the node next enters pending from a settled state, `notifyStatus`), or the node is routed through a live lane (`GlobalQueue._laneLive` → `resolveLane`, exact rather than sticky: lane-revealed inputs, optimistic or `latest`), or the node is uninitialized (nothing committed to show). The stamp itself is pending-node bookkeeping and decides nothing. Replay hygiene: an effect recorded in `_gatedSubs` that later recomputes _under_ the transaction sees its staged view and is applied by the commit (ownership) — `recompute` drops the stale recording at its start (`activeTransition._gatedSubs.delete`), and a lane's committed-view read re-records during the run, so the lane replay (`laneReadsCommitted`) is untouched.
- Settle-time re-entry, lane-routed nodes (#3334): `handleAsync`'s `settleTransition` re-enters `resolveTransition(el)` — for a lane-routed node the transaction that _owns_ the lane. That owner's commit is only the override's confirm/revert; the landing itself is revealed by the lane. If a transaction is _waiting_ on the node (`waitingTransition(el)`), the settle enters that one instead: entering the owner would fold a reveal that only waits on the flight into the owner's action (A18 node corollary, #2912). Every other transaction waiting on the flight then folds in explicitly (`enterWaiting`, #3407 — see the next bullet): each reveal that discovered the flight completes at its landing (A15).
- Pending propagation onto a held memo entangles (#3443): `notifyStatus`'s dependent walk, on a pending propagation reaching a memo another live transaction _holds_ — stamped by it AND pending on its work or carrying its staged `_pendingValue`; not behind a boundary — enters that transaction — `initTransition(sub._transition)`, merging it into the active one or, with none active, entering it and adopting the ambient batch (the write that started this flight becomes its). A memo the first transaction holds and the second flight now feeds cannot reveal before the second lands (A15: a shared derivation of both), and propagation is the one moment that is known: it marks the memo pending without recomputing it (its inputs' _values_ are unchanged), so the memo's stamped re-entry — the entanglement's usual site — never ran, the first flight landed, its transaction's verdict saw only its own reporters (the memo's reader had registered for the second flight in the _second_ transaction, INV-3 keyed by transaction), and it revealed `A: 1` beside the memo's committed `Sum: 0`. The stamp alone decides nothing (#3334): a memo the transaction once queued but holds nothing of — status clear, nothing staged — is not entangled, or a `Dynamic` switched twice mid-flight would drag the superseded first call's gate into the live second call's reveal (`call-driven-lifecycle` args-switch-gate: the second write supersedes the first through the shared output memo, and only the live answer settles it). Effects are skipped (`_type`): an effect entangles nothing by itself (A15 shared-hole corollary) — its reader registers with the flight's transaction at queue notification and the landing folds waiters in (`enterWaiting`). Consequence pinned alongside: a write whose async work flows into a held memo is held with it (`page=1` beside `count=1` while `details` re-asks — before, the ambient page=1 committed a pass ahead), which is also why the #3375 boundary-reset pin now publishes `Sum: 2` once at the reset instead of `Sum: 1 | Sum: 2`. Pinned: `tests/overlapping-flights.test.ts`.
- Pass provenance for effects (#3407): a render effect's pass belongs to whatever dirtied it. `recompute` re-enters a stamped node's transaction only for memos (their value _is_ that transaction's work); an effect stamped by a transaction — it observed that transaction's flight — and dirtied by another transaction's write, or by mainline, runs that writer's pass, reads the held flight as a stale reader (committed value, `heldFromStale`) and publishes with the writer. The pass entangles only if it _observes_ a pending flight (the carve-out refused: inputs published, lane-live, uninitialized) — the throw reaches `GlobalQueue.notify`, which registers the writer's transaction as a reporter, and the flight's landing folds it in. Before, `recompute` re-entered an effect's stamp whenever _any_ other transaction was active: a sync `action` write to a signal that merely shared a hole with a held async (`{b()}:{detailsA()}`) merged into the async's transaction and waited (`0:0 → 2:1`, no `1:0`), while the same write made plainly passed through; two independent flights read in one hole settled as one unit. Now both writers publish on their own (`1:0` at the write, `1:1` at the landing; two flights land at their own times). The re-entry's other job — delivering a landing to the transactions waiting on it — moves to the landing itself: `settleTransition` enters every parked transaction whose reporters still observe the node (`enterWaiting`, over `sourceObserved`), including the waiter of a stampless node (a flight started under a batch that committed beneath it, #3305), whose landing used to open a fresh batch that the stamped reader's re-entry folded into the waiter. Pinned: `tests/shared-effect-no-entangle.test.ts`; the reveal-completion pins (`spec-async-semantics` A15, `reveal-carve-out`, `stale-read-uninitialized-cross-transition`) are the regression net for the fold.
- The action body's end starts the correction (#3427, `endOptimism`, called from `flush` after the heap and before the verdict): with the bodies over (`_acted` and no live `_actions`) and nothing _authoritative_ in flight — no override node's own source (`transitionBlocked`), no held flight that does not derive from an override (`sourceObserved` and not `resolveLane`) — each override's truth (the staged value an A17-silent landing left, else the committed value) supersedes it now, as an arriving differing truth does (A18): the graph re-derives from the truth as the transaction's held work, and the transaction settles when _that_ lands. The lane-derived flights were questions about the guess; nobody reads their answer. Before, the settle waited for the obsolete flight, revealed the obsolete optimistic frame when it landed, then reverted and re-asked — a waterfall with a flash. A co-written plain load the action asked for (`setSaving(true); setPage(2)`) is authoritative and keeps the optimistic world up until it lands. Optimistic **store** edits opt the whole transaction out (their truth is the base layer under an overlay with no tracked/displayed split); companions (`_parentSource`) answer for their owner and snap at settlement, not here. Pinned: `tests/optimistic-lane-release.test.ts`.

Expand Down
Loading
Loading