Skip to content

fix(solid): an Errored fallback's own failure goes to the next boundary up; its wait is budgeted (#4316, #4317) - #4318

Merged
ryansolid merged 3 commits into
nextfrom
claude/project-thread-x1ore2-4316
Oct 27, 2026
Merged

ryansolid merged 3 commits into
nextfrom
claude/project-thread-x1ore2-4316

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #4316. Fixes #4317. Server only. Client bundles are byte-identical to next (0 B in all 22 size scenarios).

#4316, the fallback's own failure (7ce3c98).

Before: when an <Errored> fallback's own async hole rejected (the #4306 nested shape), #4308's wait loop re-rendered the holes with no owner. ssrHandleError threw raw and inCtx's empty catch {} swallowed it. The fragment rejected and parked as a blank child of the pending outer <Loading>. The server sent <b>outer</b> with a blank region, and the client logged key misses and then hit REACTIVITY_HALTED.

After: the failure goes to the next boundary up, the same way a fallback that throws synchronously does. An enclosing <Errored> that can draw at that position answers with its own fallback. Otherwise the error fails the enclosing content, so a pending <Loading> fails its fragment.

This also fixes the sync twin in the caught shape. An outer <Errored> reached while it wasn't rendering used to serialize a _$HY.r record for a fallback whose markup was never sent.

#4316, parked fragments (f14ce8d).

Before: any fragment that failed while parked in a pending parent fragment went out blank. registerFragment's parked branch (web/src/server.ts) stores children[key] = value ?? "" and resolves with no error. failFragment only asked an <Errored> that could draw at exactly that placeholder, which is never true with markup in between (for example <Loading><Errored><section><Loading>…).

After: failFragment settles the parked fragment, then hands the failure up with an internal ESCALATE handler argument. This replaces 7ce3c98's escalating counter, so a failing fallback uses the same path.

  • A pending <Loading> receiving the failure fails its own fragment.
  • An <Errored> whose finished markup sits raw in a pending fragment draws its fallback in place of that markup. The fallback is waited for, and its error is serialized only once it is finished. The pending <Loading> applies those regions before it settles, or fails rather than going out blank.
  • An <Errored> that is already showing its fallback, or has already settled with a failure, absorbs it.

ESCALATE is not exported from any package entry.

Shell-level fallback failure (8692ad9). This is the same bug family, found while testing the above.

Before: an <Errored> whose fallback failed while rendering, outside any <Loading>, rendered blank. The boundary's handler marked the error handled before rendering the fallback, and the catch's handled ? result : … then returned undefined. That dropped the fallback's failure. An enclosing <Errored> serialized its record but never drew. The shell got a blank region with _$HY.r records and no markup for them.

After: the fallback's failure passes up from the pull. An enclosing <Errored> draws its fallback in place of the region. With none, the request fails, the same way for an async hole and for its sync-throw twin.

#4317. Before: renderOutcome's wait had no pass limit and didn't stop when the render was torn down.

After: it counts passes against MAX_RESUME_PASSES, the same 10000 budget the <Loading> resume loop uses. MAX_RESUME_PASSES is @internal in server/signals.ts and not re-exported. Past the budget it answers what is available, which is nothing for an unfinished fallback, so the fragment rejects. Each await is raced against the fallback owner's cleanup.

Known limits:

  • Late case: if the hole rejects after the outer fragment already settled, the inner fragment rejects on its own. The client renders it fresh, and a client <Errored> catches it.
  • <Errored> inside a nested <Loading> that already settled into the pending outer one: the outer fragment fails rather than drawing. It never produces a blank success.
  • Outer <Errored> with an async fallback: it can lose to the outer <Loading> settling first. The sync twin has the same race.
  • Streamed hydration-key miss on the twin shape of an <Errored> around a pending read in a pending <Loading>: this is client side, and fix(solid): a rejected fragment's fresh render claims what the server sent #4319 fixes it.

How did you test this change?

  • scenarios.tsx gains three families, each run against a sync twin:
    • fallbackHoleFailure: 8 variants.
    • parkedFailure: 8 shapes plus 2 late variants.
    • shellFallbackFailure: 8 variants.
  • hydration-harness.spec.tsx asserts that each variant's server output equals its twin's. It also checks that there are no blank <section></section> regions, and that uncaught variants report the error through onError.
  • errored-parked-async-outcome-4306.spec.tsx runs every variant in loaded and streamed mode. Each must equal its twin in server DOM, final text, halted, done, errors and warnings. Caught variants show their fallback without halting or warnings, and claim the server's nodes.
  • New server specs:
    • errored-fallback-wait-4317.spec.tsx: the pass budget and abort.
    • parked-fragment-failure-4316.spec.tsx: a nested <Loading> settled first.
  • Every new test that doesn't compare two twins fails on next's sources and passes here.
  • Suites:
    • solid 897/897.
    • signals 5625.
    • web hydrate 594 (16 expected fail).
    • web 1446/1446.
    • web server 1861/1862. The one failure, server-functions-adapter-request, fails identically on next in this container.
    • tsc clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2

…ry up; its wait is budgeted (#4316, #4317)

#4316: when a <Loading> fails after the shell and its <Errored> renders the
fallback as the outcome (renderOutcome), a fallback hole that rejected
while the server waited for it was swallowed. The retry ran with no owner,
so the hole's error never met a handler and the outcome came back empty.
Parked in a pending outer <Loading>, the inner fragment then settled as a
blank child, and the client hydrated the blank and halted. A fallback that
threw synchronously reached the parent handler instead, but in its
no-consumer mode: an outer <Errored> serialized its error record and
rendered a fallback nobody sent.

Now a failure of the fallback, sync or async, goes to the next boundary
up. The fallback owner's handler holds it while the outcome renders, and
the retry runs under that owner. The parent is first asked for the outcome
at the same position, so an outer <Errored> whose whole content is that
position draws its fallback there and serializes its error with it.
Otherwise the failure is routed as a content failure, so a pending outer
<Loading> fails its fragment. An outer <Errored> reached that way while
it is not being pulled lets it pass and does not render.

#4317: the wait now shares the <Loading> resume loop's convergence budget
(MAX_RESUME_PASSES, internal; hydration.ts uses it in place of the 10000
literal). Past the budget it answers what is available, which is no
finished fallback. It also races each wait against the fallback owner's
teardown, so an aborted render stops waiting at once.

Tests: sync-throw twins for the parked and unparked shapes, with and
without an outer <Errored>. The server harness holds the async variant's
markup to the twin's. The hydrate spec holds its client outcome, loaded
and streamed, to the twin's, and a caught variant must claim the outer
fallback. Server specs cover the budget (a fresh pending source every
pass) and the abort.

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

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8692ad9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
solid-js Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
@solidjs/web Patch
board-example Patch
canvas-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/signals 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

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 −119 B 7.44 KB ✅
signals: + createStore 14.87 KB 0 B 0 B −1463 B 15.04 KB ✅
signals: + isPending/latest 10.37 KB 0 B 0 B −8 B 10.38 KB ✅
signals: + until/refresh (truth readers, no optimism) 8.05 KB 0 B 0 B −123 B 8.05 KB ✅
app: render + one signal (the simple-app floor) 9.77 KB 0 B 0 B −107 B 9.75 KB ⚠️ over by 20 B, 127 B minified headroom
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 18.52 KB 0 B 0 B −286 B 18.49 KB ⚠️ over by 28 B, 306 B minified headroom lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.93 KB 0 B 0 B −78 B 30.86 KB ⚠️ over by 65 B, 98 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 13.37 KB 0 B 0 B −186 B 13.40 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 15.09 KB 0 B 0 B −189 B 15.14 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.68 KB 0 B 0 B −190 B 29.70 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 −106 B 9.93 KB ⚠️ over by 38 B, 126 B minified headroom
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 26.11 KB 0 B 0 B −736 B 26.27 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 32.18 KB 0 B 0 B −787 B 32.25 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.76 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.39 KB 0 B 0 B −284 B 33.37 KB ⚠️ over by 17 B, 304 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 8.92 KB, wire.js 3.47 KB
page: live server components (base + live/GET + action + isPending/latest) 37.84 KB 0 B 0 B −91 B 37.75 KB ⚠️ over by 92 B, 111 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 8.94 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.42 KB 0 B 0 B −278 B 35.43 KB ✅ assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.20 KB, trace.js 8.94 KB, wire.js 3.47 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.64 KB 0 B 0 B −101 B 41.56 KB ⚠️ over by 81 B, 121 B minified headroom eager (counted): web.js 22.23 KB; assets.js 0.78 KB, decode.js 7.09 KB, regions.js 1.06 KB, sc-comments.js 0.19 KB, trace.js 8.94 KB, wire.js 3.47 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 40.86 KB 0 B 0 B −284 B 40.81 KB ⚠️ over by 53 B, 304 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.58 KB, trace.js 8.97 KB, wire.js 3.48 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.28 KB 0 B 0 B −98 B 47.16 KB ⚠️ over by 117 B, 118 B minified headroom eager (counted): client.js 27.37 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.32 KB, trace.js 8.95 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)

  • app: render + one signal (the simple-app floor): over brotli cap by 20 B; minified 26,922 B vs 27,029 B recorded with the cap (−107 B) — 127 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating (no stores) with Show/For/Loading/Errored/lazy: over brotli cap by 28 B; minified 53,334 B vs 53,620 B recorded with the cap (−286 B) — 306 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating + every store primitive family: over brotli cap by 65 B; minified 95,035 B vs 95,113 B recorded with the cap (−78 B) — 98 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled floor (one template, one text hole, one delegated click): over brotli cap by 38 B; minified 27,454 B vs 27,560 B recorded with the cap (−106 B) — 126 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 17 B; minified 101,275 B vs 101,559 B recorded with the cap (−284 B) — 304 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 92 B; minified 115,126 B vs 115,217 B recorded with the cap (−91 B) — 111 B of the 20 B minified allowance left; +0 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 81 B; minified 123,115 B vs 123,216 B recorded with the cap (−101 B) — 121 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 53 B; minified 125,103 B vs 125,387 B recorded with the cap (−284 B) — 304 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 117 B; minified 140,382 B vs 140,480 B recorded with the cap (−98 B) — 118 B of the 20 B minified allowance left; +0 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).

