Skip to content

fix(signals): a pending error-boundary output read only behind fallbacks holds nothing (#4638) - #4639

Merged
ryansolid merged 5 commits into
nextfrom
claude/fix-4638-loading-on-errorboundary
Nov 3, 2026
Merged

ryansolid merged 5 commits into
nextfrom
claude/fix-4638-loading-on-errorboundary

Conversation

@ryansolid

Copy link
Copy Markdown
Member

What was wrong

Two sibling <Loading on={sel()}> boundaries. The first reads an async memo through untrack(() => createErrorBoundary(() => detail(), () => null)), built in the component body. The second reads a plain async memo keyed by sel. After select(1) neither showed its fallback, and the old content stayed until both answers landed.

Root cause: the error boundary's output passes the pending by (A6), so it is STATUS_PENDING with CONFIG_REDERIVE. blockedBy (scheduler.ts) counts that as a frame reader ("a boundary showing content forwards the pending: transparent, its readers hold through it"). onScreen asks hiddenBy(output), and hiddenBy walks the output's own boundary chain. The error boundary was built outside the <Loading>, so that chain had no fallback, and the output counted as on screen even though its only reader is the render effect under the re-armed Loading, which is behind the fallback. With one boundary this didn't show, because no transaction opened. The sibling's content goes pending before its on pass re-arms it, so the pending is forwarded and opens a transaction. The error boundary's output then held that transaction, which kept both re-arm swaps off screen.

Rules check

  • F-4 / D-14 (decides it): "a flight whose only observers are render effects behind a fallback holds no change — a memo under the boundary that no render effect reads was never an observer at all, and one rendered by an effect outside the boundary is that effect's read." The error boundary is a derivation, not a render effect. Its only reader is under the re-armed Loading, so detail:1 holds nothing, and both re-arms show their fallbacks with sel's frame (D-15: "now, when nothing else on the page waits on that change"). Expected frame: loading #1 loading comments.
  • D-16 does not apply: it requires "a live reader outside the boundary also reads it (a sibling <Loading> over the same data, a header that renders it)… Only a reader whose read throws holds." Where the error boundary is created doesn't make it a reader. This PR pins the D-16 counterpart: the same page with a header render effect outside that also reads the error boundary. That frame still holds until detail lands and the fallback is never seen.
  • F-5: its "what it does not say" clause (no fallback while the frame waits on the same data read outside) is the D-16 case pinned above, and it is unchanged.
  • D-17: not involved, since on reads sel() directly.
  • D-9: "a render effect's mere existence entangles nothing". The output stands in for its readers, so it observes nothing when they are all hidden.
  • F-12 / D-39: the error boundary does not catch the pending (A6), and the change doesn't affect errors. An error fallback and its readers are handled by record/prune, not by hiddenBy.
  • No existing pin flipped.

Change

hiddenBy (boundaries.ts): a non-effect CONFIG_REDERIVE node (a boundary output) is transparent. It counts as hidden when every reader of it is hidden. A boundary output read in the open, or by a memo in the open, still holds as before.

Size: about +60–80 B minified and roughly +30 B brotli on rows that include boundaries.ts (the Loading/Errored rows). Hello world doesn't import boundaries, so the compiled floor is unchanged. CI will report the real numbers.

Tests

  • New: packages/signals/tests/loading-on-errorboundary-sibling-4638.test.ts. It covers plain and error boundary, with and without a sibling, plus the D-16 header counterpart. Without the fix, the error boundary + sibling case fails.
  • New: packages/web/test/loading-on-errorboundary-sibling-4638.spec.tsx, which mirrors the issue. It fails without the fix with Received: "Crash comments 1".
  • Signals targeted: 139 boundary/loading/error/on/3540/reveal/fallback files pass (1135 passed, 11 expected fail). 78 verdict/deferred/hold/lane/transition/isPending files pass, and the fuzz/scenario/show/mount files pass.
  • Web targeted: 53 loading/on/rearm/error/3540 specs pass.

Fixes #4638.


Generated by Claude Code

…cks holds nothing (#4638)

Two sibling `<Loading on={sel()}>` boundaries, one reading its async through
an error boundary built in the component body: after a `sel` write neither
showed its fallback. The error boundary's output passes the pending by (A6)
and is a CONFIG_REDERIVE frame reader in `blocked`; it is created outside the
Loading, so `hiddenBy` never saw the fallback its only reader is behind, and
it held the transaction the sibling's forwarded pending opened. A boundary
output is transparent: hidden when every reader of it is (F-4, D-14).

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

changeset-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f7b13c2

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.01 KB 0 B 0 B 0 B 15.03 KB ✅
signals: + isPending/latest 11.90 KB 0 B 0 B 0 B 11.91 KB ✅
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.96 KB +26 B (+0.1%) +74 B +74 B 18.94 KB ❌ over by 16 B lazy-page.js 0.04 KB
app: hydrating + every store primitive family 32.97 KB +21 B (+0.1%) +74 B +74 B 32.96 KB ❌ over by 6 B lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.63 KB +4 B (+0.0%) +74 B +74 B 13.64 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.40 KB +12 B (+0.1%) +74 B +74 B 15.40 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.01 KB −3 B (−0.0%) +74 B +74 B 30.03 KB ✅ 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.52 KB +32 B (+0.1%) +74 B +74 B 26.51 KB ❌ over by 15 B stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.76 KB −13 B (−0.0%) +74 B +74 B 32.79 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.93 KB +31 B (+0.1%) +79 B +79 B 33.92 KB ❌ over by 12 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.22 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 39.88 KB +74 B (+0.2%) +74 B +74 B 39.82 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.24 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.91 KB +47 B (+0.1%) +74 B +74 B 35.93 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.17 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 43.92 KB +56 B (+0.1%) +74 B +74 B 43.87 KB ❌ over by 46 B eager (counted): web.js 23.44 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.22 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.44 KB −81 B (−0.2%) +68 B +68 B 41.53 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.20 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 49.58 KB −32 B (−0.1%) +74 B +74 B 49.63 KB ✅ eager (counted): client.js 28.59 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.22 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.86 KB 0 B 0 B 0 B 22.87 KB ✅

❌ Fails the gate

  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 16 B; minified 53,906 B vs 53,832 B recorded with the cap (+74 B) — 54 B past the 20 B minified allowance; +74 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 6 B; minified 99,451 B vs 99,377 B recorded with the cap (+74 B) — 54 B past the 20 B minified allowance; +74 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 15 B; minified 80,314 B vs 80,240 B recorded with the cap (+74 B) — 54 B past the 20 B minified allowance; +74 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 12 B; minified 102,026 B vs 101,947 B recorded with the cap (+79 B) — 59 B past the 20 B minified allowance; +79 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 63 B; minified 120,057 B vs 119,983 B recorded with the cap (+74 B) — 54 B past the 20 B minified allowance; +74 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 46 B; minified 128,309 B vs 128,235 B recorded with the cap (+74 B) — 54 B past the 20 B minified allowance; +74 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).

…4638 spec

The public entry keeps createErrorBoundary @internal, so its types live on
solid-js/internal; the type-check step failed with TS2305.

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

Copy link
Copy Markdown
Member Author

The red Size check comes from this fix's growth: +87 B minified, and at most +63 B brotli, on the Loading/Errored scenarios. The maintainer accepted that growth. Core and hello world are unchanged. The scenario caps get re-recorded in the ledger PR after the next merge batch, so this PR leaves scripts/size alone.

The earlier Solid CI failure was this PR's own: the web spec imported createErrorBoundary from solid-js, where it is @internal and missing from the types (TS2305). 5ab7f9c moves that import to solid-js/internal.


Generated by Claude Code

@codspeed

codspeed Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

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

✅ 200 untouched benchmarks
⏩ 5 skipped benchmarks1


Comparing claude/fix-4638-loading-on-errorboundary (f7b13c2) with next (ab29345)

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

@coveralls

coveralls commented Nov 2, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41568253234

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

💛 - Coveralls

Copy link
Copy Markdown
Member Author

I don't think the CodSpeed flag comes from this change. projection derive: write one NESTED field regressed by the same −12.3% on #4634, which doesn't touch projections either. CodSpeed reports both flagged benches as run on different hardware from base. This diff only changes hiddenBy in boundaries.ts, and neither bench reaches it: they have no Loading or error boundary.


Generated by Claude Code

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