Skip to content

fix(signals): nodes created mainline during a hold are born held (A29 creation-time form) - #3451

Merged
ryansolid merged 3 commits into
nextfrom
fix/born-held
Sep 15, 2026
Merged

ryansolid merged 3 commits into
nextfrom
fix/born-held

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Was stacked on #3449; now rebased onto next (the three #3449 commits dropped). Fixes the two A29/A18(c) violation cells the oracle found, plus a swallowed-write bug the fix exposed.

The bug

A memo or effect created from mainline code while a transaction holds a value it reads — a component mounting on a click while an action is in flight:

// action holds setX(1); committed x is 0
createRoot(() => {
  const m = createMemo(() => x());
  createRenderEffect(m, render);   // published 1 — the HELD value, in the mainline frame
  createRenderEffect(x, render);   // published 0 — committed (stale-reader rule)
});
setY(1);                            // swallowed into the action: y never published

Two causes:

  • recompute's creation arms: a create pass always direct-commits (create ||) and is never held. The fresh memo was correctly served the staged value (A29) and enterStagedRead entered the transaction — then the create arm committed the value as the memo's mainline truth anyway. Same for a superseded node: the fresh memo published the truth while a fresh direct effect beside it showed the override.
  • enterStagedRead entered via initTransition from creation code, outside a flush. Nothing restores activeTransition there (the flush loop does inside a flush; action steps end with a flush), so the rest of the synchronous block — including the unrelated setY — became the action's work. The analogous reveal path (a creation-time read of a pending async node) does not do this.

The rule (maintainer, 2026-09-14)

  1. (a) A node born of the held world has no committed value until the transaction commits. Readers hold; an untracked read throws NotReady (A19 exception 1).
  2. The entry is the pass's, never the mainline block's.

The fix

Outside a flush, enterStagedRead records the transaction (stagedEntry) instead of entering. recompute stages the pass's node into it: _transition stamped, pushed to its _pendingNodes, STATUS_UNINITIALIZED kept; an effect is added to the transaction's _gatedSubs and skips its synchronous first run (effect()); commitPendingNode initializes the node and the replay runs the effect. read() holds readers of a node with a staged value and no committed one (stale readers enter instead of showing undefined). Inside a flush nothing changes — #3408's pin is untouched. Verdict pulls (GlobalQueue._verdictPull; the latest() shadow is created before it is marked optimistic, so the config bit alone cannot tell) and optimistic-posture nodes keep the entering path. supersededRead's staged truth is a staged read too.

A mainline mount therefore shows the committed frame for direct bindings and holds derived ones until the commit reveals both.

Verification

  • New tests/born-held.test.ts: fresh memo + effect held / fresh direct effect committed / commit reveals all; unrelated write after the mount stays mainline; untracked read of a born-held memo throws until commit; in-flush branch flip unchanged (2.0.0-rc.8 conditional memo reveals a held signal value early #3408).
  • Oracle: the two violation cells flip to rule. 90/90.
  • @solidjs/signals 174 files / 1895 · solid-js 618 · @solidjs/web 783 — green.
  • Spec: A29 amended in place (creation-time form); index regenerated.

Size

+316 B minified on the in-package core floor (23,365 → 23,681); brotli +105 B core floor, +126 +createStore, +146 +isPending/latest. Conscious bump, notes in treeshake.test.ts and .size-limit.js. Golfed once (flag as a GlobalQueue static instead of an exported setter; supersession entry moved into supersededRead outside the floor; dropped currentTransition/_valueTransition in the staging arm — the pass runs outside a flush, no merges happen). What remains is the rule: a new node state needs detection, staging, read semantics, commit-time init, and the effect skip.

Co-authored-by: Cursor cursoragent@cursor.com

Made with Cursor

Also: the last oracle violation (second commit)

isPending() under a displayed override now reads the arrival even before the node's first commit. The oracle state I had labeled "downstream never initialized" was really "the node itself never committed" — its first landing was held by a reveal that never landed — and computePendingState applied A19 exception (1) ("uninitialized is loading, not pending") before checking for an override. A displayed override is an observable value, so the override check comes first. Verdict module only, no core-floor cost. Pinned by tests/superseded-before-first-commit.test.ts; A18 (d) amended.

Oracle: 90 cells, zero violations. A pre-existing, shape-sensitive NotReadyError escaping flush() was observed while probing (reproduces on next) but could not be pinned stably; not this PR's.

Rebase onto next (2026-09-15)

Verified after rebase: signals 1905, solid 618, web client 786 / server 914, size-limit all scenarios pass.

@changeset-bot

changeset-bot Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4869d98

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

ryansolid and others added 2 commits September 15, 2026 01:14
… creation-time form)

A memo or effect created from mainline code while a transaction holds a
value it reads — a component mounting on a click while an action is in
flight — was served the staged value (A29) and then recompute's creation
arms committed it anyway (`create ||` direct-commit; a create pass is never
held), publishing the held value into the mainline frame beside pre-existing
readers showing the committed one. The oracle's two A29/A18(c) violation
cells. And enterStagedRead entered the transaction with initTransition from
creation code, outside any flush, leaving activeTransition and the batch
pointed at it for the rest of the synchronous block: an unrelated write
made after the mount was swallowed into the action (y never published).

