From 549f48266fc5677972eb25e4ea86a2412d5bd40b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 16 Sep 2026 23:58:42 -0700 Subject: [PATCH 1/2] perf(web,signals): trim per-node work on the hydration claim path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium profiles of the yak-bench composition cases (`tabs`, `multifile- composition`, `polymorphic-chain`) showed Solid's hydrate slower than its own mount: claiming the server DOM cost more than building it. Three of the hydrate-only costs were per-node work for page-level questions. - `gatherHydratable` called `node.closest("[data-fid]")` for every `[_hk]` element to skip frame interiors — an ancestor walk to the document root per element, paid in full on pages with no frames. Whether the root has frames is asked once (`querySelectorAll("[data-fid]")`) and containment is tested against that list. `contains` is inclusive, so a node that is itself a frame is still skipped, as `closest` (which starts at the node) did. - `insert()` during hydration built the claim array as `[...parent.childNodes]` (the iterator protocol over a live NodeList) and then ran `stripTextSeparators` over it as a second pass. `claimChildNodes` does one indexed pass that drops separators as it copies; removing a `` shifts the live list, so the index and bound step back together. `reclaimRegion`'s parent-children path uses the same helper. - `clearSnapshots` ran `delete source._x?._snapshotValue` over every source captured during hydration. The extension is a fixed-shape object with the field pre-initialized to `undefined` and every reader tests `!== undefined`; `delete` pushed each one into dictionary mode for every later read of every field. Assign `undefined`, as the store branch beside it already did. `tabs` hydrate, 6-page profile: `clearSnapshots` 2.6% → gone, claim copy 5.1% → 2.6%, `gatherHydratable` 4.7% → 3.9%; ~3% of hydrate time on the case. The rest of `gatherHydratable` is the registry itself (selector scan, `getAttribute`, `Map.set` per key) and is not a local fix. Co-authored-by: Claude via Cursor --- .changeset/hydrate-claim-path.md | 6 +++ packages/signals/src/core/core.ts | 11 ++-- packages/web/src/client.ts | 88 +++++++++++++++++++++++-------- 3 files changed, 80 insertions(+), 25 deletions(-) create mode 100644 .changeset/hydrate-claim-path.md diff --git a/.changeset/hydrate-claim-path.md b/.changeset/hydrate-claim-path.md new file mode 100644 index 000000000..0d38a0f7c --- /dev/null +++ b/.changeset/hydrate-claim-path.md @@ -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. diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index b12470546..9e5f90e62 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -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; diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index c579b39ae..053e6ee2a 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -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; }, @@ -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 @@ -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 — `