Skip to content

refactor(signals): move 3b — one implementation per rule for value selection; five store/signal parity fixes - #3523

Merged
ryansolid merged 10 commits into
nextfrom
refactor/owns-hold
Sep 17, 2026
Merged

ryansolid merged 10 commits into
nextfrom
refactor/owns-hold

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 17, 2026

Copy link
Copy Markdown
Member

Collapses the stack #3516#3517#3520#3521#3523 into one PR against next (step 1, #3515, is already merged). The per-step commits are preserved; each carries its own gate results in its message.

Why

The last two months of async fixes were ~four rules each fixed several times at different sites. docs/DESIGN-CONSOLIDATION.md (committed here) proposes one implementation per rule, gated on the posture matrix, both oracles and the fuzzer. This PR is move 3b — Rule 1, value selection — through step 6c, plus the store/signal parity fixes the fold surfaced.

What each step did and found

step change found
2 store node reads select by readerSeesCommitted S4: a render effect's untracked read of a store key held by a foreign action never replayed at the commit — stuck on the pre-action value forever
3 recordStaleReplay — one replay registration for node and backing paths S4's no-node and reconcile-adoption forms (four store shapes, all failing on next)
4 A29 at the store's untracked paths (enterStagedRead, null-node form); matrix gains memoUntracked / effectUntracked (621 → 851 cells); design doc committed S5: a mainline memo's / user effect's untracked read of a held store key published the action's unrevealed write
5 S7: an optimistic override survives its key becoming unobserved (deferSlotRelease) the slot hook released the node with the override on it; s.n read committed while the action was live
6b ownsHold / ownsLane — one ownership relation; §6 records the lane rulings zero change
6c serve(el, c, owner, committed) — Rule 1's one slow selection; read() and the store's nodeValue call it S8: a derivation's untracked read of a superseded derived-optimistic-store key was served the override, not the landed truth (A18)
deletion the store's backing visibility (readSource, pendingBackingVisible) on the shared predicates — one holdVisible(txn, c) for both hold kinds; six store-local helpers deleted (heldFromReader, foreignHold, enterHeldBacking, staleReplay, inOwnerContext, inForbiddenScope) zero change — matrix and oracles byte-identical; store −313 B minified, −87 B brotli (folded from #3525)

Ruling applied throughout: "store rules should follow signal rules" (2026-09-17). Every fix cites an existing rule (A15/A26, A29, A17, A18); no rule text changed.

Deferred, ruled: S6 (A28 at the store backing) — at the store twin it cost +402 B; pinned at the store's current value with the ruling attached.

Result

Every paired signal/store state in the 851-cell matrix is row-for-row identical (six pairs × 37 rows). The 5-reader subset of the matrix is byte-identical to next except the cells the fixes above flipped, each to the signal's value.

Cost (honest)

Core minified +164 B (replay helper +31, null-node enterStagedRead +1, ownsHold +44, serve +88). Store minified ≈ +50 B net: the four fixes ≈ +370, the refactors ≈ −320 (the backing-visibility deletion alone −313). Brotli: +createStore scenario now −48 B and hydrating + stores −112 B relative to the pre-deletion stack; core-floor scenarios +24…+48 B; caps ratcheted with one consolidated note each.

Two deletion probes on top of 6c, to test whether the consolidation pays bytes back:

  • overrideRead / readsHeldCommitted → already single implementations; nothing to delete.
  • read()'s fast block → readNodeFast: −128 B minified in a store bundle but +125 B on the core floor (readNodeFast was never in it) and +31 B brotli (the compressor already dedup'd the twin). Reverted.

The backing twin was where the bytes came back: its Rule-1 arms restated on the shared predicates deleted six helpers for −313 B with zero semantic change. The size ceiling with the whole owner-context arm removed (semantics broken) was −738 B; the remaining ~400 B is the container choice for in / Object.keys / descriptors under a hold, which no node path can replace. S6 (A28 at the backing, ruled store-follows-signal) is #3526, stacked on this, kept separate so CodSpeed judges the fix apart from the consolidation (+283 B store).

