fix(signals): lane authority — merged hold, reveal-on-flight, INV-11, override supersession with provenance (#3335 #3334 #3330 #3331), on next - #3370
Merged
Conversation
`laneHeld` looked up a lane's `_pendingAsync` nodes in its own transaction's `_asyncReporters`. 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 no longer held the merged reveal, and the frame tore. The observation is a fact about the node: `waitingTransition(node)` finds the live transaction blocked on it, whichever recorded it, and `laneHeld` asks per node. Same rule the transaction itself uses (unrendered async and fallback-caught async hold nothing). Pinned in tests/lane-hold-on-observation.test.ts; INTERNALS §2 hold rule. Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ess of stamp (#3334) Two halves of one tear. `read()`'s pending branch let a stale (render) reader of another transaction carve out a node pending in a foreign transaction and show its committed value — on the theory that the stamp meant that transaction also held the node's inputs. It does not: the stamp is pending-node bookkeeping, and the inputs may already be on screen (committed #3305, lane-revealed #3334). A render reader landing on a pending node now throws whichever transaction the node is stamped with (A15: observed async settles as one unit). And `handleAsync`'s settle-time re-entry entered the lane owner's transaction for a lane-routed landing, folding every transaction waiting on the flight into the owner's action — a reveal that discovered the flight then waited on the action instead of on the flight. It enters the waiter: the transaction whose blocker this landing clears (`waitingTransition`). Pinned in spec A15 (#3334 describe) and tests/stale-read-uninitialized-cross-transition.test.ts (re-expected). INTERNALS §3. Co-authored-by: Claude via Cursor <noreply@cursor.com>
…#3330) `recompute`'s equality gate compared a lane (OPT-dirty) recompute against `_pendingValue` — a value an earlier action write had staged for the transaction — while the lane publishes to `_value`. An identical result was called "unchanged", the override revealed without its derivation, and the frame showed `v=1 d=0`. INV-11: the gate compares against the slot this run publishes to — the override for an override-covered node, `_value` for a lane direct commit, `_pendingValue` for a transaction-staged run. Found under the same repro: `laneReadsCommitted` recorded every lane reader of a staged node for commit-time replay, including when the staged value already equals the committed one (the lane published it) — the replay re-ran effects against an unchanged frame. Recorded only when the commit will change what the reader saw. Pinned in spec A17 (#3330 pin and re-apply companion); INTERNALS §2, §5 INV-11. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
… provenance (#3331) A18 supersession. An optimistic override used to stay the graph's value until its transaction committed, even after the node's own source had answered with something else — the override's downstream flight ran to completion and only then did the truth's start, doubling the delay the reporter saw. Now a differing arrival marks the node `CONFIG_OVERRIDE_SUPERSEDED`: tracked readers see the staged truth (`read()` → `_supersededRead`), the node's lane cascade is demoted and notified on the plain channel so downstream async restarts immediately as held transaction work, and the override's own downstream flight is inert when it lands. Untracked reads and the applied frame keep the override until the commit. An equal arrival confirms silently (or un-supersedes); the authoritative-observer wake (until()'s predicate, #3164) lives in the same engine hook. `runEffect` hands a lane runner for a lane-less effect to the regular queue so the still-held transaction stashes it. Scope: "the source" is whatever recomputes the node — its own async landing (asyncWrite) or a sync recompute driven by an upstream change (`createOptimistic(() => asyncMemo())`), both recompute branches. Ordering: a source write and an override in the same batch derive nothing new (`_overrideTime` vs clock). Provenance: only the override's own question or a newer one supersedes. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override — that answer must not leak back in over the user's latest intent ("getting it not to flicker was super important"). Transactions merge, so `_overrideOwner` cannot tell the actions apart; the scheduler carries the running action's invocation sequence (`origin`: set by action() per slice, cleared at the end of flush(); captured per flight in handleAsync and re-armed by asyncWrite for the landing's propagation), the override stamps it (`_overrideStamp`), and a differing arrival from an older action holds silently to the commit. Mainline (no action) is always current. Pinned: spec A18 (#3331 describe: own-async, sync-wrapper, same-batch, provenance, simple graph; entangled pin re-expected); createOptimistic.test.ts (no-double-flicker pin unchanged; "second action while first still in flight" resolver repaired and re-expected). SPEC A17 amendment and the 2026-09-09 re-ruling log for the whole lane-authority series, INTERNALS §1/§3, treeshake NOTE (22,049 / 22,050, no bump). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…f a hold re-derive at the commit (#3330, #3331) Every rule pinned for signals in #3335/#3334/#3330/#3331 is pinned against the equivalent optimistic-store shape (tests/store/lane-authority-twins). optimistic families too — a sync derive adopting truth under the action's transaction is held truth, not lane business; unheld, handlers read the swapped-in backing early, latest() inverted, and the optimistic write compared equal to it and wrote no override. `notifyOptimisticWrites` judges the write against the view readers see (`heldMaskView(t) ?? t.v`). A held adoption notifies its nodes at the outermost setter exit (`stageHeldAdoptions`), staged under the transaction, so the commit promotes silently instead of delivering the adopted values as fresh writes that re-ran every subscriber against a frame the lane had already published. Plain-store reconcile inside an action holds the same way; a key first read under a held adoption is born holding (one `stageHeldKey` with the #3336 fold case: committed from the view readers see, the held value staged). (setSignal under projectionWriteActive) dispatches to the engine (`_landOnOverride` → supersedeOverride) instead of staging silently on the plain path; `supersededRead` serves the committed truth once a mainline landing has committed ahead of the override's revert. Core, surfaced by the store twin and reproduced with a plain signal: a reader that first links to a node while another transaction holds a staged write read the committed value and never learned of the commit (commits are silent; the staging walk was the notification). `heldFromStale` records such readers for the transaction's commit replay, skipping effects the transaction itself computed (parked run / contested re-derive) — replaying those published the frame twice. On the A28 write path the async landing's supersession decision runs at the write's promotion (promoteUnflushed's override arm hands the node to supersedeOverride, still under the flight's provenance). A settle that reverts optimism re-derives its contested effects (#3322) after the revert, with the gated replay: ahead of the heap run the truth is committed but the overrides still display, and the re-derive composed the two — the #3164 tear, surfaced by deep() over an optimistic store whose held adoption the committing transaction's own readers see. Core floor 22,252 → 22,638 (conscious). Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…unless its inputs are visible; same-value re-prediction renews provenance (A15 re-rule, #3331) Review on #3347 (GabbeV). Two fixes and a re-ruling. A15 reveal corollary, re-ruled: #3334 removed read()'s pending-branch carve-out outright — a stale (render) reader of a node pending in another transaction always held on the flight. That 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 (parallel transactions; effects don't entangle; React 19.3 stopped entangling the same shape). The carve-out returns, gated on input visibility: the reader shows committed, does not entangle, and is recorded for the transaction's commit replay (heldFromStale) — refused, holding instead, when the committed value would tear against the frame: the flight's inputs were PUBLISHED while it was pending (CONFIG_INPUTS_PUBLISHED, set by commitPendingNode's computed branch when a commit leaves the node in the air, #3305; cleared by notifyStatus when the node next enters pending from a settled state), the node rides a live lane (GlobalQueue._laneLive -> resolveLane, exact rather than sticky; optimistic / latest, #3334), or the node is uninitialized. The initialized-memo pin and its store twin return to "show v0, then v1". Replay hygiene the carve-out surfaced: an effect recorded in _gatedSubs that later recomputes UNDER the recording transaction sees its staged view and is applied by the commit (ownership) — the stale recording published the frame twice. recompute drops it at its start; a lane's committed-view read re-records during the run, so the lane replay is untouched. Provenance (#3331 follow-up): a same-value optimistic write by a newer action took the fast path — entangling the transaction — without renewing _overrideStamp, so the OLDER action's slow source superseded a value the user had just re-confirmed: a corrective downstream refetch and a pending flip for nothing. The fast path now renews the stamp to a newer origin. Pinned: tests/reveal-carve-out.test.ts (GabbeV's "revealed reader never catches up" and "conditional reader stays hidden" shapes, a replacement flight over published inputs, a retired lane), the same-value provenance twin in spec-async-semantics. SPEC A15 + re-ruling log, INTERNALS §3. Core floor 22,638 -> 22,737 (conscious). Size-limit scenarios reconciled for the lane-authority stack with per-scenario notes. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Floor 21,994 -> 22,457 (+463 B; budget 22,500) and the scenario caps re-stated against next @ 4935c7d rather than the #3337 stack the notes were written on: core 8188 -> 8369, createStore 15012 -> 15318, isPending/latest 10253 -> 10667, hydrating + stores 27608 -> 28208. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: c2d7903 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Coverage Report for CI Build 34582636998Coverage remained the same at 71.842%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will degrade performance by 57.88%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
This was referenced Sep 11, 2026
This was referenced Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four lane-authority fixes in
@solidjs/signals, one commit per issue, each with its changeset, plus a fifth commit that pins every rule against its optimistic-store twin and a sixth from review (the A15 reveal carve-out re-ruled, a provenance gap). All pre-existing onnext.This is #3347 re-based directly onto
nextso it can ship in this rc while #3337 (latest-held-till-flush, the A28 write path) is considered longer. Same six fix commits; the only re-port is where #3347 composed with A28 — see "What changed in the port" below. Closes #3347.#3335 — merged lane hold is per node, not per transaction
laneHeldlooked up a lane's_pendingAsyncnodes in its own transaction's_asyncReporters. Lanes merge across transactions (#2912), so after a merge the root's transaction knew only one member's observations and the other member's async no longer held the merged reveal. NewwaitingTransition(node)finds the live transaction blocked on a node, whichever recorded it;laneHeldasks per node.#3334 — a reveal holds on the lane flight it discovers, regardless of stamp
Two halves of one tear:
read()'s pending branch carved out nodes pending in another transaction for stale readers and showed their committed value (the stamp is bookkeeping, not evidence the inputs are held — they may already be lane-revealed); andhandleAsync's settle re-entry entered the lane owner's transaction, folding the waiting reveal into the owner's still-running action. The reader now throws (A15); the landing enters the waiter.#3330 — a lane recompute compares against the slot it publishes (INV-11)
A lane (OPT-dirty) recompute compared against a transaction-staged
_pendingValuewhile publishing to_value, called an identical result "unchanged", and revealed the override without its derivation (v=1 d=0). The gate now compares against the slot the run publishes to. Found alongside:laneReadsCommittedreplayed lane readers at commit even when the staged value already equalled the committed one — a duplicate frame; recorded only when the commit changes what was read.#3331 — own-source arrival supersedes the override, with action provenance
Per the ruling ("a new value from the source should remove the optimism immediately… folded into the parent transition"): a differing arrival marks the node superseded — tracked readers see the staged truth, the lane cascade is demoted and re-derives as held transaction work (downstream async restarts now, no waterfall), the override's own downstream flight is inert — while untracked reads and the applied frame keep the override until commit. Equal arrivals confirm silently. Scope covers the sync-wrapper shape (
createOptimistic(() => asyncMemo())) — "if the source recomputes it doesn't matter if it is async or not" — with a same-batch ordering guard.Provenance. Two rapid actions on one node merge into one transaction, and the older action's refetch can land after the newer override. That answer must not leak back in ("getting it not to flicker was super important").
_overrideOwneris post-merge and can't tell the actions apart, so 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 its landing propagates under it. The override stamps it; a differing arrival from an older action holds silently to the commit. Mainline (no action) is always current. The original no-double-flicker pin is unchanged and green; the "second action while first still in flight" pin had its second resolver repaired (it used to orphan action 1, so its final assertions passed against a transaction that never closed) and is expected to the same rule.Store twins (fifth commit)
Every rule above is pinned against the equivalent optimistic-store shape (
tests/store/lane-authority-twins.test.ts). #3335 and #3334 already held; #3330 and #3331 did not:latest()inverted, and the optimistic write compared equal to it and wrote no override;notifyOptimisticWritesnow judges against the view readers see. A held adoption stages its nodes under the transaction at the outermost setter exit (stageHeldAdoptions) so the commit promotes silently instead of re-running every subscriber against a frame the lane already published. Plain-storereconcileinside an action holds the same way; a key first read under a held adoption is born holding (heldAdoptionTransition→stageHeldKey).setSignalunderprojectionWriteActive) dispatches to the engine (_landOnOverride→supersedeOverride) instead of staging silently;supersededReadserves committed truth once a mainline landing has committed ahead of the revert.heldFromStalerecords such readers for the transaction's commit replay, skipping effects the transaction itself computed.deep()over an optimistic store).Review follow-ups (sixth commit)
From GabbeV's review on #3347:
read()'s pending-branch carve-out outright, so a new reader of a memo whose input write is itself still held waited for the landing — though showing the committed pair introduces no inconsistency (parallel transactions; effects don't entangle; React 19.3 stopped entangling the same shape). The carve-out returns, gated on input visibility: a stale reader of a node pending in another transaction shows committed, 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 flight's inputs were published while it was pending (CONFIG_INPUTS_PUBLISHED, set bycommitPendingNodewhen a commit leaves the node in the air, fix(signals): hold conditional reveals on existing async work #3305; cleared when the node next enters pending fresh), the node rides a live lane (_laneLive→resolveLane; optimistic /latest, 2.0.0-rc.7 Issue 3305 can still happen for optimistic and latest values while fixed for normal signals #3334), or the node is uninitialized. The initialized-memo pin and its store twin return to "showv0, thenv1"; every 2.0.0-rc.7 Issue 3305 can still happen for optimistic and latest values while fixed for normal signals #3334/fix(signals): hold conditional reveals on existing async work #3305 hold pin still passes._gatedSubsthat later recomputes under the recording transaction is applied by the commit itself — the stale recording published the frame twice.recomputedrops it at its start; a lane's committed-view read re-records during the run._overrideStamp, so the older action's slow source superseded a value the user had just re-confirmed (corrective downstream refetch, pending flip). The fast path renews the stamp to a newerorigin.heldFromStalerecording and are now pinned intests/reveal-carve-out.test.ts, alongside a replacement-flight-over-published-inputs shape and a retired-lane shape.What changed in the port (vs. #3347 on #3337)
next's is. On fix(signals): writes become visible at flush — latest() reads the flushed staged world (A28) #3337 the async landing's supersession decision ran at the write's promotion (promoteUnflushed's override arm). HereasyncWrite's pending-node branch dispatches directly:hasActiveOverride(el) ? GlobalQueue._supersedeOverride!(el, value) : insertSubs(el), replacing theCONFIG_AUTHORITATIVE_OBSERVED→_notifyAuthoritativeObserversarm (that wake now lives inside the hook).recompute's two override branches collapse to the same hook call exactly as on fix(signals): lane authority — merged hold, reveal-on-flight, INV-11, override supersession with provenance (#3335 #3334 #3330 #3331) #3347. Provenance is unchanged:handleAsynccapturesoriginper flight,asyncWritere-arms it before the propagation,flush()clears it.getNodeborn-holding branch covered two holds through onestageHeldKey: a held adoption (2.0.0-rc.7 Optimitstic set after short delay in action doesn't propagate through memo #3330 twin) and a held fold (2.0.0-rc.7 spooky action at a distance because of lazy latest computed creation #3336's store half, which lives in fix(signals): writes become visible at flush — latest() reads the flushed staged world (A28) #3337). Only the adoption case is here;stageHeldKeyis the minimal(node, nv, txn)stager. The fold case andheldFromReader/foreignHoldstay with fix(signals): writes become visible at flush — latest() reads the flushed staged world (A28) #3337. The fulllane-authority-twinsfile passes without them..size-limit.jsnotes are written againstnext._supersedeOverridedispatch back intopromoteUnflushed's override arm (the shapefix/lane-authority@ fae8b76 already has), and re-measures.Docs / bytes
SPEC A15 lanes corollary, A17 amendment, A18 supersession + scope/ordering/provenance, 2026-09-09 re-ruling log; INTERNALS-ASYNC §1–§3, §5 INV-11, late readers, contested post-revert; INTERNALS-STORE §3 adoption under a live transaction.
Core floor: 21,994 → 22,457 (+463 B; budget 22,500) — the same fixes cost +485 B on the #3337 stack.
.size-limit.jsre-measured againstnext@ 4935c7d (brotli): core 8188 → 8369 (+181), createStore 15012 → 15318 (+306), isPending/latest 10253 → 10667 (+414; this scenario retains the engine), simple app 10924 → 11121 (+197), hydrating 18295 → 18529 (+234), hydrating + every store family 27608 → 28208 (+600), CSR 13738 → 13921 (+183), observe 15037 → 15269 (+232), attribution 26652 → 26819 (+167), frames unchanged. Caps set ~30 B over measured.Verification
Based on
next@ 8366e09. signals 1744 passed / 1 skipped · solid 595 · web 734 ·tscclean · fullpnpm build·npm run sizegreen. (One wall-clock pin, "mounting rows with a per-row user effect is linear in N (#3350)", flaked once in four local runs; it is timing-based and unrelated to these changes.)