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(); + }); + } +}); 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 c315270db..e4e816219 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3835,8 +3835,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 }, @@ -4108,6 +4124,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)"], @@ -4336,6 +4363,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)"], @@ -4421,8 +4457,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 }, { @@ -4472,8 +4517,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 }, { @@ -4570,8 +4624,22 @@ module.exports = [ // eager chunk. Cap at measured + 10 B rounded up to 0.01 KB; recorded // minified 129,175 B. CI-confirmed to the byte (Size run 37751396603: // 41,244 / 129,175). - limit: "41.26 KB", - capMinified: 129175, + // 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"). + // Re-derived on #3909's base (router next.37 + the `solid` condition, + // `next` @ 893834ca5, 2026-10-08): 41.26 -> 41.40 KB, measured at 41,386 B + // by CI (Size run 37805628234) against #3909's 41,244 (+142 B; +334 B + // minified, 129,175 -> 129,509; recorded 129,175 -> 129,509) — the same + // frames bytes as above. Cap set at measured + 10 B rounded up to 0.01 KB. + limit: "41.40 KB", + capMinified: 129509, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -4631,8 +4699,22 @@ module.exports = [ // measured + 10 B rounded up to 0.01 KB; recorded minified 142,472 B. // CI-confirmed to the byte, two chunks included (Size run 37751396603: // 46,919 / 142,472; `client.js` 85,618 / 27,801). - limit: "46.93 KB", - capMinified: 142472, + // 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"). + // Re-derived on #3909's base (router next.37 + the `solid` condition, + // `next` @ 893834ca5, 2026-10-08): 46.93 -> 47.10 KB, measured at 47,084 B + // by CI (Size run 37805628234) against #3909's 46,919 (+165 B; +332 B + // minified, 142,472 -> 142,804; recorded 142,472 -> 142,804) — the same + // frames bytes as above. Cap set at measured + 10 B rounded up to 0.01 KB. + limit: "47.10 KB", + capMinified: 142804, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }