Repository navigation
frames: announcement-gated slot/region scans; one TreeWalker pass for collectSlots - #3913
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: ab2855f 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 37807621441Coverage remained the same at 76.43%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will degrade performance by 13.66%
|
| 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)
…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>
…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>
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>
…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>
Ruled 2026-10-08: the full walker variant (gate +
TreeWalkerbywhatToShow, +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
TreeWalkercollectSlots— "perf is important enough, it's the point here", 2026-10-08Perf trade:
collectSlots14.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 needsgit rebase --onto origin/next perf/frames-hydration-walk perf/gather-hydratable-oncefirst — verified to replay cleanly).Finding
A profile of the HN twins' story page (
/stories/30186326: 1,406 comments, 652toggleoccurrences, 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 onnext@8d23a5a13(Chromium, 100 µs sampling, unminified production build):collectSlots14.23 ms inclusive —slotStartId's regex on every node 4.39,hasSlotMarkeron every element with attributes 1.90,isTextStarton every node 1.79,afterMarker0.65,isFrameElement0.63.collectRegionElements0.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:
gatherHydratableruns oneelement.querySelectorAll('[_hk^="…"]')over the hydration root per occurrence — 37 ms on this page (36.9 ofquerySelectorAll's 39.4 ms self), more than twice the walk. Next target.What is gated on which announcement
_s:*attribute markers,<!--_s:t=…-->text markers (and the bind tier's load from detection)tierLoads.bindset (any announcement —X-Frame-Tiers,chunk.tiers, install's read of the record — or prior detection), or_$HY.r["sc:tiers"]namesbind, or there is no record at all (un-announced fallback keeps detection)["regions"]→ never scanned, bind tier never loadedTreeWalkercollectRegionElements(regions tier's element walk)record.regions(needsRegions), inside a resident tier'sresolve{$frame}record — pinnedThe record is read at each walk, not snapshotted at install: the document face re-writes
sc:tierscumulatively 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 notbindis trusted: a_s:marker on it is a producer out of step with its own announcement, not a client case.collectSlotsis oneTreeWalkerpass bywhatToShow(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,
collectSlotsinclusive, same build method:collectSlotsmsnext@8d23a5a13startsWith("slot:")before the regexwhatToShow(this PR)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 sizehead vsnext@8d23a5a13(CI'ssize-headartifact 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 inscenarios.js:floor-caps.json)floor-caps.json)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 isgatherHydratable.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-typesclean. New pinsconsistency/tier-bind-announced-gate.spec.tsx(3): announced-without-bindnever scans / never loads / no hold, range slot mounts, hydration completes (fails against the ungated walk — mutation-checked); the late cumulative re-write namingbindis detected at the walk; announcedbindonly never loadsregionsthrough 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);afterMarkerfolded intofindMarker; dev-onlydevCheckRange→devReportRange. No export, prop, option, diagnostic code or wire format touched.Open to the maintainer
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.sc:tiersrecord 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.gatherHydratable's per-occurrencequerySelectorAll(37 ms here) — the next pass.