Skip to content

@solidjs/web: streaming SSR and reconcile fixes - #1

Open
romulovalez wants to merge 3 commits into
nextfrom
fix/web-streaming-ssr-reconcile
Open

romulovalez wants to merge 3 commits into
nextfrom
fix/web-streaming-ssr-reconcile

Conversation

@romulovalez

Copy link
Copy Markdown
Owner

Three @solidjs/web fixes found while porting a production TanStack Start app to Solid 2 (rc.7 → rc.13). Each has been carried as a dist patch in that app since it was found; this ports them to source, one commit per fix, each with a test that fails without it. All three are still present on next (309b087).

1. flushEnd finalized a render that still had unresolved root holes

Problem. A drained serializer is what tells renderToStream it is finished: seroval's onDone runs doShell() and then disposes the root. flushEnd(), which does that flush, is also reached from a fragment's resolve and from a hold's release, and both can fire while the shell still has unresolved root holes. onDone finalizes whatever doShell() reported.

Disposing there kills any memo parked on an async source. The memo's retry is subscribed to the pending promise. Once the owner is disposed the retry does nothing (comp.disposed), so the memo re-throws its cached NotReadyError forever. resolveRootHoles() never converges: the root error boundary wraps the stale error in a fresh NotReadyError(Promise.all(…)) each turn, blockingPromises grows by one already-settled promise per turn, allSettled always reports progress, and the flush loop stays on microtasks. The result is 100% CPU and unbounded RSS, and the event loop never yields again. The process stops answering everything, health checks included, until it is kill -9'd. One e2e run of the app had to kill 19 servers. The awaited form (await renderToStream(…)) resolves with "" before the spin starts.

Fix. flushEnd() returns early while rootHoles && !shellCompleted. That only defers the flush: every consumer (then, pipe, pipeTo) calls flushEnd() again once resolveRootHoles() reports the holes resolved.

Test. test/server/root-hole-boundary-resolve.spec.tsx. The shape is Errored → [Loading settling at 5 ms, an uncaught read settling at 40 ms]. The Errored stands in for a router's CatchBoundary, the uncaught read for a route component reading its query outside any <Loading>. Without the fix the first case resolves with "". The second case puts a memo over the uncaught read and hangs the worker without the fix: that is the spin itself.

2. A boundary resolved inside an unresolved root hole lost its content

Problem. Before the shell flushes, a resolved <Loading> is spliced into the shell by replacePlaceholder(html, key, value). That function returns html unchanged when <template id="pl-key"> is not in it, and it is not in it whenever the boundary sits inside a pending root hole. An error boundary flattens its subtree and, while any hole in it is pending, rethrows them upward as one NotReadyError. So a single uncaught async read anywhere under it makes the whole subtree one <!--rhN-->. In the app, every route has an errorComponent and the router's Match wraps each one in a CatchBoundary, so the whole document is one root hole (measured: html.length === 10 when the boundaries resolved).

The splice missed silently. When the hole later landed it wrote the placeholder and the fallback, and nothing replaced them. The client then found _fr resolved, the pl- template present and no content, judged the fragment superseded and rebuilt the subtree from scratch.

Fix. While root holes are pending, a missed splice is held in heldFragments. resolveRootHoles() retries every held splice after each hole's markup is written into html. doShell() calls resolveRootHoles() right before sink.shell(…), so nothing spliceable is dropped. With no root hole the behavior is unchanged.

Test. The same spec, awaited and piped: the boundary's content is in the document, with no pl- template and no fallback. Without the fix the document is …<template id="pl-001"></template>FALLBACK<!--pl-001-->….

Measured in the app. On every warm /offers/:id/board the client rebuilt 74 skeleton elements and about 290 nodes. Six routes adopted only 81.9–95.6% of the server DOM; after the fix they ship zero pl- templates and adopt 99.6–100%, and the documents grew by exactly the content that used to be lost. The fix also let the app drop loader-level server awaits whose only job was to keep its chrome's boundaries from resolving inside a root hole.

3. reconcileArrays: a run whose anchor is its own first node was inserted out of order (rc.9 regression)

Problem. After hydration an insert slot can hold only part of what the server rendered in it. Example: a fragment [<Show>menu</Show>, <Outlet/>] shares a slot with an accent <style>, and the slot's first value is [style] because the rest resolved later. The next reconcile, a=[style], b=[style, menu, page], takes the append path with node = after = style.nextSibling, which is menu. insertBefore(menu, menu) is a no-op, and insertBefore(page, menu) moves the page above the menu. The DOM now reads style, page, menu while the slot believes style, menu, page. On the next navigation after = page.nextSibling = menu: the reconcile removes menu and then insertBefore(new, menu) throws NotFoundError. Since rc.10 the boundary swallows the error and the outlet stays blank.

Fix. Skip a node that is the anchor itself and step the anchor past it (if (n === node) node = n.nextSibling; else parentNode.insertBefore(n, node)), so a run that is already in place stays in place. Nothing changes when the anchor is not in the run.

Test. test/reconcile-self-anchored-run.spec.tsx calls reconcileArrays directly and through insert() with a signal, then runs the follow-up update that used to throw. Both fail without the fix. End to end, an onboarding journey e2e in the app fails without it and passes with it.

Root cause, not fixed here: the slot's partial value after hydration is the underlying issue. This fix makes reconcile robust to it.

Not fixed: a streamed Loading fallback's hydration keys miss under throttling (2.0 only)

Found while measuring hydration in a standalone benchmark (bare Solid 2 vs 1.9 vs React, same markup). On /items at 4× CPU, every Solid 2 variant missed 9 hydration keys in about 40% of loads; Solid 1.9 never did. The missed keys are the list's fallback skeleton. The race: $df has already swapped the resolved fragment into the document before the entry script runs, but the fragment's promise has not resolved yet on the client. The client then renders the fallback, which is no longer in the DOM. Reported here for visibility; no fix is proposed in this PR.

Verification

  • pnpm turbo run build --filter=!@solidjs/compiler, then pnpm turbo run test test-types typecheck --filter=!test-integration --filter=!@solidjs/compiler (the root test script): 35/35 tasks pass. @solidjs/web client 119 files, server 144, hydration 48; @solidjs/signals 249; solid-js 41. The compiler was not rebuilt (no Rust toolchain locally); the published @solidjs/compiler-darwin-arm64@2.0.0-rc.13 binary was used, and the only compiler change since rc.13 is fix(compiler): preserve coverage pragmas in JSX solidjs/solid#3301 (coverage pragmas).
  • Each new test was checked against the code without its fix: fix 1's first case fails ("") and its second hangs; fix 2's two cases fail; fix 3's two cases fail.

The repro app and the standalone benchmark are private; repros are available on request.

🤖 Generated with Claude Code

romulovalez and others added 3 commits October 1, 2026 11:32
…oot holes

A drained serializer is what tells renderToStream it is finished: seroval's
onDone runs doShell() and disposes the reactive root. flushEnd() is also
reached from a fragment's resolve and a hold's release, which can fire while
the shell still has unresolved root holes. Flushing there disposes the owner
of a memo whose retry is subscribed to a pending promise; the retry no-ops on
`comp.disposed`, the memo re-throws its cached NotReadyError forever, and the
flush loop re-pulls the same hole on microtasks: 100% CPU, unbounded memory,
the event loop never yields. The awaited form resolves with "".

Skip the flush while root holes are pending. Nothing is lost: every consumer
(then, pipe, pipeTo) calls flushEnd() again once resolveRootHoles() reports
the holes resolved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t hole

Before the shell flushes, a resolved <Loading> boundary is spliced into the
shell html by replacePlaceholder(), which returns html unchanged when the
`<template id="pl-…">` is not there. It is not there whenever the boundary
sits inside a pending root hole: an error boundary flattens its subtree and,
while any hole in it is pending, rethrows it upward as one NotReadyError, so
one uncaught async read anywhere under it makes the whole subtree a single
`<!--rhN-->`. The splice missed silently; when the hole later landed it wrote
the placeholder plus the fallback, and nothing replaced them. The client then
found `_fr` resolved, the `pl-` template present and no content, judged the
fragment superseded and rebuilt the subtree from scratch.

Hold a missed splice while root holes are pending and retry the held ones in
resolveRootHoles() after each hole's markup is written into html. doShell()
calls resolveRootHoles() right before handing the shell to the sink, so
nothing spliceable is dropped. With no root hole the behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After hydration an insert slot can hold only part of what the server
rendered in it: a fragment `[<Show>…</Show>, <Outlet/>]` sharing a slot with
a `<style>` hydrates with the slot's first value `[style]` because the rest
resolved later. The next reconcile, a=[style] b=[style, menu, page], takes
the append path with `node = after = style.nextSibling`, which IS `menu`:
insertBefore(menu, menu) is a no-op and insertBefore(page, menu) moves the
page above the menu. The DOM is now style, page, menu while the slot holds
style, menu, page, and the next update removes `menu` and then
insertBefore()s against it: NotFoundError (since rc.10 swallowed by the
boundary, leaving a blank outlet). Regressed in rc.9.

Skip a node that is the anchor itself and step the anchor past it, so a run
already in place stays in place. Nothing changes when the anchor is not in
the run.

Co-Authored-By: Claude Opus 5.5 (1M context) <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.

1 participant