Skip to content

fix(signals): a held recovery keeps its shown error for frame and verdict readers - #4396

Merged
ryansolid merged 6 commits into
nextfrom
claude/project-thread-x1ore2-staged-recover
Oct 29, 2026
Merged

ryansolid merged 6 commits into
nextfrom
claude/project-thread-x1ore2-staged-recover

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Before: a sync memo that threw, then recovered under an open action, lost its error from the screen early. createErrorBoundary(() => "X" + latest(s) + m()), with m throwing for the committed s and recovering for the value an open action writes, showed X1undefined while the action was still open. The same thing happened when the read went through a memo, or through <Errored>{"X" + latest(s) + m0()}</Errored> in JSX. Expected per the docs: the error stays the display until the frame that carries its replacement reveals (D-39, D-41; F-7 for content derived from a held change). A verdict or frame reader beside latest() reads the screen (D-25, A10), and the screen still shows the error.

After: the boundary shows the error until the action commits, then shows X11. No pass of the content computes …undefined.

The staged recovery cleared the memo's error status without setting _shownError, so a reader served the committed value got the empty _value behind the error.

How: recompute keeps the error it replaces as _shownError when a recovery is staged; the commit already clears it. read serves a frame reader of a held node the way it serves an untracked read, and the verdict arm of serve does the same. Both throw a shown error (throwShown), as an untracked read already did. At the landing, reruns re-derives an errored stale reader whose error's source is no longer errored. The #3945 skip still applies while that source is errored. The setting is limited to the staged branch on purpose: when it was set before the equality gate, a boundary tree that recovered to an equal value (loading) was forced into a change, and #2701 broke. Recovering to a value equal to the last good one under a hold is still silent; that was already true on next and this PR leaves it alone. Offsets, all perf-neutral, pay for most of the floor cost:

  • recompute captures its outgoing error and pending sources with && instead of ? : undefined, and notifyStatus/propagateStatus do the same for the pending source. Every reader of these values is truthy-guarded.
  • notifyStatus and caught drop a trailing undefined argument.
  • _shownError resets to null.

