Repository navigation
hydration: gather hydratable nodes once per adoption, not per occurrence - #3916
Conversation
… 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 detectedLatest commit: f0b3054 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 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 |
Size (brotli, eager entry chunk)
|
Coverage Report for CI Build 37854887122Coverage remained the same at 76.452%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will improve performance by 5.71%
Performance Changes
Tip Curious why performance improved? Comment Comparing |
…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>
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>
186411e to
7ddeac8
Compare
…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>
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>
Ruled 2026-10-08: taken as built, with a Size-Exception for the frames eager and page cap overages — "perf is important enough — it's the point here". Follows #3913's profile of the HN twins' story page, which named this as the next target. The over-cap caps are raised in this PR to CI's measured brotli + 10 B (see "Size").
Size-Exception: maintainer accepted +111 B br on frames eager (and the page scenarios it carries) for the one-gather-per-adoption claim index — "perf is important enough, it's the point here", 2026-10-08
Perf trade: ≈ 77 → ≈ 42 ms total hydration on the HN twins' story page (
gatherHydratable42–52 → < 1 ms; the adoption 62–68 → 30–34 ms) for +362 B minified / +115 B brotli on the frames eager entry standalone againstnext(+361 / +111 measured on the stack over #3913).Merge order: this branch is stacked on #3913 (
perf/frames-hydration-walk@e9c233c81): the source files do not conflict (#3913 editsframe-client.ts, this PRframes/src/client.ts), but both raise the same seven caps, so this PR's raises are cumulative over #3913's, measured by CI on the stack. Merge #3913 first, then this PR. Until #3913 lands, this PR's diff againstnextshows #3913's commits too. If #3913 is squash-merged, GitHub will report a conflict here inscripts/size/scenarios.js/floor-caps.json(the shared ledger lines) untilgit rebase --onto origin/next perf/frames-hydration-walk perf/gather-hydratable-once— verified to replay this PR's two commits cleanly onto the squashednext, leaving exactly this PR's diff; with a merge commit or rebase-merge of #3913 it merges as is.Finding
On
next@8d23a5a13, the HN twins' story page (examples/hackernews,/stories/30186326: 652toggleoccurrences, 654 frame elements, 11,055 elements, 1,305_hknodes) spends more time gathering hydration keys than on the rest of its hydration. Each adopted occurrence's claim window (claimRender→sharedConfig.hydrateWindow→ the scope'sgather(prefix)) ran the root's gather —element.querySelectorAll('[_hk^="sc-<fid>-toggle#k-"]')over the whole hydration root — once per occurrence: 652 full-document attribute-prefix scans. This is the per-occurrence prefix gather #3840 introduced whenhydrateWindowreplaced the frames client's range walk (gatherClaims).Re-baselined here (Chromium via CDP
Profiler, 100 µs sampling, unminified production build, two runs):next@8d23a5a13, inclusivegatherHydratablequerySelectorAll(self)hydrateWindow(all 652 windows)createFrame, incl. windows)hydrate()(the root pass)jsdom bench (
packages/web/test/frames-hydration-gather.bench.tsx, #3913's fixture shape with records present and claiming fills, 1,400 occurrences, ~11k elements): 1,401querySelectorAllcalls with a_hkselector per adoption (the root's sweep + one per window), 6,399 ms in them per iteration, 6,574 ms per iteration total.Fix
One gather per adoption.
adoptBoundarybuilds the boundary's claim scope (claimScope(el)) at adoption: oneel.querySelectorAll("[_hk]")over the adopted element, every keyed node bucketed by its occurrence prefix. The scope'sgather(id)— whathydrateWindowcalls for the occurrence's window, and what a streamed<Loading>inside a fill captures (captureBoundaryScope) and resumes through — is a map lookup: the bucket of the window's id, narrowed to the keys under it, handed to the registry.sc-<fid>-<occurrence>-: the fid and the occurrence may carry dashes, the child path after the prefix never does (formatIdspells it in[0-9a-zA-Z]), so the prefix ends at the key's last dash. A window's id names its bucket by the same rule (an occurrence prefix is its own bucket; a boundary idsc-<fid>-<occ>-2is the occurrence's bucket narrowed bystartsWith— ids are prefix-closed, sostartsWithis containment, as the selector's^=was). A bare_hk(an event-slot consumer's replay stamp) lands in the empty bucket no id names.gatherputs into the registry each node under the id that the registry does not hold — a claimed key is put back exactly as the selector put it back (boundstill keeps a fill's second window from gathering, frames: residue 3 — every fill through insert; createFrame marker-less #3866) — and only nodes still in the document, which is what a live selector over the root saw. That last rule is load-bearing: a server<Loading>inside a fill renders its fallback under the same id as its content, so their keys collide by design; the swap removes the fallback before the content's claim gathers, and a removed node must not shadow the one that replaced it (pinned, arm (c) — fails without theisConnectedtest).hydrate()root replaced the live pair still claims against the frame's root (c01-claim-window-rootsgreen).oas claim owner, snapshot/live scope when none is open,claimRoots: untouched (hydrateWindowitself is not changed)._hkstill is not).Invalidation rule for late claims. The index is a snapshot of the element at adoption. The one way keyed nodes enter an adopted element afterwards is a fragment reveal (
$dfr→_$HY.fe(id, parent); a stream re-call ships bare marker pairs, an occluded region materializes from data and is never claimed). The adoption's existingfr.subscribecallback — the reveal-is-an-apply write — now extends the index withparent's[_hk]nodes first, beforedrainRecords()and theframe.applythat re-syncs and mounts what the fragment carried. Per range: the parent the swap announced, never the element or the root; nodes indexed twice dedupe at the gather by key. A reveal after hydration-done (a server<Loading>'s content, corollary 4) therefore finds the index where the root's registry was already cleared, and the late window claims from it (pinned, arm (b)).Not covered, noted in the code: a reveal group's fallback materialization (
$dfl) announces nothing, so a fill's keyed fallback that$dfljlands between the adoption and a held occurrence's window is not indexed (the content that replaces it is, at its reveal). Today's live selector would have seen it. Reveal-grouped server<Loading>inside a client fill inside an adopted server component, with the fallback gate firing in that window — I could not construct it in the harness vocabulary.The page-level gather (
gatherHydratable's*[_hk]sweep with frame exclusion) and the streamed boundary's resume path (resumeBoundaryHydration→ the root's[_hk^=…]gather, once per resume) are unchanged; only their comments were updated.solid-jsis untouched.Before / after
Twin page, same method, two runs each:
nextrun 1 / 2gatherHydratablequerySelectorAll(self, whole page)hydrateWindow(652 windows)claimScope(the index pass)gather(652 lookups, total)createFrame)hydrate()(root pass)(
hydrate()'s own span is the root pass; the adoption runs a microtask after it, so "total hydration" on this page is the two rows together: ≈ 77 → ≈ 42 ms.)jsdom bench, 1,400 occurrences: 6,574 → 283 ms per iteration;
_hkselector calls per adoption 1,401 → 2 (the root's sweep and the adoption's pass); time in them 6,399 → 11.8 ms.Size
node scripts/size/size.mjson this branch vs a fresh build ofnext@8d23a5a13, same harness:nextmin / brnext)The measured draft raised no cap. The pass's allowance was 20 B minified; the index is ≈ 370 B minified as written (a
Map, the bucket rule, the connected-only gather, the reveal refresh —scripts/size's Rolldown output). The maintainer's expectation was "selectors → a map", close to byte-neutral; it is not, because the[_hk^=…]selector path cannot be deleted (the streamed boundary's resume still needs it at page level) and a prefix-bucketed index has no cheaper shape than this. Measured and reported rather than offset elsewhere.Caps raised in this PR (maintainer Size-Exception, 2026-10-08) — only the over-cap scenarios, cumulative over #3913's raises (this branch is stacked on it), each to CI's measured brotli + 10 B at the 0.01 KB step with the minified recorded from the same run (Size run 37765150570, the stack against
next@8d23a5a13; "base" below is #3913's heade9c233c81), dated ledger lines inscenarios.js:next)floor-caps.json)floor-caps.json)The two PRs together: frames eager +263 B brotli / +694 B minified over
next.Suites / harness
vite.config.hydrate.mjs(hydration/*+consistency/*): 94 files / 466 passed / 16 expected fail / 2 skipped. Client (default config): 136 / 1,272 / 1 xfail. Server: 161 / 1,524 / 3 xfail / 2 skipped.test-typesclean.packages/solidhydrate-window.spec+client-hydration.spec: 145 passed (solid untouched).CONSISTENCY_IGNORE=C1,C9,C19,E) 0 / 0.welcome-status-streamed.jsonmoved under the run and was reverted;write-before-resume.json/nav-before-resume.jsonuntouched.test/consistency/claim-index.spec.tsx(3): (a) the number of_hkscans an adoption makes does not grow with the occurrence count (2 for N = 1 and N = 6; fails onnextwith 7), and every fill claims its server node; (b) a post-done reveal (registry already cleared) refreshes the index with exactly one scan, on the revealed parent inside the frame — never the root (fails onnext: the extra scan is on the hydration root) — and the revealed occurrence claims the revealed node; (c) a node the index holds that has since left the document is never gathered, the node that replaced it under the same key is, and the fill is live on it (fails with theisConnectedtest removed:fresh0vsfresh1).test/frames-hydration-gather.bench.tsx(CodSpeed, default config): the story-page fixture with records and claiming fills; reports_hkscans per adoption and the time in them.Public API
None.
hydrateWindow/gatherHydratablesignatures unchanged; theClaimScopeobject the frames client passes ashydrateWindow'sscopeis module-private and gains anindex(root)method the window never reads. No export, prop, option, diagnostic code or wire format touched.