Skip to content

fix(solid): SSR boundaries resolve children in a scope mirroring the client's flatten computed (#3414) - #3428

Merged
ryansolid merged 2 commits into
nextfrom
fix/ssr-boundary-resolve-scope-3414
Sep 14, 2026
Merged

ryansolid merged 2 commits into
nextfrom
fix/ssr-boundary-resolve-scope-3414

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Closes #3414

Problem

A server-rendered <Errored> fallback hydrates dead: the HTML is right, but the fallback's button never fires and its signals never update the DOM. Reported shape (from the @solidjs/vite-plugin start entry): DefaultErrorBoundary > Document > DefaultErrorBoundary > App, where App renders <Errored fallback={() => <Fallback />}><SyncFailure /></Errored>.

It is a hydration id mismatch, not an event or reactivity problem. Errored with a zero-arg fallback hands the thunk back unresolved on both sides, and the consumer of the boundary's output unwraps it. When that consumer is another boundary, the two sides allocate ids for the unwrapped content in different places:

  • client createCollectionBoundary — two computeds under the boundary owner: c = computed(fn) (…0) and boundaryComputed(() => flatten(read(c))) (…1); flatten calls the thunk inside …1, so the fallback's <main> gets …10
  • server createErrorBoundary.resolve — one owner for fn (…0), then ctx.resolve(...) inline under the boundary owner, so <main> got …1

For the reported document: server <main _hk=00101>, client expected 001010 → "Hydration key miss", a detached fallback, dead button. The same misalignment hits Loading as the consumer, fragment children, and Show's fallback thunk. An element hole in between (compiled scope() on the server ↔ transparent insert effect on the client) is already aligned, which is why simpler repros pass.

Fix

The server Errored and Loading boundaries resolve their children's result in a virtual id scope mirroring the client's second computed — ssrScope's technique: the owner keeps its identity and only its id/_childCount are rewritten for the duration of the resolve; retry pulls resume the surviving holes in the same scope with the counter continuing where the discovery pass left it.

Why virtual and not a real createOwner(): retry wraps (buildAsyncWrap) capture the owner and read the current pull's ErrorContext handler off it. A real child owner snapshots the handler of the pull that created it; on the retry pass the stale handler rendered the fallback once, then the current pull rendered it again (double render, error serialized twice, ids off by two) — ssr-stream.spec.tsx's "Loading wrapping Errored" test caught this.

The error record is now serialized at the boundary's own id, read once (boundaryId), since owner.id is rewritten while the error lands mid-resolve.

Tests

Five parity-harness scenarios (SSR artifact → hydrate, both compilers): errored-thunk-fallback-under-errored, -under-loading, -in-fragment, -in-element (control), show-thunk-fallback-under-errored. Four failed before the fix (key miss / dead update); all pass with matching owner chains on both sides. Full web dom/server/hydrate and solid suites green.

Not verified against the reporter's Vite app (it pins rc.8 from npm); the jsdom repro reproduces its exact markup and ids (001000 record, 001010 fallback root on both sides).

…ient's flatten computed (#3414)

A server-rendered `<Errored>` fallback hydrated dead when the boundary's
children threw synchronously and the fallback was a zero-arg thunk
(`fallback={() => <Fallback />}`) inside an enclosing boundary. Both sides
hand the thunk back unresolved and the enclosing boundary unwraps it — the
client inside its second computed (`boundaryComputed(() => flatten(read(c)))`,
the boundary owner's child `…1`), the server inline under the boundary owner
(`ctx.resolve` right after the `fn` owner `…0`) — so the fallback's root
element took `…1` on the server and `…10` on the client, failed its claim,
and its handlers and effects never attached. Same misalignment for `Loading`
as the consumer, fragment children, and `Show`'s fallback thunk; an element
hole in between (compiled `scope()` ↔ transparent insert effect) was already
aligned.

The server `Errored` and `Loading` boundaries now resolve their children's
result in a virtual id scope mirroring that second computed — ssrScope's
technique: the owner keeps its identity (retry wraps capture it and read the
current pull's error handler off it; a real child owner snapshots a stale
handler and double-renders the fallback) and only its id counter is
rewritten for the resolve, retries continuing where the discovery pass left
it. The error record is serialized at the boundary's own id, read once,
since `owner.id` is rewritten while the error lands.

Parity-harness scenarios cover Errored/Loading/fragment consumers and the
Show producer, plus the element-hole control.

Closes #3414

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f100afa

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

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

`Show`'s `fallback` prop is typed as an element; the zero-arg thunk is a
runtime-accepted shape the scenario exercises on purpose, so cast it (the
same treatment as the memo-returning components in #3394).

Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34860340383

Coverage remained the same at 71.842%

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: 1007
Covered Lines: 772
Line Coverage: 76.66%
Relevant Branches: 790
Covered Branches: 519
Branch Coverage: 65.7%
Branches in Coverage %: Yes
Coverage Strength: 15.02 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 160 untouched benchmarks


Comparing fix/ssr-boundary-resolve-scope-3414 (f100afa) with next (1643d2a)

Open in CodSpeed

@ryansolid
ryansolid merged commit 5426ffb into next Sep 14, 2026
7 checks passed
@ryansolid
ryansolid deleted the fix/ssr-boundary-resolve-scope-3414 branch September 14, 2026 15:40
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