Skip to content

fix(signals): a frame node re-run outside its hold re-derives the readers of the staging it replaces (#4378) - #4390

Merged
ryansolid merged 16 commits into
nextfrom
claude/project-thread-x1ore2-4378
Oct 28, 2026
Merged

ryansolid merged 16 commits into
nextfrom
claude/project-thread-x1ore2-4378

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #4378

Before: under <Errored> › <Loading> › <Show when={epoch()} keyed> › <span>{m1()}</span>, a write that clears an upstream error (m0 stops throwing, so m1 asks for a value that rejects), batched with setEpoch(2), recreates the <span> reader in the same flush. The screen ended at <span></span> with nothing in flight. Without setEpoch(2), the kept reader ends at [ERR], which matches a fresh render. This was the async-app generator's E8 pair (birth seat).

After: both end at [ERR].

Root cause

The recreated reader waits on m1's new flight, so the flush parks. The transaction holds the frame, and the error boundary's output stages "content" with it (its committed value is the fallback). When m1 rejects, the reader errors and the boundary catches it and re-derives the output. That pass runs outside the transaction's flush, so recompute's head publishes it mainline (#3404): the held staging is dropped (_pendingValue = NOT_PENDING) and the pass returns the fallback. The fallback is equal to the committed value, so valueChanged is false and no subscriber is notified. The output's reader (the insert's render effect) had run its pass in the parked flush on the staged "content", and that run was stashed with the transaction. At the landing the stale run applies "content": the empty hole.

The generator's current E8 case (Errored inside the recreated tree, under the root Loading and Errored) has the same cause one level up. The boundary trees and outputs above hold the frame's staging as well. Each one re-runs from the heap, outside the hold, back to its committed [E], and has nothing new to tell the next level up.

How

recompute (core.ts), in the "published mainline, not held" arm for a frame reader: when the pass replaces a staging (_pendingValue !== NOT_PENDING), it marks its subscribers (insertSubs). The readers of the replaced staging then re-derive on the value the pass publishes, even when that value is the committed one and there is no change to notify them by. Without a staging, nothing extra runs.

No new exports, effects or computations.

I also tried two boundary-local versions (boundaries.ts redraw), so the floors wouldn't pay. Both cover the issue's shape but not the generator's E8 case, where the staging is replaced at boundary trees and outputs that re-run from the heap, not through redraw. The arm in recompute is the one place every frame node drops a staging.

How did you test this change?

  • New packages/signals/tests/recreated-reader-held-boundary-4378.test.ts: the issue's tree as signals boundaries (Errored › Loading › keyed child creating the reader), with the reader kept and recreated. On next, "recreated in that flush" fails with the screen at span (empty slot). With the fix both pass.
  • New packages/web/test/recreated-reader-held-boundary-4378.spec.tsx: the issue's JSX repro, kept and recreated, plus the generator's current E8 case pinned through runScenario. On next, the recreated repro and the E8 pin fail with <span></span>. With the fix all 3 pass.
  • Async-app generator (test(web): async app generator — generated async apps, liveness oracle, equivalence pairs, regression gate #4371), ASYNC_GEN=1 ASYNC_GEN_PAIRS=E8,E9 ASYNC_GEN_CASES=1000: equivalence/E8: error vs content goes from 1 to 0. The other findings show up the same way without this change.
  • Clean turbo build --force after merging next @ b68942c (next has since gained only docs commits), then:
    • signals: 377 files, 5,845 passed
    • solid: 917 passed
    • web default: 1,522 passed
    • web hydrate: 673 passed. The one failure is the c03-hydration-done-counts-holds "…has claimed by hydration end" pin, which flakes on next too.
    • web server: server-functions-adapter-request ("keeps a rebuild failure's own error…") fails here as it does on next.
  • turbo typecheck test-types: clean.
  • Size gate (scripts/size, local linux, head vs origin/next @ b68942c, both measured from clean builds in this worktree): gate.mjs passes, and check-floor-caps.mjs origin/next reports no cap raised.
Scenario brotli (base → head) minified (base → head)
signals: core floor (frozen) 7,413 → 7,432 (+19) 19,971 → 19,987 (+16)
app: render + one signal (frozen) 9,769 → 9,771 (+2) 26,803 → 26,819 (+16)
app: compiled floor (hello world) 9,933 → 9,962 (+29) 27,335 → 27,351 (+16)
app: hydrating, no stores (frozen) 18,606 → 18,631 (+25) 53,357 → 53,373 (+16)
page: base server components (frozen) 33,533 → 33,565 (+32) 101,358 → 101,374 (+16)
page: live server components (frozen) 37,954 → 37,931 (−23) 115,118 → 115,134 (+16)
signals: + isPending/latest 10,379 → 10,384 (+5) 28,678 → 28,693 (+15)
every other client scenario −21 … +92 +15 / +16
server floors, frames +0 +0

The fix sits in recompute, which every build carries, so every client scenario grows by +15 or +16 B minified, the frozen floors and hello world included. Hello world's brotli goes from 9,933 to 9,962 B. No frozen floor's cap or recorded minified moves: each still measures under its recorded minified. Every scenario that grew gets a ledger line in scripts/size/scenarios.js. Only signals: + isPending/latest measures above its recorded minified, and its capMinified goes from 28,680 to 28,693. No caps change.

🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

…ders of the staging it replaces (#4378)

A reader recreated in the flush that clears an upstream error, under
Errored > Loading, waits on the async memo's flight, so the frame holds and
the error boundary's output stages its content with it. The rejection then
re-runs the output outside that transaction's flush: it publishes mainline
(#3404), back to the fallback it had committed. That is no change, so
nothing re-derived its reader, and the landing applied the reader's held
run of the superseded content: an empty hole with nothing in flight.

The mainline re-pass now marks its subscribers when it supersedes a staging,
so readers of that staging re-derive on the value it publishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
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 28, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 091c602

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 28, 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.43 KB +19 B (+0.3%) +16 B −150 B 7.44 KB ✅
signals: + createStore 14.94 KB +10 B (+0.1%) +16 B −1373 B 15.04 KB ✅
signals: + isPending/latest 10.38 KB +6 B (+0.1%) +15 B −4 B 10.38 KB ⚠️ over by 4 B, 24 B minified headroom
signals: + until/refresh (truth readers, no optimism) 8.04 KB +7 B (+0.1%) +16 B −154 B 8.05 KB ✅
app: render + one signal (the simple-app floor) 9.77 KB +2 B (+0.0%) +16 B −210 B 9.75 KB ⚠️ over by 21 B, 230 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.64 KB −38 B (−0.2%) +16 B −219 B 18.49 KB ⚠️ over by 147 B, 239 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 31.77 KB +29 B (+0.1%) +15 B 0 B 31.80 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.41 KB +34 B (+0.3%) +16 B −259 B 13.40 KB ⚠️ over by 14 B, 279 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.10 KB −8 B (−0.1%) +16 B −267 B 15.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.72 KB +5 B (+0.0%) +16 B −266 B 29.70 KB ⚠️ over by 18 B, 286 B minified headroom lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.96 KB +29 B (+0.3%) +16 B −209 B 9.93 KB ⚠️ over by 32 B, 229 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.21 KB −16 B (−0.1%) +13 B −570 B 26.27 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.39 KB 0 B +16 B −472 B 32.25 KB ⚠️ over by 137 B, 492 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.76 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.56 KB −31 B (−0.1%) +16 B −140 B 33.37 KB ⚠️ over by 192 B, 160 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, lazy-page.js 0.04 KB, regions.js 1.06 KB, trace.js 9.02 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.97 KB −67 B (−0.2%) +16 B −42 B 37.75 KB ⚠️ over by 218 B, 62 B minified headroom 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.62 KB +70 B (+0.2%) +16 B −129 B 35.43 KB ⚠️ over by 191 B, 149 B minified headroom assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.00 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.87 KB +2 B (+0.0%) +16 B 0 B 41.56 KB ⚠️ over by 312 B, 20 B minified headroom eager (counted): web.js 22.38 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.09 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.04 KB −21 B (−0.1%) +16 B −140 B 40.81 KB ⚠️ over by 234 B, 160 B minified headroom 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.01 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.53 KB +57 B (+0.1%) +16 B 0 B 47.16 KB ⚠️ over by 369 B, 20 B minified headroom eager (counted): client.js 27.56 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.01 KB, wire.js 3.49 KB
server: floor (getRequestEvent + isServer) 1.27 KB 0 B 0 B −69 B 1.34 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: + isPending/latest: over brotli cap by 4 B; minified 28,689 B vs 28,693 B recorded with the cap (−4 B) — 24 B of the 20 B minified allowance left; +15 B minified over this PR's base
  • app: render + one signal (the simple-app floor): over brotli cap by 21 B; minified 26,819 B vs 27,029 B recorded with the cap (−210 B) — 230 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 147 B; minified 53,401 B vs 53,620 B recorded with the cap (−219 B) — 239 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • app: CSR with Show/For/Loading/Errored/lazy: over brotli cap by 14 B; minified 36,948 B vs 37,207 B recorded with the cap (−259 B) — 279 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 18 B; minified 88,235 B vs 88,501 B recorded with the cap (−266 B) — 286 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 32 B; minified 27,351 B vs 27,560 B recorded with the cap (−209 B) — 229 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 137 B; minified 100,572 B vs 101,044 B recorded with the cap (−472 B) — 492 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 192 B; minified 101,419 B vs 101,559 B recorded with the cap (−140 B) — 160 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 218 B; minified 115,175 B vs 115,217 B recorded with the cap (−42 B) — 62 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 191 B; minified 107,789 B vs 107,918 B recorded with the cap (−129 B) — 149 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 312 B; minified 123,203 B vs 123,203 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 234 B; minified 125,247 B vs 125,387 B recorded with the cap (−140 B) — 160 B of the 20 B minified allowance left; +16 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 369 B; minified 140,463 B vs 140,463 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +16 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).

…the floors don't pay

The insertSubs in recompute's mainline-publish arm cost +16 B minified on
every scenario, the frozen floors and hello world included. The case is a
boundary's output: a redraw re-derives it outside the hold that staged its
content, and it publishes the committed fallback back unchanged. The
redraw now marks the output's readers when the output carries a staging,
so they re-derive with it. boundaries.ts is tree-shaken from builds without
a boundary.

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

coveralls commented Oct 28, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40785797795

Coverage remained the same at 78.516%

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: 1355
Covered Lines: 1125
Line Coverage: 83.03%
Relevant Branches: 1084
Covered Branches: 790
Branch Coverage: 72.88%
Branches in Coverage %: Yes
Coverage Strength: 29.97 hits per line

💛 - Coveralls

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

codspeed Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 12.42%

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

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

⚡ 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 +12.42%

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-4378 (091c602) with next (178605c)

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

…aging

Reading _pendingValue in boundaries.js moved the shared mangled names and
every floor with them. CONFIG_HELD on _config, which boundaries.js reads
already, marks the same staging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Every frame node up the chain (the boundaries' trees and outputs) drops
its staging there, most of them re-run from the heap rather than through
redraw, so the generator's E8 case still failed with the redraw version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@ryansolid
ryansolid marked this pull request as ready for review October 28, 2026 21:43

ryansolid commented Oct 28, 2026 •

Copy link
Copy Markdown
Member Author

CodSpeed flags mount-rows "memo + sync render effect only (reference)" at −8.28% (31 → 33.8 ms). I read this as runner noise, not this change:

  • CodSpeed itself warns that base and head ran on different runner types.
  • test(signals, web): pin #4377 — a memo over latest(m) shows m's error as the leaf does #4386, which changes only test files, shows the same −8.14% on this bench against the same base (31 → 33.8 ms).
  • The same bench has also swung between 29.2 and 33.6 ms today on other PRs.
  • The new line is one compare, _pendingValue !== NOT_PENDING, inside recompute's published-mainline arm. It only calls insertSubs when a staging was dropped, and a plain row mount stages nothing. That last point is inferred from the code; I have not profiled it.

Generated by Claude Code

@ryansolid
ryansolid merged commit 1b32ff2 into next Oct 28, 2026
9 checks passed
ryansolid pushed a commit that referenced this pull request Oct 29, 2026
… a verdict memo read

After #4390 the E9 memo shape showed an empty screen. A nested <Show>'s
condition memo is created inside a pass that read the action's staging; it
read the verdict memo with no staged read of its own, so enterLane moved it
into the verdict lane (inline, markVerdictReader's creator walk keeps it in
the frame). Its branch then became lane work; the fresh <Loading> staged
its fallback, the seam's fold-back redrew it outside the hold, and #4390's
re-derive published the errored tree's undefined committed value.

enterLane now checks the creator chain the same way, through a shared
creatorReads helper (core.ts) that markVerdictReader also uses. Golfed so
the live server-components floor stays under its recorded 115,217 B
(115,213 B). Ledger re-recorded over next @ 1b32ff2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
ryansolid added a commit that referenced this pull request Oct 29, 2026
…'s (E9) (#4394)

* fix(signals): a verdict memo read after a staged read stays the frame's (E9)

A memo of a verdict (`createMemo(() => latest(s))`, F-6) read by a pass
that had already read the action's staging moved that pass into the
holder's verdict lane (`laneRead` -> `enterLane`), and the lane showed it
at once, with the staging it had read. Inline, `route` keeps such a pass
the frame's. The async-app generator's E9 pair found the difference
(seed 3289): `<Errored>` showed the branch the action's write recovers
while the action was open.

`enterLane` now keeps a derivation in that position the frame's, as
`route` does inline (D-25), and queues a leaf (a render effect) for the
seam's re-derive on the committed world, so `[s(), L()]` shows `v:0 l:1`
as inline does (D-26).

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

* Re-record #4394's size ledger over next @ 178605c

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

* fix(signals): a pass born inside a staged reader stays the frame's on a verdict memo read

After #4390 the E9 memo shape showed an empty screen. A nested <Show>'s
condition memo is created inside a pass that read the action's staging; it
read the verdict memo with no staged read of its own, so enterLane moved it
into the verdict lane (inline, markVerdictReader's creator walk keeps it in
the frame). Its branch then became lane work; the fresh <Loading> staged
its fallback, the seam's fold-back redrew it outside the hold, and #4390's
re-derive published the errored tree's undefined committed value.

enterLane now checks the creator chain the same way, through a shared
creatorReads helper (core.ts) that markVerdictReader also uses. Golfed so
the live server-components floor stays under its recorded 115,217 B
(115,213 B). Ledger re-recorded over next @ 1b32ff2.

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