diff --git a/.changeset/fix-async-chain-second-write.md b/.changeset/fix-async-chain-second-write.md new file mode 100644 index 000000000..9ffe00a65 --- /dev/null +++ b/.changeset/fix-async-chain-second-write.md @@ -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). diff --git a/packages/signals/docs/INTERNALS-ASYNC-STATE.md b/packages/signals/docs/INTERNALS-ASYNC-STATE.md index fe3fe62fa..a982fa9a7 100644 --- a/packages/signals/docs/INTERNALS-ASYNC-STATE.md +++ b/packages/signals/docs/INTERNALS-ASYNC-STATE.md @@ -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>` — 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>` — 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) @@ -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 @@ -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 diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 4b6fe1d6a..aaf2452b8 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -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 extends Computed { @@ -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(); } } @@ -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) diff --git a/packages/signals/src/core/async.ts b/packages/signals/src/core/async.ts index f6bebd7a9..dadf6793b 100644 --- a/packages/signals/src/core/async.ts +++ b/packages/signals/src/core/async.ts @@ -362,6 +362,13 @@ 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; + // The run that asked this flight read every input without throwing: an + // input still in flight was masked for it (an active override, A17), so + // pending state those inputs propagated onto the node earlier does not + // describe this answer. Drop it — the flight is the node's pending now. + // The landing retires only the flight's own entry (landStatus, #3373), so + // an entry that survived here would hold the node past its own answer. + el._x!._pendingSources = undefined; // 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 @@ -471,7 +478,7 @@ export function handleAsync( // A truthy capture implies `_x` exists, so the restore writes it directly. const wasReask = el._x?._reask; trimStaleDeps(el); - clearStatus(el); + landStatus(el); if (wasReask) el._x!._reask = true; const lane = resolveLane(el as any); if (lane) lane._pendingAsync.delete(el); @@ -486,7 +493,7 @@ export function handleAsync( handleError(error); return; } - if (wasUninitialized) clearStatus(el, true); + if (wasUninitialized) landStatus(el, true); } else if (el._x?._overrideValue !== undefined) { // Optimistic node — resting OR covered by an active override — holds // through the shared pending-node path, exactly like a plain async memo, @@ -854,6 +861,42 @@ export function clearStatus(el: Computed, clearUninitialized: boolean = fal if (notify) notify.call(el); } +/** + * Status clear for a flight LANDING (asyncWrite). A landing answers the + * node's OWN question — it retires the node's self entry, not the pending + * state its sources propagated onto it. An input re-asked while this flight + * was up (a second write to the signal feeding `a` while `b`'s first flight + * is in the air, #3373) marks `b` pending on `a` by propagation, with `b`'s + * flight still current: nothing superseded it (the re-ask only changed `a`'s + * status, not yet its value), so the landing arrives, and a full clear made + * `b` answer with the stale value — the transaction's reporter for `a` found + * nothing pending below it and committed the newer signal beside the older + * derived value (`2 / 1`); `isPending(b)` read false for the gap (#3376). + * With another source still pending the node stays derivatively pending on + * it; the landed value is written below (the staged answer is still the + * answer for the inputs it was asked with) and the input's own settle + * releases it, or its value change recomputes the node into a fresh flight. + * `_blocked` clears like a full clear: a landing that passed the `_inFlight` + * guard was not superseded by a re-run (recompute nulls `_inFlight` first), + * so the flag is the flight's own registration throw — the input settling + * unchanged must not re-run the node (an extra flight for the same inputs). + * The node is already STATUS_PENDING in that branch (only notifyStatus fills + * the set, with status; a loading-window park cannot coexist with a live + * flight since registration drops the set), so the flags only change when + * the first landing retires UNINITIALIZED. `_error` must move off self: a + * reader thrown NotReady(self) would park on a retired entry. Companions + * keep their verdict (pending before and after; the write re-syncs them). + */ +function landStatus(el: Computed, clearUninitialized: boolean = false): void { + const sources = el._x?._pendingSources; + // (The full clear below drops the set whether or not self was retired first.) + if (sources && (sources.delete(el), sources.size)) { + el._x!._blocked = false; + if (clearUninitialized) el._statusFlags = STATUS_PENDING; + setPendingError(el, sources.values().next().value); + } else clearStatus(el, clearUninitialized); +} + export function notifyStatus( el: Computed, status: number, diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index 4c620f35c..2a62f9e24 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -1353,6 +1353,24 @@ export function installAuthoritativeRead(): void { * 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. + * + * Flight twin (the pending-branch carve-out): the reader is served the + * node's committed, pre-flight value and now observes that flight — A15: + * async work observed by a reader settles as one unit with the writes that + * asked it — so it joins the transaction's reporters for the node. The + * reporter the transaction recorded when the flight started may be gone (a + * keyed remount disposed it, #3374); a completion check that found no live + * reporter committed the writes ahead of the answer, tearing the new + * reader's frame (`Count: 1` beside `Details: 0`). Joins only an entry the + * transaction already holds — a staged signal or a settled node has none; + * INV-3: entries open from queue notification alone, so a boundary-consumed + * flight stays consumed — and dies with the reader like every reporter + * (reporterBlocksSource: the read linked it as a dep). The node's own entry + * is the only one that can matter: a chain's intermediate memo is re-pulled + * by the read (updateIfNecessary's retry) and enters the transaction, so the + * reader holds through the normal path; a node with its own flight that is + * also pending on an upstream re-ask blocks through that flight until it + * lands, and its landing re-runs the reader into the normal path. */ function heldFromStale(el: Signal | Computed, c: Computed): boolean { const t = el._transition; @@ -1360,6 +1378,7 @@ function heldFromStale(el: Signal | Computed, c: Computed): boole const txn = currentTransition(t); const vt: Transition | null | undefined = (c as any)._valueTransition; if (vt == null || currentTransition(vt) !== txn) txn._gatedSubs.add(c); + txn._asyncReporters.get(el as Computed)?.add(c); return true; } diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 1f9b652d2..5326ce662 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -380,6 +380,27 @@ export function schedule() { if (!syncDepth && !globalQueue._running && !projectionWriteActive) queueMicrotask(flush); } +/** + * Parked transactions whose reporter set changed without a write. A + * transaction completes when nothing live reports a flight it waits on, but + * the flush only judges the ACTIVE transaction: a parked one is re-entered by + * a stamped node's landing or an action's resume. A reporter that stops + * counting for another reason — its loading boundary flipped to the fallback + * (#3375), or it was disposed by ambient work (#3372) — is neither: the + * pruning in `reporterBlocksSource` would drop it at the next check, but no + * check comes, and the writes held with it stay staged. Such sites record the + * transaction here (deduped: one idle pass per transaction, however many + * reporters changed); the flush re-enters it on an otherwise idle pass, so + * the re-evaluation adopts no unrelated ambient work. + */ +export const wokenTransitions: Transition[] = []; +/** Wake every parked transaction — for a site that knows a reporter stopped + * counting but not whose (a boundary reset). */ +export function wakeParked(): void { + for (const t of transitions) wokenTransitions.includes(t) || wokenTransitions.push(t); + schedule(); +} + /** * Permanently halts the reactive system. Called when a user error escapes * every boundary — app state is undefined at that point, so scheduling stops @@ -440,6 +461,11 @@ export interface IQueue { stashQueues(stub: QueueStub): void; restoreQueues(stub: QueueStub): void; _parent: IQueue | null; + /** Loading/error boundary queues (boundaries.ts): the status dimension the + * queue consumes, and whether it currently shows content (initialized) or + * its fallback (collecting). Read by `reporterBlocksSource`. */ + _collectionType?: number; + _initialized?: boolean; } // Identifies one child-traversal pass in `Queue.run` so a rescan after the @@ -663,6 +689,7 @@ export class GlobalQueue extends Queue { this._queues[0].length === 0 && this._queues[1].length === 0 && this._children.length === 0 && + !wokenTransitions.length && canUseSimpleSyncFlush(this) ) { this._running = true; @@ -799,6 +826,17 @@ export class GlobalQueue extends Queue { } if (__DEV__) DEV.hooks.onUpdate?.(); } finally { + // Re-enter a woken transaction (see wokenTransitions) only from an + // idle pass: entering adopts the ambient batch, and staged or dirty + // ambient work would be held behind flights it never read. `scheduled` + // is that test here — after the park exit as well as the normal one: + // it was recomputed from the heap this pass, every write since re-armed + // it, and optimistic ambient nodes reverted with the finalize — so a + // wake in a pass with work simply falls to the next. Entering re-arms + // it itself; a dead (completed) wake is a bare return in + // initTransition, and the loop moves on to the next. + while (!scheduled && !activeTransition && wokenTransitions.length) + this.initTransition(wokenTransitions.pop()); this._running = false; } } @@ -1384,6 +1422,14 @@ function runQueue(queue: QueueCallback[], type: number): void { function reporterBlocksSource(reporter: Computed, source: Computed): boolean { if (reporter._flags & (REACTIVE_ZOMBIE | REACTIVE_DISPOSED)) return false; + // Fallback-caught async holds nothing. A collecting loading boundary + // consumes the notification, so a reader under a fallback never registers — + // but a reader registered while its boundary showed content stays + // registered when the boundary's `on` later changes and it flips to the + // fallback. The reader is behind the fallback now; if nothing outside the + // boundary consumes the flight, the hold is over (ruled 2026-09-12, #3375). + for (let q: IQueue | null = reporter._queue; q; q = q._parent) + if (q._collectionType! & STATUS_PENDING && !q._initialized) return false; if (reporter._x?._pendingSources?.has(source)) return true; for (let dep = reporter._deps; dep; dep = dep._nextDep) { let current = dep._dep as Signal | Computed | undefined; @@ -1417,10 +1463,15 @@ function transitionComplete(transition: Transition): boolean { reporters.delete(reporter); } if (!hasLive) transition._asyncReporters.delete(source); - else if ( - source._statusFlags & STATUS_PENDING && - (source._x?._error as NotReadyError)?.source === source - ) { + // The source blocks while its OWN flight is up — the self entry in its + // pending sources (added by notifyStatus's source path, with status; + // retired by the landing and the supersede sweep, so it implies + // STATUS_PENDING). `_error.source` is not that test: propagation from an + // input that went pending later overwrites it with the input (#3375 — a + // boundary-consumed load re-asked under a held derivation), and the + // still-flying source read as settled, committing the writes it was + // asked with ahead of its answer. + else if (source._x?._pendingSources?.has(source)) { done = false; break; } diff --git a/packages/signals/tests/async-chain-supersession.test.ts b/packages/signals/tests/async-chain-supersession.test.ts new file mode 100644 index 000000000..a53bad2d2 --- /dev/null +++ b/packages/signals/tests/async-chain-supersession.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from "vitest"; +import { + createLoadingBoundary, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending +} from "../src/index.js"; + +// A manual clock. Async memos return promises that resolve when the clock is +// advanced past their due time, in due-time order, with a full settle (the +// microtask drain and the scheduled flush) between resolutions — the same +// interleaving a browser produces with real timers, minus the waiting. +let now = 0; +let timers: { at: number; run: () => void }[] = []; +function delay(ms: number, value?: T): Promise { + return new Promise(r => timers.push({ at: now + ms, run: () => r(value as T) })); +} +async function settle() { + for (let r = 0; r < 3; r++) { + for (let i = 0; i < 10; i++) await Promise.resolve(); + flush(); + } +} +async function advanceTo(t: number) { + while (true) { + timers.sort((a, b) => a.at - b.at); + const next = timers[0]; + if (!next || next.at > t) break; + timers.shift(); + now = next.at; + next.run(); + await settle(); + } + now = t; + await settle(); +} +function reset() { + now = 0; + timers = []; +} + +/** The publish log as frames: one entry per clock time, values sorted (effect + * order within a frame is not part of the contract). */ +function frames(log: string[], when: number[]): string[] { + const byTime = new Map(); + log.forEach((v, i) => (byTime.get(when[i]) ?? byTime.set(when[i], []).get(when[i])!).push(v)); + return [...byTime].map(([t, vs]) => `${t}: ${vs.sort().join(" | ")}`); +} + +/** A text node: publishes each distinct value with the clock time it landed. */ +function text(fn: () => string, log: string[], when?: number[]) { + let last: string | undefined; + createRenderEffect(fn, v => { + if (v !== last) { + last = v; + log.push(v); + when?.push(now); + } + }); +} + +describe("a second write while an async chain is in flight", () => { + it("#3373 the stale first-hop landing does not commit the newer signal value", async () => { + reset(); + const log: string[] = []; + let setCount!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + setCount = sc; + const a = createMemo(() => delay(1000, count())); + const b = createMemo(() => delay(1000, a())); + text(() => `${count()} / ${b()}`, log); + }); + flush(); + await settle(); + await advanceTo(2500); + setCount(1); + await settle(); + // a1 lands at 3500; b1 is due at 4500. The second write arrives between. + await advanceTo(4000); + setCount(2); + await settle(); + await advanceTo(9000); + // b1 landing (4500) must not commit count=2 alongside b=1 while a2 is in flight. + expect(log).toEqual(["0 / 0", "2 / 2"]); + }); + + it("the held first-hop answer reveals when the re-asked input lands unchanged", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + setCount = sc; + // a collapses 1 and 2: the second flight lands with the value b1 was computed from. + const a = createMemo(() => delay(1000, Math.min(count(), 1))); + const b = createMemo(() => delay(1000, a())); + text(() => `${count()} / ${b()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(2500); + setCount(1); + await settle(); + await advanceTo(4000); + setCount(2); + await settle(); + await advanceTo(9000); + // b1 (landed 4500) is the answer for a=1, which a2 confirms at 5000; nothing recomputes. + expect(log).toEqual(["0 / 0", "2 / 1"]); + expect(when).toEqual([2000, 5000]); + }); + + it("#3376 isPending stays true across the stale first-hop landing", async () => { + reset(); + const log: string[] = []; + let setCount!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + setCount = sc; + const a = createMemo(() => delay(400, count())); + const b = createMemo(() => delay(400, a())); + text(() => `Pending: ${isPending(b)}`, log); + }); + flush(); + await settle(); + await advanceTo(1000); + setCount(1); + await settle(); + await advanceTo(1600); + setCount(2); + await settle(); + await advanceTo(5000); + expect(log).toEqual(["Pending: false", "Pending: true", "Pending: false"]); + }); + + it("#3375 a Loading boundary reset ends the hold on writes only its readers observed and waits for the downstream async", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void, setPage!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + const [page, sp] = createSignal(0); + setCount = sc; + setPage = sp; + const pageData = createMemo(() => delay(1000, page())); + const details = createMemo(() => delay(2000, pageData() + count())); + text(() => `Sum: ${page() + count()}`, log, when); + const b = createLoadingBoundary( + () => { + text(() => `Details: ${details()}`, log, when); + return "content"; + }, + () => "Loading...", + { on: () => page() } + ); + text(() => `Boundary: ${b()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3500); + // count=1 restarts details under the initialized boundary: forwarded, so the + // write is held with the flight (Sum stays 0). + setCount(1); + await settle(); + await advanceTo(4000); + // page=1 resets the boundary (`on`): fallback, pageData1 due 5000. The only + // reader of details is now behind the fallback, so the hold on count=1 is + // over (ruled 2026-09-12): the reset wakes the parked transaction and it + // commits in the idle pass that follows — same drain, one pass after the + // ambient page=1 commit, hence two Sum publishes at 4000. + setPage(1); + await settle(); + await advanceTo(5500); + // page=2 resets again. Nothing is held any more: pageData2 lands at 6500 and + // re-asks details (due 8500) under the collecting boundary, which waits on + // details AND pageData (the effect it hears from is pending on both). + setPage(2); + await settle(); + await advanceTo(12000); + expect(frames(log, when)).toEqual([ + "0: Boundary: Loading... | Sum: 0", + "3000: Boundary: content | Details: 0", + "4000: Boundary: Loading... | Sum: 1 | Sum: 2", + "5500: Sum: 3", + "8500: Boundary: content | Details: 3" + ]); + }); + + it("a Loading boundary reset keeps the hold while a reader outside the boundary observes the flight", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void, setPage!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + const [page, sp] = createSignal(0); + setCount = sc; + setPage = sp; + const pageData = createMemo(() => delay(1000, page())); + const details = createMemo(() => delay(2000, pageData() + count())); + text(() => `Sum: ${page() + count()}`, log, when); + text(() => `Outside: ${details()}`, log, when); + const b = createLoadingBoundary( + () => { + text(() => `Details: ${details()}`, log, when); + return "content"; + }, + () => "Loading...", + { on: () => page() } + ); + text(() => `Boundary: ${b()}`, log, when); + }); + flush(); + await settle(); + await advanceTo(3500); + setCount(1); // details re-asks (due 5500); Details and Outside both report it + await settle(); + await advanceTo(4000); + // The reset frees Details, but Outside still consumes the flight: count=1 + // stays held. page=1 joins the hold too (its reader details lives there), + // so nothing publishes at 4000 — not even the fallback. pageData1 lands at + // 5000 and re-asks details (due 7000); everything reveals with its answer. + setPage(1); + await settle(); + await advanceTo(12000); + const f = frames(log, when); + expect(f.slice(0, 2)).toEqual([ + "0: Boundary: Loading... | Sum: 0", + "3000: Boundary: content | Details: 0 | Outside: 0" + ]); + expect(f).toHaveLength(3); + expect(f[2].startsWith("7000: ")).toBe(true); + for (const v of ["Details: 2", "Outside: 2", "Sum: 2"]) expect(f[2]).toContain(v); + // Not asserted exactly: the 7000 frame also carries a stale `Sum: 1` — + // the slot Sum computed mainline at 4000 (page=1, committed count) is + // published before the #3322 contested re-derive publishes `Sum: 2`. + // Pre-existing (identical on the branch base), tracked separately. + }); + + it("#3374 repeating the held write after remounting the reader publishes with the derived value", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void, setVersion!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + const [version, sv] = createSignal(1); + setCount = sc; + setVersion = sv; + const details = createMemo(() => delay(2000, count())); + text(() => `Count: ${count()}`, log, when); + // : remount the reader on each version. + let dispose: (() => void) | null = null; + createRenderEffect(version, () => { + dispose?.(); + dispose = createRoot(d => (text(() => `Details: ${details()}`, log, when), d)); + }); + }); + flush(); + await settle(); + await advanceTo(2500); + setCount(1); + await settle(); + await advanceTo(3000); + setVersion(2); + await settle(); + await advanceTo(3500); + setCount(1); + await settle(); + await advanceTo(9000); + expect(frames(log, when)).toEqual([ + "0: Count: 0", + "2000: Details: 0", + // The remounted reader shows the committed frame (count is still 0 on screen)... + "3000: Details: 0", + // ...and the hold survives the rewrite: both reveal with the flight's answer. + "4500: Count: 1 | Details: 1" + ]); + }); + + it("#3373 × #3374: a reader remounted onto the stale first-hop flight holds on the re-asked input", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void, setVersion!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + const [version, sv] = createSignal(1); + setCount = sc; + setVersion = sv; + const a = createMemo(() => delay(1000, count())); + const b = createMemo(() => delay(1000, a())); + text(() => `Count: ${count()}`, log, when); + let dispose: (() => void) | null = null; + createRenderEffect(version, () => { + dispose?.(); + dispose = createRoot(d => (text(() => `B: ${b()}`, log, when), d)); + }); + }); + flush(); + await settle(); + await advanceTo(2500); + setCount(1); + await settle(); + await advanceTo(4000); + setCount(2); + await settle(); + // b is in flight (b1, due 4500) AND pending on a's re-ask (a2, due 5000). The + // remount reads b: no re-pull (b has its own flight), so the carve-out serves + // the committed 0. The disposed reader was the transaction's only reporter + // for `a`; the new one holds it through b's own flight until b1 lands, and + // b1's staged answer re-runs it onto `a` through the normal path. + await advanceTo(4200); + setVersion(2); + await settle(); + await advanceTo(9000); + expect(frames(log, when)).toEqual([ + "0: Count: 0", + "2000: B: 0", + "4200: B: 0", + "6000: B: 2 | Count: 2" + ]); + }); + + it("#3374 (two hops) the remounted reader holds through an intermediate memo", async () => { + reset(); + const log: string[] = []; + const when: number[] = []; + let setCount!: (v: number) => void, setVersion!: (v: number) => void; + createRoot(() => { + const [count, sc] = createSignal(0); + const [version, sv] = createSignal(1); + setCount = sc; + setVersion = sv; + const details = createMemo(() => delay(2000, count())); + const shown = createMemo(() => `Details: ${details()}`); + text(() => `Count: ${count()}`, log, when); + let dispose: (() => void) | null = null; + createRenderEffect(version, () => { + dispose?.(); + dispose = createRoot(d => (text(shown, log, when), d)); + }); + }); + flush(); + await settle(); + await advanceTo(2500); + setCount(1); + await settle(); + await advanceTo(3000); + setVersion(2); + await settle(); + await advanceTo(3500); + setCount(1); + await settle(); + await advanceTo(9000); + // The intermediate memo is re-pulled by the remount's read and enters the + // hold, so this reader holds (no committed frame at 3000) — and does not tear. + expect(frames(log, when)).toEqual([ + "0: Count: 0", + "2000: Details: 0", + "4500: Count: 1 | Details: 1" + ]); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index b8ceae28d..dcd19b7a2 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -229,7 +229,23 @@ describe("pay-for-use tree-shaking (#2883)", () => { // Conditional pending recovery adds 191 B over next at b5bd6fba // (22,457 → 22,648 B), including the alternate dependency path guard // and the self-source skip that leaves the #3181 sweep as the one walk. - expect(minifiedBytes).toBeLessThan(22_700); + // Second write while an async chain is in flight (#3373/#3376, #3375, + // #3374): a landing retires only its own pending entry (`landStatus` — + // the partial branch keeps the node pending on an input re-asked + // mid-flight), a fresh flight drops inherited entries at registration, + // transitionComplete tests a source's own flight by its self entry + // instead of `_error.source`, and the stale-reader carve-out joins the + // reporters of a node the transaction already waits on (one Map lookup + // in heldFromStale). +132 B (22,648 → 22,780). + // Boundary reset ends the hold (#3375 ruling): a reporter whose queue + // chain passes through a collecting loading boundary does not block + // (`reporterBlocksSource` walks `_queue._parent`), and a parked + // transaction can be woken for re-judgement — `wokenTransitions`, + // deduped, entered from the finally of an idle pass (`!scheduled`: no + // dirty, staged or optimistic ambient work to adopt); the fast drain + // defers to the full path while a wake is outstanding. +135 B + // (22,780 → 22,915). + expect(minifiedBytes).toBeLessThan(23_000); }); 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 55cc54cc1..c17aedacb 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -192,7 +192,20 @@ module.exports = [ // `source`; retryReaches is core-retained as the alternate-path // guard). +191 B minified in the in-package floor (22,457 -> 22,648); // measured here at 8428 B against the 8.40 KB cap. - limit: "8.45 KB", + // Second write while an async chain is in flight (#3373/#3376, #3375, + // #3374; 2026-09-12): 8.45 -> 8.50 KB, measured at 8471 B against + // `next`'s 8428 (+43) — a landing retires only its own pending entry + // (`landStatus`), a fresh flight drops inherited entries, the + // transaction tests a source's own flight by its self entry, and the + // stale-reader carve-out joins the reporters of a node the transaction + // waits on. +132 B minified in the in-package floor (22,648 -> 22,780). + // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 8.50 -> 8.55 KB, + // measured at 8507 B against 8471 (+36) — a reporter behind a collecting + // loading boundary no longer blocks its transaction, and parked + // transactions can be woken for re-judgement (`wokenTransitions`, entered + // from the finally of an idle pass). +135 B minified in the in-package + // floor (22,780 -> 22,915). + limit: "8.55 KB", modifyEsbuildConfig }, { @@ -400,7 +413,10 @@ module.exports = [ // `notifyOptimisticWrites` judging against the view readers see, and // the authoritative landing on an override-covered node dispatching to // the engine. - limit: "15.35 KB", + // Second write while an async chain is in flight (#3373–#3376, + // 2026-09-12): 15.35 -> 15.45 KB, measured at 15413 B against `next`'s + // 15340 (+73 — the core seams, see the core floor note). + limit: "15.45 KB", modifyEsbuildConfig }, { @@ -502,7 +518,12 @@ module.exports = [ // `source`; retryReaches is core-retained as the alternate-path // guard). +191 B minified in the in-package floor (22,457 -> 22,648); // measured here at 10713 B against the 10.70 KB cap. - limit: "10.75 KB", + // Second write while an async chain is in flight (#3373–#3376, + // 2026-09-12): 10.75 -> 10.82 KB, measured at 10784 B against `next`'s + // 10713 (+71 — the core seams, see the core floor note). + // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 10.82 -> 10.85 KB, + // measured at 10820 B against 10784 (+36); see the core floor note. + limit: "10.85 KB", modifyEsbuildConfig }, { @@ -576,7 +597,12 @@ module.exports = [ // at 11121 B against `next`'s 10924 (+197 B) — the core seams (see the // core floor note) // and, where the app retains lanes, the engine they dispatch to. - limit: "11.15 KB", + // Second write while an async chain is in flight (#3373–#3376, + // 2026-09-12): 11.15 -> 11.25 KB, measured at 11212 B against `next`'s + // 11145 (+67 — the core seams, see the core floor note). + // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 11.25 -> 11.30 KB, + // measured at 11243 B against 11212 (+31); see the core floor note. + limit: "11.30 KB", modifyEsbuildConfig }, { @@ -671,7 +697,13 @@ module.exports = [ // `source`; retryReaches is core-retained as the alternate-path // guard). +191 B minified in the in-package floor (22,457 -> 22,648); // measured here at 18622 B against the 18.56 KB cap. - limit: "18.65 KB", + // Second write while an async chain is in flight (#3373–#3376, + // 2026-09-12): 18.65 -> 18.70 KB, measured at 18678 B against `next`'s + // 18622 (+56 — the core seams plus the collecting boundary recording + // every source its effect is pending on, `CollectionQueue.notify`). + // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 18.70 -> 18.75 KB, + // measured at 18721 B against 18678 (+43); see the core floor note. + limit: "18.75 KB", modifyEsbuildConfig }, { @@ -807,7 +839,12 @@ module.exports = [ // `source`; retryReaches is core-retained as the alternate-path // guard). +191 B minified in the in-package floor (22,457 -> 22,648); // measured here at 28268 B against the 28.24 KB cap. - limit: "28.30 KB", + // Second write while an async chain is in flight (#3373–#3376, + // 2026-09-12): 28.30 -> 28.35 KB, measured at 28300 B against `next`'s + // 28268 (+32 — the core seams and the collecting boundary). + // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 28.35 -> 28.40 KB, + // measured at 28380 B against 28300 (+80); see the core floor note. + limit: "28.40 KB", modifyEsbuildConfig }, { @@ -873,7 +910,12 @@ module.exports = [ // `source`; retryReaches is core-retained as the alternate-path // guard). +191 B minified in the in-package floor (22,457 -> 22,648); // measured here at 13975 B against the 13.95 KB cap. - limit: "14.00 KB", + // Second write while an async chain is in flight (#3373–#3376, + // 2026-09-12): 14.00 -> 14.05 KB, measured at 14029 B against `next`'s + // 13975 (+54 — the core seams and the collecting boundary). + // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 14.05 -> 14.15 KB, + // measured at 14098 B against 14029 (+69); see the core floor note. + limit: "14.15 KB", modifyEsbuildConfig }, { @@ -935,7 +977,12 @@ module.exports = [ // `source`; retryReaches is core-retained as the alternate-path // guard). +191 B minified in the in-package floor (22,457 -> 22,648); // measured here at 15371 B against the 15.30 KB cap. - limit: "15.40 KB", + // Second write while an async chain is in flight (#3373–#3376, + // 2026-09-12): 15.40 -> 15.45 KB, measured at 15410 B against `next`'s + // 15371 (+39 — the core seams and the collecting boundary). + // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 15.45 -> 15.50 KB, + // measured at 15465 B against 15410 (+55); see the core floor note. + limit: "15.50 KB", modifyEsbuildConfig: observeEsbuildConfig }, { @@ -1017,7 +1064,12 @@ module.exports = [ // `source`; retryReaches is core-retained as the alternate-path // guard). +191 B minified in the in-package floor (22,457 -> 22,648); // measured here at 26888 B against the 26.85 KB cap. - limit: "26.92 KB", + // Second write while an async chain is in flight (#3373–#3376, + // 2026-09-12): 26.92 -> 26.95 KB, measured at 26930 B against `next`'s + // 26888 (+42 — the core seams and the collecting boundary). + // Boundary reset ends the hold (#3375 ruling, 2026-09-12): 26.95 -> 27.05 KB, + // measured at 26978 B against 26930 (+48); see the core floor note. + limit: "27.05 KB", modifyEsbuildConfig: observeEsbuildConfig }, {