Gate (rebased over #3518 / #3522)

check result
signals suite 3011 pass
oracles + 851-cell matrix identical to the pre-rebase stack tip
fuzzer seed 3289 × 1000 994 / 0 / 6 — same as next (run per step)
solid-js 626 pass
web same 25 failures as origin/next in this worktree (compiler binary; unrelated)
CodSpeed pending on this PR

@changeset-bot

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: aec7ecd

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 7 commits September 17, 2026 12:36
…le 1, one implementation)

Step 2 of DESIGN-CONSOLIDATION move 3b. nodeValue — the store's untracked
node-read selection — restated core read()'s committed-vs-staged arm by hand
(inOwnerContext, a stale-of-foreign 'foreignHold' twin, the HELD-truth mask).
It now calls readerSeesCommitted with core's context selection (readerContext:
a root reads as its parent computed) behind the two store-only tunnels,
latest() (#3075) and authoritativeServe (truth authors).

Not zero-change — the twin was missing a precondition of the rule it copied:
a stale reader (render effect) whose untracked read is of a store key held by
a foreign action was served committed (correct, A15/A26) but never recorded
for replay at the commit (heldFromStale's _gatedSubs contract, which the
signal path performs), so the effect stayed on the pre-action value after the
action settled. Pinned as S4 in posture-store-parity (signal vs store, both
now [0, 0, 1]). The backing twin (no node: pendingBackingVisible /
heldFromReader) has the same gap and no node to record the reader on —
pinned it.fails as the step-3 item. The lane arm (laneReadsCommitted) now
also applies to untracked store node reads as it does to tracked ones; no
matrix cell or test distinguishes.

Gate: suite green (+2 pins, 1 expected fail); visibility oracles and the
621-cell posture matrix byte-identical to next; minified store −5 B, full
bundle brotli −44 B vs step 1.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…ule 1, backing-level form)

Step 3 of DESIGN-CONSOLIDATION move 3b. The stale-of-foreign clause has two
halves — serve committed, and record the reader for replay at the hold's
commit (A15 / A26, _gatedSubs). Core heldFromStale does both; the store's
backing-level twins (heldFromReader for a key with no node, readSource's
adoption-hold view for a reconcile inside an action) did only the first. A
render effect whose untracked read hit either stayed on the pre-action value
after the action settled.

The registration is now one exported helper, recordStaleReplay(txn, c),
called by heldFromStale and by the store's two backing sites (staleReplay:
reader in context, not children-forbidden — the same gate core applies
before it reaches heldFromStale).

Pins: posture-store-parity S4 grows to the four store shapes (fold hold ×
adoption hold × with/without a node) beside the signal; all four fail on
next, the it.fails from step 2 flips to a pass.

Gate: suite green; oracles and the 621-cell posture matrix byte-identical
to next; +31 B minified core (the helper), +123 B minified store (the fix);
three brotli caps ratcheted with notes.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…born held (A29 at the store's untracked paths)

Step 4 of DESIGN-CONSOLIDATION move 3b. Core read() serves a staged value to
a deriving reader and enters its transaction in the same arm — an untracked
read inside a memo is still that memo's read (context persists under
untrack), so a mainline memo over a held signal is born held. The store's
untracked paths — nodeValue, pendingBackingVisible, readSource's adoption-hold
view — restated the value half and dropped the entry: a mainline memo's or
user effect's untrack(() => s.n) PUBLISHED the action's unrevealed write.

They now call enterStagedRead on the arm that serves the staged value;
enterStagedRead takes a null node for a backing (the fold's transaction is
passed). The two store tunnels (latest() for store keys, authoritativeServe)
keep serving without entering, as before.

Discovery: the posture matrix gains memoUntracked / effectUntracked reader
kinds (621 -> 851 cells) — the kind that sees S4 and S5, and that shows two
more store/signal divergences recorded, not ruled, as S6 (staged-ambient
write read by a derivation created inside a foreign action) and S7
(optimistic override with the only reader gated away).

Commits docs/DESIGN-CONSOLIDATION.md (dropped from next by c7ec862's squash
accident) with a ledger of moves 3a/3b so far; the code citations now point
at it.

Gate: suite green (+14 pins); oracles identical; the 5-reader subset of the
matrix identical to next; +1 B minified core, +130 B minified store; two
brotli caps ratcheted.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…unobserved (S7; store follows signal)

Ruling 2026-09-17: store rules follow signal rules. Of the two divergences
the 7-reader posture matrix recorded in #3520, this ships S7 and defers S6.

S7 — the slot hook released a property node the moment its last subscriber
left, with the optimistic override on it (overrides live on nodes over a
clone the setter discards): s.n read 0 while the action was live, where an
optimistic signal keeps its override whether or not anything reads it. A
node carrying an override or a staged write now defers its release to the
flush that resolves it (deferSlotRelease feeds the scheduler's
transientStoreNodes sweep — present since the monorepo absorb with exactly
these checks, and unfed). +55 B minified store. Pinned: override visible
after gating away; node released at settle.

S6 — A28 at the backing (a mainline setStore invisible to a derivation
created inside a foreign action until the carrying flush) is ruled the same
way but DEFERRED: done at the store twin it cost +402 B minified (a node
born in the unflushed window must stage the write, and every arm existed
twice — core's and the twin's). Pinned at the store's current value with
the ruling attached; it closes when the store's value selection shares
core's serve (DESIGN-CONSOLIDATION move 3b step 6). Ledger updated.

Gate: suite green; oracles identical; matrix: only the S7 cells moved, to
the signal's values; solid/web unchanged from next; no size caps exceeded.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
… 3b step 6b); record the lane rulings

Step 6b of DESIGN-CONSOLIDATION move 3b. Three tests of 'is this hold the
running pass's world' existed: heldFromStale's t === activeTransition, the
store's foreignHold (resolved-transition equality), and readsHeldCommitted's
'same resolved transition OR same lane'. One relation now: core ownsHold(hold)
— the pass runs under the hold's transaction, through merges — and lanes.ts
ownsLane(lane, owner) built on it, adding the lane clause. heldFromStale's
identity test becomes the resolved form the other two already used; no cell
of the 851-cell matrix, no oracle cell and no test moves.

§6 of the design doc records the three rulings from 2026-09-17: store rules
follow signal rules; a lane is a transaction with an override whose world
includes the transition that owns it (an ownership relation, not a value
arm — serve gets no lane arm); collapse in place on agreement, absorption
into the open parent on disagreement (A18's mark; 3c's third cargo op).

Gate: suite green; oracles and matrix byte-identical; solid/web unchanged
from next; +44 B minified core (the function is not inlined; -4 B brotli on
the pure-signals fixture), four brotli caps and the floor budget ratcheted.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…and store nodes (move 3b step 6c); A18 for untracked store reads

read()'s slow tail — the override arm (A17, engine-routed under a lane or a
supersession), the lane entanglement gate, the born-held throw (A19 exc. 1),
the A28 unflushed arm and readerSeesCommitted / A29 — is one function,
serve(el, c, owner, committed). read() passes el._value; the store's
untracked node path (nodeValue) passes its BACKING as the committed value
(single-home rule, O6) behind its two tunnels (authoritativeServe for truth
authors, latest() for store keys, #3075). unflushedValue takes the same
committed parameter. read()'s auto-dispose sweep registration keeps its
former early-return shape (an untracked read served a visible override
returned before it) via a guard.

Not zero-change — the fold carried A18 to the store: a derivation's UNTRACKED
read of a derived optimistic store's key, after its own truth (2) landed
differently from the action's edit (3), was served the override and
published 3; the signal serves the landed truth and holds the memo with the
action. nodeValue's own override arm lacked read()'s supersession routing
(overrideRead). Pinned as S8; the matrix state 'derived optimistic store: own
truth landed 2 ≠ override 3' now matches the signal's superseded state row
for row (37/37) — the only cells that moved.

Gate: suite green; oracles identical; solid/web unchanged from next; +88 B
minified core (wrapper, parameter, guard), floor budget and two brotli caps
ratcheted. The additive half of the consolidation; the deletions it enables
(overrideRead's wrapper, nodeValue, the verdict re-derivations) are next.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
@ryansolid ryansolid changed the title refactor(signals): one ownership relation — ownsHold / ownsLane (move 3b step 6b); record the lane rulings refactor(signals): move 3b — one implementation per rule for value selection; five store/signal parity fixes Sep 17, 2026
@ryansolid
ryansolid changed the base branch from fix/store-follows-signal-a28-override to next September 17, 2026 19:41
…cates — one holdVisible for both hold kinds (move 3b, the deletion half)

readSource (adoption hold) and pendingBackingVisible (fold) each restated Rule
1's hold arms for a CONTAINER — committed or staged — with their own helpers:
heldFromReader, foreignHold, enterHeldBacking, staleReplay, inOwnerContext,
inForbiddenScope. One holdVisible(txn, c) now, on core's ownsHold /
recordStaleReplay / enterStagedRead: the pass owns the hold (or there is
none) → staged; a foreign hold → a stale pass keeps committed and is recorded
for replay, a deriving pass takes staged and enters (A29). Both callers
serve context-free and children-forbidden readers the committed container
themselves (A32), with the store-only contracts kept as written: the
speculative peek, the projection's authoritative-elect backing, HELD truth
on an optimistic family (heldTruthMasked), drafts and write-override.

This is the twin serve() could not absorb: a container has no node to
select on, so the arms stay at the store — once, on the shared predicates,
instead of six times. Zero semantic change: the 851-cell posture matrix and
both oracles are byte-identical; suite green.

Store −313 B minified / −87 B brotli (the +createStore scenario −48 B,
hydrating + stores −112 B). The size ceiling with the whole owner-context
arm removed was −738 B; the remainder is the container choice itself.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
@coveralls

coveralls commented Sep 17, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 35275421055

Coverage remained the same at 71.304%

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: 1035
Covered Lines: 783
Line Coverage: 75.65%
Relevant Branches: 798
Covered Branches: 524
Branch Coverage: 65.66%
Branches in Coverage %: Yes
Coverage Strength: 15.06 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing refactor/owns-hold (aec7ecd) with next (d60ec6f)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@ryansolid

Copy link
Copy Markdown
Member Author

One concrete blocker before merge: S7 protects optimistic value-slot nodes (target.n) when their last subscriber leaves, but not structural presence nodes (target.h).

Optimistic adds/deletes store their membership override on getHasNode(...) (optimistic.ts: the setSignal(getHasNode(...), true/false) paths). Its per-node unobserved() callback still deletes target.h[key] immediately. If the sole structural observer gates away while the action remains live, that deletion discards the active presence override; subsequent "key" in store, Object.keys(store), and descriptor reads can fall back to committed structure before the action settles. This is the structural twin of the value-slot bug S7 fixes.

The presence-node callback should defer through deferSlotRelease(created) while it has an active override or staged value, just as setSlotUnobserved now does. The existing transient-node sweep can then invoke its _unobserved callback once the carried state resolves.

Please pin both optimistic add and optimistic delete: mount the only structural observer, start the action/override, remove that observer, verify in, Object.keys, and getOwnPropertyDescriptor still expose the optimistic structure while live, then verify settlement restores committed structure and releases target.h[key].

Otherwise the consolidation looks sound. S6 is pre-existing rather than introduced here, but #3526 should follow before release.

GPT-5.6 Sol via Cursor

…bserver leaving (S7, structural half; review of #3523)

An optimistic add or delete lives on the key's PRESENCE node (target.h,
getHasNode) as its membership override. Its unobserved callback released the
node the moment the last structural observer left — with the override on it
— so `in`, Object.keys and descriptors fell back to committed structure
while the action was live: the structural twin of the value-slot bug S7
fixed. The presence node now defers its release through deferSlotRelease
while it carries an override or a staged value; the transient-node sweep
runs its callback once the state resolves.

Pinned for optimistic add and delete: the only structural observer gates
away, in / Object.keys / getOwnPropertyDescriptor keep the optimistic
structure while the action is live, settlement restores committed structure
and releases target.h[key].

Suite green; oracles and matrix identical; +35 B minified store.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member Author

Confirmed and fixed in c410709 — thanks, that was a real gap.

Reproduced first: with the only structural observer ("k" in s in a render effect) gated away while the action was live, an optimistic add read [false, false, false] for in / Object.keys / descriptor and an optimistic delete read [true, true, true] — both fallen back to committed structure, target.h.k already gone.

Fix is what you described: getHasNode's unobserved callback defers through deferSlotRelease(created) while the node carries an active override or a staged value; the transient-node sweep runs the callback once that resolves.

Pinned both shapes in posture-store-parity (S7, structural half): the observer gates away → the three structural reads keep the optimistic structure while live → settle restores committed structure and target.h[key] is released. Suite green, oracles and the 851-cell matrix unchanged, +35 B minified store.

On S6: agreed it's pre-existing; it's #3526, stacked on this one.

Claude via Cursor

ryansolid added a commit that referenced this pull request Sep 17, 2026
…/ Object.keys / descriptor under a held add and optimistic add/delete

The oracle and the posture matrix only ever read a VALUE; structure — a
key's presence through `in`, Object.keys, getOwnPropertyDescriptor — rides
presence nodes and the key-set node and had no state, so the class 'structure
under a hold or override, by posture' was invisible (the review of #3523
found an optimistic add lost to committed structure the moment its only
structural observer left; no cell could have shown it). Nine states now: the
plain store's held add and the optimistic add and delete, each through the
three structural channels. The matrix grows 851 -> 1184 cells; the
gatedAway posture over these is the reviewer's shape, kept optimistic after
the gate.

Six pre-existing divergences between structure and value under the same
rules, pinned as violations (suite green, red in the oracle report):
- latest() over a held add sees the parked value of s.n but not the parked
  structure — none of the three structural channels has a latest() tunnel;
- the descriptor trap reads no node: isPending() over it witnesses nothing,
  and a pre-existing render effect inspecting a key through
  getOwnPropertyDescriptor never re-runs for an optimistic add or delete.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
…/ Object.keys / descriptor under a held add and optimistic add/delete

The oracle and the posture matrix only ever read a VALUE; structure — a
key's presence through `in`, Object.keys, getOwnPropertyDescriptor — rides
presence nodes and the key-set node and had no state, so the class 'structure
under a hold or override, by posture' was invisible (the review of #3523
found an optimistic add lost to committed structure the moment its only
structural observer left; no cell could have shown it). Nine states now: the
plain store's held add and the optimistic add and delete, each through the
three structural channels. The matrix grows 851 -> 1184 cells; the
gatedAway posture over these is the reviewer's shape, kept optimistic after
the gate.

