Skip to content

fix(signals): a held staging beats an inherited verdict posture (#4544) - #4612

Open
ryansolid wants to merge 3 commits into
nextfrom
fix/4544-own-staging
Open

ryansolid wants to merge 3 commits into
nextfrom
fix/4544-own-staging

Conversation

@ryansolid

Copy link
Copy Markdown
Member

A pass keeps a hold's staging only when the pending value is that hold's write — a dependency the same transaction holds has a pending value of its own, not a park-seam CONFIG_HELD on a published verdict — and this pass did not open its own probe (REACTIVE_WINDOW) or carry an unanswered probe of a not-yet-final node (REACTIVE_PROBE_UNANSWERED).

That check sits at the held re-pass arm, at frameRead, and at enterLane. CONFIG_VERDICT stays the bit every other read uses.

Memo form (chrome owns isPending(plan) / latest(plan), params reads both): after setSession the branch is {plan:false, signedIn:true}, and after setPlan it is {plan:false, signedIn:true} for isPending and {plan:true, signedIn:true} for latest and a plain read. chrome's own value follows its probe. The branch shows rows and the hold lands.

Inline form (params itself calls isPending(plan)): the pass's own probe wins, {plan:false, signedIn:false}, the signed-out fetch is issued and resolves, and the hold lands on rows after it. The forms differ because a memo's value is one slot under its own posture (A31 / D-25). Same-node isPending(session) stays today's screen.

The six generator ids match origin/next (9379257c8): seed 7 #21333, seed 3289 #1831 #17777 #29047 #840 #30372.

scenario base br head br Δ br base min head min Δ min
signals: core floor 7,419 7,425 +6 19,832 19,842 +10
signals: + isPending/latest 11,452 11,522 +70 31,386 31,644 +258

Public API changes

None.

Closes #4544

Made with Cursor

A pass that already holds a transition's own staging is that transition's work. An inherited verdict posture must not drop the staging or serve the transition's committed values; the pass's own probe still does.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e0092ab

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 Nov 2, 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 −91 B 7.43 KB ✅
signals: + createStore 15.00 KB 0 B 0 B 0 B 15.01 KB ✅
signals: + isPending/latest 11.51 KB +62 B (+0.5%) +252 B +252 B 11.47 KB ❌ over by 44 B
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B −84 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.80 KB 0 B 0 B −150 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.91 KB +85 B (+0.5%) +145 B +145 B 18.84 KB ❌ over by 71 B lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.88 KB +88 B (+0.3%) +145 B +145 B 32.81 KB ❌ over by 73 B lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.55 KB +10 B (+0.1%) +144 B +144 B 13.56 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.32 KB +88 B (+0.6%) +144 B +144 B 15.26 KB ❌ over by 56 B lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.91 KB +85 B (+0.3%) +145 B +38 B 29.88 KB ❌ over by 34 B lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.97 KB 0 B 0 B −187 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.40 KB +66 B (+0.3%) +145 B −20 B 26.36 KB ⚠️ over by 36 B, 40 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.65 KB +43 B (+0.1%) +145 B −26 B 32.61 KB ⚠️ over by 37 B, 46 B minified headroom 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.89 KB +134 B (+0.4%) +150 B +150 B 33.83 KB ❌ over by 63 B assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.21 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.44 KB +198 B (+0.5%) +391 B +391 B 39.31 KB ❌ over by 131 B assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.18 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.83 KB +28 B (+0.1%) +145 B +145 B 35.82 KB ❌ over by 10 B assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.18 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 43.37 KB +120 B (+0.3%) +397 B +397 B 43.26 KB ❌ over by 109 B eager (counted): web.js 23.17 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.21 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.35 KB +31 B (+0.1%) +145 B +145 B 41.33 KB ❌ over by 19 B 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.58 KB, trace.js 9.14 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 49.02 KB +66 B (+0.1%) +403 B +403 B 48.97 KB ❌ over by 52 B eager (counted): client.js 28.35 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.14 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.85 KB 0 B 0 B 0 B 22.87 KB ✅

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

  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 36 B; minified 80,107 B vs 80,127 B recorded with the cap (−20 B) — 40 B of the 20 B minified allowance left; +145 B minified over this PR's base
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 37 B; minified 100,825 B vs 100,851 B recorded with the cap (−26 B) — 46 B of the 20 B minified allowance left; +145 B minified over this PR's base

❌ Fails the gate

  • signals: + isPending/latest: over brotli cap by 44 B; minified 31,638 B vs 31,386 B recorded with the cap (+252 B) — 232 B past the 20 B minified allowance; +252 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 71 B; minified 53,632 B vs 53,487 B recorded with the cap (+145 B) — 125 B past the 20 B minified allowance; +145 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • app: hydrating + every store primitive family: over brotli cap by 73 B; minified 99,155 B vs 99,010 B recorded with the cap (+145 B) — 125 B past the 20 B minified allowance; +145 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • app: CSR, observe tier (same app on the observe artifacts): over brotli cap by 56 B; minified 42,055 B vs 41,911 B recorded with the cap (+144 B) — 124 B past the 20 B minified allowance; +144 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 34 B; minified 88,386 B vs 88,348 B recorded with the cap (+38 B) — 18 B past the 20 B minified allowance; +145 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 63 B; minified 101,762 B vs 101,612 B recorded with the cap (+150 B) — 130 B past the 20 B minified allowance; +150 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 131 B; minified 118,678 B vs 118,287 B recorded with the cap (+391 B) — 371 B past the 20 B minified allowance; +391 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 10 B; minified 108,091 B vs 107,946 B recorded with the cap (+145 B) — 125 B past the 20 B minified allowance; +145 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 109 B; minified 126,874 B vs 126,477 B recorded with the cap (+397 B) — 377 B past the 20 B minified allowance; +397 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 19 B; minified 125,770 B vs 125,625 B recorded with the cap (+145 B) — 125 B past the 20 B minified allowance; +145 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 52 B; minified 144,364 B vs 143,961 B recorded with the cap (+403 B) — 383 B past the 20 B minified allowance; +403 B minified over this PR's base. real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need Size-Exception:)

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 Nov 2, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41414499987

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage remained the same at 78.205%

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: 1369
Covered Lines: 1131
Line Coverage: 82.62%
Relevant Branches: 1104
Covered Branches: 803
Branch Coverage: 72.74%
Branches in Coverage %: Yes
Coverage Strength: 29.98 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 27.45%

1 warning

⚠️ 3 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 improved benchmark
✅ 199 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

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

Tip

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


Comparing fix/4544-own-staging (3606fa4) with next (97e1826)

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

Copy link
Copy Markdown
Member Author

Review of b075280, tested merged with next at 3ace826 after a clean build. The memo-form repro from #4544 is fixed, and none of the full suites gained a failure (signals 6312, solid, web, hydrate; async-gen 36/36). It isn't landable yet, for the reasons below.

1. Size: four rows fail against next's caps, and hello world goes over its cap

Row brotli minified
signals: + isPending/latest +70 +258
page: live server components +73 +252
page: compiled live server components +128 +275
page: live + router +75 +282
  • Every core floor grows +10 B minified.
  • Hello world measures 9,983 B brotli, over its 9,980 cap.
  • The PR edits scripts/size/scenarios.js. Fix PRs leave the ledger to the batch re-record, so please drop that file from the PR.

2. The inline probe form still shows signed-in content with signed-out data (D-27)

When params itself calls isPending(plan), the branch the hold opened reads signedIn: false while cond() says signed in. The hold then waits on the signed-out-only fetch. Steps, using the PR's own harness:

page("inline isPending")
setGate(1); setSession("acc"); flush();
setPlan(true); flush();
release(); // tick

The screen stays "0 signed out" until resolveSignedOut(). With the issue's never-resolving promise it never lands, which is the original symptom with the probe moved inline. Without the setPlan, it lands on rows. The PR's test resolves both fetches together, so it doesn't catch this.

3. One new test asserts a Loading that never settles (F-6)

"same-node isPending(session)" expects "0 signed out" after release(). That is the hold never landing: the branch reads the committed session, calls signedOutOnly() and waits forever. next behaves the same way, so the test locks in the bug instead of the fix.

4. The enterLane clause has no test covering it

With the holdStagingHook?.(c) clause removed from enterLane, the full signals suite and the PR's web spec still pass. It either needs a test that fails without it, or it can go.

5. A behaviour change that looks fine

In the memo forms, a setPlan(true) written outside the hold now joins the hold, where next commits it at once. This matches the plain-read form (A34) and A10, so it seems fine.

Shrinking the size

Both cuts below keep the full signals suite and the PR's web spec passing:

  • In frameRead, call the hook once behind observeFlightHook. This takes hello world, the core floors, the simple-app floor and createStore back to 0 B vs next.
  • Drop the enterLane clause, which saves about 13–30 B minified.
-  const staging = (c._config & CONFIG_VERDICT) !== 0 && !!holdStagingHook?.(c);
-  const verdict = observeFlightHook && c._config & CONFIG_VERDICT && !staging;
-  const lane = laneStageHook && passLane !== null;
+  const asked = observeFlightHook && c._config & CONFIG_VERDICT;
+  const staging = asked && holdStagingHook!(c);
   if (staging && !(c._config & (EFFECT_RENDER << 23))) return false;
+  const verdict = !staging && asked;
+  const lane = laneStageHook && passLane !== null;

With both cuts, the four rows are still about +240–250 B minified. Most of that is holdStaging itself (about 190 B) plus a fourth hook slot. holdStaging also walks the deps on every frameRead of a staged verdict reader, so it costs O(deps) per held read. A suggested next step, which I haven't built or measured: compute it once at recompute's head into a flag bit, and have frameRead test the bit. That removes the call site and the per-read walk.

Perf: a staged memo reading 1,000 plain and 200 held nodes, with 200 writes during the hold, measured the same on next and the PR (within noise).


Generated by Claude Code

…4544)

