Skip to content

fix(ssr): the end of a streamed render waits for a suspended shell - #3771

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

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

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Stacked on #3770 (base fix/ssr-shell-suspend-retry). The fix here stands alone; its <Errored> awaited-mode tests also need #3770's fixes to pass. Retarget to next once #3770 merges.

Summary

flushEnd flushed the serializer whenever the fragment registry was empty. A <Loading> fragment settling while the shell was still suspended — a lazy memo throwing NotReadyError above it, the router's flash-decode shape — emptied the registry before the shell's root holes re-pulled, so the flush ran first. Seroval drops every write() after flush() and fires onDone once, so:

  • Hydration data went missing in both forms. Whatever the root-hole re-pull serialized (an async value in the re-rendered shell, say) never reached the document. The piped form still shipped the markup, so this only shows on hydration.
  • The awaited form returned an empty document. renderToStream(...).then resolved "": onDone's doShell() bailed on the pending holes and completion ran anyway.
  • With an <Errored> holding the suspended subtree, the awaited render never completed — a synchronous loop that blocked even a test's timeout.

Fix: pending root holes gate flushEnd. Both consumers' flush loops already call it again once the holes resolve. The existing serializeRootAssets guard ("A Loading boundary's resolve path can queue flushEnd while the shell is still pending…") was an earlier patch for one symptom of this race; it stays, now redundant on this path.

These are the awaited-mode bugs listed under "Not covered here" in #3770. They matter for the mode an app picks when it needs no-JS correctness (renderMode: 'async' for crawlers or no-JS pages).

Public API changes

None.

Tests

test/server/then-shell-suspend.spec.tsx:

  • awaited render, shell suspension above <Loading>, with/without <Errored> and <Document>, in both settle orders: shell present, content present, fallback gone. On next the content-first, no-<Errored> cases resolve "" and the <Errored> cases hang.
  • a shell component re-run after the suspension serializes an async value: it must appear in a hydration script, piped and awaited. Without the gate the piped form ships the markup without the data and the awaited form is empty.

web (client, server, hydrate), solid suites and web type tests pass. Size: every scenario within its cap (renderToString doesn't bundle this path).

@changeset-bot

changeset-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2d381ca

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

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

@ryansolid
ryansolid force-pushed the fix/ssr-shell-suspend-retry branch from 39c7f3c to 7ef46ec Compare October 5, 2026 08:24
@ryansolid
ryansolid force-pushed the fix/ssr-shell-suspend-serializer-flush branch from 939ea37 to be45bc7 Compare October 5, 2026 08:24
@ryansolid
ryansolid force-pushed the fix/ssr-shell-suspend-retry branch from 7ef46ec to 9408b9d Compare October 5, 2026 08:44
@ryansolid
ryansolid changed the base branch from fix/ssr-shell-suspend-retry to next October 5, 2026 08:51
flushEnd flushed the serializer whenever the fragment registry was
empty. A <Loading> fragment settling while the shell was still suspended
(a lazy memo throwing NotReadyError above it, the router's flash-decode
shape) emptied the registry before the root holes re-pulled, so the
flush ran first. Seroval drops writes after flush and fires onDone once:

- everything the re-pull serialized was missing from the hydration data,
  in both the piped and awaited forms;
- the awaited form (renderToStream(...).then) completed with an empty
  document — onDone's doShell() bailed on the pending holes and
  completion ran anyway;
- with an <Errored> holding the suspended subtree, the render never
  completed.

Pending root holes now gate flushEnd. Both consumers' flush loops call
it again once the holes resolve. (The serializeRootAssets guard was an
earlier patch for one symptom of the same race.)

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-serializer-flush branch from be45bc7 to 2d381ca Compare October 5, 2026 08:51
@github-actions

github-actions Bot commented Oct 5, 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).

@ryansolid
ryansolid merged commit 6f77b1b into next Oct 5, 2026
6 checks passed
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37286304114

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

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 5, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 23.99%

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

⚡ 2 improved benchmarks
✅ 186 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ projection derive: write one NESTED field (reference) 2.6 ms 2 ms +28.72%
⚡ memo + sync render effect only (reference) 32.1 ms 26.8 ms +19.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 fix/ssr-shell-suspend-serializer-flush (2d381ca) with next (7addcc6)

Open in CodSpeed

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