From 3154ed6bd6286a6c329990167f0f31f1c6c01602 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Thu, 17 Sep 2026 04:08:47 -0500 Subject: [PATCH 1/2] fix(solid,web): hydration-pass writes land as client renders (#3504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signal written from onSettled/createEffect during hydration revealed a 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 — '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 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 --- .changeset/hydrating-root-snapshot-scope.md | 6 + packages/solid/src/client/hydration.ts | 86 ++++++++++++- packages/solid/src/index.ts | 2 +- ...hydration-root-snapshot-scope-3504.spec.ts | 105 ++++++++++++++++ packages/web/src/client.ts | 6 + .../__artifacts__/effect-write-show.json | 5 + .../onsettled-write-show-loading.json | 5 + .../onsettled-write-show-streamed.json | 5 + .../__artifacts__/onsettled-write-show.json | 5 + packages/web/test/harness/scenarios.tsx | 114 ++++++++++++++++++ scripts/size/.size-limit.js | 11 +- 11 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 .changeset/hydrating-root-snapshot-scope.md create mode 100644 packages/solid/test/hydration-root-snapshot-scope-3504.spec.ts create mode 100644 packages/web/test/harness/__artifacts__/effect-write-show.json create mode 100644 packages/web/test/harness/__artifacts__/onsettled-write-show-loading.json create mode 100644 packages/web/test/harness/__artifacts__/onsettled-write-show-streamed.json create mode 100644 packages/web/test/harness/__artifacts__/onsettled-write-show.json diff --git a/.changeset/hydrating-root-snapshot-scope.md b/.changeset/hydrating-root-snapshot-scope.md new file mode 100644 index 000000000..aec91bed1 --- /dev/null +++ b/.changeset/hydrating-root-snapshot-scope.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +A signal written from `onSettled` or `createEffect` during hydration no longer strands the DOM it reveals (#3504). Two halves: a root created while hydrating marks the hydration snapshot scope itself, so a write during the root pass is held until the pass completes and then replays — previously the scope was marked lazily by the first hydration-aware primitive, so a `` condition created before that sat outside it and the write cascaded mid-claim. And a streamed boundary's resume window now claims only the subtree under that boundary: a write from the resumed content that reaches a signal above it (a route's `onSettled` adding a toast to a provider) re-renders that already-hydrated region as a client render — fresh nodes, live inserts — instead of claiming against the server registry, missing with a "Hydration key miss" warning, and rendering detached. diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 78f32ce6e..9a7cf0c42 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -37,7 +37,7 @@ import { type StoreSetter, type RevealOrder, createOwner, - createRoot, + createRoot as coreRoot, getContext, setContext, type Context @@ -172,6 +172,18 @@ type SharedConfig = { * @internal */ onHydrationEnd?: (callback: () => void) => void; + /** + * Whether a render under the current owner is part of the claim in + * progress. The root pass claims everything; a streamed boundary's resume + * window claims only the subtree under that boundary. A write landing in + * the window (the resumed content's `onSettled` reaching a signal above + * the boundary, #3504) re-renders an already-hydrated region: that render + * is a client render — fresh nodes, live inserts — not a claim against the + * registry. Assigned by enableHydration(); absent means "claiming". + * + * @internal + */ + isClaiming?: () => boolean; }; /** @@ -204,6 +216,19 @@ let _hydrationEndCallbacks: (() => void)[] | null = null; let _pendingBoundaries = 0; let _hydrationDone = false; let _snapshotRootOwner: Owner | null = null; +// The boundary owner whose resume window is open (null during a root pass, +// which claims everything). Reached as `sharedConfig.isClaiming`. +let _claimOwner: Owner | null = null; + +function isClaiming(): boolean { + if (!_claimOwner) return true; + let owner: Owner | null = getOwner(); + while (owner) { + if (owner === _claimOwner) return true; + owner = owner._parent; + } + return false; +} function markTopLevelSnapshotScope() { if (_snapshotRootOwner) return; @@ -267,6 +292,7 @@ let _doneValue = false; // (no hydrate() import), the hydrated* functions and their dependencies // (MockPromise, subFetch) are eliminated by the bundler. +let _createRoot: Function | undefined; let _createMemo: Function | undefined; let _createSignal: Function | undefined; let _createErrorBoundary: Function | undefined; @@ -899,7 +925,7 @@ export function materializeContainerTrace(marker: { // below): materialization runs at arg-read inside a reader's render // scope, and a version signal owned by that reader would be disposed by // its re-render while the memoized store lives on. - return createRoot(() => { + return coreRoot(() => { const [version, setVersion] = coreSignal(0); // Subscribe before creating the projection: the buffered replay runs // synchronously inside on(), filling the queue the first compute @@ -958,7 +984,7 @@ export function materializeContainerTrace(marker: { // surfaces as an unhandled error in dev. The root is never disposed — // the projection settles itself when the trace ends and is collected // with the store. - return createRoot(() => + return coreRoot(() => createProjection( (draft: any) => ({ [Symbol.asyncIterator]() { @@ -1168,6 +1194,29 @@ function hydrateStoreLike(coreFn: Function, fn: any, initialValue: any, options? return hydrateStoreLikeFn(coreFn, fn, initialValue, options, options?.ssrSource); } +// --- Hydration-aware root --- + +// A hydrating root is the snapshot scope from its first child on. The scope +// used to be marked lazily, by the first hydration-aware primitive created +// under it — so a control-flow memo made before that (Show's condition, on +// the core createMemo) sat outside it, and a user-tier write during the +// hydration pass (onSettled, createEffect) cascaded through it live: the +// branch it revealed claimed fresh templates against the registry, missed, +// and rendered detached (#3504). Marking at the root makes the scope +// creation-order independent: every write during the pass is held and +// replays at release, once the claim pass is over. +function hydratedCreateRoot(init: Function, options?: { id?: string; transparent?: boolean }) { + return coreRoot( + sharedConfig.hydrating + ? dispose => { + markTopLevelSnapshotScope(); + return init(dispose); + } + : (init as any), + options + ); +} + // --- Hydration-aware effect implementations --- function hydratedEffect(coreFn: Function, compute: any, effectFn: any, options?: any) { @@ -1271,6 +1320,7 @@ function lazyHydrationLookup( } export function enableHydration() { + _createRoot = hydratedCreateRoot; _createMemo = hydratedCreateMemo; _createSignal = hydratedCreateSignal; _createErrorBoundary = hydratedCreateErrorBoundary; @@ -1292,6 +1342,7 @@ export function enableHydration() { // the exact CSR behavior onHydrationEnd itself had. sharedConfig.isHydrationInProgress = isHydrationInProgress; sharedConfig.onHydrationEnd = onHydrationEnd; + sharedConfig.isClaiming = isClaiming; // Take ownership of streamed-fragment reveals (see the fragment ledger). // The header script creates `_$HY` before any module runs, so the hook is @@ -1759,6 +1810,27 @@ export const createOptimisticStore: { : (coreOptimisticStore as Function)(...args); }) as any; +/** + * Creates a non-tracked owner scope that doesn't auto-dispose. Pass `id` + * to seed hydration ids for the tree it owns. + * + * ```ts + * const dispose = createRoot(dispose => { + * // ... + * return dispose; + * }); + * ``` + * + * **Hydration:** a root created during hydration marks itself as the + * snapshot scope, so writes landing during the hydration pass are held + * until the pass completes (the mark used to depend on which primitive + * ran first under it). + * + * @description https://docs.solidjs.com/reference/reactive-utilities/create-root + */ +export const createRoot: typeof coreRoot = ((...args: any[]) => + (_createRoot || coreRoot)(...args)) as typeof coreRoot; + /** * Creates a reactive computation that runs during the render phase as * DOM elements are created and updated but not necessarily connected. @@ -1886,6 +1958,7 @@ function resumeBoundaryHydration( // synchronous resume window; without a capture the live globals apply. const prevRegistry = sharedConfig.registry; const prevGather = sharedConfig.gather; + const prevClaim = _claimOwner; if (scope) { sharedConfig.registry = scope.registry; sharedConfig.gather = scope.gather; @@ -1896,14 +1969,21 @@ function resumeBoundaryHydration( if (shouldHydrate) { markSnapshotScope(o); _snapshotRootOwner = o; + // The window claims this boundary's subtree only: the rest of the + // tree hydrated in the root pass, and a re-render it takes during the + // window (a write from the resumed content's user effects) is a + // client render (#3504). + _claimOwner = o; } set(); flush(); if (shouldHydrate) _snapshotRootOwner = null; _hydratingValue = false; + _claimOwner = prevClaim; if (shouldHydrate) releaseSnapshotScope(o); flush(); } finally { + _claimOwner = prevClaim; if (scope) { sharedConfig.registry = prevRegistry; sharedConfig.gather = prevGather; diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index bf4cc2d43..e7eafae2d 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -5,7 +5,6 @@ export { affects, createOwner, createReaction, - createRoot, createTrackedEffect, deep, flatten, @@ -99,6 +98,7 @@ export type { ArrayElement, Element } from "./types.js"; export { sharedConfig, enableHydration, + createRoot, createErrorBoundary, createLoadingBoundary, createRevealOrder, diff --git a/packages/solid/test/hydration-root-snapshot-scope-3504.spec.ts b/packages/solid/test/hydration-root-snapshot-scope-3504.spec.ts new file mode 100644 index 000000000..ad0da1eb1 --- /dev/null +++ b/packages/solid/test/hydration-root-snapshot-scope-3504.spec.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment jsdom + * + * #3504: a user-tier effect (onSettled / createEffect) fires during the + * hydration pass and writes a signal read by a control-flow memo created on + * the core `createMemo` (the way builds its condition). Nothing + * hydration-aware ran under the root before that memo, so the lazily marked + * snapshot scope did not cover it and the write cascaded live, mid-claim. + * A root created while hydrating now marks the scope itself: the write is + * held until hydration ends, then replays. + */ +import { describe, expect, test, afterEach } from "vitest"; +import { flush, onSettled, createMemo as coreMemo } from "@solidjs/signals"; +import { + enableHydration, + sharedConfig, + createRoot, + createRenderEffect, + createSignal, + createEffect +} from "../src/client/hydration.js"; + +enableHydration(); + +function startHydration() { + sharedConfig.hydrating = true; + (sharedConfig as any).has = () => false; + (sharedConfig as any).load = () => undefined; + (sharedConfig as any).gather = () => {}; +} + +function stopHydration() { + sharedConfig.hydrating = false; + (sharedConfig as any).has = undefined; + (sharedConfig as any).load = undefined; + (sharedConfig as any).gather = undefined; +} + +// A hydrating root whose only derived node is a core memo (no hydration-aware +// primitive marks the scope), with `write` fired from the user tier. +function mount(write: (set: () => void) => void) { + const seen: number[] = []; + createRoot( + () => { + const [toasts, setToasts] = createSignal([]); + const cond = coreMemo(() => toasts().length); + createRenderEffect( + () => cond(), + v => { + seen.push(v); + } + ); + write(() => setToasts(p => ["t", ...p])); + }, + { id: "t" } + ); + return seen; +} + +describe("#3504 hydrating root marks the snapshot scope before its first child", () => { + afterEach(() => stopHydration()); + + test("onSettled write during the hydration pass is held until release", () => { + startHydration(); + const seen = mount(set => { + onSettled(() => { + set(); + }); + }); + flush(); + // Held: the claim pass sees the server-rendered state only. + expect(seen).toEqual([0]); + stopHydration(); + flush(); + // Replayed once the pass is over. + expect(seen).toEqual([0, 1]); + }); + + test("createEffect write during the hydration pass is held until release", () => { + startHydration(); + const seen = mount(set => { + createEffect( + () => 1, + () => { + set(); + } + ); + }); + flush(); + expect(seen).toEqual([0]); + stopHydration(); + flush(); + expect(seen).toEqual([0, 1]); + }); + + test("outside hydration the root is a plain root: the write applies in the same flush", () => { + const seen = mount(set => { + onSettled(() => { + set(); + }); + }); + flush(); + expect(seen).toEqual([0, 1]); + }); +}); diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 053e6ee2a..f40b5b160 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -2327,6 +2327,12 @@ export function runHydrationEvents() { // Internal Functions function isHydrating(node) { if (!sharedConfig.hydrating) return false; + // A streamed boundary's resume window claims only the subtree under that + // boundary; the rest of the page hydrated in the root pass. A render the + // window forces outside it — the resumed content's onSettled writing a + // signal above the boundary, revealing a there (#3504) — is a + // client render: fresh nodes, live inserts, no registry lookup. + if (sharedConfig.isClaiming && !sharedConfig.isClaiming()) return false; if (!node || node.isConnected) return true; // Connectivity tells claimed SSR nodes apart from fresh template clones, // but a claimed tree isn't always IN the document: a frame adoption whose diff --git a/packages/web/test/harness/__artifacts__/effect-write-show.json b/packages/web/test/harness/__artifacts__/effect-write-show.json new file mode 100644 index 000000000..c948d263a --- /dev/null +++ b/packages/web/test/harness/__artifacts__/effect-write-show.json @@ -0,0 +1,5 @@ +{ + "name": "effect-write-show", + "shell": "
Hello
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/onsettled-write-show-loading.json b/packages/web/test/harness/__artifacts__/onsettled-write-show-loading.json new file mode 100644 index 000000000..41a254666 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/onsettled-write-show-loading.json @@ -0,0 +1,5 @@ +{ + "name": "onsettled-write-show-loading", + "shell": "
Hello
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/onsettled-write-show-streamed.json b/packages/web/test/harness/__artifacts__/onsettled-write-show-streamed.json new file mode 100644 index 000000000..b07c6990f --- /dev/null +++ b/packages/web/test/harness/__artifacts__/onsettled-write-show-streamed.json @@ -0,0 +1,5 @@ +{ + "name": "onsettled-write-show-streamed", + "shell": "
Loading…
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/onsettled-write-show.json b/packages/web/test/harness/__artifacts__/onsettled-write-show.json new file mode 100644 index 000000000..a17b23e41 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/onsettled-write-show.json @@ -0,0 +1,5 @@ +{ + "name": "onsettled-write-show", + "shell": "
Hello
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx index ef313e3eb..44e14d702 100644 --- a/packages/web/test/harness/scenarios.tsx +++ b/packages/web/test/harness/scenarios.tsx @@ -24,6 +24,8 @@ import { createSignal, createMemo, + createEffect, + onSettled, createProjection, createStore, Show, @@ -1836,6 +1838,89 @@ function polymorphicChainApp(form: keyof typeof forms) { }; } +// --------------------------------------------------------------------------- +// #3504: a user-tier effect (onSettled / createEffect) fires during +// hydration's synchronous pass and writes a signal that reveals a +// branch the server never rendered. The write lands after the claim pass, so +// the branch is created as fresh client DOM — not claimed against the +// registry (a key miss: a detached subtree plus a dev warning). The toast +// function reaches the page through a module slot; the issue used a context, +// which changes nothing about the write's timing. +let addToast: (t: string) => void = () => {}; +function ToasterProvider(props: { children: any }) { + const [toasts, setToasts] = createSignal([]); + addToast = t => setToasts(prev => [t, ...prev]); + return ( + <> + +
{toasts()[0]}
+
+ {props.children} + + ); +} +function SettledToastPage() { + onSettled(() => { + addToast("toast!"); + }); + return
Hello
; +} +function EffectToastPage() { + createEffect( + () => 1, + () => { + addToast("toast!"); + } + ); + return
Hello
; +} +function OnSettledWriteShow() { + return ( + + + + ); +} +function OnSettledWriteShowLoading() { + return ( + + Loading…}> + + + + ); +} +// Streamed shape: the page suspends on the server, so the shell carries the +// boundary fallback and the route content arrives as a late fragment. The +// route's onSettled then fires inside the boundary's resume window, after +// the root pass released its snapshot scope. +function StreamedToastPage() { + const data = createMemo(async () => { + await sleep(10); + return "Hello"; + }); + onSettled(() => { + addToast("toast!"); + }); + return
{data()}
; +} +function OnSettledWriteShowStreamed() { + return ( + + Loading…}> + + + + ); +} +function EffectWriteShow() { + return ( + + + + ); +} + export const scenarios: Scenario[] = [ { name: "polymorphic-chain", @@ -2525,5 +2610,34 @@ export const scenarios: Scenario[] = [ update: () => setStaticLabel("two"), expectedTextAfterUpdate: "twotwotwo", stableSelector: "div, a, i, b" + }, + { + name: "onsettled-write-show", + App: OnSettledWriteShow, + expectedText: "toast!Hello", + serverText: "Hello", + stableSelector: "main" + }, + { + name: "onsettled-write-show-loading", + App: OnSettledWriteShowLoading, + expectedText: "toast!Hello", + serverText: "Hello", + stableSelector: "main" + }, + { + name: "effect-write-show", + App: EffectWriteShow, + expectedText: "toast!Hello", + serverText: "Hello", + stableSelector: "main" + }, + { + name: "onsettled-write-show-streamed", + App: OnSettledWriteShowStreamed, + async: true, + expectedText: "toast!Hello", + serverText: "Hello", + stableSelector: "main" } ]; diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 4724d98a4..182e3c234 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -919,6 +919,12 @@ module.exports = [ // Hydration claim-path trim (#3513, 2026-09-17): measured at 19,931 B against // current `next`'s 19,849 (+82). One indexed childNodes claim pass replaces // iterator-copy + compaction; frame ancestry is queried once per root. + // Hydration writes land as client renders (#3504, 2026-09-17): 19.85 -> 19.95 KB, + // measured at 19,933 B (~+85 B). solid-js's createRoot gains the hydration + // slot indirection the other primitives have plus a hydrating body that + // marks the snapshot root; a resume window records its boundary owner and + // `sharedConfig.isClaiming` walks `_parent` to it; @solidjs/web's + // isHydrating consults it. 0 B in the signals floor. limit: "19.95 KB", modifyEsbuildConfig }, @@ -1109,7 +1115,10 @@ module.exports = [ // Hydration claim-path trim (#3513, 2026-09-17): measured at 30,041 B against // current `next`'s 29,985 (+56). Same one-pass claim/frame-query trade as the // no-store hydration scenario; the store engine itself is unchanged. - limit: "30.05 KB", + // Hydration writes land as client renders (#3504, 2026-09-17): 30.00 -> 30.10 KB, + // measured at 30,063 B (~+130 B); the solid-js createRoot / resume-window + // claim gate, see the hydrating (no stores) note. 0 B in the signals floor. + limit: "30.10 KB", modifyEsbuildConfig }, { From 17c0a3bffbb925860c52d7a7396c0616fdbf8a5a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 09:21:59 -0700 Subject: [PATCH 2/2] chore(size): ratchet rebased hydration caps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record combined measurements after rebasing the hydration claim fix over #3513.\n\n— GPT-5.6 Sol via Cursor Co-authored-by: Cursor --- scripts/size/.size-limit.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 182e3c234..bdab03972 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -919,13 +919,13 @@ module.exports = [ // Hydration claim-path trim (#3513, 2026-09-17): measured at 19,931 B against // current `next`'s 19,849 (+82). One indexed childNodes claim pass replaces // iterator-copy + compaction; frame ancestry is queried once per root. - // Hydration writes land as client renders (#3504, 2026-09-17): 19.85 -> 19.95 KB, - // measured at 19,933 B (~+85 B). solid-js's createRoot gains the hydration - // slot indirection the other primitives have plus a hydrating body that - // marks the snapshot root; a resume window records its boundary owner and - // `sharedConfig.isClaiming` walks `_parent` to it; @solidjs/web's - // isHydrating consults it. 0 B in the signals floor. - limit: "19.95 KB", + // Hydration writes land as client renders (#3504, 2026-09-17): 19.95 -> 20.05 KB, + // measured at 20,044 B rebased over #3513 (+113 B). solid-js's createRoot + // gains the hydration slot indirection the other primitives have plus a + // hydrating body that marks the snapshot root; a resume window records its + // boundary owner and `sharedConfig.isClaiming` walks `_parent` to it; + // @solidjs/web's isHydrating consults it. 0 B in the signals floor. + limit: "20.05 KB", modifyEsbuildConfig }, { @@ -1115,10 +1115,11 @@ module.exports = [ // Hydration claim-path trim (#3513, 2026-09-17): measured at 30,041 B against // current `next`'s 29,985 (+56). Same one-pass claim/frame-query trade as the // no-store hydration scenario; the store engine itself is unchanged. - // Hydration writes land as client renders (#3504, 2026-09-17): 30.00 -> 30.10 KB, - // measured at 30,063 B (~+130 B); the solid-js createRoot / resume-window - // claim gate, see the hydrating (no stores) note. 0 B in the signals floor. - limit: "30.10 KB", + // Hydration writes land as client renders (#3504, 2026-09-17): 30.05 -> 30.15 KB, + // measured at 30,107 B rebased over #3513 (+66 B); the solid-js createRoot / + // resume-window claim gate, see the hydrating (no stores) note. 0 B in the + // signals floor. + limit: "30.15 KB", modifyEsbuildConfig }, {