Skip to content

frames: announcement-gated slot/region scans; one TreeWalker pass for collectSlots - #3913

Merged
ryansolid merged 5 commits into
nextfrom
perf/frames-hydration-walk
Oct 8, 2026
Merged

ryansolid merged 5 commits into
nextfrom
perf/frames-hydration-walk

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Ruled 2026-10-08: the full walker variant (gate + TreeWalker by whatToShow, +333 B minified / +152 B brotli, 5.35 ms) — "perf is important enough — it's the point here". The over-cap frames and page caps are raised in this PR to CI's measured brotli + 10 B (see "Size").

Size-Exception: maintainer accepted +152 B br on frames eager (and the page scenarios it carries) for the announcement-gated slot/region scans and the one-pass TreeWalker collectSlots — "perf is important enough, it's the point here", 2026-10-08

Perf trade: collectSlots 14.23 → 5.35 ms on the HN twins' story page (−8.9 ms per adoption-time walk; the gate-only variant that fits the cap stops at 9.92 ms) for +333 B minified / +152 B brotli on the frames eager entry.

Merge order: #3916 (perf/gather-hydratable-once) is stacked on this branch (@ e9c233c81) — the source files do not conflict, but both PRs raise the same seven caps, so #3916's raises are cumulative over this PR's, measured by CI on the stack. Merge this PR first, then #3916 (if this PR is squash-merged, #3916 needs git rebase --onto origin/next perf/frames-hydration-walk perf/gather-hydratable-once first — verified to replay cleanly).

Finding

A profile of the HN twins' story page (/stories/30186326: 1,406 comments, 652 toggle occurrences, 654 frame elements, 11,055 elements, 16,897 comment nodes, no _s: position, sc:tiers = ["regions"]) put the frame runtime's adopt-time DOM walk at ≈ 15 ms. The twins' profiles pre-dated the tier mechanism; re-baselined on next @ 8d23a5a13 (Chromium, 100 µs sampling, unminified production build): collectSlots 14.23 ms inclusive — slotStartId's regex on every node 4.39, hasSlotMarker on every element with attributes 1.90, isTextStart on every node 1.79, afterMarker 0.65, isFrameElement 0.63. collectRegionElements 0.38 ms (already content-gated).

The walk tested every element and comment for binding-slot markers so an un-announced page could detect the bind tier — on a page whose server had already said, in sc:tiers, that it minted none. And it visited every node in JS, regex per node, to find 652 start markers among 16,897 comments.

