Skip to content

fix(signals): a verdict reader born into a hold reads it whichever flush runs it (#4426) - #4454

Merged
ryansolid merged 5 commits into
nextfrom
claude/project-thread-x1ore2-4426
Oct 30, 2026
Merged

ryansolid merged 5 commits into
nextfrom
claude/project-thread-x1ore2-4426

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Before: a verdict reader born into a hold T could be run by a flush that belongs to another transaction F. One example is an effect mounted by a content pass that T holds. When that reader read a node T holds, it was served the committed value.

Two deciders disagreed:

  • frameRead had just joined the pass to T because the reader has no screen (bornInto(c, txOf(el))).
  • stagedScreen then asked about the flush's transaction instead (bornInto(c, flushTransaction)), so it handed the reader the screen anyway.

In async-gen seed 3289 #911, the effect under <Show when={s0()}>, mounted for s0 = 3, read s0() === 0.

After: a reader born into a hold reads that hold's value of the nodes it holds, whichever flush runs it, and the two deciders agree. This restores D-25 (the hold's own work reads plainly) and D-27 / A29.

How

stagedScreen now takes the node being read. A held node that isn't a lane's only reaches stagedScreen from read or serve when frameRead found the reader born into it; every other held node was already served the screen. So stagedScreen answers "no screen" for that node, the same answer it already gave when the flush's transaction was that hold. Lane nodes are excluded because frameRead never asks about them.

The cost is one mask compare, on the verdict / folded-lane staging path only. Nothing changes in recompute and no effects are added. The #4438 seat-shadow known #4426 entry now gets 0 hits. It is left in place for later cleanup.

Size over next @ 9c4e4a8, measured locally (linux), brotli / minified:

scenario measured vs next, minified cap
compiled floor (hello world) 9,963 / 27,284 0 B unchanged
signals: + createStore 14,952 / 43,908 +20 B unchanged (0 B of the 20 B allowance left)
signals: + isPending/latest 11,092 / 30,596 +24 B unchanged
page: base server components (frozen floor) 33,755 / 101,638 +20 B 33.66 → 33.77 KB (Size-Exception)
page: live server components (frozen floor) 38,817 / 117,331 +24 B 38.76 → 38.83 KB (Size-Exception)
page: compiled base server components 35,702 / 108,007 +20 B 35.66 → 35.72 KB
page: compiled live server components 42,695 / 125,478 +24 B unchanged

