From 61eff5b4fb805aeb962091149fb98df79225d2b1 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 02:20:18 -0700 Subject: [PATCH 1/5] frames: announcement-gated slot/region scans; one TreeWalker pass for collectSlots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:*`, ``) 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 Co-authored-by: Cursor --- .../frames-hydration-walk-gated-scan.md | 5 + documentation/plans/frames-hydration-walk.md | 150 ++++++++++++++ packages/web/frames/src/frame-client.ts | 169 ++++++++++----- .../tier-bind-announced-gate.spec.tsx | 193 ++++++++++++++++++ .../web/test/frames-hydration-walk.bench.ts | 111 ++++++++++ 5 files changed, 573 insertions(+), 55 deletions(-) create mode 100644 .changeset/frames-hydration-walk-gated-scan.md create mode 100644 documentation/plans/frames-hydration-walk.md create mode 100644 packages/web/test/consistency/tier-bind-announced-gate.spec.tsx create mode 100644 packages/web/test/frames-hydration-walk.bench.ts diff --git a/.changeset/frames-hydration-walk-gated-scan.md b/.changeset/frames-hydration-walk-gated-scan.md new file mode 100644 index 000000000..0b2264822 --- /dev/null +++ b/.changeset/frames-hydration-walk-gated-scan.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Frames client: 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:*`), and the bind tier is never loaded for it; an un-announced page keeps detection. `collectSlots` is one TreeWalker pass over comments (and elements only when markers may exist), testing a comment's data by prefix before any regex. Same slots found, in the same order; no public API change. Measured on the HN story page (652 occurrences, 17k comments): `collectSlots` 14.2 → 5.4 ms. diff --git a/documentation/plans/frames-hydration-walk.md b/documentation/plans/frames-hydration-walk.md new file mode 100644 index 000000000..51d813704 --- /dev/null +++ b/documentation/plans/frames-hydration-walk.md @@ -0,0 +1,150 @@ +# Frames hydration walk — the adopt-time slot scan, and a slot index that is not worth its wire (2026-10-08) + +Status: steps 1–2 built on `perf/frames-hydration-walk` (this branch, off +`next` @ `8d23a5a13`); step 3 is this note — a design only, no wire change. +Follows the savings pass ([`frames-savings-pass.md`](./frames-savings-pass.md) +§2: the server-announced tier mechanism) and reads its announcement for a +second purpose. + +## 1. The finding + +A profile of the HN twins' story page (`examples/hackernews`, +`/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 DOM walk at ≈ 15 ms of +the page's hydration — `collectSlots` and what it called per node +(`slotStartId`'s regex, `hasSlotMarker`, `isTextStart`, `isFrameElement`). +The twins' own profiles pre-dated the tier mechanism; re-baselined on `next` +@ `8d23a5a13` (Chromium, 100 µs sampling, an unminified production build): + +| `next` @ `8d23a5a13`, inclusive | ms | +| ----------------------------------------------- | ---------------: | +| `collectSlots` | **14.23** | +| of which `slotStartId` (regex per node) | 4.39 | +| `hasSlotMarker` (every element with attributes) | 1.90 | +| `isTextStart` (every node) | 1.79 | +| `afterMarker` / `isFrameElement` | 0.65 / 0.63 | +| `collectRegionElements` (regions tier) | 0.38 | +| `gatherHydratable` → `querySelectorAll` | **36.9 of 39.4** | +| non-idle self total | 85.18 | + +Two things the walk paid for nothing on this page: it tested every element +and every comment for binding-slot markers (`_s:*`, ``) so an +un-announced page could detect the bind tier — but the server had already +said, in `sc:tiers`, that it minted none; and it visited every node in JS +(text nodes included) and ran a regex on each to find 652 start markers +among 16,897 comments. + +The third row-group is the larger finding and is **out of this pass's +scope**: `gatherHydratable` runs one `element.querySelectorAll('[_hk^="…"]')` +over the hydration root per occurrence, 37 ms on this page — more than +twice the walk. It is the next target. + +## 2. What was built (steps 1–2) + +1. **The announcement gates the scan, not only the load.** `collectSlots` + looks for `_s:` markers only when the page may carry them: the bind + tier's load has started (any announcement — `X-Frame-Tiers`, + `chunk.tiers`, the install's read of the record — or the walk's own + detection), or `_$HY.r["sc:tiers"]` names `bind`, or there is no record + at all (a page that minted no tier, a sync render, an older producer — + the un-announced fallback keeps detection). Only a page that announced + OTHER tiers and not `bind` is trusted to carry no position. The record is + read at each walk, not snapshotted at install: the document face + re-writes it cumulatively at each mint (`documentNeeds`), so a later data + script's name is seen by the boundary adopting after it. Regions were + already content-gated (`needsRegions` per record; the tier's element walk + runs only inside a resident tier's `resolve`) — nothing to change; pinned. +2. **One TreeWalker pass.** `whatToShow` = comments, plus elements only when + markers are looked for; a comment's data is tested by prefix + (`startsWith("slot:")`) before any regex; a range's interior is skipped + by stepping the walker to its end marker; a nested frame element is + stepped over whole when elements are shown, and a comments-only walk + tests a slot start's ancestry instead (once per start, not per node). A + filter callback was measured and rejected: one JS call per node costs + more than the walk it saves (11.7 ms against 5.4). + +Measured on the same page and build (Chromium, `collectSlots` inclusive): + +| 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 room) | 9.92 | +| gate + `startsWith("slot:")` before the regex | +123 | 11,134 (+4 over) | 8.08 | +| gate + TreeWalker with a filter callback | +241 | 11,227 (+97 over) | 11.72 | +| **gate + TreeWalker by `whatToShow` (this branch)** | **+333** | **11,264 (+134 over)** | **5.35** | + +jsdom bench (`test/frames-hydration-walk.bench.ts`, 1,400 occurrences, ms +mean, `next` → this branch): 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, so its gain there is the gate's and +the prefix test's; the browser's is above.) + +**The size line.** The frames eager scenario's cap has 18 B brotli of +headroom on `next` and the pass's allowance was 20 B minified. No variant +fits: the gate expression alone (`tierLoads.bind || !(a = _$HY?.r?.["sc:tiers"]) || a.includes("bind")`) +is ≈ 75 B minified. The gate-only variant stays under the brotli cap (the +CI gate passes it) but is 4.7× the allowance; the walker variant on this +branch is over the cap. Measured and reported, per the pass's rule — the +decision is the maintainer's (§4). + +## 3. A server-emitted slot index — design, not built + +The question: after 1–2, is the walk still a visible fraction, and would a +per-frame index the server emits remove it? + +After 1–2 the walk is 5.35 ms of ≈ 72 ms non-idle on the story page (7%): +`nextNode` 1.14 ms over 16,897 comments, `findMarker` 0.51 ms (the sibling +scan to each of 652 end markers), the rest the per-comment prefix test and +the per-slot bookkeeping (`Map.set`, `slotEnd`). What an index could remove +is the per-comment work; what it cannot remove is the need for the start +comment NODE per slot — `found` maps id → node, and the mount anchors on it. + +Three shapes: + +- **(a) ids only** — `_$HY.r["sc:slots:"] = ["toggle#0", …]`. Tells the + client which occurrences exist, not where. Does not replace the walk + (anchors are still found by walking). Useless alone. +- **(b) child-index paths** — `[[2,1,0,k,5], …]` per slot from the frame + root. ≈ 13 B per slot JSON → ≈ 8.5 KB raw on this page, ≈ 1.2 KB brotli + (regular). Client: 652 `childNodes[i]` chains (Chromium caches sequential + index access; the per-`li` index is sequential). Removes the walk entirely + (≈ 5 ms → ≈ 0.3 ms est.). Fragile: any node inserted or removed before the + frame adopts — a deferred fragment's template swapped for its content + (`$df`), a placeholder reveal, a nested frame's own fills on the stream + face, an extension — shifts every path after it. The stream face has no + index (its markup is the client's own morph), so two code paths stay. +- **(c) comment ordinals, delta-coded** — `[12,12,12,…]`: the start marker's + ordinal among the frame's comment nodes. ≈ 2 KB raw, ≈ 50–100 B brotli + (one repeated delta). Client: a comments-only `TreeWalker` stepping + `nextNode()` 16,897 times with no data read except at the 652 landings + (regex there for the id). Saves the prefix test and the `findMarker` + scans: ≈ 2–3 ms of the 5.35. Same fragility as (b) at comment granularity + (a `$df` reveal adds `` pairs), and the server must count every + comment it writes inside the frame, nested frames included. + +Verdict: **not worth its wire or its second code path at this cost.** (c) +buys ≈ 2–3 ms per 1.4k-comment page for a new record, a server-side comment +counter on the render path and an invariant (comment ordinals stable from +emit to adopt) the reveal machinery does not keep today; (b) buys 5 ms for +≈ 1.2 KB brotli per page and the same invariant at node granularity. The +remaining 5 ms are mostly the platform's own `nextNode` and the per-slot +bookkeeping a record cannot remove; the 37 ms in `gatherHydratable` is the +fraction that is visible. Revisit if a page shape appears where comments +outnumber slots by far more than 26:1 — the index's saving scales with the +comments the walk skips, its wire with the slots. + +## 4. Open to the maintainer + +1. **Which variant, given the size line.** The walker (this branch, +333 + min / +152 br, best time) needs bytes found elsewhere in the frames eager + bundle — no cap is raised here; the gate-only variant (+94 min / +14 br, + under the cap, 14.23 → 9.92 ms) passes the CI gate as it stands but not + the 20 B allowance; or neither. The pins and bench apply to any of them. +2. **Pages that mint no tier write no `sc:tiers` record** and so stay on the + detection path (jsdom: 9.4 ms against 4.3 for the announced cell). An + always-written record (`[]` when nothing was minted) would move them to + the comments-only walk for ≈ 20 B of wire per page — a wire change, not + made here. +3. **`gatherHydratable`**: the per-occurrence `querySelectorAll` over the + hydration root (37 ms on this page) — the next pass. diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 603451499..6b7f7a47f 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -15,7 +15,7 @@ */ // The module's one import, and only for the dev-tier integrity check -// (`devCheckRange`): the diagnostics channel and its console face. `solid-js` +// (`devReportRange`): the diagnostics channel and its console face. `solid-js` // is external to every frames client bundle, so this reaches the same // `OBSERVE` the rest of the page runs on — no cross-bundle seam to keep in // agreement, unlike the registered-symbol brands this module otherwise @@ -1612,7 +1612,7 @@ class FrameImpl { // positions rather than filling a range (no interior, no regions, never // replaced), and whose consumer set may change without a re-call. const found: Map & { b?: boolean } = new Map(); - if (root) collectSlots(root.firstChild, null, found, found); + if (root) collectSlots(root, found, found); else this.#collectSlots(found, found); // Whether this sync leaves an occurrence WAITING to mount — for its // record, for a `{$ref}`'s data: a claim the frame owes the page and has @@ -2016,7 +2016,7 @@ class FrameImpl { /** Collect this frame's own top-level slot ranges (bounded to its content), * and — for the slot sync — its binding-slot elements into the same map. */ #collectSlots(found, elements) { - collectSlots(this.#element.firstChild, null, found, elements); + collectSlots(this.#element, found, elements); } /** The `pl-` template in `root` (a segment's content being @@ -2595,44 +2595,109 @@ function findPlaceholder(n, end, id) { } /** - * Collect slot ranges (`slot::start`) among the siblings `[n, end)` into - * `out`, keyed by slot id. Descends through server-owned elements but never - * into a range's interior or a nested frame/region element — those are - * child-owned (the child discovers, with callbacks and records threaded - * down), so slots belonging to nested frames / client content are ignored. + * Collect slot ranges (`slot::start`) in `root`'s subtree into `out`, + * keyed by slot id. Descends through server-owned elements but never into a + * range's interior or a nested frame/region element — those are child-owned + * (the child discovers, with callbacks and records threaded down), so slots + * belonging to nested frames / client content are ignored. + * + * ONE pass per walk, as a TreeWalker over the node kinds the pass needs + * (`whatToShow` — no filter callback: one costs a JS call per node, more + * than the walk it was to save; measured on the HN story page, 11.7 ms + * against 6.1): the platform steps over text nodes natively — and over + * elements too when nothing asks for them — and a comment's data is tested + * by prefix before any regex runs, so the marker vocabulary a page's + * comments mostly ARE (`$`/`/` hole pairs, `lh:` live holes) never reaches + * one. A range's interior is skipped by stepping the walker to its end + * marker (a sibling scan from the start); the end marker missing is the + * truncated range (dev reports it) and the rest of that sibling list is + * abandoned, as before. A nested frame element is stepped over whole when + * elements are shown; a comments-only walk sees its comments and tests a + * slot start's ancestry instead — once per start marker, not per node. + * + * Binding-slot markers (`_s:*`) are looked for when the caller wants them + * — the slot sync does (`elements`); the morph's range index does not (an + * element is reconciled as an element, not relocated as a protected range) + * — AND the page may carry them (`bind`). The server knows at render time + * whether it minted a binding-slot position and announces the `bind` tier + * where it did (frames savings pass §2 — the announcement read for the + * SCAN, not only the load): the document's `sc:tiers` record + * (frame-sink.ts `documentNeeds`, re-written cumulatively — read HERE, at + * each walk, not snapshotted at install, so a name a later data script + * added is seen by the boundary it was added for); a stream's + * `X-Frame-Tiers` head or in-band `chunk.tiers`, both of which start the + * tier's load before the chunk that carries the markers applies + * (`tierLoads.bind` is set from then on, by an announcement or by the + * walk's own detection). So: the load started, or the record names the + * tier, or the page announced NOTHING (no record: a page that minted no + * tier, a sync render, an older producer) — detection stays, as the + * un-announced fallback. Only a page that announced OTHER tiers and not + * this one is trusted to carry no position: its walk sees comments alone + * (a `_s:` marker it meets anyway is a producer out of step with its own + * announcement, not a client-side case). Only then are elements shown to + * the walk at all. + * + * The BIND TIER parses the markers: a text position's start marker joins + * its parent element's consumer entry (`text`); an element's positions + * join its occurrence's consumer list, in document order (`positions`). + * With the tier absent, the walk only NOTES that a marker was met + * (`elements.b`) — the sync holds on the note and the install's flush + * re-walks. A text pair's interior (one text node, the end marker) and the + * element's interior are walked like any server content: they may hold + * further occurrences of either kind. */ -function collectSlots(n, end, out, elements) { - while (n && n !== end) { - const id = slotStartId(n); - if (id !== null) { - if ("_SOLID_DEV_") devCheckRange(n, id); +function collectSlots(root, out, elements) { + let announced; + // `elements` is the found map or undefined; `tierLoads.bind` a promise or undefined. + const bind = + elements && + (tierLoads.bind || + !(announced = (globalThis as any)._$HY?.r?.["sc:tiers"]) || + announced.includes("bind")); + const B = bind && tierLoads.bind?.r; + // `NodeFilter.SHOW_COMMENT`, `| SHOW_ELEMENT` — as literals (the same on + // every platform). + const w = root.ownerDocument.createTreeWalker(root, bind ? 0x81 : 0x80); + let n, p; + while ((n = w.nextNode())) { + if (n.nodeType !== COMMENT_NODE) { + // An element (shown only when markers are looked for): a nested frame + // is stepped over whole (below); any other with attributes is parsed + // or noted. + if (!n.hasAttribute(FRAME_ID_ATTR)) { + if (n.hasAttributes()) + B ? B.positions(n, elements) : hasSlotMarker(n) && (elements.b = true); + continue; + } + p = n; + } else if (n.data.startsWith("slot:")) { + const id = slotStartId(n); + if (!id) continue; + if (!bind) { + // Comments-only: a start inside a nested frame element is its own. + for (p = n.parentNode; p !== root; p = p.parentNode) + if (p.hasAttribute(FRAME_ID_ATTR)) break; + if (p !== root) continue; + } if (!out.has(id)) out.set(id, n); - n = afterRange(n, id); - continue; - } - // Binding-slot markers (`_s:*`), when the caller wants them — the slot - // sync does; the morph's range index does not (an element is reconciled - // as an element, not relocated as a protected range). The BIND TIER - // parses them: a text position's start marker joins its parent - // element's consumer entry (`text`); an element's positions join its - // occurrence's consumer list, in document order (`positions`). With - // the tier absent, the walk only NOTES that a marker was met - // (`elements.b`) — the sync holds on the note and the install's flush - // re-walks. A text pair's interior (one text node, the end marker) and - // the element's interior are walked like any server content: they may - // hold further occurrences of either kind. - if (elements !== undefined && isTextStart(n)) { - const B = tierLoads.bind?.r; - B ? B.text(n, elements) : (elements.b = true); - } - if (n.nodeType === ELEMENT_NODE && !isFrameElement(n)) { - if (elements !== undefined && n.hasAttributes()) { - const B = tierLoads.bind?.r; - B ? B.positions(n, elements) : hasSlotMarker(n) && (elements.b = true); + const end = findMarker(n, slotEnd(id)); + if (end) { + w.currentNode = end; + continue; } - collectSlots(n.firstChild, null, out, elements); + if ("_SOLID_DEV_") devReportRange(id); + // Truncated: abandon the rest of this sibling list (below). + p = n.parentNode; + if (p === root) return; + } else { + if (bind && n.data.startsWith(SLOT_TEXT)) B ? B.text(n, elements) : (elements.b = true); + continue; } - n = n.nextSibling; + // Step over `p`'s subtree: from its deepest last descendant, the + // walker's `nextNode` climbs out (a `nextSibling` call would stop at + // an accepted parent). + while (p.lastChild) p = p.lastChild; + w.currentNode = p; } } @@ -2813,16 +2878,16 @@ function morphNode(oldNode, newNode, claim, ranges, grafts) { } /** The sibling immediately after the `slot::end` marker for `start`. */ -const afterRange = (start, id) => afterMarker(start, slotEnd(id)); +const afterRange = (start, id) => findMarker(start, slotEnd(id))?.nextSibling; /** The sibling after a text position's end marker. */ -const afterText = start => afterMarker(start, SLOT_TEXT_END); +const afterText = start => findMarker(start, SLOT_TEXT_END)?.nextSibling; -/** The sibling immediately after the first `end` comment following `start` - * (null if the range is truncated). */ -function afterMarker(start, end) { +/** The first `end` comment among the siblings after `start` (null if the + * range is truncated). */ +function findMarker(start, end) { let n = start.nextSibling; while (n) { - if (n.nodeType === COMMENT_NODE && n.data === end) return n.nextSibling; + if (n.nodeType === COMMENT_NODE && n.data === end) return n; n = n.nextSibling; } return null; @@ -2875,21 +2940,15 @@ function devSlotOrphan(frame, occurrence, consumers, why) { } /** - * Dev-only range integrity check: a slot start marker whose end marker is not - * a later sibling means the range was corrupted between the producer and - * here. `afterRange` returning null is ambiguous (an end marker that IS the - * last sibling also has no `nextSibling`), so this re-scans for the marker - * itself and reports the two known corruption causes loudly instead of - * letting collection silently truncate at the broken range. + * Dev-only range integrity finding: a slot start marker whose end marker is + * not a later sibling (`collectSlots` found none) means the range was + * corrupted between the producer and here. Reports the two known corruption + * causes loudly instead of letting collection silently truncate at the + * broken range. */ -function devCheckRange(start, id) { +function devReportRange(id) { if (!"_SOLID_DEV_") return; const end = slotEnd(id); - let n = start.nextSibling; - while (n) { - if (n.nodeType === COMMENT_NODE && n.data === end) return; - n = n.nextSibling; - } // A finding on the one channel (`FRAME_MARKER_CORRUPTED`) and its console // face — the same code the server table reserves for the frames pair, so a // consumer sees the client-detected corruption beside the server's diff --git a/packages/web/test/consistency/tier-bind-announced-gate.spec.tsx b/packages/web/test/consistency/tier-bind-announced-gate.spec.tsx new file mode 100644 index 000000000..49738c4b9 --- /dev/null +++ b/packages/web/test/consistency/tier-bind-announced-gate.spec.tsx @@ -0,0 +1,193 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * The announcement as the SCAN's gate, not only the load's (frames savings + * pass §2; the hydration-walk pass). The adopt-time slot walk used to test + * every element and every comment of a frame's interior for binding-slot + * markers (`_s:*`, ``) so an un-announced page could detect + * the bind tier it needs. The server knows at render time whether it + * minted a binding-slot position and says so in `_$HY.r["sc:tiers"]`, so a + * page that announced OTHER tiers and not `bind` is trusted: its walk is + * comments-only (`collectSlots` / `mayBind`, frame-client.ts) and never + * starts the bind tier's load. The three faces pinned here: + * + * - Announced WITHOUT `bind` (`["regions"]`, the HN story page's record): + * a `_s:` interior is not scanned — no load, no hold, hydration-done is + * not delayed; the frame's range slots mount as before. (A marker on + * such a page is a producer out of step with its own announcement, not + * a client-side case: the elements sit inert, as an orphan would.) + * - The record is read at EACH walk, not snapshotted at install: a later + * data script's cumulative re-write that adds `bind` is seen by the + * boundary that adopts after it — detection starts the load and holds, + * exactly as tier-bind-hold's un-announced arm. + * - The regions tier stays content-gated: a page announcing `bind` only, + * whose records name no `{$frame}`, never loads `regions` through + * adoption (`needsRegions` is a per-record test; the tier's element + * walk runs only inside a resident tier's `resolve`). + * + * The un-announced fallback (no record at all → detection) is + * tier-bind-hold's first test; the announced-with-`bind` face its last. + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { untrack } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { tierLoads } from "../../frames/src/frame-client.js"; +import { + bootPage, + fillHtml, + frameHtml, + freshFid, + hydrationInProgress, + quiesce, + slotRange, + type Page +} from "./support.js"; + +/** A tier's load, gated by the test; `release(module)` lands it. */ +function gated(name: string) { + delete (tierLoads as any)[name]; + let resolve!: (m: any) => void; + const loader = vi.fn(() => new Promise(r => (resolve = r))); + return { loader, release: (m: any) => resolve(m) }; +} + +/** The document face of a data occurrence's consumer (as tier-bind-hold's row). */ +const rowHtml = + `
  • ` + + `a` + + `
  • `; + +type Row = { id: string; completed: boolean; title: string }; + +let page: Page | undefined; +const disposers: (() => void)[] = []; +afterEach(async () => { + for (const d of disposers.splice(0)) d(); + await page?.cleanup(); + page = undefined; + vi.unstubAllGlobals(); + delete (globalThis as any)._$SC; + delete (tierLoads as any).bind; + delete (tierLoads as any).regions; + document.body.innerHTML = ""; +}); + +/** + * Boot a frame carrying BOTH a data occurrence's consumer and a range slot, + * with the given announcement and gated loaders for `bind` and `regions`. + */ +function boot(announce: string[] | undefined) { + const bind = gated("bind"); + const regions = gated("regions"); + const fid = freshFid("tier-gate"); + page = bootPage( + frameHtml(fid, `
      ${rowHtml}${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `), + { + tiers: { bind: bind.loader, regions: regions.loader }, + records: announce ? { "sc:tiers": announce } : undefined + } + ); + page.slotRecord(fid, "row#1", { id: "1", completed: false, title: "a" }); + page.slotRecord(fid, "item#0", { text: "one" }); + const li = page.container.querySelector("li.todo")!; + const button = page.container.querySelector("button")!; + const mounted: string[] = []; + const items: string[] = []; + const clicks: string[] = []; + const start = () => { + const Comp = (globalThis as any)._$SC.r(fid); + const dispose = hydrate( + () => ( + { + items.push(untrack(() => p.text)); + return
  • {p.text}
  • ; + }} + row={(p: Row) => { + mounted.push(untrack(() => p.title)); + return { + get done() { + return p.completed; + }, + get title() { + return p.title; + }, + remove: () => clicks.push(p.id) + }; + }} + /> + ), + page!.container + ); + disposers.push(dispose); + }; + return { bind, regions, fid, li, button, mounted, items, clicks, start }; +} + +describe("the bind tier — the announcement gates the scan", () => { + test('announced without `bind` (`["regions"]`): the walk never looks for `_s:` markers — no bind load, no hold; the range slot mounts and hydration completes', async () => { + const f = boot(["regions"]); + // The announcement started ITS tier, and only its tier. + expect(f.regions.loader).toHaveBeenCalledTimes(1); + expect(f.bind.loader).not.toHaveBeenCalled(); + f.start(); + await quiesce(); + await quiesce(); + // The range slot is the frame's: found and mounted (claimed in place). + expect(f.items).toEqual(["one"]); + expect(page!.container.querySelector("li:not(.todo)")!.textContent).toBe("one"); + // The consumer's markers were never read: no load, no consumer entry, + // no mount, nodes untouched — and no hold: hydration is done. + expect(f.bind.loader).not.toHaveBeenCalled(); + expect(f.mounted).toEqual([]); + expect(f.li.className).toBe("todo"); + expect(f.li.textContent).toBe("a×"); + expect(hydrationInProgress()).toBe(false); + expect(page!.errors).toEqual([]); + }); + + test("the record is read at the walk: a cumulative re-write that adds `bind` after install is seen by the boundary adopting after it — detection starts the load and holds", async () => { + const f = boot(["regions"]); + expect(f.bind.loader).not.toHaveBeenCalled(); + // A later data script re-writes the record with the tier a later + // boundary needs (frame-sink.ts `documentNeeds`, cumulative). + page!.hy.r["sc:tiers"] = ["regions", "bind"]; + f.start(); + await quiesce(); + // The walk read the record, scanned, met the markers with the tier + // absent: the load started (once) and the frame holds. + expect(f.bind.loader).toHaveBeenCalledTimes(1); + expect(f.mounted).toEqual([]); + expect(f.li.className).toBe("todo"); + expect(hydrationInProgress()).toBe(true); + // The range slot does not wait on the consumer's tier. + expect(f.items).toEqual(["one"]); + f.bind.release(await import("../../frames/src/bind-tier.js")); + await quiesce(); + await quiesce(); + expect(f.mounted).toEqual(["a"]); + f.button.click(); + expect(f.clicks).toEqual(["1"]); + expect(hydrationInProgress()).toBe(false); + expect(page!.errors).toEqual([]); + }); + + test("announced with `bind` only: the regions tier is never loaded through adoption when no record names a `{$frame}` (content-gated, as before)", async () => { + const f = boot(["bind"]); + expect(f.bind.loader).toHaveBeenCalledTimes(1); + expect(f.regions.loader).not.toHaveBeenCalled(); + f.start(); + await quiesce(); + // Held on the announced bind load; the range slot mounted. + expect(f.items).toEqual(["one"]); + expect(hydrationInProgress()).toBe(true); + f.bind.release(await import("../../frames/src/bind-tier.js")); + await quiesce(); + await quiesce(); + expect(f.mounted).toEqual(["a"]); + expect(f.regions.loader).not.toHaveBeenCalled(); + expect(hydrationInProgress()).toBe(false); + expect(page!.errors).toEqual([]); + }); +}); diff --git a/packages/web/test/frames-hydration-walk.bench.ts b/packages/web/test/frames-hydration-walk.bench.ts new file mode 100644 index 000000000..23e389e9b --- /dev/null +++ b/packages/web/test/frames-hydration-walk.bench.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment jsdom + */ +// Tier-1 DOM-lane bench. The frames client's ADOPTION WALK — what a +// document-SSR boundary pays at its adopt-time slot sync to find every slot +// occurrence in markup already in the page (`FrameImpl` on the adopt path: +// `#syncSlots` → `collectSlots`, then one record lookup per occurrence). +// The fixture is shaped like the HN twins' story page +// (`examples/hackernews`, `/stories/30186326`: 1,406 comments, 652 `toggle` +// occurrences, ~11k elements, ~17k comment nodes — every text hole a +// `…` pair) flattened to ONE frame +// with 1,400 occurrences, so one sync walks the whole tree: ~11k elements, +// ~17k comment nodes, ~8k text nodes (the bind cell adds a span and a text +// pair per comment: ~12.6k / ~19.6k / ~9.8k). +// +// Three cells, one fixture shape: +// +// - `announced, no bind`: the page's `sc:tiers` record names other tiers +// (the twin page's `["regions"]`) — no `_s:` positions exist, and the +// walk must not look for them (no element attribute scan, no `_s:t=` +// test per comment, no bind-tier load). +// - `un-announced`: no `sc:tiers` record (a page that minted no tier, or +// an older producer) — the detection path: the walk keeps testing for +// markers, so a consumer it meets can hold the frame on the tier. +// - `bind announced, consumers`: `sc:tiers` names `bind`, the tier is +// resident, and every comment carries a class position and a text +// position (`_s:class`, ``) — the gated path exercised: +// the walk parses positions into consumer lists in document order. +// +// The frame adopts WITHOUT a host, so no occurrence has a record: every +// called occurrence is found and then waits (the sync's record wait), no +// fill runs and the markup is never touched — one iteration is the walk and +// the per-occurrence bookkeeping, on the same fixture every time (the frame +// is disposed at the end of each iteration; `liveFrames` stays bounded). +// Under jsdom the DOM is JS, so absolute numbers are not the browser's; the +// shape (what the walk touches per node) is what this bench tracks — the +// twin page's own numbers are in the PR that added this file. +import { afterAll, bench, describe } from "vitest"; +import { createFrame } from "../frames/src/client.js"; +import * as bindTier from "../frames/src/bind-tier.js"; +import { tierLoads } from "../frames/src/frame-client.js"; + +const OCCURRENCES = 1400; +const FID = "bench/story"; + +/** The story page's comment list, one frame, `OCCURRENCES` toggles. */ +function storyHtml(bind: boolean) { + let holes = 0; + const hole = (text: string) => `${text}`; + let html = `
      `; + for (let k = 0; k < OCCURRENCES; k++) { + html += + `
    • ` + + `
      ${hole(`user${k}`)} ${hole("4 years ago")} ago
      ` + + `
      ${hole(`

      comment ${k} with some text

      `)}
      ` + + (bind ? `t` : "") + + `` + + `` + + `
        ` + + `` + + `
      • `; + } + return html + `
      `; +} + +/** A boundary element holding the fixture, in the document. */ +function boundary(bind: boolean) { + const el = document.createElement("solid-frame"); + el.setAttribute("data-fid", FID); + el.innerHTML = storyHtml(bind); + document.body.appendChild(el); + return el; +} + +const fill = () => undefined; +const slots = { toggle: fill, row: fill }; + +// The bind cell runs with the tier resident, as an announced page has it by +// the time its boundaries adopt: the module stamped on its load (the +// runtime's dispatch table; `tierLoads` is the tier specs' seam). The other +// cells run with the tier ABSENT — a started load is itself an +// announcement the walk honours (a stream's `X-Frame-Tiers`), so the state +// is set per cell, in the bench function (vitest runs no per-iteration +// hooks). +const resident = Object.assign(Promise.resolve(), { r: bindTier }); + +const cells = [ + { name: "announced, no bind", announce: ["regions"], bind: false }, + { name: "un-announced", announce: undefined, bind: false }, + { name: "bind announced, consumers", announce: ["bind"], bind: true } +]; + +afterAll(() => { + delete (globalThis as any)._$HY; + delete (tierLoads as any).bind; + document.body.innerHTML = ""; +}); + +describe("frames adoption walk (HN story shape, 1,400 occurrences)", () => { + for (const { name, announce, bind } of cells) { + const el = boundary(bind); + const hy = { r: announce ? { "sc:tiers": announce } : {} }; + bench(name, () => { + (globalThis as any)._$HY = hy; + if (bind) (tierLoads as any).bind = resident; + else delete (tierLoads as any).bind; + const frame = createFrame(el, { id: FID, adopt: true, slots }); + frame.dispose(); + }); + } +}); From e9c233c81ba92bed0d38e78a351ea4ac7a5f9849 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 03:35:37 -0700 Subject: [PATCH 2/5] size: raise frames/page caps for the announcement-gated scans + one-pass TreeWalker collectSlots (maintainer Size-Exception, perf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 @ 8d23a5a13) 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 --- scripts/size/floor-caps.json | 8 ++-- scripts/size/scenarios.js | 92 ++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 787d05aea..cbc644b2b 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -12,12 +12,12 @@ "minified": 52977 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "33.92 KB", - "minified": 105252 + "cap": "34.06 KB", + "minified": 105747 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "37.59 KB", - "minified": 117441 + "cap": "37.77 KB", + "minified": 117790 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 09deea08e..ecb58733b 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3819,8 +3819,24 @@ module.exports = [ // #3874) land under them (+616 B minified, +224 B brotli over the C2 // note's 32,802 / 10,888). Cap set at measured + 10 B rounded up to // 0.01 KB; recorded minified 33,418 B. - limit: "11.13 KB", - capMinified: 33418, + // Size-Exception (frames: announcement-gated slot/region scans, one + // TreeWalker pass for `collectSlots`, #3913, 2026-10-08): 11.13 -> 11.28 KB, + // measured at 11,264 B by CI (Size run 37756065705) against `next` @ + // 8d23a5a13's 11,112 (+152 B; 134 B over the cap; +333 B minified, + // 33,418 -> 33,751; recorded 33,418 -> 33,751) — the adopt-time walk's + // `_s:` marker scan gated on the page's `sc:tiers` announcement (an + // announced page that did not name `bind` is never scanned for binding + // slots, never loads the tier), and the recursive per-node walk replaced + // by one `TreeWalker` by `whatToShow` (comments; elements only when + // markers may exist) with a `startsWith("slot:")` test before the regex. + // The gate expression alone is ≈ 75 B minified, so no variant of the + // change fit the 20 B allowance; the gate-only variant (+94 B minified, + // under the cap) was measured at 9.92 ms against this one's 5.35 ms on + // the HN twins' story page (`next`: 14.23 ms). Cap set at measured + 10 B + // rounded up to 0.01 KB. Accepted by the maintainer (2026-10-08, "perf is + // important enough — it's the point here": the full walker variant). + limit: "11.28 KB", + capMinified: 33751, alias: framesAlias, external: framesExternal }, @@ -4092,6 +4108,17 @@ module.exports = [ // branch's base land under the `dynamicComponent` note's 33,570 / // 104,586 (+666 B minified, +338 B brotli). Cap set at measured + 10 B // rounded up to 0.01 KB; recorded minified 105,252 B. + // Size-Exception (frames: announcement-gated slot/region scans, one + // TreeWalker pass for `collectSlots`, #3913, 2026-10-08): 33.92 -> 34.06 KB + // (floor-caps.json), measured at 34,047 B by CI (Size run 37756065705) + // against `next` @ 8d23a5a13's 33,910 (+137 B; 127 B over the cap; +334 B + // minified, 105,413 -> 105,747; recorded 105,252 -> 105,747) — the frames + // client's bytes from the frames eager note above (the `sc:tiers`-gated + // marker scan, the `TreeWalker` walk); this page carries the client + // whole. Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by + // the maintainer (2026-10-08, "perf is important enough — it's the point + // here"): `collectSlots` 14.23 -> 5.35 ms on the HN twins' story page. + // The cap is frozen again at 34.06 KB. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -4320,6 +4347,15 @@ module.exports = [ // (`liveTx`, `holdNode`). Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer 2026-10-07 on the condition hello world stays // under 10 KB. The cap is frozen again at 37.59 KB. + // Size-Exception (frames: announcement-gated slot/region scans, one + // TreeWalker pass for `collectSlots`, #3913, 2026-10-08): 37.59 -> 37.77 KB + // (floor-caps.json), measured at 37,759 B by CI (Size run 37756065705) + // against `next` @ 8d23a5a13's 37,610 (+149 B; 169 B over the cap; +334 B + // minified, 117,456 -> 117,790; recorded 117,441 -> 117,790) — the frames + // client's bytes from the frames eager note (the `sc:tiers`-gated marker + // scan, the `TreeWalker` walk). Cap set at measured + 10 B rounded up to + // 0.01 KB. Accepted by the maintainer (2026-10-08, "perf is important + // enough — it's the point here"). The cap is frozen again at 37.77 KB. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -4405,8 +4441,17 @@ module.exports = [ // into it while it is live (`liveTx`, `holdNode`). Cap set at measured + 10 // B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-07 on the // condition hello world stays under 10 KB. - limit: "35.13 KB", - capMinified: 109446, + // Size-Exception (frames: announcement-gated slot/region scans, one + // TreeWalker pass for `collectSlots`, #3913, 2026-10-08): 35.13 -> 35.34 KB, + // measured at 35,323 B by CI (Size run 37756065705) against `next` @ + // 8d23a5a13's 35,146 (+177 B; 193 B over the cap; +334 B minified, + // 109,461 -> 109,795; recorded 109,446 -> 109,795) — the frames client's + // bytes from the frames eager note (the `sc:tiers`-gated marker scan, the + // `TreeWalker` walk). Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer (2026-10-08, "perf is important enough — + // it's the point here"). + limit: "35.34 KB", + capMinified: 109795, alias: pageAlias }, { @@ -4456,8 +4501,17 @@ module.exports = [ // into it while it is live (`liveTx`, `holdNode`). Cap set at measured + 10 // B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-07 on the // condition hello world stays under 10 KB. - limit: "40.66 KB", - capMinified: 122918, + // Size-Exception (frames: announcement-gated slot/region scans, one + // TreeWalker pass for `collectSlots`, #3913, 2026-10-08): 40.66 -> 40.83 KB, + // measured at 40,819 B by CI (Size run 37756065705) against `next` @ + // 8d23a5a13's 40,651 (+168 B; 159 B over the cap; +332 B minified, + // 122,933 -> 123,265; recorded 122,918 -> 123,265) — the frames client's + // bytes from the frames eager note (the `sc:tiers`-gated marker scan, the + // `TreeWalker` walk). Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer (2026-10-08, "perf is important enough — + // it's the point here"). + limit: "40.83 KB", + capMinified: 123265, alias: pageAlias }, { @@ -4506,8 +4560,17 @@ module.exports = [ // into it while it is live (`liveTx`, `holdNode`). Cap set at measured + 10 // B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-07 on the // condition hello world stays under 10 KB. - limit: "46.02 KB", - capMinified: 144223, + // Size-Exception (frames: announcement-gated slot/region scans, one + // TreeWalker pass for `collectSlots`, #3913, 2026-10-08): 46.02 -> 46.13 KB, + // measured at 46,120 B by CI (Size run 37756065705) against `next` @ + // 8d23a5a13's 45,996 (+124 B; 100 B over the cap; +334 B minified, + // 144,238 -> 144,572; recorded 144,223 -> 144,572) — the frames client's + // bytes from the frames eager note (the `sc:tiers`-gated marker scan, the + // `TreeWalker` walk). Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer (2026-10-08, "perf is important enough — + // it's the point here"). + limit: "46.13 KB", + capMinified: 144572, alias: pageAlias }, { @@ -4535,8 +4598,17 @@ module.exports = [ // into it while it is live (`liveTx`, `holdNode`). Cap set at measured + 10 // B rounded up to 0.01 KB. Accepted by the maintainer 2026-10-07 on the // condition hello world stays under 10 KB. - limit: "47.29 KB", - capMinified: 148668, + // Size-Exception (frames: announcement-gated slot/region scans, one + // TreeWalker pass for `collectSlots`, #3913, 2026-10-08): 47.29 -> 47.47 KB, + // measured at 47,460 B by CI (Size run 37756065705) against `next` @ + // 8d23a5a13's 47,326 (+134 B; 170 B over the cap; +334 B minified, + // 148,683 -> 149,017; recorded 148,668 -> 149,017) — the frames client's + // bytes from the frames eager note (the `sc:tiers`-gated marker scan, the + // `TreeWalker` walk). Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer (2026-10-08, "perf is important enough — + // it's the point here"). + limit: "47.47 KB", + capMinified: 149017, alias: pageAlias }, { From 7ddeac85ccc06ea9715b15fb6971ad76a3abf407 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 03:05:03 -0700 Subject: [PATCH 3/5] hydration: gather hydratable nodes once per adoption, not per occurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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^=""]')` 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 ``'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 `` 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 --- ...erf-gather-hydratable-once-per-adoption.md | 5 + packages/web/frames/src/client.ts | 99 ++++++- packages/web/src/client.ts | 18 +- packages/web/src/server.ts | 8 +- .../web/test/consistency/claim-index.spec.tsx | 244 ++++++++++++++++++ .../test/frames-hydration-gather.bench.tsx | 121 +++++++++ 6 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 .changeset/perf-gather-hydratable-once-per-adoption.md create mode 100644 packages/web/test/consistency/claim-index.spec.tsx create mode 100644 packages/web/test/frames-hydration-gather.bench.tsx diff --git a/.changeset/perf-gather-hydratable-once-per-adoption.md b/.changeset/perf-gather-hydratable-once-per-adoption.md new file mode 100644 index 000000000..2cdd447c9 --- /dev/null +++ b/.changeset/perf-gather-hydratable-once-per-adoption.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Frames (document adoption): gather an adopted boundary's hydration keys once per adoption, not once per occurrence. Each adopted occurrence's claim window used to select `[_hk^=""]` over the whole hydration root — 37 ms on a 652-occurrence comment thread, more than the rest of its hydration. The boundary now indexes the `_hk` nodes under its element once (bucketed by occurrence prefix) and serves every window — the occurrence's and a streamed ``'s resume inside it — from that index; a fragment revealed into the element later extends the index for the revealed parent, so late (post-done) claims still find their nodes. The page-level gather and the streamed boundary's resume path are unchanged. diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index bdcfa6b51..4d4958def 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -407,16 +407,98 @@ export function getFrameHost() { * its occurrences' claims (which may run long after, under the frame's hold * or at a fragment's reveal) gather against the root that holds the frame * and not whichever `hydrate()` root replaced the live pair since (#2917). + * The gather is the boundary's own (`claimScope`): an index of the keys + * under its element, built once, not the root's scan of the whole page. */ type ClaimScope = { registry?: Map; gather?: (key: string) => void }; +/** + * The claim scope of an adopted boundary: the registry the page's root + * gathered into, read now, and a gather over the keys under `el` served from + * an index built ONCE here — the root's `gather` selects + * `[_hk^=""]` over the whole hydration root, which an adoption with + * hundreds of occurrences (a comment thread: 652 on the HN story page) paid + * once PER occurrence's window, more than the rest of its hydration put + * together. One `[_hk]` pass over the element buckets every keyed node by + * the occurrence it belongs to; a window's gather is a lookup. + * + * The bucket of a key is its producer prefix, `sc---`: the + * fid and the occurrence may carry dashes, the child path after the prefix + * never does (`formatId` spells it in `[0-9a-zA-Z]`), so it ends at the + * key's LAST dash. A window's id names the same bucket by the same rule — + * an occurrence's prefix is its own bucket; a streamed `` inside a + * fill resumes under a boundary id (the prefix plus a child path), whose + * bucket is the occurrence's, narrowed to the keys under the id. Ids are + * prefix-closed (a child's id extends its owner's), so `startsWith` is + * containment, as the selector's `^=` was. A bare `_hk` (an event-slot + * consumer's replay stamp) lands in the empty bucket no id ever names. + * + * `gather` hands a bucket's keys to the registry as the root's gather did: + * each node under the id that the registry does not hold — so a key the + * window already claimed (gone from the registry) IS put back, exactly as + * the selector put it back; `claimRender`'s `bound` is still what keeps a + * fill's second window from gathering — and only nodes still in the + * document, which is what a live selector over the root saw. The last + * matters: a `` the server left pending inside a fill renders its + * fallback under the SAME id as its content, so the fallback's keys and the + * content's 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. + * + * The index is a snapshot; `index(root)` extends it with what a fragment's + * reveal landed under `root` (the parent `$dfr` announces — the adoption's + * `fr.subscribe`, before the reveal's re-sync mounts anything). That is how + * keyed nodes enter an adopted element after adoption: a stream's re-call + * ships bare marker pairs (no keys; it renders fresh), an occluded region + * materializes from data (never claimed). (A reveal group's fallback + * materialization, `$dfl`, announces nothing; a fill's keyed fallback it + * lands between the adoption and a held window is not indexed — the + * content that replaces it is, at its reveal.) A reveal AFTER + * hydration-done (a server ``'s content under corollary 4) finds + * the index where the registry was cleared, so the late window claims from + * it. Nodes indexed twice (the reveal's parent re-scanned) dedupe at the + * gather, by key. + * + * Without a registry (no `hydrate()` pass has run — a client render + * adopting server markup) there is nothing to index for and nothing to + * gather into: the index stays empty and a window gathers nothing. (A + * window opened under such a scope has no registry to claim against either + * way; `claimRender` renders fresh while no root has gathered.) + */ +function claimScope(el: Element): ClaimScope & { index(root: ParentNode): void } { + const registry: Map | undefined = sharedConfig.registry; + const buckets = new Map(); + const bucketOf = (key: string) => key.slice(0, key.lastIndexOf("-") + 1); + const index = (root: ParentNode) => { + if (registry) + for (const n of root.querySelectorAll("[_hk]")) { + const p = bucketOf(n.getAttribute("_hk")!); + const b = buckets.get(p); + b ? b.push(n) : buckets.set(p, [n]); + } + }; + index(el); + return { + registry, + gather(id) { + for (const n of buckets.get(bucketOf(id)) || []) { + const k = n.getAttribute("_hk")!; + if (k.startsWith(id) && n.isConnected && !registry!.has(k)) registry!.set(k, n); + } + }, + index + }; +} + /** * Hydration re-entry for one adopted slot range: the fill renders inside a * claim window — `sharedConfig.hydrateWindow`, the same window a streamed * boundary's resume opens — under an owner whose id chain reproduces the * document producer's keys (`sc---`). The window gathers - * the range's keys by that prefix, so the fill's components take the - * server-rendered nodes by key; the range is declared as the window's claim + * the range's keys by that prefix — from the boundary's index of its + * element's keys (`scope`, see `claimScope`), not a scan of the page — so + * the fill's components take the server-rendered nodes by key; the range + * is declared as the window's claim * roots because it may be DETACHED right now (an async slot fill renders * before its boundary re-inserts it) and the runtime's hydration guards * read connectivity to tell claimed SSR nodes from fresh clones. A fill @@ -1303,6 +1385,11 @@ function adoptBoundary( } }; IS_DEV && reportRegionFragments(el); + // The root this boundary adopts under (see ClaimScope / claimScope): its + // occurrences claim against this registry however late they mount, from + // an index of this element's keys built now — one pass, not one per + // occurrence. + const scope = claimScope(el); const fr = (globalThis as any)._$HY?.fr; // The adopting frame, bound below; the reveal cascade syncs it. let frame: ReturnType | undefined; @@ -1312,6 +1399,10 @@ function adoptBoundary( // (nested server async). Scoped to the revealed parent, so each // sweep is proportional to what just landed. const inside = !!parent && el.contains(parent as Node); + // What the reveal landed joins the claim index first: the re-sync + // below mounts the occurrences it carried, and their windows gather + // from the index. + inside && scope.index(parent!); IS_DEV && inside && reportRegionFragments(parent!); // A revealed fragment also brings its occurrences' ARGS RECORDS: a // slot invoked inside a server `` ships its `sc:slot:` @@ -1368,8 +1459,6 @@ function adoptBoundary( // streamed morphs — bind consumer cleanup to this boundary's owner (see // boundaryScope for the ambient-preserving rule). const owner = getOwner(); - // The root this boundary adopts under (see ClaimScope): its occurrences - // claim against this pair however late they mount. const sc: any = sharedConfig; // The frame's error, announced to the address source below (`landing`). const [failed, onApply] = failing(); @@ -1377,7 +1466,7 @@ function adoptBoundary( adopt: true, host, id: address, - slots: slotsFor(props, { registry: sc.registry, gather: sc.gather }), + slots: slotsFor(props, scope), ownerScope: boundaryScope(owner), reveal: revealSeam(owner), onApply, diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index fadef5a3b..68a63903e 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -3005,13 +3005,17 @@ function cleanChildren(parent, current, marker, replacement) { } function gatherHydratable(element, root) { - // A prefix-scoped gather (a boundary's late resume; an adopted frame - // occurrence's claim window) names exactly what it owns — collect wherever - // the keys sit, frame interiors included: keys are namespaced by their - // producer chain, so a nested frame's content can never match a foreign - // prefix. Selected natively: it runs once per resume or per occurrence, - // and a full `_hk` sweep filtered in JS each time is a cost per - // occurrence on the whole page. + // A prefix-scoped gather (a streamed boundary's late resume) names exactly + // what it owns — collect wherever the keys sit, frame interiors included: + // keys are namespaced by their producer chain, so a nested frame's content + // can never match a foreign prefix. Selected natively: it runs once per + // resume, and a full `_hk` sweep filtered in JS each time is a cost per + // resume on the whole page. An adopted frame's occurrences do NOT gather + // here: a scan of the root per occurrence is a cost per occurrence on the + // whole page (37 ms on a 652-occurrence thread), so the frames client + // indexes its element's keys once at adoption and serves its windows from + // that (`claimScope` in frames/src/client.ts) — only a window a frame + // opens with no root gathered yet falls through to this gather. const templates = element.querySelectorAll( root ? `[_hk^="${root.replace(/["\\]/g, "\\$&")}"]` : `*[_hk]` ); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 7394faaf1..a9f042db4 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -4975,9 +4975,11 @@ function slotMarker(position, entries) { * tier completes the element at the bind and replays (bind-tier.ts). Bare * (no key): the bootstrap tests presence, and no claim ever looks this * element up — the ambient `_hk` sweep skips frame interiors (`data-fid` - * containment, client.ts `gatherHydratable`) and a prefix-scoped gather - * (`[_hk^="sc-…"]`, a boundary id) cannot match the empty value, so the dev - * completion sweep never reports it. Four bytes per element, document face + * containment, client.ts `gatherHydratable`), a prefix-scoped gather + * (`[_hk^="sc-…"]`, a boundary id) cannot match the empty value, and the + * adopted frame's own key index (frames/src/client.ts `claimScope`) buckets + * it under the empty prefix no window's id names, so the dev completion + * sweep never reports it. Four bytes per element, document face * only: the stream face arrives after hydration, when the bootstrap no * longer queues (its `events` buffer is gone with `_$HY.done`). Server * components render under NoHydration, so the element has no keyed `_hk` diff --git a/packages/web/test/consistency/claim-index.spec.tsx b/packages/web/test/consistency/claim-index.spec.tsx new file mode 100644 index 000000000..608ef8127 --- /dev/null +++ b/packages/web/test/consistency/claim-index.spec.tsx @@ -0,0 +1,244 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * The adopted boundary's claim index (frames/src/client.ts `claimScope`): + * an adoption gathers the hydration keys under its element ONCE, bucketed + * by occurrence prefix, and every occurrence's claim window + * (`claimRender` → `sharedConfig.hydrateWindow` → the scope's `gather`) + * looks its range up — instead of selecting `[_hk^=""]` over the + * whole hydration root once per occurrence (37 ms on the HN story page's + * 652 toggles, more than the rest of its hydration). + * + * Pinned here, by counting `querySelectorAll` calls whose selector names + * `_hk` (the root's sweep, the index's pass, and — on the old path — one + * per window) and by node identity: + * + * (a) the number of `_hk` scans an adoption makes does not grow with the + * number of occurrences, and every fill claims its server node; + * (b) a fragment revealed into the element AFTER hydration-done (the + * registry already cleared — corollary 4) refreshes the index for the + * revealed parent only: one more scan, on a node inside the frame, + * never the root; the revealed occurrence claims the revealed node; + * (c) a node the index holds that has since left the document is never + * handed to the registry — the node that replaced it under the same + * key is (the rule that keeps a server ``'s removed fallback, + * keyed under the same id as its content, from shadowing the content + * at the content's claim). + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createSignal, flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { sharedConfig } from "solid-js/internal"; +import { + bootPage, + fillHtml, + fillHtml2, + frameHtml, + freshFid, + hydrationInProgress, + placeholderHtml, + quiesce, + slotRange, + type Page +} from "./support.js"; + +let page: Page | undefined; +afterEach(async () => { + await page?.cleanup(); + page = undefined; +}); + +/** Every `querySelectorAll` whose selector names `_hk`, with its receiver. */ +function watchKeyScans() { + const scans: { root: Node; selector: string }[] = []; + const wrap = (proto: { querySelectorAll(selector: string): NodeList }) => { + const original = proto.querySelectorAll; + return vi.spyOn(proto, "querySelectorAll").mockImplementation(function ( + this: Node, + selector: string + ) { + if (selector.includes("_hk")) scans.push({ root: this, selector }); + return original.call(this, selector); + }); + }; + const spies = [wrap(Element.prototype), wrap(Document.prototype)]; + return { + scans, + /** Scans since the last `take()`. */ + take() { + return scans.splice(0, scans.length); + }, + stop() { + for (const s of spies) s.mockRestore(); + } + }; +} + +const items = (fid: string, n: number) => + Array.from({ length: n }, (_, i) => + slotRange(`item#${i}`, fillHtml(fid, `item#${i}`, `t${i}`)) + ).join(""); + +describe("adopted boundary claim index — one gather per adoption, not per occurrence", () => { + test("(a) the number of _hk scans does not grow with the number of occurrences; every fill claims its node", async () => { + const scansFor = async (n: number) => { + const fid = freshFid("cix"); + page = bootPage(frameHtml(fid, `
        ${items(fid, n)}
      `)); + for (let i = 0; i < n; i++) page.slotRecord(fid, `item#${i}`, { text: `t${i}` }); + const Comp = (globalThis as any)._$SC.r(fid); + const before = [...page.container.querySelectorAll("li")]; + const frameEl = page.container.querySelector("solid-frame")!; + const watch = watchKeyScans(); + const dispose = hydrate( + () =>
    • {p.text}
    • } />, + page.container + ); + await quiesce(); + const scans = watch.take(); + watch.stop(); + const after = [...page.container.querySelectorAll("li")]; + expect(after.length).toBe(n); + for (let i = 0; i < n; i++) expect(after[i]).toBe(before[i]); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + dispose(); + await page.cleanup(); + page = undefined; + return { scans, frameEl }; + }; + const one = await scansFor(1); + const six = await scansFor(6); + // The root's own sweep (`*[_hk]` over the hydration root) and the + // adoption's one pass over its element — whatever the occurrence count. + // On the per-occurrence path this is 1 + n (2 vs 7). + expect(six.scans.length).toBe(one.scans.length); + expect(six.scans.length).toBe(2); + expect(six.scans.filter(s => s.root === six.frameEl).length).toBe(1); + }); + + test("(b) a reveal after hydration-done refreshes the index for the revealed parent, and the revealed occurrence claims its node", async () => { + const fid = freshFid("cib"); + const frag = "cib"; + page = bootPage( + frameHtml( + fid, + `
        ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
      • ${placeholderHtml(frag, "loading")}
      ` + ) + ); + page.declareFragment(frag); + page.slotRecord(fid, "item#0", { text: "one" }); + const Comp = (globalThis as any)._$SC.r(fid); + const frameEl = page.container.querySelector("solid-frame")!; + const pending = page.container.querySelector("li.pending")!; + let invocations = 0; + const dispose = hydrate( + () => ( + { + invocations++; + return
    • {p.text}
    • ; + }} + /> + ), + page.container + ); + await quiesce(); + expect(invocations).toBe(1); + // Corollary 4: the server's inner loading state registers nothing, so + // hydration-done precedes the reveal — and the drain's setTimeout has + // cleared the root's registry by now. What the late window claims from + // is the adoption's index, refreshed with what the reveal lands. + expect(hydrationInProgress()).toBe(false); + expect(sharedConfig.registry!.size).toBe(0); + + const watch = watchKeyScans(); + page.slotRecord(fid, "item#1", { text: "two" }); + page.revealFragment(frag, slotRange("item#1", fillHtml(fid, "item#1", "two"))); + const revealed = page.container.querySelector("li.pending > li")!; + expect(revealed.textContent).toBe("two"); + await quiesce(); + const scans = watch.take(); + watch.stop(); + expect(invocations).toBe(2); + // Claimed in place: the fill's
    • IS the revealed server node. + expect(page.container.querySelector("li.pending > li")).toBe(revealed); + expect(page.container.textContent).toBe("onetwo"); + // One scan for the refresh, on the parent the swap announced — inside + // the frame, never the hydration root. + expect(scans.length).toBe(1); + expect(scans[0].root).toBe(pending); + expect(frameEl.contains(scans[0].root)).toBe(true); + expect(scans[0].root).not.toBe(page.container); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + dispose(); + }); + + test("(c) a node that left the document is never gathered; its replacement under the same key is", async () => { + const fid = freshFid("cic"); + const frag = "cic"; + // The occurrence's range and the pending placeholder share one parent — + // as a server 's fallback and the content that replaces it do. + page = bootPage( + frameHtml( + fid, + `
      • ${slotRange("item#0", fillHtml2(fid, "item#0", "stale", "0", "b"))}${placeholderHtml(frag, "loading")}
      ` + ) + ); + page.declareFragment(frag); + // item#0's record is DECLARED but not settled: the occurrence is held + // (its window has not opened), its node is in the index. + const record = page.declareSlotRecord(fid, "item#0"); + const Comp = (globalThis as any)._$SC.r(fid); + const [tick, setTick] = createSignal(0); + let invocations = 0; + const dispose = hydrate( + () => ( + { + invocations++; + return ( + + {p.text} + {tick()} + + ); + }} + /> + ), + page.container + ); + await quiesce(); + expect(invocations).toBe(0); + // The indexed node leaves the document and a node carrying the SAME key + // takes its place — what the swap does to a fallback keyed under the + // content's id (same owner id on both sides, by design). The reveal + // under that parent re-indexes it: the index now holds both nodes under + // one key, the detached one first. + const stale = page.container.querySelector("b")!; + const fresh = stale.cloneNode(true) as HTMLElement; + (fresh.childNodes[1] as Text).data = "fresh"; + stale.replaceWith(fresh); + page.revealFragment(frag, "revealed"); + await quiesce(); + expect(page.container.textContent).toBe("fresh0revealed"); + // The held occurrence mounts: its window must claim the node that is in + // the document, not the one the index met first. Liveness tells them + // apart: a claim of the detached node leaves this one standing but + // inert (the mismatch keeps the server node, binds nothing to it). + record.settle({ text: "fresh" }); + await quiesce(); + await quiesce(); + expect(invocations).toBe(1); + expect(page.container.querySelector("b")).toBe(fresh); + expect(stale.isConnected).toBe(false); + setTick(1); + flush(); + expect(fresh.textContent).toBe("fresh1"); + expect(page.container.textContent).toBe("fresh1revealed"); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + dispose(); + }); +}); diff --git a/packages/web/test/frames-hydration-gather.bench.tsx b/packages/web/test/frames-hydration-gather.bench.tsx new file mode 100644 index 000000000..73ca97357 --- /dev/null +++ b/packages/web/test/frames-hydration-gather.bench.tsx @@ -0,0 +1,121 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Tier-1 DOM-lane bench. What a document-SSR boundary pays, at its +// adopt-time slot sync, to GATHER the hydration keys its occurrences claim +// by: each adopted occurrence re-enters hydration through the claim window +// (`sharedConfig.hydrateWindow` → the scope's `gather(prefix)`), and the +// gather is what turns the page's `_hk` nodes into registry entries the +// fill's `getNextElement` takes. +// +// The fixture is the HN twins' story page shape (`examples/hackernews`, +// `/stories/30186326`: 1,406 comments, 652 `toggle` occurrences, ~11k +// elements, ~17k comment nodes), flattened to ONE frame with 1,400 +// occurrences — the same markup as frames-hydration-walk.bench.ts's cell, +// with the slot RECORDS present (every occurrence mounts at t=0) and a fill +// that claims its two keyed nodes the way a compiled fill does, so one +// iteration is: the page's root `hydrate()` pass, the adoption, 1,400 claim +// windows, 2,800 claims. The gather's share is what moves between the +// per-occurrence root scan (`element.querySelectorAll('[_hk^="…"]')` over +// the hydration root, once per window — 37 ms on the twin page) and the +// per-adoption index (one `[_hk]` scan over the frame element, bucketed by +// occurrence prefix; a window is a map lookup). +// +// `querySelectorAll` calls with a `_hk` selector are counted per iteration +// (`hkScans`, reported once at the end): the per-occurrence form makes one +// per window; the indexed form makes one per adoption. +// +// Under jsdom the DOM is JS, so absolute numbers are not the browser's; the +// shape — how many root scans an adoption makes — is what this bench tracks. +// The twin page's own numbers are in the PR that added this file. +import { afterAll, bench, describe } from "vitest"; +import { getNextElement, hydrate } from "@solidjs/web"; +import { + bootPage, + fillKey, + frameHtml, + freshFid, + quiesce, + slotRange +} from "./consistency/support.js"; + +const OCCURRENCES = 1400; + +/** The story page's comment list, one frame, `OCCURRENCES` toggles. */ +function storyHtml(fid: string) { + let holes = 0; + const hole = (text: string) => `${text}`; + let html = `
        `; + for (let k = 0; k < OCCURRENCES; k++) { + const occ = `toggle#${k}`; + html += + `
      • ` + + `
        ${hole(`user${k}`)} ${hole("4 years ago")} ago
        ` + + `
        ${hole(`

        comment ${k} with some text

        `)}
        ` + + `` + + slotRange( + occ, + `` + + `
          ` + ) + + `` + + `
        • `; + } + return html + `
        `; +} + +// The fill: the server rendered `` as two keyed top-level elements +// under the occurrence's producer chain (`sc--toggle#k-0`, `-1`); the +// client claims them by key, as a compiled hydratable template's first +// `getNextElement` does for each of its roots. +const toggle = () => [getNextElement(), getNextElement()]; + +let hkScans = 0; +let hkScanMs = 0; +const countScans = (proto: any) => { + const original = proto.querySelectorAll; + proto.querySelectorAll = function (selector: string) { + if (!selector.includes("_hk")) return original.call(this, selector); + hkScans++; + const start = performance.now(); + try { + return original.call(this, selector); + } finally { + hkScanMs += performance.now() - start; + } + }; + return () => { + proto.querySelectorAll = original; + }; +}; +const restore = [countScans(Element.prototype), countScans(Document.prototype)]; +let iterations = 0; + +afterAll(() => { + for (const r of restore) r(); + const n = Math.max(iterations, 1); + // eslint-disable-next-line no-console + console.log( + `\n[frames-hydration-gather] ${OCCURRENCES} occurrences: ${hkScans / n} "_hk" querySelectorAll call(s) per adoption, ` + + `${(hkScanMs / n).toFixed(1)} ms in them per iteration (page-level sweep included)` + ); +}); + +describe("frames adoption gather (HN story shape, 1,400 occurrences)", () => { + bench( + "hydrate + adopt + claim every occurrence", + async () => { + const fid = freshFid("gather"); + const page = bootPage(frameHtml(fid, storyHtml(fid))); + for (let k = 0; k < OCCURRENCES; k++) page.slotRecord(fid, `toggle#${k}`, {}); + const Comp = (globalThis as any)._$SC.r(fid); + iterations++; + const dispose = hydrate(() => , page.container); + await quiesce(1); + dispose(); + await page.cleanup(); + }, + { iterations: 3, time: 2000 } + ); +}); From 8c82c2bb0161ac8dbc9b72b26a33734e828b2bf4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 03:50:19 -0700 Subject: [PATCH 4/5] size: raise frames/page caps for the one-gather-per-adoption claim index (maintainer Size-Exception, perf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #3913 (perf/frames-hydration-walk @ e9c233c81): 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 @ 8d23a5a13) 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 --- scripts/size/floor-caps.json | 8 +-- scripts/size/scenarios.js | 98 ++++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 14 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index cbc644b2b..0d4d4dc90 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -12,12 +12,12 @@ "minified": 52977 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "34.06 KB", - "minified": 105747 + "cap": "34.22 KB", + "minified": 106107 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "37.77 KB", - "minified": 117790 + "cap": "37.94 KB", + "minified": 118150 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index ecb58733b..9590274b7 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3835,8 +3835,26 @@ module.exports = [ // the HN twins' story page (`next`: 14.23 ms). Cap set at measured + 10 B // rounded up to 0.01 KB. Accepted by the maintainer (2026-10-08, "perf is // important enough — it's the point here": the full walker variant). - limit: "11.28 KB", - capMinified: 33751, + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): 11.28 -> 11.39 KB, measured at + // 11,375 B by CI (Size run 37765150570) against #3913's e9c233c81's + // 11,264 (+111 B; 95 B over the cap; +361 B minified, 33,751 -> 34,112; + // recorded 33,751 -> 34,112; the two PRs together: +263 B brotli / +694 B + // minified over `next` @ 8d23a5a13's 11,112 / 33,418) — `adoptBoundary` + // builds the boundary's claim index at adoption (one `[_hk]` query over + // the adopted element, every keyed node bucketed by its occurrence + // prefix — the key's last dash), the window's `gather(id)` is a map + // lookup narrowed by `startsWith` to nodes still in the document, and + // the fragment-reveal callback extends the index with the revealed + // parent before the apply — in place of one `querySelectorAll('[_hk^=…]')` + // over the whole hydration root per occurrence (652 on the HN twins' + // story page: `gatherHydratable` 42–52 ms -> < 1 ms, total hydration + // ≈ 77 -> ≈ 42 ms). The `[_hk^=…]` selector path stays for the streamed + // boundary's resume at page level, so the index is additive. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // (2026-10-08, "perf is important enough — it's the point here"). + limit: "11.39 KB", + capMinified: 34112, alias: framesAlias, external: framesExternal }, @@ -4119,6 +4137,20 @@ module.exports = [ // the maintainer (2026-10-08, "perf is important enough — it's the point // here"): `collectSlots` 14.23 -> 5.35 ms on the HN twins' story page. // The cap is frozen again at 34.06 KB. + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): 34.06 -> 34.22 KB + // (floor-caps.json), measured at 34,209 B by CI (Size run 37765150570) + // against #3913's e9c233c81's 34,047 (+162 B; 149 B over the cap; +360 B + // minified, 105,747 -> 106,107; recorded 105,747 -> 106,107; the two PRs + // together: +299 B brotli / +694 B minified over `next` @ 8d23a5a13's + // 33,910 / 105,413) — the adoption-time claim index from the frames + // eager note (the frames client carries the index; `@solidjs/web`'s + // page-level gather and resume path are unchanged, comments only — the + // non-SC hydrating scenarios measure 0). Cap set at + // measured + 10 B rounded up to 0.01 KB. 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. The cap is + // frozen again at 34.22 KB. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -4356,6 +4388,16 @@ module.exports = [ // scan, the `TreeWalker` walk). Cap set at measured + 10 B rounded up to // 0.01 KB. Accepted by the maintainer (2026-10-08, "perf is important // enough — it's the point here"). The cap is frozen again at 37.77 KB. + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): 37.77 -> 37.94 KB + // (floor-caps.json), measured at 37,924 B by CI (Size run 37765150570) + // against #3913's e9c233c81's 37,759 (+165 B; 154 B over the cap; +360 B + // minified, 117,790 -> 118,150; recorded 117,790 -> 118,150; the two PRs + // together: +314 B brotli / +694 B minified over `next` @ 8d23a5a13's + // 37,610 / 117,456) — the adoption-time claim index from the frames + // eager note. Cap set at measured + 10 B rounded up to 0.01 KB. Accepted + // by the maintainer (2026-10-08, "perf is important enough — it's the + // point here"). The cap is frozen again at 37.94 KB. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -4450,8 +4492,17 @@ module.exports = [ // `TreeWalker` walk). Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer (2026-10-08, "perf is important enough — // it's the point here"). - limit: "35.34 KB", - capMinified: 109795, + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): 35.34 -> 35.47 KB, measured at + // 35,459 B by CI (Size run 37765150570) against #3913's e9c233c81's + // 35,323 (+136 B; 119 B over the cap; +360 B minified, 109,795 -> 110,155; + // recorded 109,795 -> 110,155; the two PRs together: +313 B brotli / + // +694 B minified over `next` @ 8d23a5a13's 35,146 / 109,461) — the + // adoption-time claim index from the frames eager note. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // (2026-10-08, "perf is important enough — it's the point here"). + limit: "35.47 KB", + capMinified: 110155, alias: pageAlias }, { @@ -4510,8 +4561,17 @@ module.exports = [ // `TreeWalker` walk). Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer (2026-10-08, "perf is important enough — // it's the point here"). - limit: "40.83 KB", - capMinified: 123265, + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): 40.83 -> 40.95 KB, measured at + // 40,932 B by CI (Size run 37765150570) against #3913's e9c233c81's + // 40,819 (+113 B; 102 B over the cap; +363 B minified, 123,265 -> 123,628; + // recorded 123,265 -> 123,628; the two PRs together: +281 B brotli / + // +695 B minified over `next` @ 8d23a5a13's 40,651 / 122,933) — the + // adoption-time claim index from the frames eager note. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // (2026-10-08, "perf is important enough — it's the point here"). + limit: "40.95 KB", + capMinified: 123628, alias: pageAlias }, { @@ -4569,8 +4629,17 @@ module.exports = [ // `TreeWalker` walk). Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer (2026-10-08, "perf is important enough — // it's the point here"). - limit: "46.13 KB", - capMinified: 144572, + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): 46.13 -> 46.30 KB, measured at + // 46,282 B by CI (Size run 37765150570) against #3913's e9c233c81's + // 46,120 (+162 B; 152 B over the cap; +360 B minified, 144,572 -> 144,932; + // recorded 144,572 -> 144,932; the two PRs together: +286 B brotli / + // +694 B minified over `next` @ 8d23a5a13's 45,996 / 144,238) — the + // adoption-time claim index from the frames eager note. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // (2026-10-08, "perf is important enough — it's the point here"). + limit: "46.30 KB", + capMinified: 144932, alias: pageAlias }, { @@ -4607,8 +4676,17 @@ module.exports = [ // `TreeWalker` walk). Cap set at measured + 10 B rounded up to 0.01 KB. // Accepted by the maintainer (2026-10-08, "perf is important enough — // it's the point here"). - limit: "47.47 KB", - capMinified: 149017, + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): 47.47 -> 47.57 KB, measured at + // 47,556 B by CI (Size run 37765150570) against #3913's e9c233c81's + // 47,460 (+96 B; 86 B over the cap; +360 B minified, 149,017 -> 149,377; + // recorded 149,017 -> 149,377; the two PRs together: +230 B brotli / + // +694 B minified over `next` @ 8d23a5a13's 47,326 / 148,683) — the + // adoption-time claim index from the frames eager note. Cap set at + // measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer + // (2026-10-08, "perf is important enough — it's the point here"). + limit: "47.57 KB", + capMinified: 149377, alias: pageAlias }, { From f0b3054227f58fcd3055b9960e9209e1e6918668 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 15:39:42 -0700 Subject: [PATCH 5/5] 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 --- scripts/size/scenarios.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 46a9d3fb6..4ec499cf2 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4788,11 +4788,12 @@ module.exports = [ // +360 B minified over #3913's 47,460 / 149,017) — the adoption-time // claim index from the frames eager note. Accepted by the maintainer // (2026-10-08, "perf is important enough — it's the point here"). - // Re-derived onto #3913's post-#3909 cap (47,084 / 142,804) by that same - // delta: 47,180 B / 143,164 minified. Cap at that sum + 10 B rounded up - // to 0.01 KB. - limit: "47.19 KB", - capMinified: 143164, + // Re-derived onto #3913's post-#3909 cap. The carried +96 B brotli delta + // landed at 47.19 KB; CI on this merge (Size run 37852899252) measured + // 47,281 B / 143,158 minified (+197 B brotli / +354 B minified over + // #3913's 47,084 / 142,804). Cap at measured + 10 B rounded up to 0.01 KB. + limit: "47.30 KB", + capMinified: 143158, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }