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
11 changes: 11 additions & 0 deletions .changeset/fix-async-chain-second-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@solidjs/signals": patch
---

Fix a second write arriving while an async chain is still in flight (#3373, #3374, #3375, #3376).

- A flight's landing now retires only the node's own pending entry. When an input was re-asked mid-flight (`a` restarted while `b`'s first flight was up), `b` stays pending on `a`; the stale landing no longer let the transaction commit the newer signal beside the older derived value (`2 / 1`, #3373) or blip `isPending` to `false` (#3376). A fresh flight drops pending entries its inputs propagated earlier — the run read them, so a masked input (an active override, A17) does not hold it.
- A transaction now tests whether a source's own flight is still up by its self entry rather than `_error.source`, which a later-pending input overwrites; the held write no longer commits ahead of its answer once the load is re-asked under an `on`-scoped boundary (#3375).
- A collecting `Loading` boundary records every source the notifying effect is pending on, not only the one the notification carries — an `on` reset no longer reveals content when the boundary's one collected source settles while the effect is still pending on a flight it already carried (#3375).
- A render effect served a pending node's committed value (the A15 reveal carve-out) joins the transaction's reporters for that node, so a keyed remount that disposes the original reader no longer lets a same-value rewrite commit the held write while the derivation is in flight (`Count: 1` beside `Details: 0`, #3374).
- A `Loading` boundary's `on` reset ends the hold on writes that only its readers observed: a reader registered while the boundary showed content stops blocking once the boundary flips to its fallback, and the parked transaction is woken and re-judged in the same drain. A reader outside the boundary that also observes the flight still holds it (#3375).
16 changes: 11 additions & 5 deletions packages/signals/docs/INTERNALS-ASYNC-STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,16 @@ Semantics of the `(_pendingValue, _overrideValue)` pair for an optimistic node

- Created by `initTransition` on the first transition-worthy write; at most one `activeTransition` per flush; concurrent ones merge (`mergeTransitionState`, `_done` forwarding pointer).
- `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. **Populated only from `GlobalQueue.notify` during render-effect status notification** `[ruled — async-registration-invariants rule]`.
- `_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`).
- `_optimisticNodes` — nodes whose override reverts at completion (`resolveOptimisticNodes`).
- Incomplete-transition flush stashes queues (`stashQueues`) and continues with a fresh view; completion restores them, commits pending, reverts optimistic, replays `_gatedSubs`, cleans lanes.
- `_contested` — effects whose single value slot was written under this transaction and then overwritten by another live transaction or by mainline (#3322). Effects are not shared state, so a shared effect never merges transactions (memos do, via their `_transition` stamp); instead `Effect._valueTransition` records which view produced `_value`, `recompute`, when that owner changes, registers the effect on every owed live transaction, and `finalizePureQueue` re-dirties them **before** its heap run so the re-derive and the effect phase land in the same pass — the other view's value is never published. Exception: a settle that reverts optimism (a non-empty `_optimisticNodes`) re-dirties them **after** `_resolveOptimistic`, with the gated replay — between `commitPendingNodes` and the revert the truth is committed but the overrides still display, and a re-derive there composes the two (the #3164 tear; the reveal wake sits post-revert for the same reason). The slot meanwhile holds the frame already on screen, so nothing new is published early. Rules that fall out: a stale (render) reader with no transaction active is mainline and sees a foreign transaction's staged signal as committed (`read`'s fast path and `readNodeFast` apply `stale && el._transition !== null`, matching the slow path); a value computed mainline needs no protection (mainline publishes what it computes, and a transaction whose writes never touched the effect finds it still correct at commit).
- 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.
- `transitionComplete`: prunes dead reporters (`reporterBlocksSource`), transition is done when no live reporter still blocks a source whose **own flight is up** and no active-override node is blocked on someone else's async. "Own flight is up" is the source's self entry in its `_pendingSources` (#3375) — not `_error.source`, which a later-pending input overwrites on propagation while the flight is still in the air.
- Fallback-caught async holds nothing — in both orders (ruled 2026-09-12, #3375). A collecting boundary consumes the notification, so a reader under a fallback never registers. A reader registered while its boundary showed content (forwarded) stays registered when the boundary's `on` changes and it flips to the fallback; `reporterBlocksSource` therefore walks the reporter's `_queue._parent` chain and treats a reporter behind a collecting pending-type boundary (`_collectionType & STATUS_PENDING && !_initialized`) as not live. If nothing outside the boundary consumes the flight, the hold is over; a reader outside it still holds. The reset itself calls `wakeParked()` so the re-judgement happens in the same drain.
- Wake of parked transactions (`wokenTransitions`): the flush judges only the _active_ transaction; a parked one is re-entered by a stamped node's landing (`settleTransition`), a stamped recompute, or an action resuming. A reporter that stops counting for another reason — its boundary reset (above), or its disposal by ambient work (#3372) — is none of those: `reporterBlocksSource` would prune it at the next check, but no check comes, and the writes held with it stay staged. Such sites record the transaction (deduped) and schedule; the flush re-enters a woken transaction from the `finally` of a full pass — reached from the park exit and the normal exit alike — and only when idle: no `activeTransition` and `!scheduled`, which at that point means an empty dirty heap, no write since the heap ran (every write re-arms it) and, the finalize having reverted them, no optimistic ambient nodes. Entering adopts the ambient batch, and ambient work present at that instant would be held behind flights it never read; a wake in a pass with work just falls to the next. Entries are popped in a loop until one enters: a wake whose transaction completed by other means is a bare return (`initTransition` on `_done`) and must not strand the ones behind it. The fast drain defers to the full path while a wake is outstanding so such dead entries are still consumed. A wake with other live reporters re-parks; the idle pass is its only cost. Known shape: the ambient write that triggered the reset commits in its own pass and the released hold in the idle pass after it — two effect runs in one synchronous drain (`Sum: 1`, `Sum: 2` at the same clock time in the #3375 pin), never a visible tear.
- Reveal-hold and its carve-out (#3305, #3334, re-ruled 2026-09-10): a reader landing on a node with `STATUS_PENDING` throws — the throw reaches `GlobalQueue.notify`, which opens a transaction for the reveal if none is active (#3305) and records the source as its reporter (INV-3); the reveal completes when the flight lands. One carve-out, the staged-value rule's twin for flights: a **stale** (render) reader of a node pending in some **other** transaction shows the node's committed value, does not entangle (its own writes stay outside that transaction), is recorded for that transaction's commit replay (`heldFromStale`), and joins the transaction's reporters for the node when it has an entry (#3374) — the reader displays the pre-flight value, so the transaction cannot commit the flight's inputs ahead of its answer just because the reader that opened the entry was disposed (a keyed remount). It is refused — the reader holds — when the committed value would tear against the frame: the node carries `CONFIG_INPUTS_PUBLISHED` (a batch or transaction committed with the node still pending, `commitPendingNode`'s computed branch: the flight's inputs are on screen; cleared when the node next enters pending from a settled state, `notifyStatus`), or the node is routed through a live lane (`GlobalQueue._laneLive` → `resolveLane`, exact rather than sticky: lane-revealed inputs, optimistic or `latest`), or the node is uninitialized (nothing committed to show). The stamp itself is pending-node bookkeeping and decides nothing. Replay hygiene: an effect recorded in `_gatedSubs` that later recomputes _under_ the transaction sees its staged view and is applied by the commit (ownership) — `recompute` drops the stale recording at its start (`activeTransition._gatedSubs.delete`), and a lane's committed-view read re-records during the run, so the lane replay (`laneReadsCommitted`) is untouched.
- Settle-time re-entry, lane-routed nodes (#3334): `handleAsync`'s `settleTransition` re-enters `resolveTransition(el)` — for a lane-routed node the transaction that _owns_ the lane. That owner's commit is only the override's confirm/revert; the landing itself is revealed by the lane. If a transaction is _waiting_ on the node (`waitingTransition(el)`), the settle enters that one instead: entering the owner would make the waiter's stamped recompute merge the owner into it (`recompute` → `initTransition`), folding a reveal that only waits on the flight into the owner's action (A18 node corollary, #2912). Several waiters on one flight still merge with each other through their stamped readers at the landing (A15).

## 4. Write paths (all must stay equivalent)
Expand All @@ -109,7 +111,7 @@ Every path that produces a value for a node must maintain the companions via
`syncCompanions` `[ruled — #2831 fix]`:

1. `setSignal` — direct write (line ~1033).
2. `asyncWrite` — async resolution, four branches: setter / override-active / lane-routed / plain `setSignal` fallback.
2. `asyncWrite` — async resolution, four branches: setter / override-active / lane-routed / plain `setSignal` fallback. Its status clear is `landStatus`, not `clearStatus`: a landing answers the node's OWN question, so it retires the self entry only. An input re-asked while the flight was up (`a` restarted while `b`'s first flight was in the air, #3373) marks `b` pending on `a` by propagation with `b`'s flight still current — nothing superseded it — and the landing keeps `b` pending on `a` (value written and held; `a`'s settle releases it or its value change re-asks `b`). The complement at registration: `handleAsync` drops entries inputs propagated earlier — the run read them, so a pending input was masked for it (an active override, A17) and does not describe the new flight's answer.
3. `recompute` — transition-held sync derivation (line ~334, `activeTransition || el._transition` guard).

Comparator (`_equals`) errors on any of these paths are node errors, routed
Expand Down Expand Up @@ -138,7 +140,11 @@ Confidence: **high** = implementation self-consistency, assert now.
assert.)
- **INV-3 (high)** `_asyncReporters` gains entries only inside
`GlobalQueue.notify` (render-effect notification path). Guard flag around the
legal write site; assert on any other mutation. `[ruled]`
legal write site; assert on any other mutation. `[ruled]` A reporter joining
an EXISTING entry has one further site: the reveal carve-out in `read()`
(`heldFromStale`, #3374) — the transaction already waits on that node, the
reader now observes it. A boundary-consumed flight has no entry and stays
consumed.
- **INV-4 (medium)** After any of the three write paths completes for node `el`
with value `v`: if `el._pendingSignal` exists it reflects
`computePendingState(el)`, and if `el._latestValueComputed` exists its signal
Expand Down
15 changes: 14 additions & 1 deletion packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
import type { IQueue, Signal } from "./core/index.js";
import { emitDiagnostic, reportDiagnostic } from "./core/dev.js";
import { attrHooks } from "./core/attribution-hooks.js";
import { haltReactivity, schedule } from "./core/scheduler.js";
import { haltReactivity, schedule, wakeParked } from "./core/scheduler.js";
import { accessor, type Accessor } from "./signals.js";

export interface BoundaryComputed<T> extends Computed<T> {
Expand Down Expand Up @@ -307,6 +307,10 @@ export class CollectionQueue extends Queue {
this._prevOn = currentOn;
this._initialized = false;
this._sources.clear();
// Readers forwarded while this boundary showed content are behind the
// fallback now: they stop blocking (`reporterBlocksSource`), and the
// transactions they were holding must be re-judged for it (#3375).
wakeParked();
}
}

Expand All @@ -329,6 +333,15 @@ export class CollectionQueue extends Queue {
if (source) {
const wasEmpty = this._sources.size === 0;
this._sources.add(source);
// A collecting boundary waits on everything the effect is pending on,
// not only the source this notification carries. Status propagation
// dedupes on the effect's `_pendingSources`: a source it already
// carries (a flight that started before an `on` reset cleared the
// set) is never re-reported, and that source's later re-flight
// stays invisible — the boundary revealed when its one collected
// source settled while the effect was still pending (#3375).
if (this._collectionType & STATUS_PENDING)
node._x?._pendingSources?.forEach(s => this._sources.add(s));
if (wasEmpty) {
setSignal(this._disabled, true);
if (__OBSERVE__ && attrHooks !== null && this._collectionType & STATUS_PENDING)
Expand Down
Loading
Loading