The check sits in core.js, so every bundle that ships stagedScreen carries about 20 B minified. That includes + createStore, compiled CSR and compiled hydrating, as well as the verdict pages. An earlier version of this body said the rest of the dist was byte-identical to next, and that was wrong. Next was already 13 B minified over the recorded size on the base server-components floor (#4452), so this takes it past the allowance. The brotli on that page (+94 B) is layout of those bytes. The one available reorder came out worse.

Size-Exception: the server-component pages carry the D-25 born-into-hold check that fixes #4426 (+20–24 B minified, the brotli is layout); the base page crosses its record only with next's +13 B (#4452); correctness.

How did you test this change?

Fixes #4426


Generated by Claude Code

…y transaction's flush (#4426)

`read` asked two deciders whether the pass was born into the hold, with
two different holds. `frameRead` asked about the node's hold
(`bornInto(c, txOf(el))`) and joined the pass, and `stagedScreen` then
asked about the flush's transaction (`bornInto(c, flushTransaction)`).
For a verdict reader born into T and run by another transaction's flush,
the second answer was no, so it was served the committed value. That is
the screen the first answer had just ruled it does not have (D-25: the
hold's own work, born in its world, reads plainly; D-27 / A29: a node
born into a hold has no screen).

`stagedScreen` now takes the node. A held node (not a lane's) that `read`
or `serve` brings here is one `frameRead` found the reader born into,
because `frameRead` served every other one the screen. So it answers "no
screen" for it, as it already did when the flush's transaction was that
hold.

Fixes #4426

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…-x1ore2-4426

# Conflicts:
#	packages/signals/docs/RULES-INDEX.md
#	packages/signals/src/core/core.ts
Measured locally (linux) over next @ 7135a77. On page: live server
components (a frozen floor): 38,795 B brotli / 117,251 B minified, +24 B
minified, so the cap goes 38.76 -> 38.81 KB and floor-caps.json's
recorded minified 117,227 -> 117,251. On page: compiled live server
components: 42,689 / 125,398, so the cap goes 42.67 -> 42.70 KB and the
recorded minified 125,374 -> 125,398. Caps are at measured + 10 B
rounded up to 0.01 KB. Every other scenario passes the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@changeset-bot

changeset-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6658563

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

This PR includes changesets to release 15 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
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example 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

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.42 KB 0 B 0 B +13 B 7.43 KB ✅
signals: + createStore 14.95 KB +20 B (+0.1%) +20 B +20 B 14.95 KB ⚠️ over by 2 B, 0 B minified headroom
signals: + isPending/latest 11.09 KB +13 B (+0.1%) +24 B +27 B 11.10 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB 0 B 0 B +13 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.78 KB 0 B 0 B +13 B 9.78 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.75 KB 0 B 0 B +13 B 18.74 KB ⚠️ over by 5 B, 7 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.42 KB 0 B +22 B +41 B 32.45 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.45 KB 0 B 0 B 0 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.18 KB −15 B (−0.1%) 0 B +13 B 15.20 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.76 KB −6 B (−0.0%) 0 B 0 B 29.78 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.96 KB 0 B 0 B +13 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.30 KB +4 B (+0.0%) +20 B +20 B 26.31 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.44 KB −64 B (−0.2%) +20 B +20 B 32.52 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.75 KB 0 B 0 B 0 B 11.77 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.76 KB +50 B (+0.1%) +20 B 0 B 33.77 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.03 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 38.82 KB +67 B (+0.2%) +24 B 0 B 38.83 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.03 KB, wire.js 3.47 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.70 KB +69 B (+0.2%) +20 B 0 B 35.72 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 8.99 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 42.70 KB +21 B (+0.0%) +24 B +28 B 42.71 KB ✅ eager (counted): web.js 22.77 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.07 KB, sc-comments.js 0.19 KB, trace.js 9.07 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.18 KB −26 B (−0.1%) +20 B +33 B 41.23 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, server.js 1.03 KB, serverForms.js 3.57 KB, trace.js 9.02 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.37 KB +51 B (+0.1%) +24 B +29 B 48.44 KB ✅ eager (counted): client.js 27.91 KB; assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.07 KB, server.js 1.03 KB, serverForms.js 3.30 KB, trace.js 9.00 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B 0 B 1.29 KB ✅
server: renderToString (the server-render floor) 22.79 KB 0 B 0 B 0 B 22.80 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • signals: + createStore: over brotli cap by 2 B; minified 43,908 B vs 43,888 B recorded with the cap (+20 B) — 0 B of the 20 B minified allowance left; +20 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 5 B; minified 53,598 B vs 53,585 B recorded with the cap (+13 B) — 7 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 30, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41057603758

Coverage remained the same at 78.1%

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: 1362
Covered Lines: 1125
Line Coverage: 82.6%
Relevant Branches: 1090
Covered Branches: 790
Branch Coverage: 72.48%
Branches in Coverage %: Yes
Coverage Strength: 29.81 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 13.17%

2 warnings

⚠️ 4 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 1 benchmark with a significant change ran on different hardware

Its base and head results were measured on machines with a different CPU, core count or memory, so the change might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

⚡ 1 improved benchmark
✅ 199 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ projection derive: write one NESTED field (reference) 2.4 ms 2.1 ms +13.17%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-x1ore2-4426 (6658563) with next (9c4e4a8)

Open in CodSpeed

Footnotes

  1. 5 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. ↩

Ledger conflicts resolved to next's entries, then re-measured over next
@ 9c4e4a8: frozen floors base SC 33.77 KB (101,638) and live SC 38.83 KB
(117,331); compiled base SC 35.72 KB (108,007). Size-Exception in the PR
body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Copy link
Copy Markdown
Member Author

The red job is the hydrate spec test/consistency/c09-no-phantom.spec.tsx (case (b), invocations.length 0 instead of 1). It's flaky on next too, so it isn't this PR's.

I checked it on a clean build of next @ 9c4e4a8: 7 of 15 runs failed case (b) or (c). On this branch 10 of 15 runs failed, but those ran with more CPU load at the same time. Both cases wait a fixed two quiesce() rounds for the fill to claim, so they lose that race when the machine is busy. No fix for the test exists yet. I'm looking at making it wait on the claim itself, as a separate PR. Re-running the failed job once.


Generated by Claude Code

@ryansolid
ryansolid merged commit 2e05808 into next Oct 30, 2026
10 of 11 checks passed
ryansolid added a commit that referenced this pull request Oct 30, 2026
createStore and the compiled CSR app were over their caps on next after #4454
and #4457 landed together; raise both by the cap rule and re-record the
minified of every non-frozen scenario that moved.


Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

Co-authored-by: Claude <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 30, 2026
…ad; seat rows D1''/L1''/V3'' (#4472)

* fix(signals): a netted write read in a strict untrack is no staged read; seat rows D1''/L1''/V3''

#4452 made a signal's staging equal to its committed value (writes that
netted, D-8) a read of the screen in `read`'s fast block. The reader-seat
table still raised Fl on that read (rows D1 / L1 / D7, and V3 for a
verdict reader), so the shadow's seat parted from the flags and every
later read of the pass reported too ("world after the read", "verdict
seat after the read", B2 "born held"): 8 INV-seat-3 classes (x59) in the
signals suite, 7 in web, and 60 in the generator (37 / 35 / 43 at 2,000
cases x seeds 3289 / 91501 / 7, every pair). Bisected over every merge
7135a77..131ceb8: 0 before #4452 (66c6f56), 60 at it, none added by
#4453, #4458, #4460, #4454, #4457 or #4455. The runtime follows the rule
(D-8: a tick whose writes net to the committed value made no proposal),
so the table was stale: rows D1''/L1'' (D7'' in a lane) and V3'' serve P
and raise nothing. Shadow: 0 classes in signals, web and the generator.

With the rows in, the shadow found the path #4452 missed: in dev a
labelled `untrack` read (`strictRead`) takes the slow path, where `serve`
still called `stagedRead`, so dev held the errored re-run that prod (fast
block) does not. `serve` now skips it for a netted signal in dev; prod
folds the test away (0 B on every scenario).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* fix(signals): gate serve's netted skip on the strict read, not on dev

Only the strict untrack read skips the fast block in dev; a hydration
snapshot or a promoted write reaches serve in dev and prod alike and keeps
the staged-read mark. 0 B in prod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

---------

Co-authored-by: Claude <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 30, 2026
…dict reader (E2) (#4473)

* fix(signals): a held frame's leaf is the hold's own work, not its verdict reader (E2)

A render effect in a frame a held pass built (CONFIG_HELD_FRAME, e.g. the
branch `<Show when={y() > 0}>` opens under an action holding `y`) re-run
by the hold's next step showed two ways depending on memo wrapping:

- inline `latest(y)`: `route` seated it as the hold's verdict reader, so
  its run went out at the verdict lane's reveal, ahead of its frame;
- `createMemo(() => latest(y))` read plainly: `laneRead`'s L7' arm served
  the memo's staging as the hold's own value and the run waited for the
  landing (and a later run, with nothing staged, entered the verdict lane
  through `enterLane`).

`[10, 20]` beside `[10]` for one hold; with #4455, the two-hold review
shape (`[latest(y), m()]` inline vs a memo of it, or split leaves) the
same. A member of the hold's held frame is born in its world: it reads
plainly and a probe does not rescue it (D-25, as #4426/#4454 for its
reads), and its runs are the frame's, which commits with the hold (D-27's
joined pass, A29 ruling A). `route` and `enterLane`'s verdict arm now
leave it the frame's when the lane's hold is the one it was born into;
every form waits for the landing (the branch is not on screen before it).
The reader-seat table carries the same clause (test-only).

#4455's pin (`verdict-seat-merged-lane-4427`) asserted the inline leaf
running ahead of its held frame (`1/20` at A's step); it now expects the
frame's `1/10` until B's landing, and gains the mainline-mount contrast
(`<Show when={latest(y) > 0}>`), which still shows `1/20`.

Lives in verdict.ts / lanes.ts only: 0 B on every floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* chore(size): record the held-frame verdict clause's cost on the lane pages

Measured locally (linux) over next @ 131ceb8. signals: + isPending/latest
11,125 B brotli / 30,704 B minified (+63 B), cap 11.12 -> 11.14 KB;
page: compiled live server components 42,810 / 125,600 (+82 B), cap
42.73 -> 42.82 KB. Caps at measured + 10 B rounded up to 0.01 KB; recorded
minified = measured. page: live server components (frozen floor) +63 B
minified, under its brotli cap (38,825 vs 38.85 KB): not raised. Every
floor without the verdict engine is 0 B. (app: hydrating (no stores) is
over its cap on next as well, unchanged here.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* refactor(signals): drop #4455's held-hold comparison in laneRead; the held-frame clause covers it

laneRead's D-28 seat reset goes back to comparing verdict lanes by identity.
With the held-frame clause, no shape depends on the hold comparison, and the
seat table's #4427 known entry no longer matches anything. -48 B minified on
the verdict pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

* chore(size): re-record over next @ d0fc285

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

---------

Co-authored-by: Claude <noreply@anthropic.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.

3 participants