Skip to content

fix(solid,web): hydration-pass writes land as client renders (#3504) - #3507

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:fix/hydrating-root-snapshot-scope-3504
Sep 17, 2026
Merged

ryansolid merged 2 commits into
solidjs:nextfrom
brenelz:fix/hydrating-root-snapshot-scope-3504

Conversation

@brenelz

@brenelz brenelz commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #3504.

The bug

A signal written from onSettled (or a createEffect callback) during hydration reveals a <Show> branch the server never rendered. The render runs under the hydrating flag, claims fresh templates against the server registry, misses (Hydration key miss), and renders detached — nothing appears and nothing is interactive. It happens in two windows, and the fix has a half for each.

Root pass

Writes landing during the hydration pass are meant to be held by the hydration snapshot scope and replayed once the pass completes. That scope was marked lazily, by the first hydration-aware primitive created under the root. A control-flow memo created before that — <Show>'s condition, built on the core createMemo — sat outside it, so the write cascaded live, mid-claim.

solid-js's createRoot now goes through the same hydration slot as the other primitives. While hydrating, the root marks itself as the snapshot scope before its first child exists, so the hold no longer depends on creation order. Outside hydration it is the core root; createRoot from @solidjs/signals is untouched.

Resume window (the Router shape — thanks @ryansolid)

With the route streamed behind a <Loading> boundary, the route's onSettled fires inside the boundary's resume window — after the root pass released its scope. The write reaches the provider's signal above the boundary. That region already hydrated in the root pass; its re-render is a client render, not a claim — but the web runtime treated every render under the hydrating flag as claiming.

The resume now records its boundary owner and exposes sharedConfig.isClaiming() (true when the current owner descends from the resuming boundary; a root pass claims everything). @solidjs/web's isHydrating() consults it, so the revealed branch is created fresh and inserted live. Frames adoption and the root pass keep claiming everything, as before.

Tests

  • Parity harness: onsettled-write-show (bare), onsettled-write-show-loading, effect-write-show, and onsettled-write-show-streamed (async: true; fails only in streamed mode without the claim gate — the write lands in the resume window there). All fail on next.
  • packages/solid/test/hydration-root-snapshot-scope-3504.spec.ts — hold-then-replay for the root pass, both writers, plus the non-hydrating control.
  • Full suites green on the rebase over current next: signals, solid-js (incl. client-hydration.spec.ts), web client, web hydration. The web server suite's 12 failures in the ownerPath-label specs are pre-existing here (stale local compiler binary).

Size

Two caps ratcheted to the measured output (scripts/size/.size-limit.js notes): hydrating (no stores) 19,933 B, hydrating + stores 30,063 B — about +85 / +130 B brotli from the createRoot slot indirection, the hydrating root body, the resume-window owner walk, and the web isHydrating check; 0 B in the signals floor. The other caps are within their limits (CSR app has 4 B of headroom on next already).

Not taken

Ending the claim pass before the user effect tier (a scheduler hook clearing sharedConfig.hydrating between the render and user tiers, the 1.x runUserEffects precedent) fixes the symptom but breaks 22 client-hydration.spec.ts cases and moves onHydrationEnd inside the initial mount (the streamed portal-async-content scenario then trips ASYNC_OUTSIDE_LOADING_BOUNDARY). The snapshot scope plus an explicit claim region is the smaller change.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 17c0a3b

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/web Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal 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

@codspeed

codspeed Bot commented Sep 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing brenelz:fix/hydrating-root-snapshot-scope-3504 (17c0a3b) with next (f46ae70)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@ryansolid

Copy link
Copy Markdown
Member

I rebased this locally over current next (including #3505 and #3506). The affected package builds, full Solid/web suites, and rebased size checks pass.

However, the actual #3504 reproduction still fails against the rebased PR artifacts: a fresh page logs Hydration key miss for "1701020" for <div>HYDRATION MISS</div>, and the toast remains absent/detached. I aligned the temporary app to the repository-pinned Vite plugin, removed the unrelated stale @solidjs/meta failure, forced solid-js, @solidjs/signals, and @solidjs/web to the local artifacts, and excluded the ecosystem packages from prebundling so there was only one Solid runtime. The warning still reproduced on a fresh navigation.

This suggests the parity harness does not capture something in the real Router/application topology. I did not push the rebase because the reported issue remains reproducible.

GPT-5.6 Sol via Cursor

@brenelz
brenelz force-pushed the fix/hydrating-root-snapshot-scope-3504 branch 2 times, most recently from 6226bea to 408cf92 Compare September 17, 2026 09:09
@brenelz brenelz changed the title fix(solid): a hydrating root marks the snapshot scope itself (#3504) fix(solid,web): hydration-pass writes land as client renders (#3504) Sep 17, 2026
@brenelz

brenelz commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — you were right, and the harness was missing the streamed shape. Rebased over current next and pushed a second half.

What the first push missed. In the real app the route is streamed behind the <Loading> in App, so its onSettled fires inside the boundary's resume window (resumeBoundaryHydration: _hydratingValue = true, snapshot scope on the boundary owner only), after the root pass has already released its scope. The write reaches the toaster's signal above the boundary, and that <Show> re-renders under the hydrating flag. The web runtime treated every render under the flag as a claim, so the branch claimed a key the registry never had — your 1701020 — and rendered detached. The first push only covered the root-pass window, and none of the existing scenarios had a write escaping a resume window.

Fix. The resume records its boundary owner and exposes sharedConfig.isClaiming() (the current owner descends from the resuming boundary; a root pass claims everything). isHydrating() in @solidjs/web consults it, so a render outside the resuming subtree is a client render: fresh nodes, live insert. The createRoot half stays, for the non-streamed shape.

Repro in the harness. New parity scenario onsettled-write-show-streamed (route reads an async memo, so the content arrives as a late fragment) — fails only in streamed mode without the gate, with exactly the missing toast, and passes with it. Loaded mode was already covered by the root-pass fix, which is why the earlier scenarios were green.

If you still have the temporary app wired to local artifacts, this push should clear the fresh-navigation warning; happy to look again if not.

@brenelz
brenelz force-pushed the fix/hydrating-root-snapshot-scope-3504 branch from 408cf92 to 4f865fc Compare September 17, 2026 09:11
Brenley Dueck and others added 2 commits September 17, 2026 09:08
…#3504)

A signal written from onSettled/createEffect during hydration revealed a
<Show> branch whose DOM never reached the document: the render ran under
the hydrating flag, claimed fresh templates against the server registry,
missed ("Hydration key miss"), and rendered detached. Two windows, two
halves.

Root pass: the snapshot scope that holds such writes was marked lazily,
by the first hydration-aware primitive created under the root, so a
control-flow memo created before that — <Show>'s condition, built on the
core createMemo — sat outside it and the write cascaded live mid-claim.
solid-js's createRoot now goes through the same hydration slot as the
other primitives; while hydrating it marks the root as the snapshot scope
before the first child exists, so the hold no longer depends on creation
order. Outside hydration it is the core root.

Resume window: with the route streamed behind a <Loading> boundary, its
onSettled fires inside the boundary's resume window — after the root pass
released its scope — and the write reaches the provider's signal above the
boundary. That region already hydrated; its re-render is a client render,
not a claim. The resume records its boundary owner and exposes
`sharedConfig.isClaiming()` (the current owner descends from it; a root
pass claims everything); @solidjs/web's isHydrating() consults it, so the
revealed branch is created fresh and inserted live.

Tests: parity-harness scenarios onsettled-write-show (bare),
onsettled-write-show-loading, effect-write-show and
onsettled-write-show-streamed (fails only in streamed mode without the
gate), plus a solid-level spec pinning hold-then-replay for the root pass.
Size caps: hydrating (no stores) 19,933 B and hydrating + stores 30,063 B
ratcheted to the measured output; 0 B in the signals floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Record combined measurements after rebasing the hydration claim fix over solidjs#3513.\n\n— GPT-5.6 Sol via Cursor

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid force-pushed the fix/hydrating-root-snapshot-scope-3504 branch from 4f865fc to 17c0a3b Compare September 17, 2026 16:22
@ryansolid
ryansolid merged commit 590ff3a into solidjs:next Sep 17, 2026
7 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