fix(solid,web): hydration-pass writes land as client renders (#3504) - #3507
Conversation
🦋 Changeset detectedLatest commit: 17c0a3b The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
Merging this PR will not alter performance
Comparing Footnotes
|
|
6226bea to
408cf92
Compare
|
Thanks — you were right, and the harness was missing the streamed shape. Rebased over current What the first push missed. In the real app the route is streamed behind the Fix. The resume records its boundary owner and exposes Repro in the harness. New parity scenario 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. |
408cf92 to
4f865fc
Compare
…#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>
4f865fc to
17c0a3b
Compare
Fixes #3504.
The bug
A signal written from
onSettled(or acreateEffectcallback) 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 corecreateMemo— sat outside it, so the write cascaded live, mid-claim.solid-js'screateRootnow 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;createRootfrom@solidjs/signalsis untouched.Resume window (the Router shape — thanks @ryansolid)
With the route streamed behind a
<Loading>boundary, the route'sonSettledfires 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'sisHydrating()consults it, so the revealed branch is created fresh and inserted live. Frames adoption and the root pass keep claiming everything, as before.Tests
onsettled-write-show(bare),onsettled-write-show-loading,effect-write-show, andonsettled-write-show-streamed(async: true; fails only in streamed mode without the claim gate — the write lands in the resume window there). All fail onnext.packages/solid/test/hydration-root-snapshot-scope-3504.spec.ts— hold-then-replay for the root pass, both writers, plus the non-hydrating control.next: signals, solid-js (incl.client-hydration.spec.ts), web client, web hydration. The web server suite's 12 failures in theownerPath-label specs are pre-existing here (stale local compiler binary).Size
Two caps ratcheted to the measured output (
scripts/size/.size-limit.jsnotes): hydrating (no stores) 19,933 B, hydrating + stores 30,063 B — about +85 / +130 B brotli from thecreateRootslot indirection, the hydrating root body, the resume-window owner walk, and the webisHydratingcheck; 0 B in the signals floor. The other caps are within their limits (CSR app has 4 B of headroom onnextalready).Not taken
Ending the claim pass before the user effect tier (a scheduler hook clearing
sharedConfig.hydratingbetween the render and user tiers, the 1.xrunUserEffectsprecedent) fixes the symptom but breaks 22client-hydration.spec.tscases and movesonHydrationEndinside the initial mount (the streamedportal-async-contentscenario then tripsASYNC_OUTSIDE_LOADING_BOUNDARY). The snapshot scope plus an explicit claim region is the smaller change.🤖 Generated with Claude Code