Review fix (liveness): a memo the content created on its first pass and kept in a closure ("X" + latest(s) + (cached ||= createMemo(...))()) is disposed with the pass that created it. Its staged recovery throws the shown error, and that error names the dead memo as its source. The error boundary recorded the dead memo, the seam dropped it at once (#4384 drops an entry whose reader or source is disposed), and the output re-derived, re-caught and re-recorded it until the flush threw "Potential Infinite Loop Detected". caught now waits on the reader when the source that threw is disposed. The reader stays errored until the landing re-derives it (reruns), then the boundary shows X11. reruns also guards an error with no source (?.source?. defaults to errored, so the #3945 skip still applies).

I also tried moving the verdict arm of serve behind a hook that verdict.ts installs. It cut the floors by about 22 B, but the hook's wrapper and install code cost every isPending/latest build about +60 B and broke the live server-components floor, so I dropped it.

How did you test this change?

  • packages/signals/tests/error-staged-recovery.test.ts: the read inline and through a memo, with the action's flush, a mainline write that re-runs the view over the held recovery, and the landing. It fails on next (X1undefined). It also pins the review's closure-kept memo (inline and through a memo), which looped on the previous head.
  • packages/web/test/errored-held-recovery-latest.spec.tsx: <Errored> with "X" + latest(s) + m0() under an open action (inline and through a memo), plus the review's lazy-cached shape: <Errored>{"X" + latest(s) + (cached ||= createMemo(...))()}</Errored>.
  • Every case from the review's scratch files (signals A–K, web component/lazy-cached/isPending/span-leaf/grouped) passes, with no undefined on screen and no loop.
  • After a clean npx turbo build --force of signals, solid-js and web, merged with next @ 957f065:
    • signals: 384 files, 5867 passed.
    • solid: 917 passed.
    • web default: 1537 passed. Hydrate: 686 passed. Server: 1892 passed, plus the one known env failure (server-functions-adapter-request).
  • CI's earlier failure on e36a6b9 was attribution-waterfall-eval.test.ts "FINDING: one scope reading two async sources serializes them" timing out at 5 s. That test is timing-based (sleep(12) chains) and passes locally in about 40 ms across 15 repeated runs. The same timeout also hit CI on two unrelated branches on 2026-10-27 (feat/low-priority-modulepreload, claude/project-thread-x1ore2-4322). It is a pre-existing flake, not this change.
  • Size: on request, this PR records no ledger lines and raises no caps. The ledger is re-recorded on next after the batch merge. Measured locally against next @ 957f065: the core floor is +15 B minified and the compiled floor (hello world) is 9,987 B brotli (+25). gate.mjs flags recorded-minified growth on three scenarios: hydrating + every store family +28, compiled live server components +36, live + router +36. check-floor-caps.mjs origin/next reports no cap raised.

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2


Generated by Claude Code

…dict readers

An errored sync memo that recovers in a staged pass cleared its error
status without keeping the error as the shown one. A frame or verdict
reader served the committed value then read `undefined`: under an open
action, `createErrorBoundary(() => "X" + latest(s) + m())` showed
`X1undefined` while the screen still owed the error (D-39, D-41, F-7).

The staged recovery now keeps the error it replaces as `_shownError`
until its commit. A frame reader served the committed value, and a
verdict reader served the screen, read that error, as an untracked read
already did. At the landing, an errored stale reader whose error's source
is no longer errored re-derives (the #3945 skip still holds for a source
that is). Offsets: two trailing `undefined` arguments dropped and
`_shownError` reset to `null`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
…inified), over next @ 1b32ff2

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 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: da52352

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 29, 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.45 KB +16 B (+0.2%) +15 B −135 B 7.44 KB ⚠️ over by 8 B, 155 B minified headroom
signals: + createStore 14.96 KB +21 B (+0.1%) +14 B −1359 B 15.04 KB ✅
signals: + isPending/latest 10.40 KB +27 B (+0.3%) +22 B −18 B 10.38 KB ⚠️ over by 23 B, 38 B minified headroom
signals: + until/refresh (truth readers, no optimism) 8.07 KB +24 B (+0.3%) +14 B −140 B 8.05 KB ⚠️ over by 17.99999999999909 B, 160 B minified headroom
app: render + one signal (the simple-app floor) 9.79 KB +20 B (+0.2%) +14 B −196 B 9.75 KB ⚠️ over by 41 B, 216 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.70 KB +14 B (+0.1%) +25 B −136 B 18.49 KB ⚠️ over by 212 B, 156 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 31.89 KB +96 B (+0.3%) +25 B +25 B 31.80 KB ❌ over by 94 B lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.42 KB −6 B (−0.0%) +28 B −173 B 13.40 KB ⚠️ over by 20 B, 193 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.19 KB +23 B (+0.2%) +28 B −181 B 15.14 KB ⚠️ over by 45 B, 201 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.78 KB +29 B (+0.1%) +28 B −180 B 29.70 KB ⚠️ over by 76 B, 200 B minified headroom lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.99 KB +25 B (+0.3%) +14 B −195 B 9.93 KB ⚠️ over by 57 B, 215 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.30 KB +77 B (+0.3%) +28 B −498 B 26.27 KB ⚠️ over by 25 B, 518 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.43 KB −41 B (−0.1%) +31 B −397 B 32.25 KB ⚠️ over by 179 B, 417 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.66 KB +73 B (+0.2%) +25 B −57 B 33.37 KB ⚠️ over by 293 B, 77 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) 38.05 KB +17 B (+0.0%) +27 B +13 B 37.75 KB ⚠️ over by 295 B, 7 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.60 KB −18 B (−0.1%) +19 B −52 B 35.43 KB ⚠️ over by 169 B, 72 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.90 KB −65 B (−0.2%) +33 B +33 B 41.56 KB ❌ over by 341 B eager (counted): web.js 22.43 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.20 KB +176 B (+0.4%) +22 B −60 B 40.81 KB ⚠️ over by 392 B, 80 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.56 KB +18 B (+0.0%) +33 B +33 B 47.16 KB ❌ over by 405 B eager (counted): client.js 27.57 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: core floor (createSignal/Memo/Effect/Root/flush): over brotli cap by 8 B; minified 20,002 B vs 20,137 B recorded with the cap (−135 B) — 155 B of the 20 B minified allowance left; +15 B minified over this PR's base
  • signals: + isPending/latest: over brotli cap by 23 B; minified 28,675 B vs 28,693 B recorded with the cap (−18 B) — 38 B of the 20 B minified allowance left; +22 B minified over this PR's base
  • signals: + until/refresh (truth readers, no optimism): over brotli cap by 17.99999999999909 B; minified 21,806 B vs 21,946 B recorded with the cap (−140 B) — 160 B of the 20 B minified allowance left; +14 B minified over this PR's base
  • app: render + one signal (the simple-app floor): over brotli cap by 41 B; minified 26,833 B vs 27,029 B recorded with the cap (−196 B) — 216 B of the 20 B minified allowance left; +14 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 212 B; minified 53,484 B vs 53,620 B recorded with the cap (−136 B) — 156 B of the 20 B minified allowance left; +25 B minified over this PR's base
  • app: CSR with Show/For/Loading/Errored/lazy: over brotli cap by 20 B; minified 37,034 B vs 37,207 B recorded with the cap (−173 B) — 193 B of the 20 B minified allowance left; +28 B minified over this PR's base
  • app: CSR, observe tier (same app on the observe artifacts): over brotli cap by 45 B; minified 41,982 B vs 42,163 B recorded with the cap (−181 B) — 201 B of the 20 B minified allowance left; +28 B minified over this PR's base
  • app: CSR, observe tier + attribution engine enabled: over brotli cap by 76 B; minified 88,321 B vs 88,501 B recorded with the cap (−180 B) — 200 B of the 20 B minified allowance left; +28 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 57 B; minified 27,365 B vs 27,560 B recorded with the cap (−195 B) — 215 B of the 20 B minified allowance left; +14 B minified over this PR's base
  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 25 B; minified 80,004 B vs 80,502 B recorded with the cap (−498 B) — 518 B of the 20 B minified allowance left; +28 B minified over this PR's base
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 179 B; minified 100,647 B vs 101,044 B recorded with the cap (−397 B) — 417 B of the 20 B minified allowance left; +31 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 293 B; minified 101,502 B vs 101,559 B recorded with the cap (−57 B) — 77 B of the 20 B minified allowance left; +25 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 295 B; minified 115,230 B vs 115,217 B recorded with the cap (+13 B) — 7 B of the 20 B minified allowance left; +27 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 169 B; minified 107,866 B vs 107,918 B recorded with the cap (−52 B) — 72 B of the 20 B minified allowance left; +19 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 392 B; minified 125,327 B vs 125,387 B recorded with the cap (−60 B) — 80 B of the 20 B minified allowance left; +22 B minified over this PR's base

