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..bdab03972 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -919,7 +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. - 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 }, { @@ -1109,7 +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. - limit: "30.05 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 }, {