Skip to content

perf(web,signals): trim per-node work on the hydration claim path - #3513

Merged
ryansolid merged 2 commits into
nextfrom
perf/hydrate-claim-path
Sep 17, 2026
Merged

ryansolid merged 2 commits into
nextfrom
perf/hydrate-claim-path

Conversation

@ryansolid

Copy link
Copy Markdown
Member

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 on tabs), while React's hydrate is 25% cheaper than its mount. A Chromium CPU profile of tabs hydrate 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.

gatherHydratable

For every [_hk] element it called node.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. contains is inclusive, so a node that is itself a frame is still skipped, as closest (which starts at the node) did. The prefix-scoped gather (boundary resume) is unchanged.

claimInitial / reclaimRegion

Every hydrating insert() built its claim array as [...parent.childNodes] — the iterator protocol over a live NodeList — then ran stripTextSeparators over it as a second pass. claimChildNodes does one indexed pass that drops the separators as it copies; removing a <!--!$--> shifts the live list, so index and bound step back together. stripTextSeparators stays for the paths that hand insert() a prebuilt array.

clearSnapshots (@solidjs/signals)

At hydration end it ran delete source._x?._snapshotValue over every source captured during hydration. The extension is a fixed-shape object with _snapshotValue pre-initialized to undefined in ext(), and every reader tests !== undefineddelete pushed each extension into dictionary mode for every later read of every field. Assign undefined, which is what the STORE_SNAPSHOT_PROPS branch beside it already did, with a comment saying why.

Measured

tabs hydrate, same 6-page profile protocol, baseline (next at #3509) vs this branch:

baseline this PR
clearSnapshots 2.6% gone (<0.4%)
claimInitial + stripTextSeparators 5.1% claimChildNodes 2.6%
gatherHydratable 4.7% 3.9%
in-window total 108.3 ms 104.8 ms (−3.2%)

Wall-clock in the harness (4× throttle, 15 pages): tabs −3.5%, multifile-composition −7%, polymorphic-chain −5%, flat btn-variant unchanged — 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: the closest walk was only ~0.8 of gatherHydratable's 4.7 points. The remaining 3.9% is the registry itself — the [_hk] selector scan, getAttribute per node, Map.set hashing 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.

@changeset-bot

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 388ebd3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/signals Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch
solid-js Patch
@solidjs/universal Patch

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

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35192319861

Warning

No base build found for commit 3da9f43 on next.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 71.46%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 1018
Covered Lines: 772
Line Coverage: 75.83%
Relevant Branches: 790
Covered Branches: 520
Branch Coverage: 65.82%
Branches in Coverage %: Yes
Coverage Strength: 14.93 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 172 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing perf/hydrate-claim-path (388ebd3) with next (1e7ebe9)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

ryansolid and others added 2 commits September 17, 2026 02:03
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>
@ryansolid
ryansolid force-pushed the perf/hydrate-claim-path branch from 1fdc9f3 to 388ebd3 Compare September 17, 2026 09:10
@ryansolid
ryansolid merged commit f46ae70 into next Sep 17, 2026
7 checks passed
ryansolid added a commit to brenelz/solid that referenced this pull request Sep 17, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants