Skip to content

fix(ssr): a shell suspension above <Errored> keeps its <Loading> content and its owners - #3770

Merged
ryansolid merged 1 commit into
nextfrom
fix/ssr-shell-suspend-retry
Oct 5, 2026
Merged

ryansolid merged 1 commit into
nextfrom
fix/ssr-shell-suspend-retry

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Summary

Two SSR failures when the shell suspends inside an <Errored> subtree that no <Loading> covers. Found through the router's flash-decode read on the page after a no-JS form post (the todos-server example lost its list on that page); a lazy() route under <Errored> with no <Loading> in between triggers the second for every request.

1. A <Loading> below the boundary lost its content (@solidjs/web)

<Errored> holds its children — placeholders included — in its retry state, so the whole subtree is one pending root hole and the <Loading> placeholder is not in the shell html yet. When the <Loading> content settled before the shell's suspension did, its pre-flush inline (replacePlaceholder(html, key, value)) found no placeholder, the fragment left the registry, and when the root hole was spliced in later it brought the fallback with nothing left to swap it. _fr still resolved true.

Fix: a fragment settling pre-flush while root holes are pending and its placeholder is absent is parked; resolveRootHoles splices parked markup in once a re-pull lands the placeholder.

2. Every component above the boundary re-rendered per retry pass (solid-js)

ssrScope (the per-hole id scope) runs the hole's expression and then unwraps accessor chains in place. Under a layout's {props.children} hole, the expression resolves to the <Errored> accessor, which throws the suspension — so the whole scope thunk became the retry, and each pass re-read props.children, re-creating App, the router, and everything else above the boundary. A component that owns its pending source started a fresh one per pass and the render never finished. (The router's flash code is already hardened against being re-created mid-request, which looks like this bug seen from the other side.)

Fix: when an accessor reached by the unwrap suspends, the scope remembers it and the retry resumes from it, with the scope's id counter restored to where it was. A suspension thrown by the expression itself still re-runs it, as before. This matches the client, which reads the expression once and unwraps in an inner effect.

Public API changes

None. Behaviour change: after a suspension, a scoped hole's retry no longer re-runs the hole's expression when what suspended was an accessor that expression returned.

Tests

  • test/server/shell-suspend-loading-inline.spec.tsx — a server-component answer and a plain async memo under <Loading>, below a shell suspension, with/without <Errored> and <Document>: content present, fallback gone, nothing rendered twice. The four <Errored> cases fail on next.
  • test/server/errored-sync-suspend-retry.spec.tsx — owner rebuild counts for request- and instance-scoped pending sources, body and template reads, plus a lazy() route under <Errored> with no <Loading>. The <Document> + <Errored> cases fail on next (instance-scoped ones time out; the lazy route's layout renders twice).
  • web (client, server, hydrate), solid suites and both packages' type tests pass. Size: every scenario within its cap (renderToString floor 20,377 B / 20.38 KB).

Not covered here

  • Awaited mode (renderToStream(...).then) under a shell suspension has its own pre-existing bugs, found while adding .then variants: without <Errored> the document resolves empty (seroval's onDone completes the render while a root hole is still pending — doShell() bails and completion runs anyway), and with <Errored> the render hangs synchronously. Both reproduce on next without this change; they'll be a separate PR with the .then variants.
  • No hydrate pass: with the fix the server output is an ordinary shell with the content inlined, a shape existing hydration specs cover.

@changeset-bot

changeset-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9408b9d

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

This PR includes changesets to release 12 packages
Name Type
solid-js Patch
@solidjs/web Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal 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

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.32 KB 0 B 7.33 KB ✅
signals: + createStore 14.51 KB 0 B 14.53 KB ✅
signals: + isPending/latest 9.45 KB 0 B 9.45 KB ✅
app: render + one signal (the simple-app floor) 9.81 KB 0 B 9.83 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.65 KB 0 B 17.66 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.79 KB 0 B 28.80 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.81 KB 0 B 12.82 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.39 KB 0 B 14.39 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.59 KB 0 B 28.61 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 13.77 KB 0 B 13.78 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 44.76 KB 0 B 44.78 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 48.44 KB 0 B 48.45 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.41 KB 0 B 20.42 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps 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).

@coveralls

coveralls commented Oct 4, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37285561682

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 75.991%

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: 1195
Covered Lines: 962
Line Coverage: 80.5%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.97 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 6.15%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 187 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 27.8 ms 29.6 ms -6.15%

Tip

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


Comparing fix/ssr-shell-suspend-retry (9408b9d) with next (6be6c51)

Open in CodSpeed

…ent and its owners

Two failures when the shell suspends inside an <Errored> subtree that is
not under a <Loading> (the router's flash-decode read after a no-JS post,
a lazy() route with no <Loading> between):

- <Errored> holds its children, placeholders included, in its retry
  state, so the whole subtree is a pending root hole. A <Loading> below
  it whose content settled first inlined into a shell that did not yet
  hold its placeholder: the replace was a no-op, the fragment left the
  registry, and the fallback shipped with nothing to swap it. Fragments
  settling in that window are now parked and spliced in when a root-hole
  re-pull lands their placeholder.

- ssrScope unwraps accessor chains in place. When the accessor that
  suspended was returned by the hole's expression ({props.children}
  resolving to the <Errored>), the whole scope thunk became the retry,
  so every pass re-read the expression and re-created every component
  above the boundary; one owning its pending source never converged.
  The retry now resumes the accessor that suspended, with the scope's
  counter where it was.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the fix/ssr-shell-suspend-retry branch from 7ef46ec to 9408b9d Compare October 5, 2026 08:44
@ryansolid
ryansolid merged commit 7addcc6 into next Oct 5, 2026
6 checks passed
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.

2 participants