Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/hydration-late-claim-keeps-nodes-in-place.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

Hydration: a `<Loading>` around `lazy()` whose module is still loading when hydration starts no longer moves its server-rendered nodes once the module lands. The boundary claims those nodes on its late resume, but the enclosing insert had tracked its region as empty, so it re-inserted them, which moved connected nodes and blurred a focused input. While hydration is in progress, an insert whose region is untracked now leaves the incoming nodes in place when every one of them is already a child of its parent. Once hydration completes, inserts behave as before. Fixes #3749.
17 changes: 17 additions & 0 deletions packages/web/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2754,6 +2754,23 @@ function insertExpression(parent, value, current, marker) {
return value;
}
if (value === current) return value;
// A region tracked as empty can receive nodes that already sit in it:
// server nodes a boundary claims on a late resume, after this insert's
// claim pass saw no value (#3749). Re-inserting them would move connected
// nodes and blur a focused input, so they stay put. Only while hydrating
// (the resume window sets the flag): then claimed server nodes are the only
// nodes of ours already in `parent`, and they arrive in server order. Every
// item must be a node in `parent` (a raw primitive is a failed text claim
// and still needs inserting); an empty array keeps its clear.
if (
hydrationRt !== null &&
sharedConfig.hydrating &&
current == null &&
value &&
[].concat(value).every(n => n?.parentNode === parent) &&
value.length !== 0
)
return value;
const t = typeof value,
multi = marker !== undefined;

Expand Down
160 changes: 160 additions & 0 deletions packages/web/test/hydration/loading-lazy-resume-3749.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*
* #3749: a settled <Loading> whose lazy() module is still loading when
* hydration starts renders nothing in the root pass and claims its server
* nodes once the module lands. The insert holding the boundary's value sits
* outside the boundary, so it lands them as a client insert — and must not
* re-insert nodes that are already in place: moving a connected node blurs a
* focused input the user is typing in.
*/
import { afterEach, describe, expect, test } from "vitest";
import { createSignal, flush, isHydrating, lazy, Loading, type Component } from "solid-js";
import { hydrate } from "@solidjs/web";

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

let setCount!: (v: number) => void;
function FragmentPage() {
const [count, set] = createSignal(0);
setCount = set;
return (
<>
<header>Header</header>
<main>
<input aria-label="Type" />
<button>Count: {count()}</button>
</main>
</>
);
}
function ElementPage() {
const [count, set] = createSignal(0);
setCount = set;
return (
<main>
<input aria-label="Type" />
<button>Count: {count()}</button>
</main>
);
}

// renderToStream output for each tree below (the module resolved during the
// render, a manifest answering "/assets/Page.js"): the boundary's fragment is
// settled and its content inlined, with its module map under `1_assets`.
const FRAGMENT_HTML =
'<div _hk=0><header _hk=10000>Header</header><main _hk=10001><input aria-label="Type"><button>Count: <!--$-->0<!--/--></button></main></div>';
const ELEMENT_HTML =
'<div _hk=0><main _hk=10000><input aria-label="Type"><button>Count: <!--$-->0<!--/--></button></main></div>';

