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 5f8cde9f6..528770879 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -3035,13 +3035,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 3c205fc6d..839b4a9ad 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -4992,9 +4992,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 } + ); +}); 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 e4e816219..4ec499cf2 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3851,8 +3851,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 }, @@ -4135,6 +4153,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)"], @@ -4372,6 +4404,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)"], @@ -4466,8 +4508,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 }, { @@ -4526,8 +4577,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 }, { @@ -4638,8 +4698,17 @@ module.exports = [ // 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, + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): measured on the pre-#3909 router + // at 46,282 B / 144,932 minified (Size run 37765150570; +162 B brotli / + // +360 B minified over #3913's 46,120 / 144,572) — 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 (41,386 / 129,509) by that same + // delta: 41,548 B / 129,869 minified. Cap at that sum + 10 B rounded up + // to 0.01 KB. + limit: "41.56 KB", + capMinified: 129869, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -4713,8 +4782,18 @@ module.exports = [ // 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, + // Size-Exception (hydration: gather hydratable nodes once per adoption, + // #3916, 2026-10-08, stacked on #3913): measured on the pre-#3909 router + // at 47,556 B / 149,377 minified (Size run 37765150570; +96 B brotli / + // +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. 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 }