Skip to content

fix(signals): held-input rules — a staged read enters the transaction, dependencies are the committed frame's (#3408, #3410) - #3431

Merged
ryansolid merged 1 commit into
nextfrom
fix/held-conditional-memo
Sep 14, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/held-conditional-memo

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 14, 2026

Copy link
Copy Markdown
Member

Fixes #3408, fixes #3410.

Two sides of one gap. The engine already says "the staged world belongs to its transaction" for writes (setSignal on a stamped node enters it) and for stamped recomputes (recompute's head enters it). Reads and dependency trimming did not.

#3408 — a memo that starts reading a held signal revealed its staged value

const details = createMemo(() => delay(1000, count()));       // count write → held
const panel   = createMemo(() => (show() ? count() : "hidden"));

setCount(1) is held by details. setShow(true) flips panel mainline; it now reads count, and non-stale readers are served the staged value (readers keep speculation — by design). But nothing joined panel to the hold, so it published a value derived from the held world into the mainline frame: Panel: 1 beside Count: 0.

Rule: a tracked computation served a live transaction's _pendingValue enters that transaction (enterStagedRead), on each of read()'s value selections (both fast paths and the slow path). No-op for the ambient batch, the active transaction, and probe reads (pendingCheckActive). It's the read twin of the existing write-side and recompute-side entries.

#3410 — a memo whose held pass trimmed an input disagreed with its committed inputs

const selected = createMemo(() => (fixed() ? 2 : count()));
const details  = createMemo(() => delay(1000, selected()));

setFixed(true) is held. selected's held pass stops reading count and trimStaleDeps dropped the edge — so setCount(1) never reached a memo whose committed value still derived from count: Count: 1 beside Selected: 0 / Fixed: false.

Rule: dependencies are the committed frame's until the frame is replaced — the deps twin of held children (#3404). A pass that staged its value leaves the previous pass's dependency tail linked; commitPendingNode trims it (after a clean pass — _error set means the last pass threw and _depsTail marks where it stopped). The count write then dirties the stamped memo, which enters the hold, exactly as an unconditional count() read does. Decided at commit rather than recompute because a plain flush knows nothing at recompute time: the transaction that holds the pass may open later in the same flush. __OBSERVE__ fan-in counting now walks only the validated prefix so held tails don't inflate distinct-source counts.

Both scenarios now reveal atomically at the landing (tests/held-conditional-memo.test.ts).

Verification

  • @solidjs/signals: 167 files / 1785 tests green; full monorepo turbo test --concurrency 1 green for every package except 3 @solidjs/compiler parity tests that fail identically on origin/next (compiler/babel-plugin trees are untouched here).
  • Core floor 23,058 → 23,181 (+123 B, conscious bump, noted in treeshake.test.ts); eight brotli caps ratcheted by 0.05 KB with notes in scripts/size/.size-limit.js.
  • src/ typechecks clean. No existing expectation moved.
  • Docs: the two rules are user-observable semantics, so they are spec rules — A29 (a tracked read served a live transaction's staged value enters that transaction) under Reads and visibility and A30 (a memo's dependencies are the committed frame's until the frame is replaced) under Transactions and holds in SPEC-ASYNC-SEMANTICS.md, each with Status / Pinned by / Mechanism; cited by ID from enterStagedRead, recompute's tail, commitPendingNode and the two pins, so the rules-index gate covers them (RULES-INDEX.md regenerated: 307 rules, every src citation resolves, every live A-rule test-cited). A28 is fix(signals): writes become visible at flush — latest() reads the flushed staged world (A28) #3337's. The INTERNALS-ASYNC-STATE.md §3 bullets are reduced to mechanism notes pointing at the rules. Changeset included.

Rebased on next after #3432 / #3433.

Co-authored-by: Claude noreply@anthropic.com

@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dee461b

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

coveralls commented Sep 14, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34889669512

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

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 14, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
❌ 1 regressed benchmark
✅ 158 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
projection derive: delete + set one ROOT key (#3352) 455.3 µs 500.6 µs -9.05%
projection derive: write one NESTED field (reference) 523.8 µs 224.7 µs ×2.3

Tip

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


Comparing fix/held-conditional-memo (dee461b) with next (25c5064)

Open in CodSpeed

@ryansolid
ryansolid force-pushed the fix/held-conditional-memo branch from 7b389f8 to 39c9a0d Compare September 14, 2026 17:18
@ryansolid

Copy link
Copy Markdown
Member Author

@codspeedbot fix this regression

Requesting the flame-graph diff for projection derive: delete + set one ROOT key (#3352) (455.5 → 500 µs, reproduced at the same magnitude across two runs, pre- and post-rebase). Local optimized runs of base vs head show no difference on this benchmark, so the per-function instruction counts are what will settle whether it is the added commitPendingNode branch, read() losing inlining from the enterStagedRead calls, or the base run's GC state (the sibling write one NESTED field benchmark, untouched by this PR, moved ×2.4 in the same report).

Claude via Cursor

@codspeed

codspeed Bot commented Sep 14, 2026

Copy link
Copy Markdown

@codspeedbot fix this regression

Requesting the flame-graph diff for projection derive: delete + set one ROOT key (#3352) (455.5 → 500 µs, reproduced at the same magnitude across two runs, pre- and post-rebase). Local optimized runs of base vs head show no difference on this benchmark, so the per-function instruction counts are what will settle whether it is the added commitPendingNode branch, read() losing inlining from the enterStagedRead calls, or the base run's GC state (the sibli...

I reproduced the benchmark under the same instrument locally and bisected it. None of the three candidates is the cause, and there is nothing in the PR to fix — the 8.91% is a measurement artifact of how that bench file is built.

What the flame-graph diff says

The whole +44.5 µs is one frame's self time (recompute: 23 → 71.6 µs), with every callee subtree flat within 2 µs and identical call counts. That attribution is not trustworthy: head recompute's JIT code range encloses the address range that base used for other functions, and ~200 µs also swaps between write (projection.ts:17) and the closure at projection.ts:166 — with 4 and 2 calls respectively in both runs. So I went to instruction counts instead.

Local instruction counts (codspeed run --mode simulation, Ir per benchmark)

build ROOT key (#3352) NESTED (untouched) createStore setter (untouched)
next (da6ed76) 537,655 180,890 513,411
this PR (39c9a0d) 623,679 181,599 513,424
PR, re-run 623,677 181,599 513,424
PR − enterStagedRead call sites 623,361 181,182 512,508
PR − deps change (enterStagedRead kept) 537,872 181,041 512,238
next + tail trimStaleDeps(el) only 508,044 181,705 627,428
next + a provably dead statement 537,454 180,663 511,585

Reproducible to ±2 instructions, so the +86,024 (+16% Ir) is real and not GC scheduling in the usual sense.

  • read() / enterStagedRead: +316 instructions (0.05%). Not it.
  • Reverting only the 2.0.0-rc.8 conditional memo disagrees with its inputs during a held branch change #3410 deps move restores the base number exactly (537,872 vs 537,655).
  • But the cost is not the trim's work. In this benchmark the projection memo has exactly one dependency (measured: 1 link, 1 trimStaleDeps, 0 unlinkSubs per commit), the fan-in walk is 1 iteration and breaks on _depsTail, and trimStaleDeps's own cost is 2,649 vs 2,879 instructions. Every other symbol in the window is bit-identical between base and head (wrapDraft 45,499, flattenOverlay 33,820, get 11,254, setSignal 6,849 …). No reactive-graph work changed.

What actually moves

A single ~86–120k-instruction chunk of one-off V8 work that attaches to whichever JS frame is running when it happens, and lands in exactly one of the three benchmark windows in the file.

The decisive control: add only the tail trimStaleDeps(el) call to the next tree, where it is a pure no-op because the original trim already ran three lines earlier. That makes the "regressed" benchmark 5.5% faster than base (508,044) and the untouched createStore setter benchmark 22% slower (627,428) — the same chunk, relocated. A dead statement in the same position moves nothing (537,454), so the trigger is the extra executed call perturbing V8's per-function tier-up budgets, not the source text.

That is also what happened in the report you're looking at: write one NESTED field, which this PR does not touch, moved ×2.4 (instructions 242.6 µs → 52.6 µs) in the same run. Locally the two builds differ by 0.4% on it.

Recommendation

Acknowledge the regression on CodSpeed — the changed code path costs ~300 instructions, not 86k. The instability is structural to projection-root-write.bench.ts: three benchmarks share one process and one set of 20k-key fixtures, and each body performs a single commit, so one iteration is measured while V8's tier-up state is still moving, and a ~100k chunk is ~20% of it. If you want the guard to be trustworthy at this resolution, having each body run N commits (or a warmup loop) would amortize it — happy to push that as a separate change if you want it, since it shifts the absolute numbers and re-baselines those three benchmarks.

No changes committed to this branch.

Summary
  • Warming up the performance wizard...
  • Locate base and head CodSpeed runs — head 39c9a0d (6aa82f6e) vs base da6ed76 (6aa823af).
  • Compare flame graphs for the regressed benchmark — Whole +44.5 µs delta sits in one frame's self time; every callee subtree flat within 2 µs.
  • Attribute the regression — Bisected: enterStagedRead costs +316 instr; the deferred trimStaleDeps triggers a floating ~86-120k V8 chunk that lands in an arbitrary benchmark window.
  • Reproduce locally under Valgrind — Reproduced deterministically: 537,655 → 623,679 instructions (±2 across repeats).
  • Report findings — No source change needed; the measured cost is not in the changed code path.
  • Handling comment — Comment handling finished successfully

Learnings updated. See what CodSpeed remembers about this repository.

Was this response helpful? React with 👍 or 👎 to help us improve CodSpeedBot.

@ryansolid
ryansolid force-pushed the fix/held-conditional-memo branch from 39c9a0d to 9307d53 Compare September 14, 2026 19:45
…, dependencies are the committed frame's (#3408, #3410)

Two sides of one gap. The engine already says "the staged world belongs
to its transaction" for writes (setSignal on a stamped node enters it)
and for stamped recomputes (recompute's head enters it). Reads and
dependency trimming did not.

a signal a transaction holds. Non-stale readers are served the staged
value (readers keep speculation), but nothing joined the reader to the
hold, so it published a value derived from the held world into the
mainline frame (`Panel: 1` beside `Count: 0`). `enterStagedRead`: a
tracked computation served a live transaction's `_pendingValue` enters
the transaction, on each of read()'s value selections (both fast paths
and the slow path). No-op for the ambient batch, the active transaction,
and probe reads.

stopped reading count and trimmed the edge, so a later count write never
reached the memo whose committed value still derived from it (`Count: 1`
beside `Selected: 0` / `Fixed: false`). Dependencies are the committed
frame's until the frame is replaced — the deps twin of held children
(#3404): a pass that staged its value leaves the previous pass's tail
linked and `commitPendingNode` trims it (after a clean pass; `_error`
set means the last pass threw and `_depsTail` marks where it stopped).
The write then dirties the stamped memo, which enters the hold, exactly
as an unconditional count() read does. Decided at commit because a plain
flush knows nothing at recompute time: the transaction that holds the
pass may open later in the same flush.

Both now reveal atomically at the landing. +123 B core floor (conscious
bump), eight brotli caps ratcheted by 0.05 KB with notes. No existing
expectation moved.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the fix/held-conditional-memo branch from 9307d53 to dee461b Compare September 14, 2026 19:53
@ryansolid
ryansolid merged commit c3ae310 into next Sep 14, 2026
6 of 7 checks passed
ryansolid added a commit that referenced this pull request Sep 14, 2026
…dy (#3435)

Three benchmarks share one process and one set of 20k-key fixtures, and
each body performed a single commit. V8 emits a one-off ~100k-instruction
tier-up chunk that attaches to whichever frame is running when it fires and
lands in exactly one of the three windows — at ~540k Ir per root write that
is 16-20% of a window, and it moves between windows on unrelated changes.
CodSpeed's bisection of #3431 (2026-09-14) made this exact: a change costing
~300 instructions on its own path read as -8.9% on the root write and x2.4
on the untouched nested write; a provably no-op extra call added to `next`
read as -5.5% on the root write and +22% on the untouched store setter
(537,655 → 508,044 and 513,411 → 627,428 Ir; every reactive-graph symbol
bit-identical).

Each body now runs COMMITS = 20 commits. The chunk amortizes to <1% of a
window — below the 5% gate — and the three stay per-commit comparable,
which is the parity this file guards (#3352 derive vs #3044 setter floor).
Absolute numbers re-baseline ×20.

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