@coveralls

coveralls commented Oct 27, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 40460326369

Coverage remained the same at 78.589%

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: 1350
Covered Lines: 1120
Line Coverage: 82.96%
Relevant Branches: 1074
Covered Branches: 785
Branch Coverage: 73.09%
Branches in Coverage %: Yes
Coverage Strength: 28.1 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 23.9%

2 warnings

⚠️ 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

⚠️ 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

⚡ 2 improved benchmarks
✅ 194 untouched benchmarks
⏩ 5 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ createStore setter: delete + set one root key (#3044 overlay) 2.4 ms 1.8 ms +32.97%
⚡ memo + sync render effect only (reference) 34.2 ms 29.7 ms +15.44%

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-4316 (8692ad9) with next (542ff6f)

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

@ryansolid
ryansolid marked this pull request as ready for review October 27, 2026 05:53
…s to the nearest boundary (#4316)

A <Loading> that failed after the shell while its fragment was parked in
an enclosing <Loading> still pending was settled as a blank region that
reported success. The renderer (web/src/server.ts registerFragment)
parks a settled child into its pending parent and resolves its `_fr`
whether or not it failed. Nothing upstream heard of the failure: the
error was routed only to an <Errored> that could draw at exactly that
position (errorOutcome). The client then hydrated the blank region as
success and halted or drew nothing.

Now the failure is the enclosing fragment's content's, as when the same
read fails in that fragment's own render pass. Handlers get an
escalation mode (ESCALATE, server-internal) for a failure that surfaced
after the render passed them:
- A <Loading> whose fragment is still pending fails it, the way a hole
  failure in its own pass does.
- An <Errored> whose finished markup sits raw in such a fragment renders
  its fallback in place of that markup (FragmentContainer.region): the
  fallback is waited for, its error is serialized once it is finished,
  and its own failure goes up the same way. An <Errored> still pending
  shows the fallback on its next pull, and one already showing its
  fallback absorbs the failure.
- A boundary already settled with a failure absorbs it.
- Otherwise the failure goes to the next boundary up.

The enclosing <Loading> applies the regions to its markup before it
settles, outermost first. If a region is not found, or its fallback never
finishes, that fragment fails with the region's error. This replaces the
`escalating` counter from 7ce3c98: an <Errored> fallback that fails
while the server waits for it uses the same mode, so it is drawn one
level up too.

Not covered:
- The late case. If the enclosing fragment settled before the inner read
  failed, its markup is already out. The inner fragment rejects on its own
  and the client renders it, and the client's <Errored> meets the failure,
  unchanged.
- An <Errored> inside a nested fragment that settled into the pending one
  first. Its markup is out of the enclosing fragment's own markup, so the
  enclosing fragment fails instead of drawing.

Tests: parked shapes with an <Errored> between, around the inner
<Loading> with a failing fallback, and around everything, each against a
twin with no inner <Loading> (the failure met in the enclosing pass). The
server harness holds the stream text to the twin's. The hydrate spec,
loaded and streamed, holds the client outcome to the twin's and checks
that the drawn fallback is claimed. Server specs cover the late case and
a nested fragment that settled first.

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

An `<Errored>` whose fallback failed while rendering (a hole that rejects,
or a synchronous throw) for its content's error was left blank on the
server: the boundary's error handler had already marked the error handled
before rendering the fallback, so the fallback's own failure was dropped
and the pull returned `undefined`. At shell level, with no `<Loading>`,
the shell got a blank region with the errors recorded and no markup for
them; an enclosing `<Errored>` serialized its record but never drew.

The fallback's failure now passes up from the pull: an enclosing
`<Errored>` draws its fallback in place of the region, and with none the
request fails, the same for the async hole and its sync-throw twin.
Server only; client bundles are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hPxMkC3jg3Uivh65gvaf2
@ryansolid
ryansolid merged commit 8f1d9d2 into next Oct 27, 2026
9 checks passed
ryansolid pushed a commit that referenced this pull request Oct 27, 2026
Keeps both sides of the harness scenarios and the 4306 spec imports. #4318's
new artifacts and its rejected-fragment expectations take this branch's empty
outcome template (`e></template>`, no lone space).

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