❌ Fails the gate

  • app: hydrating + every store primitive family: over brotli cap by 94 B; minified 97,070 B vs 97,045 B recorded with the cap (+25 B) — 5 B past the 20 B minified allowance; +25 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 341 B; minified 123,257 B vs 123,224 B recorded with the cap (+33 B) — 13 B past the 20 B minified allowance; +33 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 405 B; minified 140,518 B vs 140,485 B recorded with the cap (+33 B) — 13 B past the 20 B minified allowance; +33 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 Oct 29, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40801139067

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

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 12.08%

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 regressed benchmark
✅ 199 untouched benchmarks
⏩ 5 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ projection derive: write one NESTED field (reference) 2.1 ms 2.4 ms -12.08%

Tip

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


Comparing claude/project-thread-x1ore2-staged-recover (da52352) with next (56eb192)

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

Four perf-neutral rewrites in the floors' own code pay for most of the
fix. `recompute` now captures its outgoing error and pending sources with
`&&` instead of `? : undefined`. `notifyStatus` and `propagateStatus` do
the same for the pending source. Every reader of those values is
truthy-guarded, and `settleErroredDependents` now also skips a null
error, which it never needed to walk. The core floor goes from +36 B to
+7 B minified over next, the frameworks' floors from +35 B to +6 B, and
the live server-components floor ends at 115,182 B. The ledger is
re-recorded against next @ 1b32ff2.

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

Copy link
Copy Markdown
Member Author

CodSpeed projection derive: write one NESTED field (reference) at −12.35% on 9b777de is noise, not a regression from this PR:


Generated by Claude Code

…hat threw is disposed

A memo its verdict reader owned, created on the reader's first pass and kept
by closure past the pass that disposed it, recovers staged and throws its
shown error. The boundary recorded that dead source, which the seam dropped
at once: the output re-derived, re-caught and re-recorded it until the
flush's loop guard fired. A disposed source settles nothing now: the boundary
waits on the reader, as it does an error that names no source. reruns guards
a missing source too.

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

Copy link
Copy Markdown
Member Author

The size check fails on purpose, and this PR won't change it. It goes over the recorded minified sizes in the scripts/size/scenarios.js ledger: hydrating + every store family +25 B, and compiled live server components +33 B. No frozen floor cap is raised.

The maintainer has asked for open PRs to merge as a batch without re-recording the ledger in each one. The ledger will be re-recorded once on next after the batch lands. Hello world is 9.99 KB brotli on this head.


Generated by Claude Code

Copy link
Copy Markdown
Member Author

CodSpeed flags the store projection-root-write "write one NESTED field (reference)" bench (−12%) on this PR, and on e36a6b9 also non-ascii-template "arrow in template". Both are known noise.


Generated by Claude Code

@ryansolid
ryansolid merged commit 2ba8a02 into next Oct 29, 2026
7 of 9 checks passed
ryansolid pushed a commit that referenced this pull request Oct 29, 2026
…y fix

lanes.ts's import line keeps `bornInto` beside next's `creatorReads`; the
creatorReads / enterLane / markVerdictReader hunks are next's (this branch
carried them byte-identical). #4394's verdict-memo-after-staged-read pins
and #4396's held-recovery pins come in as they are on next.

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