Six pre-existing divergences between structure and value under the same
rules, pinned as violations (suite green, red in the oracle report):
- latest() over a held add sees the parked value of s.n but not the parked
  structure — none of the three structural channels has a latest() tunnel;
- the descriptor trap reads no node: isPending() over it witnesses nothing,
  and a pre-existing render effect inspecting a key through
  getOwnPropertyDescriptor never re-runs for an optimistic add or delete.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member Author

Follow-up to the fix above (02ace2c): the reason this was missable is that the oracle and the posture matrix only ever read a value. Added nine structural states — a held add, an optimistic add and an optimistic delete, each through in, Object.keys and getOwnPropertyDescriptor — so the matrix (851 → 1184 cells) now covers structure by posture; its gatedAway cells over the optimistic add are exactly the shape you flagged, and they hold the optimistic structure after the gate.

The new states also surface six pre-existing structure/value divergences, pinned as violation(...) cells (green suite, red in the oracle report) and left out of this PR: latest() over a held add sees the parked value but not the parked structure (no structural channel has a latest() tunnel); and the descriptor trap reads no node — an isPending() probe over it witnesses nothing, and a pre-existing render effect inspecting a key through getOwnPropertyDescriptor never re-runs for an optimistic add or delete. Those are follow-up fixes.

Claude via Cursor

@ryansolid
ryansolid merged commit 152ff7e into next Sep 17, 2026
7 checks passed
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