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/hydrate-claim-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@solidjs/web": patch
"@solidjs/signals": patch
---

Trim per-node work on the hydration claim path. `gatherHydratable` asks once whether the root contains frame regions and tests containment against that list, instead of walking every keyed node's ancestor chain with `closest("[data-fid]")`; `insert()` builds a parent's claim array in one indexed pass over `childNodes` that drops separators as it copies, instead of an iterator spread followed by a compacting pass; and `clearSnapshots` assigns `undefined` to the extension's `_snapshotValue` rather than `delete`-ing it, which pushed every hydrated source's extension object into dictionary mode.
11 changes: 8 additions & 3 deletions packages/signals/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,15 @@ function releaseSubtree(owner: Owner): void {
export function clearSnapshots(): void {
if (snapshotSources) {
for (const source of snapshotSources) {
delete source._x?._snapshotValue;
// The extension is a fixed-shape object with `_snapshotValue`
// pre-initialized to undefined (see ext()), and every reader tests
// `!== undefined` — assign, don't `delete`: deleting a field pushes the
// object to dictionary mode for every later read of every field.
const x = source._x;
if (x != null) x._snapshotValue = undefined;
// StoreNode targets share one pre-initialized hidden class (see
// createStoreProxy) — assign undefined instead of deleting, and only
// when present so signal-node sources don't grow the field.
// createStoreProxy) — same rule, and only when present so signal-node
// sources don't grow the field.
if (source[STORE_SNAPSHOT_PROPS] !== undefined) source[STORE_SNAPSHOT_PROPS] = undefined;
}
snapshotSources = null;
Expand Down
88 changes: 66 additions & 22 deletions packages/web/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1118,8 +1118,8 @@ export function installHydrationRuntime() {
// hydration, dropping server text-hole separators.
claimInitial(parent, multi, initial) {
if (isHydrating(parent)) {
if (!multi && initial === undefined && parent) initial = [...parent.childNodes];
if (Array.isArray(initial)) stripTextSeparators(initial);
if (!multi && initial === undefined && parent) initial = claimChildNodes(parent);
else if (Array.isArray(initial)) stripTextSeparators(initial);
}
return initial;
},
Expand Down Expand Up @@ -1151,7 +1151,7 @@ export function installHydrationRuntime() {
nodes.unshift(node);
node = node.previousSibling;
}
} else nodes = [...parent.childNodes];
} else return claimChildNodes(parent);
return stripTextSeparators(nodes);
},
// eventHandler(): replayed server events are deduped against the live
Expand Down Expand Up @@ -1180,20 +1180,49 @@ function stripTextSeparators(nodes) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i],
t = node.nodeType;
if (t === 8) {
const v = node.nodeValue;
if (v === "!$") {
node.remove();
continue;
}
if (v.startsWith("pl-")) continue;
} else if (t === 1 && node.localName === "template" && node.id.startsWith("pl-")) continue;
if (t === 8 && node.nodeValue === "!$") {
node.remove();
continue;
}
if (isPlaceholderScaffolding(node, t)) continue;
nodes[j++] = node;
}
nodes.length = j;
return nodes;
}

// The claim array for a parent's children: one indexed pass over the live
// childNodes with the separators dropped as it copies. This runs for every
// insert() during hydration, so it avoids `[...parent.childNodes]` (the
// iterator protocol over a live NodeList) followed by a second compacting
// pass. Removing a `<!--!$-->` shifts the live list, so the index holds.
function claimChildNodes(parent) {
const live = parent.childNodes;
const out = [];
for (let i = 0, n = live.length; i < n; i++) {
const node = live[i],
t = node.nodeType;
if (t === 8 && node.nodeValue === "!$") {
node.remove();
i--;
n--;
continue;
}
if (isPlaceholderScaffolding(node, t)) continue;
out.push(node);
}
return out;
}

// A pending boundary's placeholder scaffolding — `<template id="pl-X">` and
// its `<!--pl-X-->` end marker — is excluded from claim arrays but kept in
// the DOM (see stripTextSeparators).
function isPlaceholderScaffolding(node, t) {
return t === 8
? node.nodeValue.startsWith("pl-")
: t === 1 && node.localName === "template" && node.id.startsWith("pl-");
}