frameRead was calling the hook on every verdict bit, which the core floors paid for with the verdict engine absent. The enterLane clause had no test that failed without it, and the size ledger stays with the batch re-record.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member Author

Dropped scripts/size/scenarios.js. frameRead calls holdStagingHook once, and only when observeFlightHook is set, and the enterLane clause is gone. The #4544 specs pass (signals 5, web 4) and the signals suite passes. 3606fa47d.

The inline probe and the same-node probe stay on the predicate. The held re-pass joins a CONFIG_VERDICT node when holdStaging is true. holdStaging returns false for a pass carrying REACTIVE_WINDOW or REACTIVE_PROBE_UNANSWERED. At the end of the pass, CONFIG_VERDICT is replaced by this pass's REACTIVE_PROBED (the same bit): a pass that probed keeps the posture, and a pass that only read a verdict memo does not. #4544's repro is that second pass — chrome probes plan, params reads chrome and holds session. params calling isPending(plan) itself, and params calling isPending(session), are this pass's own probe, and the specs record the screen those probes produce. The same-node screen is the one next already shows.

— Grok via Cursor

Copy link
Copy Markdown
Member Author

Re-review of 3606fa4, tested merged with next at 97e1826 after a clean build. Thanks for taking both size cuts.

Fixed

  • Hello world (9,974 B) and every floor row are back to 0 B vs next.
  • The untested enterLane clause is gone, and scripts/size is out of the diff.
  • No regressions: the full suites pass (signals 6316, solid, web, hydrate), async-gen is 36/36, the seed-3289 run (2,000 cases) finds nothing, and the held-verdict microbench is at parity.

