perf(web,signals): trim per-node work on the hydration claim path - #3513
Conversation
🦋 Changeset detectedLatest commit: 388ebd3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Coverage Report for CI Build 35192319861Warning No base build found for commit Coverage: 71.46%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
Merging this PR will not alter performance
Comparing Footnotes
|
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 <noreply@cursor.com>
Co-authored-by: GPT-5.6 Sol via Cursor <cursoragent@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1fdc9f3 to
388ebd3
Compare
Record combined measurements after rebasing the hydration claim fix over solidjs#3513.\n\n— GPT-5.6 Sol via Cursor Co-authored-by: Cursor <cursoragent@cursor.com>
Part of #3389 (hydration per-element overhead). Re-measuring the composition cases after #3509 showed hydrate still at 0.44–0.53× React on
tabs/multifile-composition/polymorphic-chain— and slower than Solid's own mount on all three (54.9 vs 47.6 ms ontabs), while React's hydrate is 25% cheaper than its mount. A Chromium CPU profile oftabshydrate vs mount (6 fresh pages each, sourcemap-attributed) put the hydrate-only cost at ~3.6 ms/page of claiming against ~1.5 ms of DOM writes saved. Three of those costs were per-node work for page-level questions; this PR takes them.gatherHydratableFor every
[_hk]element it callednode.closest("[data-fid]")+element.contains(frame)to skip frame interiors — an ancestor walk to the document root per element, paid in full on pages with no frames at all. Now:querySelectorAll("[data-fid]")once, containment tested against that list.containsis inclusive, so a node that is itself a frame is still skipped, asclosest(which starts at the node) did. The prefix-scoped gather (boundary resume) is unchanged.claimInitial/reclaimRegionEvery hydrating
insert()built its claim array as[...parent.childNodes]— the iterator protocol over a live NodeList — then ranstripTextSeparatorsover it as a second pass.claimChildNodesdoes one indexed pass that drops the separators as it copies; removing a<!--!$-->shifts the live list, so index and bound step back together.stripTextSeparatorsstays for the paths that handinsert()a prebuilt array.clearSnapshots(@solidjs/signals)At hydration end it ran
delete source._x?._snapshotValueover every source captured during hydration. The extension is a fixed-shape object with_snapshotValuepre-initialized toundefinedinext(), and every reader tests!== undefined—deletepushed each extension into dictionary mode for every later read of every field. Assignundefined, which is what theSTORE_SNAPSHOT_PROPSbranch beside it already did, with a comment saying why.Measured
tabshydrate, same 6-page profile protocol, baseline (nextat #3509) vs this branch:clearSnapshotsclaimInitial+stripTextSeparatorsclaimChildNodes2.6%gatherHydratableWall-clock in the harness (4× throttle, 15 pages):
tabs−3.5%,multifile-composition−7%,polymorphic-chain−5%, flatbtn-variantunchanged — inside the lane noise on this machine tonight, so the profile is the number to trust. Smaller than the 2.1 ms/page I'd estimated from the first profile: theclosestwalk was only ~0.8 ofgatherHydratable's 4.7 points. The remaining 3.9% is the registry itself — the[_hk]selector scan,getAttributeper node,Map.sethashing a fresh string per key — which is the floor of the registry design and not a local fix.Tests: web hydrate (185), DOM (825), server (1001), signals (2741), solid (622) all green. Harness artifacts unchanged.