Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/hydrating-root-snapshot-scope.md
Original file line number Diff line number Diff line change
@@ -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 `<Show>` 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.
86 changes: 83 additions & 3 deletions packages/solid/src/client/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
type StoreSetter,
type RevealOrder,
createOwner,
createRoot,
createRoot as coreRoot,
getContext,
setContext,
type Context
Expand Down Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1271,6 +1320,7 @@ function lazyHydrationLookup<T>(
}

export function enableHydration() {
_createRoot = hydratedCreateRoot;
_createMemo = hydratedCreateMemo;
_createSignal = hydratedCreateSignal;
_createErrorBoundary = hydratedCreateErrorBoundary;
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/solid/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ export {
affects,
createOwner,
createReaction,
createRoot,
createTrackedEffect,
deep,
flatten,
Expand Down Expand Up @@ -99,6 +98,7 @@ export type { ArrayElement, Element } from "./types.js";
export {
sharedConfig,
enableHydration,
createRoot,
createErrorBoundary,
createLoadingBoundary,
createRevealOrder,
Expand Down
105 changes: 105 additions & 0 deletions packages/solid/test/hydration-root-snapshot-scope-3504.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <Show> 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<string[]>([]);
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]);
});
});
6 changes: 6 additions & 0 deletions packages/web/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Show> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "effect-write-show",
"shell": "<main _hk=31>Hello</main>",
"rest": ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "onsettled-write-show-loading",
"shell": "<main _hk=30001>Hello</main>",
"rest": ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "onsettled-write-show-streamed",
"shell": "<template id=\"pl-30\"></template><main _hk=300>Loading…</main><!--pl-30--><script>(self.$R=self.$R||{})[\"\"]=[];_$HY.r[\"$B\"]=($R[0]={30000:$R[1]=($R[2]=($R[3]=() => {\n\tconst resolver = {\n\t\tp: 0,\n\t\ts: 0,\n\t\tf: 0\n\t};\n\tresolver.p = new Promise((resolve, reject) => {\n\t\tresolver.s = resolve;\n\t\tresolver.f = reject;\n\t});\n\treturn resolver;\n})()).p,\"30_fr\":$R[4]=($R[5]=$R[3]()).p});(b=>{for(var k in b)_$HY.r[k]=b[k];delete _$HY.r[\"$B\"]})(_$HY.r[\"$B\"]);</script>",
"rest": "<template id=\"30\"><main _hk=30002>Hello</main></template><script>($R[6]=(resolver, data) => {\n\tresolver.s(data);\n\tresolver.p.s = 1;\n\tresolver.p.v = data;\n})($R[2],\"Hello\");$df(\"30\");function $df(e){return _$HY.f?_$HY.f(e):$dfr(e)}function $dfr(e,n,o,t){if(!(n=document.getElementById(e)))return 0;if(!(o=document.getElementById(\"pl-\"+e)))return(_$HY.dq=_$HY.dq||{})[e]=1,0;for(;o&&(8!==o.nodeType||o.nodeValue!==\"pl-\"+e);)t=o.nextSibling,o.remove(),o=t;t=o.parentNode,o.replaceWith(n.content),n.remove(),(_$HY.v=_$HY.v||{})[e]=1,_$HY.fe(e,t),_$HY.hp&&_$HY.hp[e]&&($dh(_$HY.hp[e]),delete _$HY.hp[e]),$dfd();return 1}function $dfl(e,o,n){if(!(o=document.getElementById(\"pl-\"+e)))return(_$HY.dlq=_$HY.dlq||{})[e]=1,0;if(o._$fl)return 1;for(n=o.nextSibling;n;){if(8===n.nodeType&&n.nodeValue===\"pl-\"+e){o.parentNode&&o.parentNode.insertBefore(o.content.cloneNode(!0),n),o._$fl=1,$dfd();return 1}n=n.nextSibling}return 0}function $dflj(e,i){for(i=0;i<e.length;i++)$dfl(e[i])}function $dfd(e,i){if(e=_$HY.dq){_$HY.dq=0;for(i in e)$df(i)}if(e=_$HY.dlq){_$HY.dlq=0;for(i in e)$dfl(i)}}function $dfs(e,c,d){(_$HY.sc=_$HY.sc||{})[e]=c,d&&((_$HY.sd=_$HY.sd||{})[e]=1)}function $dfg(e,g,i,k){if(!(g=_$HY.sg&&_$HY.sg[e]))return;for(i=0;i<g.length;i++)if(_$HY.sc&&_$HY.sc[g[i]]>0)return;for(i=0;i<g.length;i++)k=g[i],delete _$HY.sg[k],$df(k)}function $dfc(e){if(--_$HY.sc[e]<=0){delete _$HY.sc[e],_$HY.sg&&_$HY.sg[e]?$dfg(e):!(_$HY.sd&&_$HY.sd[e])&&$df(e);_$HY.sd&&delete _$HY.sd[e]}}function $dfj(e,i,n){for(i=0;i<e.length;i++)if(_$HY.sc&&_$HY.sc[e[i]]>0){for(n=0;n<e.length;n++)(_$HY.sg=_$HY.sg||{})[e[n]]=e;return}for(i=0;i<e.length;i++)$df(e[i])};</script><script>$R[6]($R[5],!0);</script>"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "onsettled-write-show",
"shell": "<main _hk=31>Hello</main>",
"rest": ""
}
Loading
Loading