describe("#3749: Loading around lazy() keeps server nodes in place on a late module", () => {
let dispose: (() => void) | undefined;
let container: HTMLDivElement | undefined;
afterEach(async () => {
dispose?.();
dispose = undefined;
await sleep(0);
container?.remove();
});

async function hydrateWithLateModule(Page: Component, html: string) {
container = document.createElement("div");
document.body.appendChild(container);
container.innerHTML = html;
const server = [...container.firstChild!.childNodes];
const input = container.querySelector("input")!;

// The page module is still in flight when hydration starts.
let land!: () => void;
const fr: any = Promise.resolve(true);
fr.s = 1;
fr.v = true;
const hy: any = {
events: [],
completed: new WeakSet(),
r: { "1_assets": { "1000": "/assets/Page.js" }, "1_fr": fr },
fe() {},
modules: {},
loading: {}
};
hy.loading["1000"] = new Promise<void>(r => (land = r)).then(() => {
hy.modules["1000"] = { default: Page };
});
(globalThis as any)._$HY = hy;

// The user is typing before hydration.
input.focus();
input.value = "typed";

const LazyPage = lazy(() => Promise.resolve({ default: Page }), undefined, "src/Page.tsx");
const moved: Node[] = [];
const observer = new MutationObserver(records => {
for (const r of records) moved.push(...r.addedNodes, ...r.removedNodes);
});
observer.observe(container, { childList: true, subtree: true });

dispose = hydrate(
() => (
<div>
<Loading fallback="Loading...">
<LazyPage />
</Loading>
</div>
),
container
);
flush();
await sleep(10);
land();
await sleep(10);
flush();
await sleep(10);
observer.disconnect();

expect([...container.firstChild!.childNodes]).toEqual(server);
for (let i = 0; i < server.length; i++)
expect(container.firstChild!.childNodes[i]).toBe(server[i]);
expect(moved).toEqual([]);
expect(document.activeElement).toBe(input);
expect(input.value).toBe("typed");
}

test("a fragment page claims in place and keeps focus", async () => {
await hydrateWithLateModule(FragmentPage, FRAGMENT_HTML);
// Hydrated: the button's text hole is live.
setCount(1);
flush();
expect(container!.textContent).toBe("HeaderCount: 1");
});

test("a single-element page claims in place and keeps focus", async () => {
await hydrateWithLateModule(ElementPage, ELEMENT_HTML);
setCount(1);
flush();
expect(container!.textContent).toBe("Count: 1");
});

test("after hydration an untracked region re-inserts nodes already in its parent", async () => {
container = document.createElement("div");
document.body.appendChild(container);
container.innerHTML = "<div _hk=0></div>";
(globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} };
const a = document.createElement("b");
const b = document.createElement("i");
const [show, setShow] = createSignal(false);
dispose = hydrate(() => <div>{show() ? [a, b] : undefined}</div>, container);
flush();
await sleep(10);
expect(isHydrating()).toBe(false);

// User code put the nodes in the region, ahead of a node it doesn't track.
const host = container.firstChild as HTMLElement;
const x = document.createElement("span");
host.append(a, b, x);
setShow(true);
flush();
// As on `next`: the run is re-inserted at the end.
expect([...host.childNodes]).toEqual([x, a, b]);
});
});
2 changes: 1 addition & 1 deletion scripts/size/floor-caps.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"signals: core floor (createSignal/Memo/Effect/Root/flush)": "9.51 KB",
"app: render + one signal (the simple-app floor)": "12.05 KB",
"app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "19.69 KB",
"page: base server components (hydrating + dynamic + frames + sf reference)": "46.24 KB",
"page: base server components (hydrating + dynamic + frames + sf reference)": "46.25 KB",
"page: live server components (base + live/GET + action + isPending/latest)": "50.45 KB",
"server: floor (getRequestEvent + isServer)": "1.34 KB",
"server: renderToString (the server-render floor)": "20.38 KB"
Expand Down
12 changes: 10 additions & 2 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -3104,8 +3104,16 @@ module.exports = [
// two blocks swap places; no code is added), so a prop getter's
// hydration id lands where the server's attribute walk takes it. Brotli
// layout only: three equivalent encodings measured +59..+129 B here and
// -21..+13 B on the live page; this is the smallest. Pending maintainer
// review.
// -21..+13 B on the live page; this is the smallest. Accepted by the
// maintainer. The cap is frozen again at 46.24 KB.
// Size-Exception (#3749, 2026-10-02): 46.24 -> 46.25 KB, measured at
// 46,249 B against `next` @ 9e85a092d's 46,240 (+9 B; +92 B minified).
// While hydrating, an untracked region whose incoming nodes are already
// children of its parent leaves them in place: a <Loading> claiming its
// server nodes on a late lazy() resume no longer moves them (focus loss).
// Twelve equivalent encodings measured; this is the only one over by
// page base alone. Accepted by the maintainer. The cap is frozen again
// at 46.25 KB.
limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"],
alias: pageAlias
},
Expand Down
Loading