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

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