Larger, out of scope: gatherHydratable runs one element.querySelectorAll('[_hk^="…"]') over the hydration root per occurrence — 37 ms on this page (36.9 of querySelectorAll's 39.4 ms self), more than twice the walk. Next target.

What is gated on which announcement

scan gate result on the story page
_s:* attribute markers, <!--_s:t=…--> text markers (and the bind tier's load from detection) the page MAY carry them: tierLoads.bind set (any announcement — X-Frame-Tiers, chunk.tiers, install's read of the record — or prior detection), or _$HY.r["sc:tiers"] names bind, or there is no record at all (un-announced fallback keeps detection) ["regions"] → never scanned, bind tier never loaded
elements shown to the walk at all same gate comments-only TreeWalker
collectRegionElements (regions tier's element walk) unchanged: per record, record.regions (needsRegions), inside a resident tier's resolve not run without a {$frame} record — pinned

The record is read at each walk, not snapshotted at install: the document face re-writes sc:tiers cumulatively at each mint (documentNeeds), so a later data script's name is seen by the boundary adopting after it (pinned). A page that announced OTHER tiers and not bind is trusted: a _s: marker on it is a producer out of step with its own announcement, not a client case.

collectSlots is one TreeWalker pass by whatToShow (comments; elements only when markers may exist); startsWith("slot:") before the regex; a range's interior skipped by stepping the walker to its end marker; a nested frame stepped over whole (elements shown) or excluded by a per-start ancestry test (comments only); a truncated range still abandons its sibling list (dev finding unchanged). Same slots, same order, same records. A filter callback was measured and rejected (one JS call per node: 11.7 ms vs 5.4).

Bench before/after

Twin page, Chromium, collectSlots inclusive, same build method:

variant min Δ (B) brotli (cap 11,130) collectSlots ms
next @ 8d23a5a13 0 11,112 14.23
gate only (recursive walk unchanged) +94 11,126 (4 B room) 9.92
gate + startsWith("slot:") before the regex +123 11,134 (+4 over) 8.08
gate + TreeWalker with filter callback +241 11,227 (+97 over) 11.72
gate + TreeWalker by whatToShow (this PR) +333 11,264 (+134 over) 5.35

jsdom bench (packages/web/test/frames-hydration-walk.bench.ts, 1,400 occurrences, ms mean, next → this PR): announced-no-bind 11.67 → 4.31; un-announced 11.57 → 9.38; bind announced with consumers 17.30 → 14.94. (jsdom's TreeWalker is JS; the browser numbers are the ones above.)

Size

pnpm size head vs next @ 8d23a5a13 (CI's size-head artifact for the base): non-SC scenarios 0; frames eager +333 B minified / +152 B brotli (11,264 vs cap 11,130); compiled SC pages +333 min / +115…+247 br, each over its cap by 97–223 B. The measured draft raised no cap: the pass's allowance was 20 B minified; the gate expression alone is ≈ 75 B minified, so no variant fits it. The gate-only variant stays under the brotli cap (CI's gate would pass it) at +94 min, 14.23 → 9.92 ms — described exactly in the design note §2/§4 as the fallback.

Caps raised in this PR (maintainer Size-Exception, 2026-10-08) — only the over-cap scenarios, each to CI's measured brotli + 10 B at the 0.01 KB step with the minified recorded from the same run (Size run 37756065705 against next @ 8d23a5a13), dated ledger lines in scenarios.js:

scenario CI brotli (base) cap recorded minified
frames: eager client consumer 11,264 (11,112) 11.13 → 11.28 KB 33,418 → 33,751
page: base SC (floor-caps.json) 34,047 (33,910) 33.92 → 34.06 KB 105,252 → 105,747
page: live SC (floor-caps.json) 37,759 (37,610) 37.59 → 37.77 KB 117,441 → 117,790
page: compiled base SC 35,323 (35,146) 35.13 → 35.34 KB 109,446 → 109,795
page: compiled live SC 40,819 (40,651) 40.66 → 40.83 KB 122,918 → 123,265
page: base + router 46,120 (45,996) 46.02 → 46.13 KB 144,223 → 144,572
page: live + router 47,460 (47,326) 47.29 → 47.47 KB 148,668 → 149,017

Design note (step 3)

documentation/plans/frames-hydration-walk.md: after 1–2 the walk is 5.35 ms of ≈ 72 ms non-idle (7%). A server-emitted per-frame slot index in _$HY.r — ids only (does not locate anchors; useless alone), child-index paths (≈ 8.5 KB raw / ≈ 1.2 KB br per page, removes the walk, fragile to any reveal-time node change), or delta-coded comment ordinals (≈ 50–100 B br, saves ≈ 2–3 of the 5.35 ms, same fragility at comment granularity plus a server-side comment counter) — not worth its wire or its second code path; the visible fraction is gatherHydratable.

Suites / harness

All on this branch's final source: frames-* + lifecycle matrix 31 files / 189 tests; vite.config.hydrate.mjs (hydration/* + consistency/*, incl. tier-prepare, tier-bind-hold, tier-regions-hold) 94 files / 466 passed, 16 expected fail, 2 skipped; consistency harness 500 cases × seeds 3289 / 91501: SC arm 0 findings, generic arm (CONSISTENCY_IGNORE=C1,C9,C19,E) 0 findings; test-types clean. New pins consistency/tier-bind-announced-gate.spec.tsx (3): announced-without-bind never scans / never loads / no hold, range slot mounts, hydration completes (fails against the ungated walk — mutation-checked); the late cumulative re-write naming bind is detected at the walk; announced bind only never loads regions through adoption. Recorder fixtures (welcome-status-streamed / write-before-resume / nav-before-resume) untouched.

Public API

None. collectSlots (module-private) changed signature (n, end, out, elements) → (root, out, elements); afterMarker folded into findMarker; dev-only devCheckRange → devReportRange. No export, prop, option, diagnostic code or wire format touched.

Open to the maintainer

  1. Which variant given the size line — this walker (+333 min; needs bytes found elsewhere), gate-only (+94 min, under the cap, 9.92 ms), or neither. Ruled 2026-10-08: this walker, caps raised under the Size-Exception above.
  2. Pages that mint no tier write no sc:tiers record and stay on the detection path (jsdom 9.4 vs 4.3 ms); an always-written record ([]) would be a ≈ 20 B wire change — not made.
  3. gatherHydratable's per-occurrence querySelectorAll (37 ms here) — the next pass.

… collectSlots

The adopt-time slot walk reads the server's tier announcement for the
SCAN, not only the load: a page whose `sc:tiers` record names other tiers
and not `bind` is never scanned for binding-slot markers (`_s:*`,
`<!--_s:t=…-->`) and never loads the bind tier for it; the un-announced
page (no record) keeps detection; a started load (`X-Frame-Tiers`,
`chunk.tiers`) is itself an announcement. The record is read at each walk,
so a later data script's cumulative re-write naming `bind` is seen by the
boundary adopting after it. Regions were already content-gated — pinned.

`collectSlots` is one TreeWalker pass by `whatToShow`: comments, plus
elements only when markers may exist; a comment's data is tested by prefix
before any regex; a range's interior is skipped by stepping the walker to
its end marker; a nested frame is stepped over whole (elements shown) or
excluded by a per-start ancestry test (comments only). Same slots, same
order, same records; a truncated range still abandons its sibling list.

HN story page (652 occurrences, 16.9k comments, Chromium): `collectSlots`
14.23 → 5.35 ms inclusive. Frames eager +333 B minified / +152 B brotli —
over the scenario's cap and the pass's 20 B allowance; measured variants
and the slot-index design note in documentation/plans/frames-hydration-walk.md.

Pins: consistency/tier-bind-announced-gate.spec (3); bench
test/frames-hydration-walk.bench.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ab2855f

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

This PR includes changesets to release 12 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
todos-server-example Patch
@solidjs/compiler Patch
@solidjs/signals 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

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.43 KB 0 B 0 B +15 B 7.45 KB ✅
signals: + createStore 14.70 KB 0 B 0 B +10 B 14.70 KB ✅
signals: + isPending/latest 9.63 KB 0 B 0 B +15 B 9.65 KB ✅
app: render + one signal (the simple-app floor) 9.92 KB 0 B 0 B +15 B 9.93 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB 0 B 0 B +15 B 17.91 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.15 KB 0 B 0 B +65 B 29.19 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.92 KB 0 B 0 B +15 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.53 KB 0 B 0 B +15 B 14.53 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.85 KB 0 B 0 B +15 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB 0 B 0 B +15 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.22 KB 0 B 0 B +65 B 25.24 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.21 KB 0 B 0 B +10 B 31.17 KB ⚠️ over by 43 B, 10 B minified headroom stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.26 KB +152 B (+1.4%) +333 B 0 B 11.28 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.05 KB +137 B (+0.4%) +334 B 0 B 34.06 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.21 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.76 KB +149 B (+0.4%) +334 B 0 B 37.77 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.32 KB +177 B (+0.5%) +334 B 0 B 35.34 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.82 KB +168 B (+0.4%) +332 B 0 B 40.83 KB ✅ eager (counted): web.js 22.02 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.21 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.39 KB +142 B (+0.3%) +334 B 0 B 41.40 KB ✅ assets.js 0.78 KB, bind.js 1.85 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.58 KB, trace.js 8.23 KB, wire.js 0.94 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.08 KB +165 B (+0.4%) +332 B 0 B 47.10 KB ✅ eager (counted): client.js 27.80 KB; assets.js 0.78 KB, bind.js 1.86 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.31 KB, trace.js 8.20 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 43 B; minified 99,682 B vs 99,672 B recorded with the cap (+10 B) — 10 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 8, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37807621441

Coverage remained the same at 76.43%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.56 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 13.66%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 187 untouched benchmarks
🆕 3 new benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 28 ms 32.4 ms -13.66%
🆕 announced, no bind N/A 51 ms N/A
🆕 bind announced, consumers N/A 168.9 ms N/A
🆕 un-announced N/A 120.1 ms N/A

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/frames-hydration-walk (ab2855f) with next (893834c)

Open in CodSpeed

ryansolid and others added 2 commits October 8, 2026 03:35
…ass TreeWalker collectSlots (maintainer Size-Exception, perf)

Only the seven over-cap scenarios, each to CI's measured brotli + 10 B at
the 0.01 KB step (Size run 37756065705 against next @ 8d23a5a) with the
minified recorded from the same run; dated ledger lines in scenarios.js.

  frames: eager client consumer   11.13 -> 11.28 KB  (33,418 -> 33,751)
  page: base SC (floor-caps.json) 33.92 -> 34.06 KB  (105,252 -> 105,747)
  page: live SC (floor-caps.json) 37.59 -> 37.77 KB  (117,441 -> 117,790)
  page: compiled base SC          35.13 -> 35.34 KB  (109,446 -> 109,795)
  page: compiled live SC          40.66 -> 40.83 KB  (122,918 -> 123,265)
  page: base + router             46.02 -> 46.13 KB  (144,223 -> 144,572)
  page: live + router             47.29 -> 47.47 KB  (148,668 -> 149,017)

Accepted by the maintainer 2026-10-08 ("perf is important enough — it's
the point here"): the full walker variant, collectSlots 14.23 -> 5.35 ms
on the HN twins' story page.

Co-authored-by: Cursor <cursoragent@cursor.com>
The Size workflow reads the PR body from the triggering event; the cap
raise was pushed before the body carried the exception line.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 8, 2026
…dex (maintainer Size-Exception, perf)

Stacked on #3913 (perf/frames-hydration-walk @ e9c233c): the caps are
cumulative over its raises. Only the seven over-cap scenarios, each to CI's
measured brotli + 10 B at the 0.01 KB step (Size run 37765150570, the stack
against next @ 8d23a5a) with the minified recorded from the same run;
dated ledger lines in scenarios.js.

  frames: eager client consumer   11.28 -> 11.39 KB  (33,751 -> 34,112)
  page: base SC (floor-caps.json) 34.06 -> 34.22 KB  (105,747 -> 106,107)
  page: live SC (floor-caps.json) 37.77 -> 37.94 KB  (117,790 -> 118,150)
  page: compiled base SC          35.34 -> 35.47 KB  (109,795 -> 110,155)
  page: compiled live SC          40.83 -> 40.95 KB  (123,265 -> 123,628)
  page: base + router             46.13 -> 46.30 KB  (144,572 -> 144,932)
  page: live + router             47.47 -> 47.57 KB  (149,017 -> 149,377)

Accepted by the maintainer 2026-10-08 ("perf is important enough — it's
the point here"): total hydration ≈ 77 -> ≈ 42 ms on the HN twins' story
page.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid marked this pull request as ready for review October 8, 2026 11:03
ryansolid and others added 2 commits October 8, 2026 09:02
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	scripts/size/scenarios.js
…37805628234)

The approved frames Size-Exception's bytes (+334/+332 B minified, same as
measured on the old base) re-measured on the router next.37 caps: base +
router 41.40 KB (129,509 B), live + router 47.10 KB (142,804 B), each at CI
brotli + 10 B rounded up to 0.01 KB.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit abe12fc into next Oct 8, 2026
7 of 8 checks passed
ryansolid added a commit that referenced this pull request Oct 8, 2026
#3913 is on next, so the stacked hydration gather rebases onto it.
Router size caps are #3913's post-#3909 measurements plus this PR's
previously measured +162/+96 B brotli and +360 B minified.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 8, 2026
…nce (#3916)

* frames: announcement-gated slot/region scans; one TreeWalker pass for collectSlots

The adopt-time slot walk reads the server's tier announcement for the
SCAN, not only the load: a page whose `sc:tiers` record names other tiers
and not `bind` is never scanned for binding-slot markers (`_s:*`,
`<!--_s:t=…-->`) and never loads the bind tier for it; the un-announced
page (no record) keeps detection; a started load (`X-Frame-Tiers`,
`chunk.tiers`) is itself an announcement. The record is read at each walk,
so a later data script's cumulative re-write naming `bind` is seen by the
boundary adopting after it. Regions were already content-gated — pinned.

`collectSlots` is one TreeWalker pass by `whatToShow`: comments, plus
elements only when markers may exist; a comment's data is tested by prefix
before any regex; a range's interior is skipped by stepping the walker to
its end marker; a nested frame is stepped over whole (elements shown) or
excluded by a per-start ancestry test (comments only). Same slots, same
order, same records; a truncated range still abandons its sibling list.

HN story page (652 occurrences, 16.9k comments, Chromium): `collectSlots`
14.23 → 5.35 ms inclusive. Frames eager +333 B minified / +152 B brotli —
over the scenario's cap and the pass's 20 B allowance; measured variants
and the slot-index design note in documentation/plans/frames-hydration-walk.md.

Pins: consistency/tier-bind-announced-gate.spec (3); bench
test/frames-hydration-walk.bench.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* size: raise frames/page caps for the announcement-gated scans + one-pass TreeWalker collectSlots (maintainer Size-Exception, perf)

Only the seven over-cap scenarios, each to CI's measured brotli + 10 B at
the 0.01 KB step (Size run 37756065705 against next @ 8d23a5a) with the
minified recorded from the same run; dated ledger lines in scenarios.js.

  frames: eager client consumer   11.13 -> 11.28 KB  (33,418 -> 33,751)
  page: base SC (floor-caps.json) 33.92 -> 34.06 KB  (105,252 -> 105,747)
  page: live SC (floor-caps.json) 37.59 -> 37.77 KB  (117,441 -> 117,790)
  page: compiled base SC          35.13 -> 35.34 KB  (109,446 -> 109,795)
  page: compiled live SC          40.66 -> 40.83 KB  (122,918 -> 123,265)
  page: base + router             46.02 -> 46.13 KB  (144,223 -> 144,572)
  page: live + router             47.29 -> 47.47 KB  (148,668 -> 149,017)

Accepted by the maintainer 2026-10-08 ("perf is important enough — it's
the point here"): the full walker variant, collectSlots 14.23 -> 5.35 ms
on the HN twins' story page.

Co-authored-by: Cursor <cursoragent@cursor.com>

* hydration: gather hydratable nodes once per adoption, not per occurrence

An adopted boundary's occurrences claim through the claim window
(`sharedConfig.hydrateWindow` → the scope's `gather(prefix)`), and the
gather was the root's: `element.querySelectorAll('[_hk^="<prefix>"]')`
over the whole hydration root, once per occurrence — on the HN story page
(652 toggles, 11k elements) 37–52 ms, more than the rest of its hydration.

The boundary now owns its gather (`claimScope`): one `[_hk]` pass over the
adopted element at adoption buckets every keyed node by its occurrence
prefix (the key up to its last dash — the child path after the prefix
never carries one), and a window's gather is a lookup: the bucket of the
window's id, narrowed to the keys under it (ids are prefix-closed), handed
to the registry as before — nodes the registry does not hold and that are
still in the document (a server `<Loading>`'s fallback and content share
an id; the swap removes the fallback before the content's claim). A
fragment revealed into the element extends the index for the revealed
parent (the adoption's `fr.subscribe`, before the re-sync mounts), so a
post-done window finds its nodes after the registry was cleared. The
captured scope still carries the adoption-time registry (#2917); a
streamed `<Loading>` inside a fill captures the indexed gather and resumes
from the same bucket. The page-level gather and the streamed boundary
resume path are unchanged.

Twin page (Chromium, unminified prod): `gatherHydratable` 42–52 → 0.8–1.0
ms inclusive, `hydrateWindow` 46–52 → 3.9–5.7 ms, the adoption
(`createFrame`) 62–68 → 30–34 ms. jsdom bench (1,400 occurrences): 6,574 →
283 ms per iteration; `_hk` selector calls per adoption 1,401 → 2.

Size: frames eager +362 B minified / +115 B brotli (over the scenario's
cap and the pass's 20 B allowance — not raised); hydrating scenarios 0.

Pins: consistency/claim-index.spec (3: scans do not grow with N; a
post-done reveal refreshes the parent only and claims; a node that left
the document is never gathered); bench test/frames-hydration-gather.bench.

Co-authored-by: Cursor <cursoragent@cursor.com>

* size: raise frames/page caps for the one-gather-per-adoption claim index (maintainer Size-Exception, perf)

Stacked on #3913 (perf/frames-hydration-walk @ e9c233c): the caps are
cumulative over its raises. Only the seven over-cap scenarios, each to CI's
measured brotli + 10 B at the 0.01 KB step (Size run 37765150570, the stack
against next @ 8d23a5a) with the minified recorded from the same run;
dated ledger lines in scenarios.js.

  frames: eager client consumer   11.28 -> 11.39 KB  (33,751 -> 34,112)
  page: base SC (floor-caps.json) 34.06 -> 34.22 KB  (105,747 -> 106,107)
  page: live SC (floor-caps.json) 37.77 -> 37.94 KB  (117,790 -> 118,150)
  page: compiled base SC          35.34 -> 35.47 KB  (109,795 -> 110,155)
  page: compiled live SC          40.83 -> 40.95 KB  (123,265 -> 123,628)
  page: base + router             46.13 -> 46.30 KB  (144,572 -> 144,932)
  page: live + router             47.47 -> 47.57 KB  (149,017 -> 149,377)

Accepted by the maintainer 2026-10-08 ("perf is important enough — it's
the point here"): total hydration ≈ 77 -> ≈ 42 ms on the HN twins' story
page.

Co-authored-by: Cursor <cursoragent@cursor.com>

* size: set #3916's live+router cap from CI

The carried +96 B brotli delta under-shot the router next.37 bundle.
Size run 37852899252 measured 47,281 B / 143,158 minified; cap at
measured + 10 B.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

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