Skip to content

fix(signals): an effect's error boundary keeps no superseded staged error (#4527) - #4533

Merged
ryansolid merged 2 commits into
nextfrom
claude/project-thread-x1ore2-4527
Oct 31, 2026
Merged

ryansolid merged 2 commits into
nextfrom
claude/project-thread-x1ore2-4527

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Before: an action re-asks a store and a memo, so its transition stays open. The store's re-ask fails, then a second re-ask answers before the commit. An effect that reads the store through createErrorBoundary(() => issues, () => null), created in its compute, gets null at the commit. That is the fallback for the failure the second answer replaced. The effect then never runs again, while the screen shows the new rows.

After: the effect gets len2 at the commit, matching the screen. Nothing sees the superseded failure.

Fixes #4527.

Root cause

The bug needs two holes together:

  1. handleAsync asks every object a memo returns whether it is a flight, by reading Symbol.asyncIterator and then under untrack. On a store proxy, that probe goes through the get trap, which pulls the derive (pullFamily). During the held transition the derive carries the rejection as a staged error (D-41), so the pull throws it into the boundary's children memo. The memo stages the error, but the read was untracked, so there is no link. The second re-ask (a pending mark, [2.0 / next] A visible button's condition throws in its click handler during a held update #4109) and its answer never reach the memo, and the commit publishes the stale error onto it for good.
  2. The error boundary born inside the hold catches that staged error. When the reader's replacement lands in the same hold, prune keeps the fallback under Loading on should behave as a keyed <Show> around the boundary; a boundary mounted under a hold must not be born held (A29) #3540's rule that a boundary which has not shown content waits for the reader's commit. That rule is right for a loading boundary: what a fresh boundary catches holds nothing outside it. In an error boundary it lets the commit show an error the frame no longer carries, and the content arrives a round later.

How

I tried one alternative and backed it out: skipping the shape probe for store proxies entirely. It broke projection-proxy-then-array-4361, which relies on the probe's pending pull.

Rules

  • D-41: an error is an answer. A staged error shows only at the reveal, and a derivation that reads it carries it with the frame. A carrier the supersession can't reach breaks "nothing shows it unless the frame carries it".
  • D-39: a shown error stays the display until the frame that carries its replacement reveals. This one was never shown, and its replacement is in the same frame.
  • fix(signals): link a store reader to its derive's staged error (#4517) #4519: the same linking rule, for tracked leaf reads.

Tests

  • New packages/signals/tests/error-boundary-superseded-staged-error-4527.test.ts: optimistic and derived store × createEffect and createRenderEffect. All 4 fail on next and pass here.
  • New packages/web/test/error-boundary-superseded-error-4527.spec.tsx: the issue's repro, with its expected frames.
  • Results:
    • signals: 428 files, 6165 passed
    • web client: 213 files, 1600 passed
    • hydrate: 140 files, 716 passed
    • async-gen: 16 files, 36 passed
    • test-types: clean
  • Web server config: 1 failure, in server-functions-adapter-request.spec.tsx. It also fails without this change.

Size (local, signals rebuilt from the same tree, before vs after)

row minified brotli
signals core floor +0 +0
signals + createStore +24 +11
apps with stores (hydrating all families, compiled CSR/hydrating) +27 −15 … +58
apps without stores, server-component pages +3 −78 … +23

The brotli swings in both directions on +3 B minified are mangle noise.

🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