/**
* Compiler-emitted primitive; not for hand-written code.
* @internal
Expand Down Expand Up @@ -2738,6 +2767,20 @@ function cleanChildren(parent, current, marker, replacement) {

function gatherHydratable(element, root) {
const templates = element.querySelectorAll(`*[_hk]`);
// The ambient sweep claims only what this hydration root itself walks.
// Frame regions ("data-fid" — the frame runtime's element brand, an
// importless duplicate like FRAME_ID_ATTR in frame-client/frame-sink)
// are another layer's property: their fills claim through scoped
// registries on their own schedule (a lazy route module may adopt long
// after this root completes), so collecting them here only sets up the
// completion sweep to report legitimately-late claims as unclaimed.
// Whether the root has frames is one question about the page, not one per
// keyed node: find them once and test containment against the list, rather
// than `closest("[data-fid]")` from every node — an ancestor walk to the
// document root for each element, paid in full on pages with no frames.
const frames = root ? null : element.querySelectorAll("[data-fid]");
const frameCount = frames ? frames.length : 0;
const registry = sharedConfig.registry;
for (let i = 0; i < templates.length; i++) {
const node = templates[i];
const key = node.getAttribute("_hk");
Expand All @@ -2747,18 +2790,19 @@ function gatherHydratable(element, root) {
// Keys are namespaced by their producer chain, so a nested frame's
// content can never match a foreign prefix.
if (!key.startsWith(root)) continue;
} else {
// The ambient sweep claims only what this hydration root itself walks.
// Frame regions ("data-fid" — the frame runtime's element brand, an
// importless duplicate like FRAME_ID_ATTR in frame-client/frame-sink)
// are another layer's property: their fills claim through scoped
// registries on their own schedule (a lazy route module may adopt long
// after this root completes), so collecting them here only sets up the
// completion sweep to report legitimately-late claims as unclaimed.
const frame = node.closest("[data-fid]");
if (frame && frame !== element && element.contains(frame)) continue;
} else if (frameCount !== 0) {
// `contains` is inclusive: a node that is itself a frame is skipped too,
// as `closest` (which starts at the node) did before.
let inFrame = false;
for (let j = 0; j < frameCount; j++) {
if (frames[j].contains(node)) {
inFrame = true;
break;
}
}
if (inFrame) continue;
}
if (!sharedConfig.registry.has(key)) sharedConfig.registry.set(key, node);
if (!registry.has(key)) registry.set(key, node);
}
} /** Hydration-walk primitive; not for hand-written code. @internal */
export function getHydrationKey(): string | undefined;
Expand Down
15 changes: 12 additions & 3 deletions scripts/size/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,10 @@ module.exports = [
// the O5 fix, measured at 16,493 B against `next`'s 16,481 (+12 brotli for
// `_companionChildren?.delete(n)` in unlinkFirewallChild). The 16.50 KB cap
// is unchanged; core floor and isPending/latest scenarios are unchanged.
limit: "16.50 KB",
// Hydration claim-path trim (#3513, 2026-09-17): measured at 16,517 B against
// current `next`'s 16,495 (+22). The fixed-shape `_snapshotValue` cleanup
// replaces `delete` with assignment; the pure core floor shrinks by 1 B.
limit: "16.55 KB",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -913,7 +916,10 @@ module.exports = [
// the core walks it on solid-js's server owners (`ownerPath`,
// `OBSERVE.exclude`). ~+40 B across the prod scenarios; the observe ones
// moved by gzip noise or shrank.
limit: "19.85 KB",
// 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",
modifyEsbuildConfig
},
{
Expand Down Expand Up @@ -1100,7 +1106,10 @@ module.exports = [
// A projection's leaf companions die with it; latest() of a dead leaf creates
// none (spec O5, 2026-09-16): 29,953 B (+43 over the cap); +104 B minified in
// owner.ts (core floor), the shadow retirement lives in verdict.ts.
limit: "30.00 KB",
// 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",
modifyEsbuildConfig
},
{
Expand Down
Loading