Now, outside a flush, enterStagedRead records the transaction (stagedEntry)
instead of entering; recompute stages the pass's node INTO it — stamped,
pushed to its pending nodes, STATUS_UNINITIALIZED kept — and for an effect
adds it to the transaction's gated subs and skips the synchronous first run
(effect()); the commit initializes the node and replays the effect. read()
holds readers of a node with a staged value and no committed one: a stale
reader enters instead of showing undefined, an untracked reader throws
NotReady (A19 exception 1). Inside a flush nothing changes (#3408's pin).
Verdict pulls (GlobalQueue._verdictPull — the latest() shadow is created
before it is marked optimistic) and optimistic-posture nodes keep the
entering path; supersededRead's staged truth is a staged read too.

Ruling (2026-09-14): 1(a) — a node born of the held world has no committed
value until the commit; 2 — the entry is the pass's, never the block's.

Pins: tests/born-held.test.ts; the oracle cells flip from violation to rule.
Size: +316 B minified core floor (23,365 → 23,681), brotli +105 core /
+126 store / +146 verdict; caps ratcheted with notes. Signals 1895, solid
618, web 783 green.

Co-authored-by: Cursor <cursoragent@cursor.com>
…re the node's first commit (A18 d)

computePendingState gated its held-value branch on !STATUS_UNINITIALIZED
before checking for an override, so an optimistic node whose first landing
was held (a downstream reveal that never landed) and was then superseded
under its override read false while the arrival differed — the oracle's
last violation cell. The uninitialized suppression is A19 exception (1),
"no observable value exists to be non-final"; a displayed override is an
observable value, so the override check now comes first and the
suppression applies only without one.

Pinned by tests/superseded-before-first-commit.test.ts and the oracle cell
(flipped from violation to rule; the state is renamed for its actual
condition — the node never committed, not "downstream uninitialized").
A18 (d) amended in place. Verdict module only: no core-floor cost.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid changed the base branch from test/visibility-oracle to next September 15, 2026 08:15
Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34946380801

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.09 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 15, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks


Comparing fix/born-held (4869d98) with next (61a114c)

Open in CodSpeed

@ryansolid
ryansolid merged commit d80cd1f into next Sep 15, 2026
7 checks passed
@ryansolid
ryansolid deleted the fix/born-held branch September 15, 2026 08:41
ryansolid added a commit that referenced this pull request Sep 28, 2026
…k, not a transaction entry (#3698)

A <Show> over an optimistic value held an unrelated synchronous write for
the lifetime of the action that wrote the optimism: `drag()` stayed at the
committed value while `latest(drag)` moved and `isPending(drag)` was true,
and the Show kept its content. Reading an active override is lane
membership, not transaction entry (#3460 ruling: "a held lane is basically
a micro transition from the outside… we wouldn't hold a sync write on a
transition. Lanes are the same"), so the write should have published at
once with the action still open.

Root cause — not the A29 entering path: `enterStagedRead` is never reached
for a read served the active override (`overrideRead`'s non-superseded arm
returns the override without entering). The report reproduces only when
the lane-member memo OWNS children: the compiler emits a memo inside
Show's `when` getter, so Show's condition memo does. A lane pass over such
a memo publishes its result as a derived override (lane work, correct) but
parks the memo's previous children as a #3404 transaction zombie — which
queued the memo as the action's pending node "for the zombies alone",
stamped `_transition`, and made its next mainline recompute re-enter the
hold through `recompute`'s stamped-memo arm, adopting the unrelated write.
This is exactly #3662's diagnosis for effects; #3662 left memos unchanged
on the ground that a memo's lane pass publishes an override, which is true
of the value but not of the children.

Fix: a memo's lane pass parks a LANE frame like an effect's
(`CONFIG_LANE_FRAME`) — `recompute`'s parking gate `isEffect && lane` ->
`lane`. The retired children drain when the lane's queue applies the pass
(in the pass's flush for a lane that is not held, at the release for one
that is); the memo is neither queued nor stamped for the action's commit.
The A29 "optimistic-posture nodes keep the entering path" clause (#3451,
born-held) is untouched: it governs whether a CONFIG_OPTIMISTIC pass takes
the born-held staging path, and is not involved here.

Tests: tests/optimistic-read-lane-not-transaction-3698.test.ts (the report
shape with primitives, revert and confirm settles, lane-frame drain timing
for a memo, the held-lane #3460 shape, and the supersession contrast where
entry is legitimate and still happens); packages/web
test/show-optimistic-sync-write-3698.spec.tsx (the report through
<Show keyed> and <Show>).

Size: -3 B minified in the core; brotli layout moves the ledger -104..+61 B.
Two frozen caps raised under a Size-Exception (hydrating no-stores 19.68 ->
19.69 KB, live server-components page 49.07 -> 49.13 KB), one scenario
limit ratcheted (observe + attribution 30.60 -> 30.61 KB).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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