…rror (#4527)

Here is the case: an action re-asks a derived store and a memo, so its
transition stays open. The store's re-ask is rejected (staged, D-41), and a
second re-ask answers before the commit. An effect that created
`createErrorBoundary(() => store, ...)` in its compute got the fallback at
the commit and never ran again.

- store: handleAsync's untracked shape probe of a returned proxy pulls the
  derive. When that pull throws a staged error, it now links the pass, as a
  tracked read does (#4519). The memo carries the error and stays reachable
  by the re-ask and the answer that supersede it.
- boundaries: prune's "fresh boundary keeps its fallback until the reader
  commits" clause (#3540) applies to loading boundaries only. In an error
  boundary, a recovery that lands in the hold that staged the error joins
  that hold and reveals with its commit (D-39, D-41).

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

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9f050c9

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 31, 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.41 KB 0 B 0 B −3 B 7.43 KB ✅
signals: + createStore 14.94 KB +11 B (+0.1%) +24 B +24 B 14.99 KB ✅
signals: + isPending/latest 11.38 KB 0 B 0 B 0 B 11.40 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.04 KB 0 B 0 B −1 B 8.06 KB ✅
app: render + one signal (the simple-app floor) 9.79 KB 0 B 0 B −3 B 9.80 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.75 KB −37 B (−0.2%) +3 B +3 B 18.80 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.51 KB +26 B (+0.1%) +35 B +35 B 32.51 KB ❌ over by 1.000000000003638 B lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.43 KB −1 B (−0.0%) +3 B +16 B 13.47 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.20 KB +23 B (+0.2%) +3 B +16 B 15.20 KB ⚠️ over by 3 B, 4 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.77 KB −14 B (−0.0%) +3 B +3 B 29.80 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.98 KB 0 B 0 B −1 B 9.98 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.37 KB +58 B (+0.2%) +27 B +27 B 26.36 KB ❌ over by 9 B stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.53 KB −15 B (−0.0%) +27 B +27 B 32.56 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.67 KB −78 B (−0.2%) +3 B +3 B 33.81 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.19 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.10 KB +18 B (+0.0%) +3 B +19 B 39.10 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.22 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.67 KB −54 B (−0.2%) +3 B +3 B 35.74 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 9.16 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 43.05 KB +7 B (+0.0%) +3 B +3 B 43.06 KB ✅ eager (counted): web.js 23.03 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.22 KB +17 B (+0.0%) +3 B −22 B 41.25 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.58 KB, trace.js 9.16 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.65 KB −33 B (−0.1%) +3 B +3 B 48.69 KB ✅ eager (counted): client.js 28.13 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: CSR, observe tier (same app on the observe artifacts): over brotli cap by 3 B; minified 41,998 B vs 41,982 B recorded with the cap (+16 B) — 4 B of the 20 B minified allowance left; +3 B minified over this PR's base

❌ Fails the gate

  • app: hydrating + every store primitive family: over brotli cap by 1.000000000003638 B; minified 98,741 B vs 98,706 B recorded with the cap (+35 B) — 15 B past the 20 B minified allowance; +35 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: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 9 B; minified 80,154 B vs 80,127 B recorded with the cap (+27 B) — 7 B past the 20 B minified allowance; +27 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 31, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41185007330

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.134%

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: 1367
Covered Lines: 1129
Line Coverage: 82.59%
Relevant Branches: 1098
Covered Branches: 797
Branch Coverage: 72.59%
Branches in Coverage %: Yes
Coverage Strength: 29.93 hits per line

💛 - Coveralls

…elsewhere

- store: link the pass the staged error is thrown into whether the pull is
  tracked or not (a tracked read links anyway, and `link` dedupes). This
  drops the `tracking` test, and `c &&` replaces `c !== null`.
- boundaries: an error boundary is born `_initialized` (the field is
  loading-only: no other error-boundary path reads it), so prune's #3540
  clause stays as it was.

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 gate fails on two rows at 9f050c9:

  • app: hydrating + every store primitive family: over its brotli cap by 1 B, +35 B minified vs recorded.
  • app: compiled CSR (JSX todo app …): over its brotli cap by 9 B, +27 B minified vs recorded.

On store rows the fix costs +27 B minified: +24 B for the pullFamily link and +3 B for the error-boundary carve-out. That is already trimmed from +42, and getting it under 20 B would mean dropping the link the fix needs. This PR doesn't touch scripts/size. The batch ledger re-record will cover these rows.


Generated by Claude Code

@codspeed

codspeed Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 6.48%

1 warning

⚠️ 2 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
❌ arrow in template 11.4 ms 12.1 ms -6.48%

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-4527 (9f050c9) with next (322fa07)

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

CodSpeed flags one benchmark, non-ascii-template "arrow in template" (−6.48%), as a regression. It's a known noisy bench and not caused by this PR. It runs renderToString on the server and UTF-8 encodes the output. This PR only changes the client signals store pull path and error boundary init, so none of it runs there. The bench has swung by this much on unrelated PRs whose base had a fast recorded run. The other 199 benchmarks are unchanged.


Generated by Claude Code

@ryansolid
ryansolid merged commit 19e0951 into next Oct 31, 2026
7 of 9 checks passed
ryansolid added a commit that referenced this pull request Oct 31, 2026
…4537)

Raises the compiled hydrating cap (32.56 -> 32.61 KB; minified fell 35 B,
the brotli rise is mangle layout from #4494) and re-records minified on six
rows that drifted toward the 20 B allowance. Floor caps are unchanged.


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