Still open

1. Size: four rows still grow past the +20 B allowance

Row brotli minified
signals: + isPending/latest +62 +252
page: live server components +139 +246
page: compiled live server components +80 +252
page: live + router +33 +258

Most of it is holdStaging plus the fourth hook slot. Computing the staging bit once at recompute's head, instead of calling the hook from frameRead, may cut the call site and the per-read dep walk. That idea hasn't been measured. (next itself is currently over cap on other rows until #4616 re-records #4611; this PR adds 0 B to those.)

2. The inline isPending form still shows signed-in content with signed-out data (D-27)

page("inline isPending")
setGate(1); setSession("acc"); flush();
setPlan(true); flush();
release(); // tick

The screen stays "0 signed out", and the branch shows {plan:false, signedIn:false} inside a branch whose condition says signed in. It lands only after the signed-out fetch resolves; with the issue's never-resolving promise it never lands.

3. "same-node isPending(session)" still asserts a Loading that never settles (F-6)

After release() the branch shows {signedIn:false, busy:true} and session stays undefined, so the test pins the hold never landing.


Generated by Claude Code

The branch a hold opened was still reading the signed-out session when its own pass called isPending, so a never-resolving signed-out fetch kept the hold from landing. That write now stays the staging; the probed node stays the screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member Author

The inline isPending(plan) hang and the same-node assertion were still open, and the four rows were still past the allowance. e0092ab85 takes both.

params calling isPending(plan) keeps the hold's session. After setPlan the branch is {plan:false, signedIn:true}, and release() lands 1 rows without waiting on the signed-out fetch. isPending(plan) then plan() stays the screen. Same-node isPending(session) ends {signedIn:true, busy:true}, and release() lands 1 rows. The spec no longer expects the hold to sit on 0 signed out.

holdStaging runs once at recompute's head. frameRead tests that answer instead of walking deps on the read. A probed node, a flight's source, and a shown error stay the screen. A sync write parked by an earlier flush() is the staging.

The #4544 signals tests (5) and the web spec (4) pass, and the signals suite passes.

— Grok via Cursor

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