Skip to content

fix(signals): lane authority — merged hold, reveal-on-flight, INV-11, override supersession with provenance (#3335 #3334 #3330 #3331), on next - #3370

Merged
ryansolid merged 7 commits into
nextfrom
fix/lane-authority-next
Sep 11, 2026
Merged

ryansolid merged 7 commits into
nextfrom
fix/lane-authority-next

Conversation

@ryansolid

Copy link
Copy Markdown
Member

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 on next.

This is #3347 re-based directly onto next so 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

laneHeld looked up a lane's _pendingAsync nodes 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. New waitingTransition(node) finds the live transaction blocked on a node, whichever recorded it; laneHeld asks 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); and handleAsync'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 _pendingValue while 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: laneReadsCommitted replayed 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"). _overrideOwner is 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:

  • 2.0.0-rc.7 Optimitstic set after short delay in action doesn't propagate through memo #3330 twin. An adoption under a live transaction now holds on 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 now 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-store reconcile inside an action holds the same way; a key first read under a held adoption is born holding (heldAdoptionTransitionstageHeldKey).
  • 2.0.0-rc.7 async downstream of optimistic delays reveal of final result #3331 twin. The authoritative landing on an override-covered store node (setSignal under projectionWriteActive) dispatches to the engine (_landOnOverridesupersedeOverride) instead of staging silently; supersededRead serves committed truth once a mainline landing has committed ahead of the revert.
  • Core, surfaced by the twin and reproduced with a plain signal: a reader that first links to a node while another transaction holds a staged write read committed and never learned of the commit (commits are silent). heldFromStale records such readers for the transaction's commit replay, skipping effects the transaction itself computed.
  • A settle that reverts optimism re-derives its contested effects (2.0.0-rc.7 render effect depending on non async values from two concurrent updates commit with the wrong data #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 2.0.0-rc until doesnt entangle the update resolving it with the action update #3164 tear; surfaced by deep() over an optimistic store).

Review follow-ups (sixth commit)

From GabbeV's review on #3347:

What changed in the port (vs. #3347 on #3337)

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.js re-measured against next @ 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 · tsc clean · full pnpm build · npm run size green. (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.)

ryansolid and others added 7 commits September 11, 2026 01:26
`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-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c2d7903

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/signals Patch
test-integration Patch
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
solid-js Patch
@solidjs/universal Patch

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

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34582636998

Coverage remained the same at 71.842%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1007
Covered Lines: 772
Line Coverage: 76.66%
Relevant Branches: 790
Covered Branches: 519
Branch Coverage: 65.7%
Branches in Coverage %: Yes
Coverage Strength: 15.03 hits per line

💛 - Coveralls

@codspeed-hq

codspeed-hq Bot commented Sep 11, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 57.88%

❌ 1 regressed benchmark
✅ 159 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
projection derive: write one NESTED field (reference) 219.9 µs 522.2 µs -57.88%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/lane-authority-next (c2d7903) with next (8366e09)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants