@solidjs/web: streaming SSR and reconcile fixes - #1
Open
romulovalez wants to merge 3 commits into
Open
romulovalez wants to merge 3 commits into
romulovalez wants to merge 3 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three
@solidjs/webfixes 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 onnext(309b087).1.
flushEndfinalized a render that still had unresolved root holesProblem. A drained serializer is what tells
renderToStreamit is finished: seroval'sonDonerunsdoShell()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.onDonefinalizes whateverdoShell()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 cachedNotReadyErrorforever.resolveRootHoles()never converges: the root error boundary wraps the stale error in a freshNotReadyError(Promise.all(…))each turn,blockingPromisesgrows by one already-settled promise per turn,allSettledalways 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 iskill -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 whilerootHoles && !shellCompleted. That only defers the flush: every consumer (then,pipe,pipeTo) callsflushEnd()again onceresolveRootHoles()reports the holes resolved.Test.
test/server/root-hole-boundary-resolve.spec.tsx. The shape isErrored→ [Loadingsettling at 5 ms, an uncaught read settling at 40 ms]. TheErroredstands in for a router'sCatchBoundary, 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 byreplacePlaceholder(html, key, value). That function returnshtmlunchanged 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 oneNotReadyError. So a single uncaught async read anywhere under it makes the whole subtree one<!--rhN-->. In the app, every route has anerrorComponentand the router'sMatchwraps each one in aCatchBoundary, so the whole document is one root hole (measured:html.length === 10when 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
_frresolved, thepl-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 intohtml.doShell()callsresolveRootHoles()right beforesink.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/boardthe 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 zeropl-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 withnode = after = style.nextSibling, which ismenu.insertBefore(menu, menu)is a no-op, andinsertBefore(page, menu)moves the page above the menu. The DOM now readsstyle, page, menuwhile the slot believesstyle, menu, page. On the next navigationafter = page.nextSibling = menu: the reconcile removesmenuand theninsertBefore(new, menu)throwsNotFoundError. 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.tsxcallsreconcileArraysdirectly and throughinsert()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
Loadingfallback'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
/itemsat 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:$dfhas 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, thenpnpm turbo run test test-types typecheck --filter=!test-integration --filter=!@solidjs/compiler(the roottestscript): 35/35 tasks pass.@solidjs/webclient 119 files, server 144, hydration 48;@solidjs/signals249;solid-js41. The compiler was not rebuilt (no Rust toolchain locally); the published@solidjs/compiler-darwin-arm64@2.0.0-rc.13binary was used, and the only compiler change since rc.13 is fix(compiler): preserve coverage pragmas in JSX solidjs/solid#3301 (coverage pragmas)."") 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