From 085af0e5bd55cfe3ffee5038e2aef10ce0ff3c39 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 18:13:32 -0700 Subject: [PATCH 1/2] =?UTF-8?q?frames:=20C4=20=E2=80=94=20regions=20tier:?= =?UTF-8?q?=20nested=20server-content=20regions=20as=20a=20lazy=20chunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nested server-content regions — server content passed as a PROP to a client fill (`{$frame}` in the occurrence's record), rendered as a `` region element with a frame bound over it that the host routes the region's chunks to — are now the frames client's REGIONS TIER (frames savings pass §3 row C4), loaded through `prepareTier("regions")`: - `@solidjs/web/frames/regions` — a NEW `@solidjs/web` export path, the tier module (`frames/src/regions-tier.ts`): the per-frame region cache (a `WeakMap` the tier keeps, no frame field), element discovery in an adopted interior (`collectRegionElements`, the range walk), the `{$frame}` arm of arg resolution (`resolve`: mint / reuse / rename), the bind of a frame over each region element (`bind`: `createFrame` from the eager entry, the parent linked as `options.parent`), disposal (`unmount`), and the staged preview's two region reads (`changed`, `frames`). The module's exports ARE its appliers: `prepareTier` records the installed module in a dispatch table (`tierModules`) and the frame calls through it; no `install()` — nothing of this tier lives outside the runtime's dispatch. In the dist the tier's import of the client entry is externalized to `@solidjs/web/frames` (instance identity: `createFrame` must be the runtime the app's host routes to); frame-client's pure DOM helpers (`eachInRange`, `makeFrameElement`, `isFrameElement`, exported `@internal`) bundle as the chunk's own copy. - The eager client keeps: `needsRegions` — one test on the host's `record.regions` note (no arg walk) at TWO sites: a fresh mount waits in the held set (adopt path: the hold registers under frames-rulings 3.1, the server interior stays on screen), and a MOUNTED occurrence's new record naming a region stays pending in the store (the live binding keeps the args it shows) until the install's flush — either way the check starts the load (the un-announced fallback); the document face's `sc:region:` drain arm in client.ts (an occluded region's html lands in the store regardless; the frame the tier binds on install seeds from it); the loader entry; an `options` getter on the frame (`@internal`, not on `Frame`) the tier reads the parent's host / claim scope / owner scope from. The slot-resolution thread-up (`#resolveSlot` / `#resolveSlotRecord` / `#removeSlotRecord`) walks a `parent` link (`#outer`) by private access across instances — the three per-region option closures are gone. - The rename machinery (`renameRegion`, the rename arm) is MOVED into the tier, not deleted — flagged: it is live, not dead. A single-flight response renders a shown boundary's regions under the call's address while a direct response and the document render them under the function id, so a flight refresh renames every region of the boundary (`preview`'s region check anticipates exactly this; `lifecycle-matrix/call-driven-args` › regions and `frames-optimistic-hold` pin the rebind). It deletes with S7's store-boundary normalization (principles §5.3), not here. Eager cost of keeping it: 0. Already gone on the base: `isFrameRef` (the host's inline `$frame` note is the detection), `tableFor`'s prefix walk and `#reconcileRegions` (A1b). - `InstallOptions.tiers` types a tier's module as the new exported `TierModule` (`{ install?(): void; [applier: string]: unknown }`): the old `{ install?(): void }` is a TypeScript weak type a module whose exports are its appliers cannot satisfy. Measured before written (edited dist copies through scripts/size's bundler, re-attribution §7), then the real build, against C3's head 89954fa1b: frames eager −1,078 min / −237 br (the deletion alone −1,596 / −423; the glue +518 / +186 — over the ≈ 150-min line, reported: the installed-module table, the loader entry, `needsRegions` at both sites, the tier calls at bind / resolve / unmount / preview, the `options` getter, the `parent` thread-up; TypeScript's TS18030 — no private name in an optional chain — forced the longer `#outer && #outer.#x()` form, +33 min over the edited copy's −250 br); page base −1,083 / −281; page live −1,083 / −261; the four non-SC scenarios 0. Chunk `regions.js` 1,872 min / 805 br (reported, not counted). Vs `next` 9d89df731: −1,524 / −158, −24,812 / −6,565, −24,904 / −6,709. Caps lowered (the ratchet, measured + 10 B at the 0.01 KB step): frames eager 13.79 → 13.64 KB (under the cap for the first time since Phase A), page base 38.61 → 38.33 KB, page live 42.16 → 41.90 KB; no cap raised. Pins: new `consistency/tier-regions-hold.spec` (4 — un-announced `{$frame}` in an adopted record → held under 3.1, interior and region content on screen, mounts with the held record, the adopted region element discovered and bound, no TypeError, hydration-done after; announced → the import starts at install; an occluded `sc:region:` record drained before the tier seeds the bound frame; stream face: a mounted occurrence's region-naming record waits and applies at the install into the live binding). `tier-prepare`'s regions test releases the real module. Resident cells warm with `prepareTier("regions")`: `lifecycle-matrix/call-driven-args`, `frames-hn-client`, `frames-occlusion-client`, `frames-optimistic-hold`, `frames-used-region-client`; `test/server/frame-hn` (the runtime without the entry) registers the loader itself. Harness 500 × seeds 3289 / 91501: SC arm 0 / 0; generic arm (`C1,C9,C19,E` ignored) 0 / 0. Wire unchanged; 0 of 150 artifacts changed. Suites: web client (128 files), server (159), hydrate + consistency (85), solid (42) green; `test-types` green on web and solid. Co-authored-by: Cursor --- .changeset/frames-regions-tier.md | 5 + packages/web/frames/src/client.ts | 46 ++- packages/web/frames/src/frame-client.ts | 362 ++++++++--------- packages/web/frames/src/regions-tier.ts | 272 +++++++++++++ packages/web/package.json | 4 + packages/web/rollup.config.js | 54 ++- .../test/consistency/tier-prepare.spec.tsx | 25 +- .../consistency/tier-regions-hold.spec.tsx | 366 ++++++++++++++++++ packages/web/test/frames-hn-client.spec.tsx | 12 +- .../web/test/frames-occlusion-client.spec.tsx | 11 +- .../web/test/frames-optimistic-hold.spec.tsx | 12 +- .../test/frames-used-region-client.spec.tsx | 11 +- packages/web/test/lifecycle-matrix/MATRIX.md | 2 +- .../call-driven-args.spec.tsx | 14 +- packages/web/test/server/frame-hn.spec.tsx | 18 +- packages/web/tsconfig.build.json | 3 +- packages/web/vite.config.hydrate.mjs | 10 +- packages/web/vite.config.mjs | 20 +- scripts/size/floor-caps.json | 8 +- scripts/size/scenarios.js | 50 ++- 20 files changed, 1021 insertions(+), 284 deletions(-) create mode 100644 .changeset/frames-regions-tier.md create mode 100644 packages/web/frames/src/regions-tier.ts create mode 100644 packages/web/test/consistency/tier-regions-hold.spec.tsx diff --git a/.changeset/frames-regions-tier.md b/.changeset/frames-regions-tier.md new file mode 100644 index 000000000..c876f38a2 --- /dev/null +++ b/.changeset/frames-regions-tier.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: C4 — the regions tier. Nested server-content regions (`{$frame}` slot args resolved to `` region elements with frames bound over them) leave the eager frames client for the lazy chunk `@solidjs/web/frames/regions` (a new `@experimental` export path), loaded through the tier mechanism: the server announces `regions` where it mints one; a record naming a region met before the tier is resident waits — a fresh mount in the held set (the frame's hold registered under frames-rulings 3.1 on the adopt path, the server interior on screen), a mounted occurrence's new record pending in the store until the install's flush. `InstallOptions.tiers` now types a tier's module as the new exported `TierModule` (its exports are the tier's appliers; `install()` stays optional). Frames eager −1,078 B minified / −237 B brotli; the page scenarios −1,083 B minified; the non-SC scenarios unchanged. diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 356b8fd45..ae6d7e9d0 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -41,7 +41,8 @@ import { createFrameHost, FRAME_ID_ATTR, prepareTier, - tierLoaders + tierLoaders, + type TierModule } from "./frame-client.js"; import { COMPONENT_BINDING, @@ -72,6 +73,15 @@ import { createLoadingBoundary, sharedConfig } from "solid-js/internal"; // in the lazy codec chunk. const TRACE_STATE = Symbol.for("solid.container-trace-state"); tierLoaders.trace = () => import("@solidjs/web/frames/trace"); +// The regions tier (frames savings pass §3 row C4): nested server-content +// regions — `{$frame}` slot args resolved to region elements with frames +// bound over them — as the chunk `@solidjs/web/frames/regions` +// (regions-tier.ts). The server announces `regions` where it mints one; a +// record naming a region met while the tier is absent waits for it +// (frame-client.ts, `needsRegions`). This entry keeps the document face's +// `sc:region:` drain (below): an occluded region's html lands in the +// store regardless, and the frame the tier binds on install seeds from it. +tierLoaders.regions = () => import("@solidjs/web/frames/regions"); // Build-time literal (see diagnostics.ts): dev-only guidance folds out of prod. const IS_DEV = "_SOLID_DEV_" as unknown as boolean; @@ -112,6 +122,8 @@ export { createFrameElement, FRAME_APPLIED_EVENT } from "./frame-client.js"; +// The shape a tier loader resolves (`InstallOptions.tiers`); type-only. +export type { TierModule } from "./frame-client.js"; export { FRAME_STREAM_HEADER, FRAME_HAVE_HEADER, @@ -1647,14 +1659,16 @@ function adoptBoundary( */ export interface InstallOptions { /** - * Frames-client tiers by name → loader. A tier's module exports - * `install()`, called once the import resolves; every live frame is then - * flushed so what the tier makes applicable applies (a held occurrence - * mounts). A name with no loader is resident (eager); `trace` has a - * built-in loader (`@solidjs/web/frames/trace`) that an entry here - * replaces. See `installServerComponents`. + * Frames-client tiers by name → loader. A tier's module's exports are its + * appliers (the runtime dispatches to them once resident) and its + * optional `install()` is called once the import resolves; every live + * frame is then flushed so what the tier makes applicable applies (a held + * occurrence mounts). A name with no loader is resident (eager); `trace` + * (`@solidjs/web/frames/trace`) and `regions` (`@solidjs/web/frames/regions`) + * have built-in loaders that an entry here replaces. See + * `installServerComponents`. */ - tiers?: Record Promise<{ install?(): void }>>; + tiers?: Record Promise>; } /** @@ -1674,13 +1688,15 @@ export interface InstallOptions { * call again to rebind to a custom host. * * `options.tiers` maps a frames-client tier's name to its loader (`() => - * import(...)`, the module exporting `install()`): the client resolves - * tier chunks itself, so the server announces NAMES only - * (`_$HY.r["sc:tiers"]`, `X-Frame-Tiers`) and the loads start here from - * the document's record — the `modulepreload` the document may also carry - * made the fetch warm. The built-in table carries `trace` (the container - * tier's client half, `@solidjs/web/frames/trace`); a loader given here - * for a name replaces the built-in one (tests gate a tier's load this way). + * import(...)`, the module whose exports are the tier's appliers, with an + * optional `install()`): the client resolves tier chunks itself, so the + * server announces NAMES only (`_$HY.r["sc:tiers"]`, `X-Frame-Tiers`) and + * the loads start here from the document's record — the `modulepreload` + * the document may also carry made the fetch warm. The built-in table + * carries `trace` (the container tier's client half, + * `@solidjs/web/frames/trace`) and `regions` (nested server-content + * regions, `@solidjs/web/frames/regions`); a loader given here for a name + * replaces the built-in one (tests gate a tier's load this way). * @experimental */ export function installServerComponents(host: any = getFrameHost(), options?: InstallOptions) { diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 70bbe505b..3b03cddcb 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -1110,19 +1110,41 @@ export const FRAME_APPLIED_EVENT = "frame:applied"; // holds, so an un-announced response converges to the same DOM. // // `tierLoaders` is the seam a tier plugs into: `name -> () => import(...)`, -// the module exporting an `install()` that registers its appliers into -// this runtime's dispatch. The built-in table (the frames client entry, -// client.ts) carries the tiers that have been cut — `trace`, the container -// tier's client half (plan step C3) — and `installServerComponents({ tiers })` -// adds or replaces entries; a name with no loader is eager and resident by -// definition (`bind`, `regions`, `assets`, `wire` today). -export const tierLoaders: Record Promise<{ install?(): void }>> = {}; +// the module whose exports are the tier's APPLIERS — the functions this +// runtime dispatches to once the module is resident (`tierModules`) — and +// whose optional `install()` writes whatever state lives elsewhere (the +// traces tier sets the shared host's `revive` and the plugin's +// materializer). The built-in table (the frames client entry, client.ts) +// carries the tiers that have been cut — `trace`, the container tier's +// client half (plan step C3); `regions`, nested server-content regions +// (C4) — and `installServerComponents({ tiers })` adds or replaces +// entries; a name with no loader is eager and resident by definition +// (`bind`, `assets`, `wire` today). +/** + * A frames-client tier's module, as its loader resolves it: its exports are + * the tier's appliers — the functions the runtime dispatches to once the + * module is resident (the regions tier's `resolve` / `bind` / …) — and the + * optional `install()` runs once at the load, for state that lives outside + * the runtime's dispatch (the traces tier sets the shared host's `revive`). + * @experimental + */ +export interface TierModule { + install?(): void; + [applier: string]: unknown; +} +export const tierLoaders: Record Promise> = {}; // `name -> the load`, a promise stamped `r` (resident) once the module has // installed. One per name for the page's lifetime: tiers never uninstall. // Exported for the tier specs alone (a test re-arms a tier's hold by // deleting its load; the dist's entry never re-exports it). /** @internal */ export const tierLoads = {}; +// `name -> the installed module`: the dispatch table. A frame reaches a +// tier's appliers through it (`tierModules.regions?.bind(...)`); absent +// until the load installs, so every read is conditional on residency — and +// the readiness checks (`tierReady`) guarantee a record that NEEDS a tier +// never reaches an applier before it is here. +const tierModules = {}; // Every live frame, so an install can wake them all: a frame whose sync // held an occurrence on the tier re-syncs and mounts it; the rest see a // no-op flush. @@ -1142,11 +1164,14 @@ export function prepareTier(name) { const loader = tierLoaders[name]; tierLoads[name] = load = loader ? loader().then(module => { - // The install: the module registers its appliers, then one flush - // per live frame — the write is empty, so a frame re-walks what - // it holds and applies what the tier now makes applicable (the - // held occurrence mounts and its hold releases; a buffered - // record applies). A frame with no version yet keeps none. + // The install: the module's exports become the tier's appliers + // (`tierModules`), its `install` hook writes any state that lives + // elsewhere, then one flush per live frame — the write is empty, + // so a frame re-walks what it holds and applies what the tier now + // makes applicable (the held occurrence mounts and its hold + // releases; a buffered record applies). A frame with no version + // yet keeps none. + tierModules[name] = module; load.r = true; module.install?.(); for (const frame of liveFrames) frame.apply({ version: frame.version, r: {} }); @@ -1163,6 +1188,19 @@ export function prepareTier(name) { */ const tierReady = name => !tierLoaders[name] || prepareTier(name).r; +// The regions tier's reason to wait (frames savings pass §1, "regions"; §3 +// row C4): a record naming a `{$frame}` region — the host noted the args +// that are addressing (`record.regions`, see `settleArgs`) — while the +// tier that resolves it to a region element and binds a frame over it is +// absent. One test, on the note the host already made (no arg walk). A +// fresh mount waits in the held set (adopt path: the hold registers under +// frames-rulings 3.1, the server interior stays on screen); a MOUNTED +// occurrence's new record stays pending in the store — the live binding +// keeps showing the previous args — until the install's flush re-syncs. +// Either way the check starts the load (`tierReady`): the un-announced +// fallback. +const needsRegions = record => record.regions && !tierReady("regions"); + // The traces tier's reason to hold (frames savings pass §1, "traces"): a // container-trace marker — `{ $tr, $ta }`, the eval face's literal for a // trace (frame-container-plugin.js) — somewhere in a record's args while @@ -1193,6 +1231,10 @@ class FrameImpl { #start; #end; #options; + // The frame this one is a region OF (`options.parent`, set by the regions + // tier at bind): slot callbacks, records and record removal thread up + // this link (`#resolveSlot` …). (`#parent` is the DOM parent, below.) + #outer; #version; #store = Object.create(null); // The applied state is keyed by RECORD identity (frames-rulings 2.1, @@ -1226,7 +1268,10 @@ class FrameImpl { #slotCleanups = new Map(); #slotArgs = new Map(); #slotUpdaters = new Map(); - #slotRegions = new Map(); + // No region state here: a frame's nested server-content regions — the + // `{$frame}` args' elements and the frames bound over them — are the + // REGIONS TIER's (`@solidjs/web/frames/regions`, plan C4), kept by that + // module per frame and reached through `tierModules.regions`. #slotNodes = new Map(); // Data occurrences (§9.2.3): the consumer set last handed to the mount, // and the mount's rebind callback (`ctx.onRebind`) for when it changes. @@ -1276,6 +1321,7 @@ class FrameImpl { this.#end = end; this.#options = options; this.#slots = options.slots; + this.#outer = options.parent; // Enumerable for a tier's install (see `prepareTier`), until disposal. liveFrames.add(this); // Adopt: the boundary already holds server-rendered content, so the first @@ -1403,36 +1449,30 @@ class FrameImpl { preview(records, inherited?) { const adopted = new Set(); if (this.#disposed) return adopted; + const R = tierModules.regions; for (const key in records) { const record = records[key]; if (!record || record.kind !== "slot" || !key.startsWith("slot:")) continue; if (inherited && key in this.#store) continue; const occurrence = key.slice(5); const update = this.#mountedSlots.has(occurrence) && this.#slotUpdaters.get(occurrence); - if (!update || this.#regionsChange(occurrence, record)) continue; + // A record that adds a region to the occurrence or renames one of its + // regions is not previewed (its chunks ride the new name, so the + // rename lands with them at the commit) — the regions tier reads its + // cache for that; with the tier absent no region can exist yet, so + // any region the record names is an addition. + if (!update || (R ? R.changed(this, occurrence, record) : record.regions)) continue; this.#slotArgs.set(occurrence, record); adopted.add(key); update(this.#resolveArgs(occurrence, record)); } - for (const regions of this.#slotRegions.values()) - for (const entry of regions.values()) - if (entry.frame) for (const key of entry.frame.preview(records, true)) adopted.add(key); + if (R) + for (const frame of R.frames(this)) + for (const key of frame.preview(records, true)) adopted.add(key); if (!inherited) for (const key of adopted) this.#store[key] = records[key]; return adopted; } - /** Whether the record adds a region to the occurrence or renames one of - * its regions (its chunks ride the new name, so the rename lands with - * them). Read off the host's note of the record's `{$frame}` args. */ - #regionsChange(occurrence, record) { - const regions = this.#slotRegions.get(occurrence); - for (const key in record.regions) { - const entry = regions && regions.get(key); - if (!(entry && entry.childId === record.regions[key])) return true; - } - return false; - } - /** * The applied state is one version's (frames-rulings 2.1): the version * bump and the rebind replace it wholesale — the store (every record of @@ -1538,9 +1578,17 @@ class FrameImpl { this.#syncSlots(); } - /** Resolve a slot callback by prop: this frame's slots, then ancestors'. */ + /** + * Resolve a slot callback by prop: this frame's slots, then ancestors'. + * A nested region frame carries its parent (`options.parent`, set by the + * regions tier when it binds the region — frame-internal, not a public + * option), and the three thread-ups below walk that link: private + * access across instances of this class, so the chain costs no closure + * per region. (`#outer && #outer.#x()`, not `parent?.#x()` — TypeScript + * rejects a private name in an optional chain, TS18030.) + */ #resolveSlot(prop) { - return this.#slots?.[prop] ?? this.#options.resolveSlot?.(prop); + return this.#slots?.[prop] ?? (this.#outer && this.#outer.#resolveSlot(prop)); } /** @@ -1553,7 +1601,7 @@ class FrameImpl { #resolveSlotRecord(occurrence) { const record = this.#store[`slot:${occurrence}`]; if (record !== undefined) return record; - return this.#options.resolveSlotRecord?.(occurrence); + return this.#outer && this.#outer.#resolveSlotRecord(occurrence); } /** @@ -1575,7 +1623,7 @@ class FrameImpl { const key = `slot:${occurrence}`; if (key in this.#store) { if (!this.#mountedSlots.has(occurrence)) delete this.#store[key]; - } else this.#options.removeSlotRecord?.(occurrence); + } else this.#outer && this.#outer.#removeSlotRecord(occurrence); } // `root`, when given, scopes discovery to a detached fragment instead of the @@ -1585,7 +1633,7 @@ class FrameImpl { // skipped by the next full sync; the unmount sweep is full-frame-only (a // scoped fill only ADDS occurrences, never removes the frame's others). #syncSlots(root) { - if (!this.#slots && !this.#options.resolveSlot) return; + if (!this.#slots && !this.#outer) return; // Range-driven discovery: find every server-owned slot occurrence in this // frame's content. An occurrence id is the marker key (e.g. "children" or @@ -1605,6 +1653,10 @@ class FrameImpl { // record, for a `{$ref}`'s data: a claim the frame owes the page and has // not made yet (see the hold at the end). let waiting = false; + // The regions tier's appliers, if resident (an install cannot land + // mid-sync: it is a load's continuation). Absent, nothing in this sync + // can need them — a record naming a region waits (`needsRegions`). + const R = tierModules.regions; for (const [occurrence, start] of found) { const callback = this.#resolveSlot(propOf(occurrence)); @@ -1682,8 +1734,9 @@ class FrameImpl { // A fresh mount also waits for the TIER its occurrence needs (frames // savings pass §2 — the server-announced tier mechanism): a data // occurrence needs `bind` (its positions), a called occurrence whose - // record names a region needs `regions`, one whose literal args carry - // a container-trace marker needs `trace` (`needsTrace` — the marker + // record names a region needs `regions` (`needsRegions` — the host's + // note of the `{$frame}` args), one whose literal args carry a + // container-trace marker needs `trace` (`needsTrace` — the marker // walk, run only while that tier is absent). Resident tiers cost one // test; an absent one has its load started by the check (`tierReady`) // and the occurrence stays as the server left it — its interior on @@ -1696,7 +1749,7 @@ class FrameImpl { ((record && record.pending) || (consumers ? !tierReady("bind") - : record && ((record.regions && !tierReady("regions")) || needsTrace(record.args)))) + : record && (needsRegions(record) || needsTrace(record.args)))) ) { waiting = true; // Remember what the adopted interior was rendered from. A hold is @@ -1728,15 +1781,13 @@ class FrameImpl { // stream introduces mount with EMPTY interiors (the producer ships // bare marker pairs), so consumers' existing-content gate already // excludes them from claiming. - // Discover the interior's region elements BEFORE invoking, on the - // adopt path — claim wiring, not identity recovery (A5): the t=0 - // record names every region arg by `{$frame}` address; discovery's - // job is locating the already-rendered ELEMENTS those addresses - // resolve to, so #resolveArgs hands the wrapper the adopted node - // instead of minting an empty one. A fresh mount has no interior - // regions yet; discovery is a no-op then, and #resolveArgs creates - // its entries during the invoke instead. - if (this.#options.adopt) this.#discoverRegions(occurrence, start); + // Regions (the regions tier, through #resolveArgs): on the adopt + // path the record's `{$frame}` args resolve to the region ELEMENTS + // already rendered in the interior — the tier discovers them before + // the fill runs, claim wiring, not identity recovery (A5) — so the + // wrapper is handed the adopted node instead of an empty one; a + // fresh mount has no interior regions yet and the tier mints its + // elements during the invoke. // A held occurrence mounts with the record it was held on (see the // hold above); a current record that differs applies right after, // through the mounted path below. @@ -1764,14 +1815,13 @@ class FrameImpl { this.#slotNodes.set(occurrence, nodes); } this.#mountedSlots.add(occurrence); - // Re-scan after invoke: a fresh mount's regions come from - // #resolveArgs during the invoke, and the callback's output may have - // introduced more. A claim on the adopt path (nodes === null) left - // the interior untouched, so the pre-invoke discovery already saw - // everything — skip the repeat walk (it is per-occurrence over a - // large adopted tree). - if (!this.#options.adopt || nodes) this.#discoverRegions(occurrence, start); - this.#bindRegions(occurrence); + // Bind the occurrence's regions (the tier): a frame over each region + // element — those #resolveArgs minted or found, plus a re-scan of + // the fill's OUTPUT when it wrote one (`nodes`): a claim on the + // adopt path left the interior untouched, so the pre-invoke + // discovery already saw everything — the repeat walk (per + // occurrence, over a large adopted tree) is skipped. + R?.bind(this, occurrence, nodes && start); if (mountRecord === record || !record || record.kind !== "slot") continue; } // A mounted data occurrence whose CONSUMERS changed — a morph replaced @@ -1788,6 +1838,15 @@ class FrameImpl { if (rebind) rebind(consumers); } if (record !== this.#slotArgs.get(occurrence)) { + // A new record that names a region while the regions tier is absent + // (a refetch adding a `{$frame}` arg to a mounted occurrence — the + // response announced the tier, its load is in flight) is NOT taken: + // it stays pending in the store, the live binding keeps the args it + // shows, and the install's flush re-syncs to here with the tier in + // place. The check starts the load when nothing announced it. (A + // mounted occurrence reaching here has a record: a called one + // without left above, a bare one's is `undefined` on both sides.) + if (needsRegions(record)) continue; // A re-sent record, live binding (the mount registered // ctx.onUpdate): push the re-resolved props into the LIVE // occurrence instead of re-calling — the consumer's reactive props @@ -1807,7 +1866,7 @@ class FrameImpl { const props = this.#resolveArgs(occurrence, record); this.#slotArgs.set(occurrence, record); update(props); - this.#bindRegions(occurrence); + R?.bind(this, occurrence); continue; } // Args changed (incl. late args): re-call this occurrence only, @@ -1823,7 +1882,7 @@ class FrameImpl { if (nodes) this.#replaceRange(occurrence, start, nodes); this.#slotNodes.set(occurrence, nodes); } - this.#bindRegions(occurrence); + R?.bind(this, occurrence); } } @@ -1919,14 +1978,14 @@ class FrameImpl { }; // One record shape (A5): the t=0 record carries used regions as // `{$frame}` refs like any stream record would, and #resolveArgs - // resolves them to the elements #discoverRegions seeded from the - // adopted interior — the wrapper's own reactivity OWNS the - // already-rendered element from the first render (a client-only - // toggle can hide/show it at t=0, no re-arming stream needed). - // `adopted` doubles as the claiming hint: this mount is about to hydrate - // server markup rendered from these args (see #resolveArgs). + // resolves them to the elements the regions tier discovers in the + // adopted interior (`start`, on the adopt path) — the wrapper's own + // reactivity OWNS the already-rendered element from the first render + // (a client-only toggle can hide/show it at t=0, no re-arming stream + // needed). `adopted` doubles as the claiming hint: this mount is about + // to hydrate server markup rendered from these args (see #resolveArgs). const props = - record && record.kind === "slot" ? this.#resolveArgs(occurrence, record, adopted) : {}; + record && record.kind === "slot" ? this.#resolveArgs(occurrence, record, adopted, start) : {}; // Run under the boundary's owner (when the creator provided one): slot // content reads the mount point's context (routers, stores) and bounds // its lifetime there. The t=0 adopt sync happens to run inside the @@ -1959,11 +2018,10 @@ class FrameImpl { this.#heldRecords.delete(key); this.#removeSlotRecord(key); this.#runSlotCleanups(key); - const regions = this.#slotRegions.get(key); - if (regions) { - disposeRegions(regions); - this.#slotRegions.delete(key); - } + // The occurrence's regions (the tier's): their frames dispose, the + // entries go. Nothing to do while the tier is absent — no region was + // ever bound. + tierModules.regions?.unmount(this, key); } #runSlotCleanups(key) { @@ -1973,10 +2031,15 @@ class FrameImpl { for (const fn of cleanups) fn(); } - #regionsFor(slotKey) { - let regions = this.#slotRegions.get(slotKey); - if (!regions) this.#slotRegions.set(slotKey, (regions = new Map())); - return regions; + /** + * The frame's options, for the regions tier: a nested region frame + * inherits the host, the owner scope and the claim scope of the frame it + * is bound under (`regions-tier.ts`, `bind`). Not on the `Frame` + * interface. + * @internal + */ + get options() { + return this.#options; } /** @@ -1988,23 +2051,19 @@ class FrameImpl { * - frame ref `{$frame}` (named in `record.regions`) -> a nested * reconciled region delivered as a frame ELEMENT the wrapper places, * **cached per slot** so a re-call reuses the same element and its - * bound frame. + * bound frame — the REGIONS TIER's work (`regions-tier.ts`, `resolve`; + * this runs only with the tier resident: a record naming a region + * waits for it, `needsRegions`). On the adopt path (`claiming`, with + * the range's `start`) the tier first discovers the region elements + * already rendered in the interior, so the wrapper is handed those. * - a literal -> through the host's `revive` (document-face container * traces arrive as inline markers), HERE rather than at the write: an * adopted occurrence's claim must read the container as the markup * was rendered from it, and the materializer keys that on the claim * (frames-rulings 3.6 (iii)) — `claiming` is that hint, true for the * adopt-time mount (`#invokeSlot`'s `adopted`), threaded to `revive`. - * - * Regions cache by ARG NAME, not wire id: `(occurrence, arg)` IS the - * region's identity, while its `$frame` childId is a per-stream wire name - * — different producers prefix it differently (the document and direct - * responses render under the function id, a single-flight region under - * the call's address). A re-sent ref whose only change is the wire name - * keeps the region — same element, same live interior — and the bound - * frame REBINDS to the new name so the incoming stream's chunks reach it. */ - #resolveArgs(slotKey, record, claiming) { + #resolveArgs(slotKey, record, claiming, start) { const { args, regions, decoded } = record; const revive = this.#options.host && this.#options.host.revive; const props = {}; @@ -2013,80 +2072,10 @@ class FrameImpl { const value = args[key]; props[key] = revive && !(decoded && key in decoded) ? revive(value, claiming) : value; } - if (regions) { - const cache = this.#regionsFor(slotKey); - for (const key in regions) { - // A nested server-content region: a single frame ELEMENT the wrapper - // places. On re-call the wrapper re-places the SAME element (the - // platform moves the subtree as one node — no marker range to walk, - // no fragment refill), and the bound frame's parent follows live. - const childId = regions[key]; - let entry = cache.get(key); - if (!entry) { - cache.set( - key, - (entry = { childId, element: makeFrameElement(childId), frame: undefined }) - ); - } else if (entry.childId !== childId) renameRegion(entry, childId); - props[key] = entry.element; - } - } + if (regions) tierModules.regions.resolve(this, slotKey, regions, props, claiming && start); return props; } - /** - * Element discovery for the adopt path — claim wiring only (A5): the t=0 - * record names every region arg by `{$frame}` address, and this walk - * locates the already-rendered ELEMENTS those addresses resolve to, - * seeding entries (marked `adopt`) so `#resolveArgs` reuses the adopted - * node instead of minting an empty one. Seed from the OUTERMOST region - * elements in the interior (a region's own deeper regions belong to its - * occurrences and are discovered recursively when those claim); - * `#bindRegions` then constructs adopting frames over them, which run - * their own slot sync — this is what wires nested occurrences at boot. - */ - #discoverRegions(slotKey, start) { - // A data occurrence has no interior (its args are data; a region arg - // has nowhere to render at an attribute position). - if (!start || Array.isArray(start)) return; - const regions = this.#regionsFor(slotKey); - eachInRange(start, slotKey, n => collectRegionElements(n, regions)); - } - - #bindRegions(slotKey) { - const regions = this.#slotRegions.get(slotKey); - if (!regions) return; - for (const entry of regions.values()) { - if (!entry.frame) { - // Bind eagerly — the region ELEMENT always exists (unlike the old - // marker range, which needed placement in a fragment/DOM to count). - // This is what makes an OCCLUDED region work: its element is created - // when args resolve but the wrapper doesn't place it until (e.g.) - // expand, so it must bind and fill (from buffered/streamed chunks) - // off-DOM, then reveal in place when the wrapper finally inserts the - // single node. Host buffering flushes any queued childId chunks. The - // region inherits this frame's slot resolution, so client slots - // revealed in its streamed content are filled by the same callbacks - // the client threaded down — no global registry. - entry.frame = new FrameImpl(entry.element, null, null, { - id: entry.childId, - host: this.#options.host, - // Regions discovered from adopted document elements already hold - // their server-rendered content (adopt); streamed regions start - // empty. Claim scoping threads the root boundary's id down. - adopt: entry.adopt, - claimScope: this.#options.claimScope ?? this.#options.id, - // Element-claim sweeps in the region bind cleanup to the same - // boundary owner as the root's. - ownerScope: this.#options.ownerScope, - resolveSlot: prop => this.#resolveSlot(prop), - resolveSlotRecord: occurrence => this.#resolveSlotRecord(occurrence), - removeSlotRecord: occurrence => this.#removeSlotRecord(occurrence) - }); - } - } - } - /** 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) { @@ -2193,8 +2182,8 @@ class FrameImpl { // (an ancestor's, for a region frame's nested occurrences) so a torn-down // region leaves nothing stale to dedupe a later re-navigation against. for (const key of this.#mountedSlots) this.#removeSlotRecord(key); - for (const regions of this.#slotRegions.values()) disposeRegions(regions); - this.#slotRegions.clear(); + // Every region bound under this frame disposes with it (the tier's). + tierModules.regions?.unmount(this); this.#mountedSlots.clear(); } @@ -2563,16 +2552,22 @@ export function createFrameElement(options) { * defined: an undefined custom element is inert HTMLUnknownElement, and * `display:contents` makes it generate no box, so its children lay out in the * frame's parent. + * + * Exported (with `isFrameElement` and `eachInRange`) for the regions tier + * (`regions-tier.ts`), which bundles its own copy of these pure helpers; + * the eager entry re-exports none of them. + * @internal */ -function makeFrameElement(id) { +export function makeFrameElement(id) { const el = document.createElement(FRAME_TAG); el.style.display = "contents"; if (id !== undefined) el.setAttribute(FRAME_ID_ATTR, id); return el; } -/** Whether `node` is a frame boundary/region element (carries our id attr). */ -function isFrameElement(node) { +/** Whether `node` is a frame boundary/region element (carries our id attr). + * @internal */ +export function isFrameElement(node) { return node.nodeType === ELEMENT_NODE && node.hasAttribute(FRAME_ID_ATTR); } @@ -2594,11 +2589,6 @@ function isCalled(occurrence) { return occurrence.indexOf("#") !== -1; } -/** Dispose every bound frame in a slot's region-entry map. */ -function disposeRegions(regions) { - for (const { frame } of regions.values()) frame?.dispose(); -} - /** Remove siblings from `n` (inclusive) up to `stop` (exclusive). */ function removeUntil(parent, n, stop) { while (n && n !== stop) { @@ -2824,56 +2814,14 @@ function collectSlots(n, end, out, elements) { } } -/** - * Collect the OUTERMOST frame region elements in `node`'s subtree into - * `regions` (keyed by arg name — the childId's final segment, always the - * dot-free arg identifier — seeded for adoption). A region is opaque — its - * own deeper regions belong to its occurrences, discovered when they claim — - * so the walk stops descending at each region element. Client wrapper - * elements around a region are descended through. - */ -function collectRegionElements(node, regions) { - if (node.nodeType !== ELEMENT_NODE) return; - if (isFrameElement(node)) { - const childId = node.getAttribute(FRAME_ID_ATTR); - // Region ids are dotted (`..`); bare ids - // belong to nested document BOUNDARIES, which own their interiors (the - // walk stops at every frame element, so a nested boundary's own regions - // are never reachable from here). The producer prefix is wire-relative — - // a mount registered under a call ADDRESS still adopts markup produced - // under the function id — so region membership is structural - // (outermost-in-this-interior), not prefix-matched. - if (childId && childId.includes(".")) { - const argKey = childId.slice(childId.lastIndexOf(".") + 1); - if (!regions.has(argKey)) { - regions.set(argKey, { childId, element: node, frame: undefined, adopt: true }); - } - } - return; - } - for (let c = node.firstChild; c; c = c.nextSibling) collectRegionElements(c, regions); -} - -/** - * Point a cached region entry at a new wire name: the identity (occurrence, - * arg) and the live element/interior stay, while the bound frame re-registers - * under the id the incoming stream addresses its content by. An entry not - * bound yet (discovery just seeded it) only updates its element's id — the - * eager bind that follows registers under the new name. - */ -function renameRegion(entry, childId) { - entry.childId = childId; - if (entry.frame) entry.frame.rebind(childId); - else entry.element.setAttribute(FRAME_ID_ATTR, childId); -} - /** * Walk a slot range's interior — every node between `start` and the range's * end marker — calling `cb` on each. The next sibling is captured before the * callback runs, so callbacks may detach the node. Returns the end marker * (null if the range is truncated). + * @internal (exported for the regions tier — see `makeFrameElement`) */ -function eachInRange(start, key, cb) { +export function eachInRange(start, key, cb) { const end = slotEnd(key); let n = start.nextSibling; while (n && !(n.nodeType === COMMENT_NODE && n.data === end)) { diff --git a/packages/web/frames/src/regions-tier.ts b/packages/web/frames/src/regions-tier.ts new file mode 100644 index 000000000..d572b0e4b --- /dev/null +++ b/packages/web/frames/src/regions-tier.ts @@ -0,0 +1,272 @@ +/** + * `@solidjs/web/frames/regions` — the frames client's REGIONS tier (frames + * savings pass §3 row C4): nested server-content regions, loaded on demand + * through the tier mechanism (`prepareTier("regions")`, frame-client.ts). + * + * A region is server content passed as a PROP to a client fill (`{$frame}` + * in the occurrence's record, the arg's wire id): a `` region + * ELEMENT the wrapper places like any node — the platform moves the subtree + * as one — with a frame bound over it that the host routes the region's + * chunks to, so the region fills and morphs wherever (and whether) the + * wrapper placed it. Occluded regions (not placed yet — behind an expand) + * bind and fill off-DOM, then reveal in place when the wrapper inserts the + * single node. On the document face a used region's content is already in + * the adopted interior, and the record's `{$frame}` resolves to THAT + * element (claim wiring, not identity recovery — A5). + * + * What rides in this chunk, and so leaves the eager frames client: the + * per-frame region cache (arg name -> entry), element discovery in an + * adopted interior, the `{$frame}` arm of the frame's arg resolution + * (mint / reuse / rename), binding a frame over each region element with + * the parent's slot resolution threaded down, and disposal. The eager + * client keeps one test — a record naming a region while this tier is + * absent WAITS (`needsRegions`: a fresh mount in the held set, the frame's + * hold registered under frames-rulings 3.1 on the adopt path; a mounted + * occurrence's new record pending in the store) — and the document face's + * `sc:region:` drain, which lands an occluded region's html in the host + * store regardless (the frame this tier binds seeds from it on install). + * + * The module's exports ARE its appliers: `prepareTier` records the module + * in the runtime's dispatch table (`tierModules.regions`) and the frame + * calls `resolve` / `bind` / `unmount` / `changed` / `frames` through it; + * no `install()` is needed — nothing of this tier lives outside the + * runtime's dispatch. After the install, one flush per live frame re-syncs + * what the tier made applicable: a held occurrence mounts with its region + * element, a pending record applies. + * + * The server announces this tier where it mints a region (`sink.needs + * ("regions")` in `region()`, the document face's `documentNeeds("regions")` + * where a slot arg is server content — frame-sink.ts), so the load is a + * warm start; an un-announced record starts it from the readiness check + * and waits (the same DOM, later). + * + * Region identity and wire names: the cache keys by ARG NAME, because + * `(occurrence, arg)` IS the region's identity while its `$frame` childId + * is a per-stream wire name — producers prefix it differently (the + * document and direct responses render under the function id, a + * single-flight region under the call's address). A re-sent ref whose only + * change is the wire name keeps the region — same element, same live + * interior — and the bound frame REBINDS to the new name so the incoming + * stream's chunks reach it (`rename`). This is the compensation + * server-components-principles.md §4 row 19 names; it deletes when regions + * become store substructure keyed `(parent address, occurrence, arg)` (§5.3, + * the SC audit's S7) and the wire id normalizes at the store boundary — not + * here, where a direct response and a flight refresh of the same boundary + * still address one region by two names. + * @experimental + */ +// The eager frames client — the SHARED instance the app runs (external in the +// dist build: rollup.config.js's externalizeFramesClient resolves this to +// `@solidjs/web/frames`; a bundled copy would mint frames nobody routes to). +// `createFrame` is how the tier binds a frame over a region element: the +// runtime's own class, registered with the parent's host. +import { createFrame } from "./client.js"; +// Pure DOM helpers, bundled into this chunk as their own copy (no module +// state, no instance to keep in agreement — frame-client is importless by +// design). +import { eachInRange, isFrameElement, makeFrameElement, FRAME_ID_ATTR } from "./frame-client.js"; + +/** One region of an occurrence: its wire id, its element, the frame bound + * over it once `bind` ran, and whether the element was adopted from the + * document (its content is already there). */ +interface RegionEntry { + childId: string; + element: Element; + frame: any; + adopt?: boolean; +} +type Regions = Map; + +// Per frame: occurrence -> (arg name -> entry). Weak, so a frame that goes +// away takes its cache with it even if `unmount` never ran for it. +const byFrame = new WeakMap>(); + +const regionsFor = (frame: any, occurrence: string): Regions => { + let slots = byFrame.get(frame); + if (!slots) byFrame.set(frame, (slots = new Map())); + let regions = slots.get(occurrence); + if (!regions) slots.set(occurrence, (regions = new Map())); + return regions; +}; + +/** + * Collect the OUTERMOST frame region elements in `node`'s subtree into + * `regions` (keyed by arg name — the childId's final segment, always the + * dot-free arg identifier — seeded for adoption). A region is opaque — its + * own deeper regions belong to its occurrences, discovered when they claim — + * so the walk stops descending at each region element. Client wrapper + * elements around a region are descended through. + */ +function collectRegionElements(node: Node, regions: Regions): void { + if (node.nodeType !== 1) return; + if (isFrameElement(node)) { + const childId = (node as Element).getAttribute(FRAME_ID_ATTR); + // Region ids are dotted (`..`); bare ids + // belong to nested document BOUNDARIES, which own their interiors (the + // walk stops at every frame element, so a nested boundary's own regions + // are never reachable from here). The producer prefix is wire-relative — + // a mount registered under a call ADDRESS still adopts markup produced + // under the function id — so region membership is structural + // (outermost-in-this-interior), not prefix-matched. + if (childId && childId.includes(".")) { + const argKey = childId.slice(childId.lastIndexOf(".") + 1); + if (!regions.has(argKey)) + regions.set(argKey, { childId, element: node as Element, frame: undefined, adopt: true }); + } + return; + } + for (let c = node.firstChild; c; c = c.nextSibling) collectRegionElements(c, regions); +} + +/** + * Element discovery for the adopt path — claim wiring only (A5): the t=0 + * record names every region arg by `{$frame}` address, and this walk + * locates the already-rendered ELEMENTS those addresses resolve to, + * seeding entries (marked `adopt`) so `resolve` reuses the adopted node + * instead of minting an empty one. Seed from the OUTERMOST region elements + * in the interior (a region's own deeper regions belong to its + * occurrences and are discovered recursively when those claim); `bind` + * then constructs adopting frames over them, which run their own slot + * sync — this is what wires nested occurrences at boot. A data occurrence + * (`start` is its consumer list) has no interior: its args are data, and a + * region arg has nowhere to render at an attribute position. + */ +function discover(frame: any, occurrence: string, start: unknown): void { + if (!start || Array.isArray(start)) return; + const regions = regionsFor(frame, occurrence); + eachInRange(start, occurrence, (n: Node) => collectRegionElements(n, regions)); +} + +/** + * Point a cached region entry at a new wire name: the identity (occurrence, + * arg) and the live element/interior stay, while the bound frame re-registers + * under the id the incoming stream addresses its content by. An entry not + * bound yet (discovery just seeded it) only updates its element's id — the + * eager bind that follows registers under the new name. + */ +function rename(entry: RegionEntry, childId: string): void { + entry.childId = childId; + if (entry.frame) entry.frame.rebind(childId); + else entry.element.setAttribute(FRAME_ID_ATTR, childId); +} + +/** + * The `{$frame}` arm of a frame's arg resolution: for each region arg of + * the record (`regions`: arg name -> wire id), the region ELEMENT the + * wrapper places goes into `props` — cached per occurrence, so a re-call + * re-places the SAME element (no marker range to walk, no fragment refill) + * and the bound frame's parent follows live. `start` is given on the + * adopt-time mount (the frame's `claiming`): the interior's region elements + * are discovered first, so the record's addresses resolve to them. + */ +export function resolve( + frame: any, + occurrence: string, + regions: Record, + props: Record, + start?: unknown +): void { + if (start) discover(frame, occurrence, start); + const cache = regionsFor(frame, occurrence); + for (const key in regions) { + const childId = regions[key]; + let entry = cache.get(key); + if (!entry) + cache.set(key, (entry = { childId, element: makeFrameElement(childId), frame: undefined })); + else if (entry.childId !== childId) rename(entry, childId); + props[key] = entry.element; + } +} + +/** + * Bind a frame over each of the occurrence's region elements that has none + * yet. With `start` (the fill wrote the range: a fresh mount's output, or + * an adopted interior the fill replaced), the output is re-scanned first — + * a fresh mount's regions come from `resolve` during the invoke, and the + * output may have introduced more. Bind eagerly — the region ELEMENT + * always exists (unlike a marker range, which needs placement to count). + * This is what makes an OCCLUDED region work: its element is created when + * args resolve but the wrapper doesn't place it until (e.g.) expand, so it + * must bind and fill (from buffered/streamed chunks) off-DOM, then reveal + * in place when the wrapper finally inserts the single node. Host + * buffering flushes any queued childId chunks. The region inherits this + * frame's slot resolution (`parent`), so client slots revealed in its + * streamed content are filled by the same callbacks the client threaded + * down — no global registry. + */ +export function bind(frame: any, occurrence: string, start?: unknown): void { + if (start) discover(frame, occurrence, start); + const regions = byFrame.get(frame)?.get(occurrence); + if (!regions) return; + const o = frame.options; + for (const entry of regions.values()) { + if (!entry.frame) { + entry.frame = createFrame(entry.element, { + id: entry.childId, + host: o.host, + // Regions discovered from adopted document elements already hold + // their server-rendered content (adopt); streamed regions start + // empty. Claim scoping threads the root boundary's id down. + adopt: entry.adopt, + claimScope: o.claimScope ?? o.id, + // Element-claim sweeps in the region bind cleanup to the same + // boundary owner as the root's. + ownerScope: o.ownerScope, + parent: frame + } as any); + } + } +} + +/** + * Dispose the frames bound over an occurrence's regions and forget them; + * without an occurrence, every region of the frame (the frame is being + * disposed). + */ +export function unmount(frame: any, occurrence?: string): void { + const slots = byFrame.get(frame); + if (!slots) return; + const dispose = (regions: Regions) => { + for (const { frame: bound } of regions.values()) bound?.dispose(); + }; + if (occurrence === undefined) { + for (const regions of slots.values()) dispose(regions); + byFrame.delete(frame); + } else { + const regions = slots.get(occurrence); + if (regions) { + dispose(regions); + slots.delete(occurrence); + } + } +} + +/** + * For the staged preview (`Frame.preview`): whether the record adds a + * region to the occurrence or renames one of its regions — its chunks ride + * the new name, so the rename must land with them at the commit, not in + * the preview. Read off the host's note of the record's `{$frame}` args. + */ +export function changed( + frame: any, + occurrence: string, + record: { regions?: Record } +): boolean { + const regions = byFrame.get(frame)?.get(occurrence); + for (const key in record.regions) { + const entry = regions && regions.get(key); + if (!(entry && entry.childId === record.regions[key])) return true; + } + return false; +} + +/** The frames bound over the frame's regions (the staged preview recurses + * into them). */ +export function frames(frame: any): any[] { + const out: any[] = []; + const slots = byFrame.get(frame); + if (slots) + for (const regions of slots.values()) + for (const { frame: bound } of regions.values()) if (bound) out.push(bound); + return out; +} diff --git a/packages/web/package.json b/packages/web/package.json index 492ba604a..7c1c3606e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -337,6 +337,10 @@ "types": "./types/frames/trace-tier.d.ts", "default": "./frames/dist/trace.js" }, + "./frames/regions": { + "types": "./types/frames/regions-tier.d.ts", + "default": "./frames/dist/regions.js" + }, "./types/*": "./types/*" }, "scripts": { diff --git a/packages/web/rollup.config.js b/packages/web/rollup.config.js index 3bac9f3ea..7f1fdd84f 100644 --- a/packages/web/rollup.config.js +++ b/packages/web/rollup.config.js @@ -106,19 +106,24 @@ const externalizeSharedClient = { } }; -// The frames TRACES TIER entry (`frames/src/trace-tier.ts` → -// `@solidjs/web/frames/trace`, loaded by the frames client through -// `prepareTier("trace")`) wires itself into the eager frames client at -// install — the shared host's `revive` (`getFrameHost()`). That must be the -// SAME frames client instance the app mounted its boundaries through, so the -// tier's import of the client entry resolves to the external package -// specifier, never to a bundled private copy (whose `getFrameHost()` would -// mint a host nothing reads). Same instance-identity reasoning as -// externalizeSharedClient above. +// The frames TIER entries (`frames/src/trace-tier.ts` → +// `@solidjs/web/frames/trace`, `frames/src/regions-tier.ts` → +// `@solidjs/web/frames/regions`; loaded by the frames client through +// `prepareTier(name)`) wire themselves into the eager frames client: the +// traces tier sets the shared host's `revive` (`getFrameHost()`) at install, +// the regions tier binds frames (`createFrame`) the parent frame's host +// routes to. That must be the SAME frames client instance the app mounted +// its boundaries through, so a tier's import of the client entry resolves +// to the external package specifier, never to a bundled private copy (whose +// `getFrameHost()` would mint a host nothing reads, whose `createFrame` +// would mint frames nothing routes to). Same instance-identity reasoning as +// externalizeSharedClient above. A tier's imports of frame-client.js's pure +// helpers stay bundled (its own copy — the module is importless and keeps +// no state those helpers read). const externalizeFramesClient = { name: "externalize-frames-client", resolveId(source, importer) { - if (!importer || !/[\\/]trace-tier\.(js|ts)$/.test(importer)) return null; + if (!importer || !/[\\/](trace|regions)-tier\.(js|ts)$/.test(importer)) return null; if (source === "./client.js") return { id: "@solidjs/web/frames", external: true }; return null; } @@ -398,7 +403,12 @@ export default [ // engine's one edge into a server-component page) and the plugin's // client half — loads behind the server's announcement or the first // adopt-time record whose args carry a trace. Its own entry below. - "@solidjs/web/frames/trace" + "@solidjs/web/frames/trace", + // Lazily imported (`tierLoaders.regions`): the regions tier — nested + // server-content regions (`{$frame}` args) — loads behind the + // server's announcement or the first record naming a region. Its own + // entry below. + "@solidjs/web/frames/regions" ], // Prod build: strip `_SOLID_DEV_` like the main `dist/web.js` entry, so the // frame runtime's dev checks/warnings (marker-integrity diagnostics) do @@ -421,7 +431,8 @@ export default [ "seroval-plugins/web", "@solidjs/web/server-functions/client", "@solidjs/web/serialization/decode", - "@solidjs/web/frames/trace" + "@solidjs/web/frames/trace", + "@solidjs/web/frames/regions" ], plugins: [replaceFlags(false, true), externalizeSharedTransport] .concat(plugins) @@ -440,7 +451,8 @@ export default [ "seroval-plugins/web", "@solidjs/web/server-functions/client", "@solidjs/web/serialization/decode", - "@solidjs/web/frames/trace" + "@solidjs/web/frames/trace", + "@solidjs/web/frames/regions" ], plugins: [replaceDev(true), externalizeSharedTransport] .concat(plugins) @@ -467,6 +479,22 @@ export default [ ], plugins: [externalizeFramesClient].concat(plugins) }, + { + // The regions tier (`@solidjs/web/frames/regions`, frames/src/regions-tier.ts): + // nested server-content regions as a lazy chunk the frames client loads + // through `prepareTier("regions")` — the per-frame region cache, + // discovery in an adopted interior, the `{$frame}` arm of arg + // resolution, the bind of a frame over each region element, disposal. + // Bundles its own copy of frame-client.js's pure DOM helpers + // (`eachInRange`, `makeFrameElement`, `isFrameElement`); the eager + // client entry is external by instance (see externalizeFramesClient) — + // `createFrame` must be the runtime the app's host routes to. No + // `_SOLID_DEV_` gates of its own, so one build serves every condition. + input: "frames/src/regions-tier.ts", + output: { file: "frames/dist/regions.js", format: "es" }, + external: ["solid-js", "solid-js/internal", "@solidjs/web"], + plugins: [externalizeFramesClient].concat(plugins) + }, { // Prod build, like the main server entry above: the sink's own // `_SOLID_DEV_` gates (and the bundled observe emitters') must strip — diff --git a/packages/web/test/consistency/tier-prepare.spec.tsx b/packages/web/test/consistency/tier-prepare.spec.tsx index 98a248a26..78fb4dde8 100644 --- a/packages/web/test/consistency/tier-prepare.spec.tsx +++ b/packages/web/test/consistency/tier-prepare.spec.tsx @@ -31,7 +31,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createRoot, Loading } from "solid-js"; import { dynamic, hydrate } from "@solidjs/web"; -import { prepareTier, tierLoaders } from "../../frames/src/frame-client.js"; +import { prepareTier, tierLoaders, type TierModule } from "../../frames/src/frame-client.js"; import { applyFrameResponse, installServerComponents } from "../../frames/src/client.js"; import { createServerReference } from "../../server-functions/src/client.js"; import { createChunk } from "../../server-functions/src/shared.js"; @@ -48,13 +48,15 @@ import { type Page } from "./support.js"; -/** A loader the test settles: the import's promise, and the module. */ +/** A loader the test settles: the import's promise, and the module — the + * test's `install` hook over the appliers of `module` (a real tier's, when + * the mount the install flushes needs them). */ function deferredTier() { - let resolve!: (m: { install?(): void }) => void; - const promise = new Promise<{ install?(): void }>(r => (resolve = r)); + let resolve!: (m: TierModule) => void; + const promise = new Promise(r => (resolve = r)); const install = vi.fn(); const loader = vi.fn(() => promise); - return { loader, install, resolve: () => resolve({ install }) }; + return { loader, install, resolve: (module?: object) => resolve({ ...module, install }) }; } /** A held frame-stream Response with the given extra headers. */ @@ -151,7 +153,11 @@ describe("the held set — bind (adopt path, un-announced: detection starts the }); describe("the stream path — regions (announced on the head; the record buffers until the install)", () => { - // The ONE test for `regions` while it is not resident. + // The ONE test for `regions` while it is not resident. `regions` is a REAL + // tier since C4 (`@solidjs/web/frames/regions`): the mount the install + // flushes resolves its `{$frame}` through the module's appliers, so the + // deferred loader settles with the real module (the install hook is the + // test's own, counted the same way). test("`X-Frame-Tiers` starts the load before the body is read; the occurrence's record stays pending in the store until the install's flush mounts it", async () => { const regions = deferredTier(); const WIRE = "tier/regions-wire"; @@ -213,9 +219,10 @@ describe("the stream path — regions (announced on the head; the record buffers expect(div.querySelector("article")).not.toBeNull(); expect(mounts).toBe(0); expect(div.querySelector(".wrap")).toBeNull(); - // The install: one flush, the pending record applies — the occurrence - // mounts with the record it was held on, its region inside. - regions.resolve(); + // The install (the real tier's appliers, the test's hook): one flush, + // the pending record applies — the occurrence mounts with the record it + // was held on, its region inside. + regions.resolve(await import("../../frames/src/regions-tier.js")); await pump(); expect(regions.install).toHaveBeenCalledTimes(1); expect(mounts).toBe(1); diff --git a/packages/web/test/consistency/tier-regions-hold.spec.tsx b/packages/web/test/consistency/tier-regions-hold.spec.tsx new file mode 100644 index 000000000..54d2de9e3 --- /dev/null +++ b/packages/web/test/consistency/tier-regions-hold.spec.tsx @@ -0,0 +1,366 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * The regions tier's timing pin (frames savings pass §1, row "regions"; §3 + * row C4). The race: a `slot:` record naming a `{$frame}` ref — or a + * `data-fid` region element inside adopted content — before + * `@solidjs/web/frames/regions` has loaded. Lost, the fill would receive + * the raw `{$frame}` ref (wrong content) and an occluded region could not + * mount from the store. The bound: ANNOUNCE + HOLD / BUFFER. + * + * - Document face, un-announced (the fallback): the adopt-time sync finds + * the record names a region (`needsRegions`), starts the load and HOLDS + * the occurrence — its server interior on screen, the region element + * and its content untouched, the frame's hold registered under + * frames-rulings 3.1 (hydration-done waits). It mounts with the record + * it was held on; the tier discovers the adopted region element and + * binds a frame over it, so the stream's chunks reach it. + * - Document face, announced: `_$HY.r["sc:tiers"]` names `regions`; + * `installServerComponents` starts the import before any boundary + * adopts (the warm start the `modulepreload` made a cache hit). + * - An occluded region's `sc:region:` record drained BEFORE the tier lands + * in the host store regardless (the eager drain arm); the frame the + * tier binds on install seeds from it — the region shows the record's + * html once the fill places it, with nothing re-delivered. + * - Stream face, a MOUNTED occurrence: a new record naming a region while + * the tier is absent is not taken — the live binding keeps the args it + * shows, the record stays pending in the store — and the install's + * flush applies it: the region element is placed and its html lands. + * + * A tier, once resident, stays so for the worker: each test gates the load + * itself (`installServerComponents({ tiers })` replaces the built-in loader) + * and drops it between tests (`tierLoads`, the runtime's test seam). + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createRoot, Loading } from "solid-js"; +import { dynamic, hydrate } from "@solidjs/web"; +import { installServerComponents } from "../../frames/src/client.js"; +import { tierLoads } from "../../frames/src/frame-client.js"; +import { createServerReference } from "../../server-functions/src/client.js"; +import { + bootPage, + fillKey, + frameHtml, + freshFid, + hydrationInProgress, + makeHost, + onHydrationEnd, + openFrameResponse, + pump, + quiesce, + slotRange, + type Page +} from "./support.js"; + +/** The regions tier's load, gated by the test; `release()` installs the real module. */ +function gatedRegions() { + delete (tierLoads as any).regions; + let resolve!: (m: any) => void; + const loader = vi.fn(() => new Promise(r => (resolve = r))); + return { + tiers: { regions: loader }, + loader, + release: async () => resolve(await import("../../frames/src/regions-tier.js")) + }; +} +const resident = () => !!(tierLoads as any).regions?.r; + +/** A used region's element as the document carries it inside a fill's output. */ +const regionHtml = (childId: string, inner: string) => + `${inner}`; +/** The server render of `p =>
  • {p.body}
  • ` with `body` a used region. */ +const regionFillHtml = (fid: string, occurrence: string, childId: string, inner: string) => + `
  • ${regionHtml(childId, inner)}
  • `; + +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; + document.body.innerHTML = ""; +}); + +describe("the regions tier — document face", () => { + test("un-announced: a `{$frame}` in an adopted record before the load holds the occurrence (interior and region content on screen, hydration waits); it mounts with the held record on install, the adopted region element bound and reachable by the stream", async () => { + const gate = gatedRegions(); + const fid = freshFid("tier-regions-a"); + const childId = `${fid}.item#0.body`; + page = bootPage( + frameHtml( + fid, + `
      ${slotRange("item#0", regionFillHtml(fid, "item#0", childId, "server body"))}
    ` + ), + { tiers: gate.tiers } + ); + page.slotRecord(fid, "item#0", { label: "first", body: { $frame: childId } }); + // Nothing announced: no load at install. + expect(gate.loader).not.toHaveBeenCalled(); + const Comp = (globalThis as any)._$SC.r(fid); + const li = page.container.querySelector("li")!; + const regionEl = page.container.querySelector(`solid-frame[data-fid="${childId}"]`)!; + const em = page.container.querySelector("em")!; + const mounted: string[] = []; + const bodies: unknown[] = []; + let mountedAtEnd = -1; + let inProgressAtEnd: boolean | undefined; + const dispose = hydrate( + () => ( + { + mounted.push(p.label); + bodies.push(p.body); + return
  • {p.body}
  • ; + }} + /> + ), + page.container + ); + disposers.push(dispose); + onHydrationEnd(() => { + mountedAtEnd = mounted.length; + inProgressAtEnd = hydrationInProgress(); + }); + await quiesce(); + // The adopt-time sync found the record names a region with the tier + // absent: it started the load (once) and HELD — no fill ran, the + // server's nodes are untouched down to the region's content, and + // hydration is not done (3.1: the hold is a pending boundary). + expect(gate.loader).toHaveBeenCalledTimes(1); + expect(resident()).toBe(false); + expect(mounted).toEqual([]); + expect(page.container.querySelector("li")).toBe(li); + expect(page.container.querySelector(`solid-frame[data-fid="${childId}"]`)).toBe(regionEl); + expect(page.container.querySelector("em")).toBe(em); + expect(em.textContent).toBe("server body"); + expect(hydrationInProgress()).toBe(true); + expect(mountedAtEnd).toBe(-1); + expect(page.errors).toEqual([]); + + // The tier lands: its appliers register, then one flush per live frame. + // The occurrence mounts ONCE, with the record it was held on; the + // `{$frame}` resolves to the ADOPTED region element (discovered in the + // interior, not minted), which the fill places back where it was. + await gate.release(); + await quiesce(); + await quiesce(); + expect(resident()).toBe(true); + expect(mounted).toEqual(["first"]); + expect(bodies).toEqual([regionEl]); + expect(page.container.querySelector("li")).toBe(li); + expect(page.container.querySelector(`solid-frame[data-fid="${childId}"]`)).toBe(regionEl); + expect(em.textContent).toBe("server body"); + // Hydration-done came after the mount, not before; nothing warned, no + // `TypeError` from a `{$frame}` handed to the fill raw. + expect(hydrationInProgress()).toBe(false); + expect(inProgressAtEnd).toBe(false); + expect(mountedAtEnd).toBe(1); + expect(page.warnings.filter(w => w.includes("unclaimed"))).toEqual([]); + expect(page.errors).toEqual([]); + + // The region is a live frame now: a later chunk addressed to its wire + // id morphs its interior in place. + page.host.apply({ type: "html", id: childId, version: 1, html: "streamed body" }); + await quiesce(); + expect(page.container.querySelector(`solid-frame[data-fid="${childId}"]`)).toBe(regionEl); + expect(regionEl.textContent).toBe("streamed body"); + expect(page.errors).toEqual([]); + }); + + test('announced: `_$HY.r["sc:tiers"]` names `regions` — the import starts at install, before any boundary adopts; the held occurrence mounts on the install', async () => { + const gate = gatedRegions(); + const fid = freshFid("tier-regions-b"); + const childId = `${fid}.item#0.body`; + page = bootPage( + frameHtml( + fid, + `
      ${slotRange("item#0", regionFillHtml(fid, "item#0", childId, "one"))}
    ` + ), + { tiers: gate.tiers, records: { "sc:tiers": ["regions"] } } + ); + // Started at install (the record), ahead of the adopt-time sync. + expect(gate.loader).toHaveBeenCalledTimes(1); + expect(resident()).toBe(false); + page.slotRecord(fid, "item#0", { body: { $frame: childId } }); + const Comp = (globalThis as any)._$SC.r(fid); + let mounts = 0; + const dispose = hydrate( + () => ( + { + mounts++; + return
  • {p.body}
  • ; + }} + /> + ), + page.container + ); + disposers.push(dispose); + await quiesce(); + // Held on the load the announcement started; asked no second time. + expect(mounts).toBe(0); + expect(gate.loader).toHaveBeenCalledTimes(1); + expect(hydrationInProgress()).toBe(true); + await gate.release(); + await quiesce(); + await quiesce(); + expect(mounts).toBe(1); + expect(page.container.textContent).toBe("one"); + expect(hydrationInProgress()).toBe(false); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + }); + + test("an occluded region's `sc:region:` record drained before the tier lands in the store; the frame the tier binds on install seeds from it when the fill places the region", async () => { + const gate = gatedRegions(); + const fid = freshFid("tier-regions-c"); + const childId = `${fid}.item#0.body`; + // The fill's server render placed NO region (occluded — behind an + // expand), so the interior is the fill's shell alone and the region's + // html rides the `sc:region:` record. + page = bootPage( + frameHtml( + fid, + `
      ${slotRange("item#0", `
    • `)}
    ` + ), + { tiers: gate.tiers } + ); + page.slotRecord(fid, "item#0", { body: { $frame: childId } }); + page.regionRecord(childId, "

    occluded body

    "); + const Comp = (globalThis as any)._$SC.r(fid); + let region: Element | undefined; + let mounts = 0; + const dispose = hydrate( + () => ( + { + mounts++; + region = p.body; + // Claims the shell in place; places the region later, on expand. + return ( +
  • + +
  • + ); + }} + /> + ), + page.container + ); + disposers.push(dispose); + await quiesce(); + // Held on the tier; the drain already ran (the records are the page's). + expect(gate.loader).toHaveBeenCalledTimes(1); + expect(mounts).toBe(0); + expect(hydrationInProgress()).toBe(true); + // The region's record is consumed from `_$HY.r` by the adopt-time + // drain — before the tier — and sits in the host store under the + // region's id; delete it from the page so nothing could re-deliver it. + delete page.hy.r[`sc:region:${childId}`]; + await gate.release(); + await quiesce(); + await quiesce(); + expect(mounts).toBe(1); + expect(hydrationInProgress()).toBe(false); + // The region element exists (minted by the tier: nothing to discover in + // the interior) and its frame — bound off-DOM — already holds the + // record's html from the store seed. + expect(region).toBeInstanceOf(Element); + expect(region!.getAttribute("data-fid")).toBe(childId); + expect(region!.isConnected).toBe(false); + expect(region!.textContent).toBe("occluded body"); + // The wrapper expands: the single node goes in, content and all. + page.container.querySelector("li")!.appendChild(region!); + expect(page.container.querySelector("li")!.textContent).toBe("expandoccluded body"); + expect(page.errors).toEqual([]); + }); +}); + +describe("the regions tier — stream face, a mounted occurrence", () => { + test("a new record naming a region while the tier is absent is not taken: the live binding keeps its args, the record stays pending, and the install's flush applies it — the region element placed, its html landing", async () => { + const gate = gatedRegions(); + const WIRE = "tier-regions/stream-wire"; + const fid = freshFid("tier-regions-d"); + const getPanel = createServerReference(fid); + const held = openFrameResponse(WIRE); + vi.stubGlobal("fetch", async () => held.response); + const { host } = makeHost(); + installServerComponents(host, { tiers: gate.tiers }); + const Page = dynamic(() => getPanel() as any); + const labels: string[] = []; + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + fallback}> + { + labels.push(p.label); + return ( +
    + {p.label} + {p.body} +
    + ); + }} + /> +
    +
    ; + document.body.appendChild(div); + return d; + }); + disposers.push(dispose); + await pump(); + // The first response: no region, the tier is not needed — the + // occurrence mounts and shows its label. + held.send({ type: "start", id: WIRE, version: 1 }); + held.send({ type: "slot", id: WIRE, version: 1, key: "panel#0", args: { label: "plain" } }); + held.send({ + type: "html", + id: WIRE, + version: 1, + html: "
    " + }); + held.send({ type: "complete", id: WIRE, version: 1 }); + held.close(); + await pump(); + expect(labels).toEqual(["plain"]); + expect(div.querySelector(".wrap b")!.textContent).toBe("plain"); + expect(gate.loader).not.toHaveBeenCalled(); + + // A refetch (the next version) adds a `{$frame}` arg — un-announced, + // written to the ADDRESS the mount is bound to (the argless call's: its + // function id). The record is NOT taken while the tier is absent: the + // live binding shows the first record's label, the region's html warms + // its store, and the check started the load. + const childId = `${fid}.panel#0.body`; + host.apply({ + type: "slot", + id: fid, + version: 2, + key: "panel#0", + args: { label: "with region", body: { $frame: childId } } + }); + host.apply({ type: "html", id: childId, version: 1, html: "region body" }); + await pump(); + expect(gate.loader).toHaveBeenCalledTimes(1); + expect(resident()).toBe(false); + expect(div.querySelector(".wrap b")!.textContent).toBe("plain"); + expect(div.querySelector(".wrap solid-frame")).toBeNull(); + expect(labels).toEqual(["plain"]); + + // The install: the pending record applies into the LIVE binding (no + // re-call), the region element is placed and seeds from its store. + await gate.release(); + await pump(); + expect(resident()).toBe(true); + expect(labels).toEqual(["plain"]); + expect(div.querySelector(".wrap b")!.textContent).toBe("with region"); + const region = div.querySelector(".wrap solid-frame")!; + expect(region).not.toBeNull(); + expect(region.getAttribute("data-fid")).toBe(childId); + expect(region.textContent).toBe("region body"); + }); +}); diff --git a/packages/web/test/frames-hn-client.spec.tsx b/packages/web/test/frames-hn-client.spec.tsx index a6fb1b47e..7dac165e6 100644 --- a/packages/web/test/frames-hn-client.spec.tsx +++ b/packages/web/test/frames-hn-client.spec.tsx @@ -11,16 +11,26 @@ // already contains the comments AND the client wrappers' markup; slots // claim their rendered DOM via ctx.existing (Astro-style opaque slots) — // zero hydration data, the page source carries each text once. -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { createRoot, createSignal, flush, Loading } from "solid-js"; import { dynamic } from "../src/index.js"; import { installServerComponents, createFrame, createFrameHost } from "../frames/src/client.js"; +import { prepareTier } from "../frames/src/frame-client.js"; import { createJSONDataTable } from "../serialization/src/serializer.js"; import { createServerReference } from "../server-functions/src/client.js"; import { createChunk } from "../server-functions/src/shared.js"; const settle = () => new Promise(r => setTimeout(r)); +// The hand-framed responses below announce no tier; the story comments are +// `{$frame}` regions, so the frames client's REGIONS TIER +// (`@solidjs/web/frames/regions`, loaded through `prepareTier("regions")` at +// the first record naming one) is warmed here — these cells pin the slice's +// UX with the tier resident, as the production host has it after that +// first load; the load itself is pinned in +// `test/consistency/tier-regions-hold.spec.tsx`. +beforeAll(() => prepareTier("regions")); + function frameResponse(chunks: any[]) { const body = new ReadableStream({ start(c) { diff --git a/packages/web/test/frames-occlusion-client.spec.tsx b/packages/web/test/frames-occlusion-client.spec.tsx index a930159c7..d9712166d 100644 --- a/packages/web/test/frames-occlusion-client.spec.tsx +++ b/packages/web/test/frames-occlusion-client.spec.tsx @@ -8,15 +8,24 @@ // documentBoundary must drain those records into the host BEFORE binding // the adopting frame, so the first slot sync claims WITH real args and the // wrapper mounts the body from the frame store on expand — zero network. -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { createRoot, createSignal, flush, Loading } from "solid-js"; import { dynamic } from "../src/index.js"; import { installServerComponents, createFrameHost } from "../frames/src/client.js"; +import { prepareTier } from "../frames/src/frame-client.js"; import { createJSONDataTable } from "../serialization/src/serializer.js"; import { createServerReference } from "../server-functions/src/client.js"; const settle = () => new Promise(r => setTimeout(r)); +// Occluded regions are the REGIONS TIER's (`@solidjs/web/frames/regions`, +// loaded through `prepareTier("regions")` at the first record naming one; +// the document pages here announce nothing). Warmed, so these cells pin the +// occlusion records with the tier resident; the load itself — the hold, +// the `sc:region:` html landing in the store before it — is pinned in +// `test/consistency/tier-regions-hold.spec.tsx`. +beforeAll(() => prepareTier("regions")); + function makeHost() { const table = createJSONDataTable(); return createFrameHost({ diff --git a/packages/web/test/frames-optimistic-hold.spec.tsx b/packages/web/test/frames-optimistic-hold.spec.tsx index 9e6ec5958..f226e0319 100644 --- a/packages/web/test/frames-optimistic-hold.spec.tsx +++ b/packages/web/test/frames-optimistic-hold.spec.tsx @@ -16,7 +16,7 @@ // multi-flight — the mutation returns plain data; the action then // `refresh`es the source the boundary reads, and the // refetched region arrives on its own response. -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { action, createMemo, @@ -29,7 +29,7 @@ import { } from "solid-js"; import { dynamic } from "../src/index.js"; import { installServerComponents } from "../frames/src/client.js"; -import { FRAME_ID_ATTR } from "../frames/src/frame-client.js"; +import { FRAME_ID_ATTR, prepareTier } from "../frames/src/frame-client.js"; import { SERVER_COMPONENT, SERVER_COMPONENT_ADDRESS, @@ -161,6 +161,14 @@ function expectHeld(trace: string[]) { expect(trace.at(-1)).toBe("true/true"); } +// The nested-region cells below (`{$frame}` args in hand-framed responses +// that announce no tier) run with the frames client's REGIONS TIER +// (`@solidjs/web/frames/regions`, loaded through `prepareTier("regions")` at +// the first record naming one) RESIDENT — what this file pins is the hold +// of optimism over a region's fill, not the tier's load (that is +// `test/consistency/tier-regions-hold.spec.tsx`). +beforeAll(() => prepareTier("regions")); + const unsubscribes: (() => void)[] = []; afterEach(() => { vi.unstubAllGlobals(); diff --git a/packages/web/test/frames-used-region-client.spec.tsx b/packages/web/test/frames-used-region-client.spec.tsx index 5d30f0a33..26c62d2d0 100644 --- a/packages/web/test/frames-used-region-client.spec.tsx +++ b/packages/web/test/frames-used-region-client.spec.tsx @@ -12,15 +12,24 @@ // config doesn't compile hydratable JSX, so claimRender's registry path — // which needs `_hk` on the adopted wrapper — can't engage here.) // Own spec file: the boundary marker index is a once-per-boot module cache. -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { createRoot, flush, Loading } from "solid-js"; import { dynamic } from "../src/index.js"; import { installServerComponents, createFrameHost } from "../frames/src/client.js"; +import { prepareTier } from "../frames/src/frame-client.js"; import { createJSONDataTable } from "../serialization/src/serializer.js"; import { createServerReference } from "../server-functions/src/client.js"; const settle = () => new Promise(r => setTimeout(r)); +// The `{$frame}` region the changed record introduces is the REGIONS TIER's +// (`@solidjs/web/frames/regions`, loaded through `prepareTier("regions")` at +// the first record naming one; the hand-applied chunks here announce +// nothing). Warmed, so this cell pins the #547 guarantee with the tier +// resident; the load itself is pinned in +// `test/consistency/tier-regions-hold.spec.tsx`. +beforeAll(() => prepareTier("regions")); + function makeHost() { const table = createJSONDataTable(); return createFrameHost({ diff --git a/packages/web/test/lifecycle-matrix/MATRIX.md b/packages/web/test/lifecycle-matrix/MATRIX.md index 51c6d49a8..71f6b44a9 100644 --- a/packages/web/test/lifecycle-matrix/MATRIX.md +++ b/packages/web/test/lifecycle-matrix/MATRIX.md @@ -63,7 +63,7 @@ is mount-kind-independent; enumerated once where it is richest) · **existing** | `{$ref}` object args resolve via the streamed data table (rich values incl. Date) | `call-driven-args` › `call-driven/args/data-refs` | pass | | `{$ref}` re-sent, decodes EQUAL → adopted silently (no re-call, no props churn) | `call-driven-args` › `call-driven/args/data-refs` (same value) | pass | | `{$ref}` re-sent, decodes DIFFERENT → live-props update | `call-driven-args` › `call-driven/args/data-refs` (different value) | pass | -| `{$frame}` region: streams into the wrapper; interior survives root morphs; REBINDS on wire-name change (new stream reaches the same element) | `call-driven-args` › `call-driven/args/regions` | pass | +| `{$frame}` region: streams into the wrapper; interior survives root morphs; REBINDS on wire-name change (new stream reaches the same element) | `call-driven-args` › `call-driven/args/regions` | pass — with the frames client's REGIONS TIER resident (`@solidjs/web/frames/regions`, frames savings pass §3 row C4; warmed by `prepareTier("regions")` as the production host has it after the first record naming a region). The tier's load — the adopt-time hold under frames-rulings 3.1, the stream record pending until the install, the occluded `sc:region:` html seeding the bound frame — is pinned in `test/consistency/tier-regions-hold.spec.tsx` | | promise arg: read suspends on the fill's own ``, settles on the patch record | `call-driven-args` › `call-driven/args/async-values` (promise) | pass | | async-iterable arg: read updates per yield; last value holds between yields | `call-driven-args` › `call-driven/args/async-values` (iterable) | pass | | async-occluded region records at adoption | — | existing — `frames-occlusion-client.spec.tsx` (sync + promise-valued `sc:region:` records) | diff --git a/packages/web/test/lifecycle-matrix/call-driven-args.spec.tsx b/packages/web/test/lifecycle-matrix/call-driven-args.spec.tsx index cee6eb030..cf25a85f0 100644 --- a/packages/web/test/lifecycle-matrix/call-driven-args.spec.tsx +++ b/packages/web/test/lifecycle-matrix/call-driven-args.spec.tsx @@ -5,14 +5,24 @@ // Lifecycle matrix — mount kind: FRESH CALL-DRIVEN MOUNT, crossed with the // ARG TIER dimension: scalars ride the chunk, `{$ref}` args resolve against // the response's streamed data table (async values settle through patch -// records), `{$frame}` args are nested server regions. See MATRIX.md. -import { afterEach, describe, expect, test, vi } from "vitest"; +// records), `{$frame}` args are nested server regions — the frames client's +// REGIONS TIER (`@solidjs/web/frames/regions`, frames savings pass §3 row +// C4), fetched through `prepareTier("regions")` behind the server's +// announcement or the first record naming a region. The region cell runs +// with it RESIDENT (warmed below, as the production host has it after that +// first load); the load itself — the hold, the buffered record — is pinned +// in `test/consistency/tier-regions-hold.spec.tsx`. See MATRIX.md. +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { createMemo, createRoot, createSignal, flush, Loading } from "solid-js"; import { dynamic } from "../../src/index.js"; import { installServerComponents } from "../../frames/src/client.js"; +import { prepareTier } from "../../frames/src/frame-client.js"; import { createServerReference } from "../../server-functions/src/client.js"; import { makeHost, frameResponse, dataChunks, createDataSource, pump, settle } from "./harness.js"; +// The regions tier, resident before any cell runs (see the header). +beforeAll(() => prepareTier("regions")); + const getScalars = createServerReference("matrix/args/scalars"); const getRef = createServerReference("matrix/args/ref"); const getRefSame = createServerReference("matrix/args/ref-same"); diff --git a/packages/web/test/server/frame-hn.spec.tsx b/packages/web/test/server/frame-hn.spec.tsx index 5c45042b1..b5bcf6044 100644 --- a/packages/web/test/server/frame-hn.spec.tsx +++ b/packages/web/test/server/frame-hn.spec.tsx @@ -12,7 +12,7 @@ // serialization between html and slot args (transport dispatch case // 1) — and the request carries only the story id (client collapse state is // server-invisible). -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; // @ts-expect-error jsdom ships no types; used only to fabricate a document import { JSDOM } from "jsdom"; globalThis.document = new JSDOM("").window.document; @@ -24,7 +24,21 @@ import { registerServerFunction } from "../../server-functions/src/server.js"; import { createJSONDataTable } from "../../serialization/src/serializer.js"; -import { createFrame, createFrameHost } from "../../frames/src/frame-client.js"; +import { + createFrame, + createFrameHost, + prepareTier, + tierLoaders +} from "../../frames/src/frame-client.js"; + +// The client half here is the frame RUNTIME alone (frame-client.ts), not +// the frames client entry — so the entry's built-in tier loaders are not +// registered. The nested regions this slice is about are the REGIONS TIER's +// (`@solidjs/web/frames/regions`, frames savings pass §3 row C4): wire its +// loader as the entry does and warm it, so every region binds as it would +// on a page that announced it. +tierLoaders.regions = () => import("../../frames/src/regions-tier.js"); +beforeAll(() => prepareTier("regions")); type CommentData = { id: number; text: string; replies: CommentData[] }; diff --git a/packages/web/tsconfig.build.json b/packages/web/tsconfig.build.json index 5e9b6fa08..7a027fc1c 100644 --- a/packages/web/tsconfig.build.json +++ b/packages/web/tsconfig.build.json @@ -13,7 +13,8 @@ "@solidjs/web/server-functions/client": ["./server-functions/src/client.ts"], "@solidjs/web/serialization": ["./serialization/src/serializer.ts"], "@solidjs/web/serialization/decode": ["./serialization/src/serializer-decode.ts"], - "@solidjs/web/frames/trace": ["./frames/src/trace-tier.ts"] + "@solidjs/web/frames/trace": ["./frames/src/trace-tier.ts"], + "@solidjs/web/frames/regions": ["./frames/src/regions-tier.ts"] } }, "include": [ diff --git a/packages/web/vite.config.hydrate.mjs b/packages/web/vite.config.hydrate.mjs index e4f8d119e..7e81b522c 100644 --- a/packages/web/vite.config.hydrate.mjs +++ b/packages/web/vite.config.hydrate.mjs @@ -42,13 +42,11 @@ export default defineConfig({ rootDir, "serialization/src/serializer-decode.ts" ), - "@solidjs/web/serialization": resolve( - rootDir, - "serialization/src/serializer.ts" - ), - // The frames client's traces tier (lazy, through the packaged - // specifier) — to the source, for the same single-instance reason. + "@solidjs/web/serialization": resolve(rootDir, "serialization/src/serializer.ts"), + // The frames client's tiers (lazy, through the packaged specifiers) — + // to the source, for the same single-instance reason. "@solidjs/web/frames/trace": resolve(rootDir, "frames/src/trace-tier.ts"), + "@solidjs/web/frames/regions": resolve(rootDir, "frames/src/regions-tier.ts"), "@solidjs/web": resolve(rootDir, "src/index.ts") } } diff --git a/packages/web/vite.config.mjs b/packages/web/vite.config.mjs index e70188111..ba0cbfece 100644 --- a/packages/web/vite.config.mjs +++ b/packages/web/vite.config.mjs @@ -51,22 +51,18 @@ export default defineConfig({ // The frames specs stub fetch/createServerReference against the // runtime SOURCE, so route the specifier to that same module — one // instance, like every from-source consumer of this seam. - "@solidjs/web/server-functions/client": resolve( - rootDir, - "server-functions/src/client.ts" - ), + "@solidjs/web/server-functions/client": resolve(rootDir, "server-functions/src/client.ts"), "@solidjs/web/serialization/decode": resolve( rootDir, "serialization/src/serializer-decode.ts" ), - "@solidjs/web/serialization": resolve( - rootDir, - "serialization/src/serializer.ts" - ), - // The frames client's traces tier, lazy-imported through the packaged - // specifier (external in its dist build); route it to the source so - // the tier installs into the same client instance the specs drive. - "@solidjs/web/frames/trace": resolve(rootDir, "frames/src/trace-tier.ts") + "@solidjs/web/serialization": resolve(rootDir, "serialization/src/serializer.ts"), + // The frames client's tiers, lazy-imported through the packaged + // specifiers (external in their dist builds); route them to the + // source so a tier installs into the same client instance the specs + // drive. + "@solidjs/web/frames/trace": resolve(rootDir, "frames/src/trace-tier.ts"), + "@solidjs/web/frames/regions": resolve(rootDir, "frames/src/regions-tier.ts") } } }); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index c2fb32f32..36212950d 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -12,12 +12,12 @@ "minified": 52794 }, "page: base server components (hydrating + dynamic + frames + sf reference)": { - "cap": "38.61 KB", - "minified": 122028 + "cap": "38.33 KB", + "minified": 120945 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "42.16 KB", - "minified": 133899 + "cap": "41.90 KB", + "minified": 132816 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index ce54078af..1a3cab8f8 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -74,12 +74,15 @@ const floorMinified = Object.fromEntries( // `solid-js/internal/container-trace` entry, plus the plugin's client half; // the store engine's one edge into these pages) is its second dynamic import // and reports the same way: `trace.js` is the tier + the engine, lazy, not -// counted. The "frames: eager client consumer" scenario measures the -// package; these measure the page. Subpath aliases first (see above) — the -// two tier specifiers before `@solidjs/web/frames` and `solid-js/internal`, -// which would otherwise swallow them. +// counted; its REGIONS TIER (`@solidjs/web/frames/regions` — plan step C4, +// 2026-10-06: nested server-content regions) is the third, `regions.js`. +// The "frames: eager client consumer" scenario measures the package; these +// measure the page. Subpath aliases first (see above) — the tier specifiers +// before `@solidjs/web/frames` and `solid-js/internal`, which would +// otherwise swallow them. const pageAlias = { "@solidjs/web/frames/trace": "../../packages/web/frames/dist/trace.js", + "@solidjs/web/frames/regions": "../../packages/web/frames/dist/regions.js", "solid-js/internal/container-trace": "../../packages/solid/dist/container-trace.js", "@solidjs/web/server-functions/client": "../../packages/web/server-functions/dist/client.js", "@solidjs/web/server-functions": "../../packages/web/server-functions/dist/client.js", @@ -122,9 +125,11 @@ const framesExternal = [ "solid-js/internal", // The traces tier: lazily imported by the frames client (plan step C3, // 2026-10-06) — external like the codec, so this scenario keeps measuring - // the eager graph alone; its own `solid-js` entry rides with it. + // the eager graph alone; its own `solid-js` entry rides with it. The + // regions tier (C4) likewise. "solid-js/internal/container-trace", "@solidjs/web/frames/trace", + "@solidjs/web/frames/regions", "@solidjs/web", "@solidjs/web/serialization", "@solidjs/web/serialization/decode" @@ -3438,8 +3443,23 @@ module.exports = [ // Cap unchanged (over it); recorded minified lowered to 42,968 B (the // ratchet: a cap not lowered only ever has its recorded minified // lowered). - limit: "13.79 KB", - capMinified: 42968, + // Frames savings pass C4 — the regions tier (2026-10-06): 13.79 -> 13.64 KB, + // measured at 13,629 B against the C3 head 89954fa1b's 13,866 (-237 B; + // -1,078 B minified, 42,968 -> 41,890) and `next` @ 9d89df731's 13,787 + // (-158 B; -1,524 B minified). Nested server-content regions left for the + // lazy `@solidjs/web/frames/regions` chunk (the per-frame region cache, + // discovery in an adopted interior, the `{$frame}` arm of arg + // resolution with the wire-name rename, the bind of a frame over each + // region element, disposal: -1,596 B minified / -423 B brotli, measured + // on an edited dist copy); the glue left behind — the installed-module + // table, the loader entry, the `needsRegions` wait at the fresh-mount + // and update sites, the tier calls at bind / resolve / unmount / the + // staged preview, the `options` getter and the `parent` thread-up — + // costs +518 / +186. First time under the cap since Phase A: cap set at + // measured + 10 B at the 0.01 KB step (the ratchet); recorded minified + // 41,890 B. + limit: "13.64 KB", + capMinified: 41890, alias: framesAlias, external: framesExternal }, @@ -3625,6 +3645,15 @@ module.exports = [ // before), the store symbols the chunk shares with the eager one, and // the tier's trigger in the frames client (its note). Cap set at measured // + 10 B at the 0.01 KB step (the ratchet); recorded minified 122,028 B. + // Frames savings pass C4 — the regions tier (2026-10-06): 38.61 -> 38.33 KB + // (floor-caps.json), measured at 38,317 B against the C3 head + // 89954fa1b's 38,598 (-281 B; -1,083 B minified, 122,028 -> 120,945) and + // `next` @ 9d89df731's 44,882 (-6,565 B). Nested server-content regions + // leave this page's eager chunk for `regions.js` — reported above as + // lazy, not counted, 1,872 B minified / 805 B brotli — which the frames + // client fetches when the document announces the tier or a record names + // a `{$frame}` region (the frames note). Cap set at measured + 10 B at + // the 0.01 KB step (the ratchet); recorded minified 120,945 B. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -3755,6 +3784,13 @@ module.exports = [ // / 8,158 B brotli); the store hydration adapters stay. Cap set at // measured + 10 B at the 0.01 KB step (the ratchet); recorded minified // 133,899 B. + // Frames savings pass C4 — the regions tier (2026-10-06): 42.16 -> 41.90 KB + // (floor-caps.json), measured at 41,886 B against the C3 head + // 89954fa1b's 42,147 (-261 B; -1,083 B minified, 133,899 -> 132,816) and + // `next` @ 9d89df731's 48,595 (-6,709 B). The same split as the base page + // (its note): regions leave for `regions.js` (lazy, not counted, 1,872 B + // minified / 802 B brotli). Cap set at measured + 10 B at the 0.01 KB + // step (the ratchet); recorded minified 132,816 B. limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], From 920bcab321915691186c0ce38fdddb61d9807ebc Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 18:13:42 -0700 Subject: [PATCH 2/2] =?UTF-8?q?docs(plans,=20server-components):=20frames?= =?UTF-8?q?=20savings=20pass=20=E2=80=94=20C4=20landed=20(measured=20?= =?UTF-8?q?=E2=88=92237=20/=20=E2=88=92281=20/=20=E2=88=92261=20br;=20dele?= =?UTF-8?q?tion=20=E2=88=92423,=20glue=20+186=20br=20vs=20the=20=E2=89=88?= =?UTF-8?q?=20150-min=20line;=20chunk=201,872=20min=20/=20805=20br);=20pri?= =?UTF-8?q?nciples=20=C2=A74=20row=2019=20re-dispositioned=20=E2=80=94=20t?= =?UTF-8?q?he=20region=20rename=20is=20live=20(the=20flight=20path=20renam?= =?UTF-8?q?es=20under=20the=20call's=20address),=20moved=20into=20the=20re?= =?UTF-8?q?gions=20tier,=20deletes=20with=20S7's=20normalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- documentation/plans/frames-savings-pass.md | 2 +- documentation/server-components/server-components-principles.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index eed06d25f..b9993648d 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -362,7 +362,7 @@ cap on the same terms; no frames / page cap moved. | **C1** | **Holes tier** (E.a1; cheapest, buffer-only). `tier-holes.js` = `#applyHole`, `#applyAttrs` (less its owned-position arms, which are bind's), `findLiveTarget`, the hole pass, `pumpLiveChannel` + the op log + `applyLiveOp`. The eager client keeps `chunkToRecords`' `hole` / `attr` cases (records must land in the store before the tier is resident) and a one-line dispatch in `#flush`. Under the **8.0 reading** this step is skipped and holes stay eager (§6 decision 1). | **−546 / −508 / −508 / 0** (measured: `T+holes` → `L8`; page `T+holes` → `L8`; live page the same cut) | `tier-holes.js` ≈ 1,900 min / **≈ 620 br** (`est.`: the 2,116-min cut as its own module + the install glue) | `tier-holes-buffer.spec` (new, §1); C13 control + C18 catch-up arms unchanged; `frames-live-holes-*`, `document-live-*` green through the tier. Size: frames eager ≤ 11.9. | B | none (the record shapes and `sc:live` are unchanged; the hole appliers were never exported) | | **C2** | **Live wire tier** (E.a2; preload-at-call). `tier-wire.js` = `connections` / `hold` / the join-or-hold arm of `handle`, `resume` + `encodeHaveList` / `FRAME_HAVE_*`, the have-list ledger (`#have` / `have()` / `#recordHave` and the record fields that feed it), `applyFrames`' connection wiring + `connection.cancel`, `isEventStream` + the SSE reader selection, `deserializeStream`'s live arm. The eager client keeps a one-line `LIVE_WIRE` dispatch in `handle` and `bump`'s cancel hook (a no-op without the tier). `live()`'s decorator fires the `onLive` hook (set by frames through `configureServerFunctionsClient`) that calls `prepareTier("wire")` before its first fetch; the arm awaits it. | **−433 / −355 / −355 / 0** (measured: `T+wire` → `L8`; the live page keeps the chunk lazy — its eager measurement drops the same bytes) | `tier-wire.js` ≈ 1,300 min / **≈ 470 br** (`est.`) | `tier-wire-preload.spec` (new); the live suite green; the audit's `live` branch gap (22/61) closed to ≥ 45/61 in the same PR (the tier's own tests). Size: frames eager ≤ 11.5; live page unchanged ±50 (the chunk is reported, not counted). | B; independent of C1 | `ServerFunctionsClientConfig.onLive` (new, internal hook on `configureServerFunctionsClient`); `FRAME_HAVE_HEADER` / `FRAME_HAVE_BUDGET` are exported constants today and move to the tier's module — **re-export from the eager entry** to keep the surface, or flag the move | | **C3** | **Traces tier = S1 re-based** (§5; S1 merges **here**, not first). The materializer entry (`solid-js/internal/container-trace`) and `loadContainers` as S1 built them; S1's `prepareData` / `prepareArgs` / `#argsUnprepared` become B's `prepareTier("trace")` + A2's registered held set; the codec-face node scan stays as the un-announced fallback behind the header flag; the eager half of F.trace (`reviveContainerTraces` / `materialize` / `isContainerTraceMarker` / `isMaterializedContainer` / `setContainerTraceMaterializer` / `getFrameHost.revive`) moves into `container-trace.js`'s `installTier`, leaving a ≈ 150-min trigger. S1's commit 3 re-bases onto A2's park: the `claiming` hint (`revive(value, claiming?)`) and the held-record mount land here, the detached root and the backlog are already on `next`. **`container-trace-hold-hydration-end` re-pins under 3.1 at merge** (A2 is in). The +134 B frames exception S1 as built would have needed **never needs granting**: B's seam is already paid and the tier cut is a saving. **Landed (2026-10-06, `feat/frames-traces-tier`, measured before written on edited dist copies per re-attribution §7).** The chunk is `@solidjs/web/frames/trace` (frames/src/trace-tier.ts — a NEW `@solidjs/web` export path: the tier module imports solid's `solid-js/internal/container-trace` entry and the plugin's client half, and its `install()` sets the materializer on the plugin's shared state and the shared host's `revive`); `installTier` is B's `install()`, unchanged in shape. The eager client keeps the trigger alone: the loader entry, the container probe read off the plugin's registered state, the held-set predicate (`needsTrace`: the `{ $tr }` marker walk, run only while the tier is not resident — a resident tier means a decoded arg may be a live container whose traps throw), the `claiming` thread and the held-record mount (`#heldRecords`, S1 commit 3's, generalized to every adopt-path hold). S1's codec-face node scan (`prepareData(chunk)`) **dropped**: B's in-band `tiers` rides the very `data` chunk that carries the node, so the scan was unreachable behind it (keeping it measured +79 min / +25 br); the un-announced codec face (a producer predating the tier) decodes the inert marker — a skew one package never ships. The park is keyed on the claim again (3.6 "Landed" closed): `revive(value, claiming)` from the adopt-time mount; a fresh mount pays no beat. | **≈ −250 / ≈ −6,640 / ≈ −6,600 / 0** (S1's measured page savings −6,390 / −6,352 plus F.trace's eager half: 843 attributed, −289 measured as `T+trace` → `L8`, less the trigger ≈ −250; S1's +134 on frames does not recur — its two faces are B's seam. **Measured: −83 / −6,612 / −6,726 / 0** br (−484 / −24,069 / −24,161 / 0 min) against B's head; the deletion alone −239 / −8,016 / −8,031 br on an edited copy (F.trace's eager half −951 min; the engine, the materializer and the shared symbols out of the pages), the trigger +467 min / +156 br on frames (loader entry + probe +160 / +45, the held-set predicate +126 / +67, `claiming` +6 / −9, the held-record mount +175 / +53) — ≈ ×3 the ≈ 150-min estimate, which counted the loader alone; the predicate and the held-record mount were S1's and never budgeted. Page brotli lands ≈ 1.2 KB above the edited-copy figure because the real chunk pins its shared imports (store symbols, `withStoreHydration`'s adapters) in the eager graph.) | `container-trace.js` 24.3 KB / **7.86 KB br measured** (S1) + the eager half (≈ +700 min / +200 br → ≈ 8.1 KB br) **Measured: `trace.js` 25,409 min / 8,170 br** (page base; 8,158 on live) | S1's seven surviving pins green through the general seam (`frames-container-lazy-{codec,document}`, `hydration/welcome-status-lazy`, `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`); **re-pin** `container-trace-hold-hydration-end` (_hydration waits for the load; the mount claims before done_); **flip** S1's C3 (b); the `.fails` id-drift pin → 3.4 (3c). Size: frames eager ≤ 11.25; page base ≤ 36.0, live ≤ 39.8 (S1's caps 38.45 / 42.12 are superseded by these at landing). **Landed:** the seven pins green through the general seam (the hold re-armed per test by dropping the tier's load — `tierLoads`, the runtime's test seam); hydration-end re-pinned; C3 (b) was never `.fails` on `next` (the eager materializer made it pass) — it now asserts the hold and the 3.1 order; the id-drift pin stays `.fails` under 3.4 (unfixed on `next`); new `tier-trace-hold.spec` (§1's pin: un-announced hold + held-record mount, announced start-at-install, the codec wait). Harness 500 × 2 seeds: SC 0, generic (`C1,C9,C19,E` ignored) 0. Caps lowered (the ratchet): page base 44.89 → **38.61 KB**, live 48.60 → **42.16 KB**; frames eager's cap stays 13.79 (still 76 B over by Phase A's and B's bytes; its recorded minified lowered to 42,968, so the gate passes by the minified rule). | B, A2 (the hold registers; the park is on `next`), A3 | S1's: `revive(value, claiming?)`, `setContainerTraceMaterializer(…, claiming?)`, the entry, `withStoreHydration` / `applyPatches` / `forwardIteratorReturn` `@internal` on the main entry; **not shipped:** S1's `prepareData` / `prepareArgs` (replaced by `prepareTier` before they exist on `next`). `reviveContainerTraces` / `setContainerTraceMaterializer` move behind the tier — **flag**: re-export lazily-resolving wrappers or accept the move. **Landed:** the move accepted (both were `@internal` module exports, never on the public entry); new export path `@solidjs/web/frames/trace` (`install()`); `FrameHost.revive` documented as assignable after creation (the tier's install sets it); `solid-js` loses `materializeContainerTrace` from `solid-js/internal` and the main entry (the new subpath carries it); `tierLoads` exported from the internal runtime module for the specs. | -| **C4** | **Regions tier.** `tier-regions.js` = `#bindRegions` / `#regionsFor` / `#discoverRegions` / `collectRegionElements` / `disposeRegions` / `makeFrameElement` / `isFrameRef`, the `{$frame}` arm of `#resolveArgs`, the `resolveSlot` / `resolveSlotRecord` / `removeSlotRecord` thread-up, `tableFor`'s prefix walk, `drainRecords`' `sc:region:` arm. The eager client keeps the `{$frame}` detection in `#resolveArgs` (one `isFrameRef` test → hold, registered). The rename machinery (`renameRegion` / `#reconcileRegions`, D) deletes outright — it is not moved. | **−489 / ≈ −480 / ≈ −480 / 0** (measured on frames: `T+regions` → `L8`; pages `est.` at the same cut) | `tier-regions.js` ≈ 1,900 min / **≈ 540 br** (`est.`) | `tier-regions-hold.spec` (new); `frames-regions-*`, lifecycle matrix region rows green; principles §4 row 19 (the rename compensations) deleted with D. Size: frames eager ≤ 10.75. | B, A2 (the adopt-path hold registers — no 3.1 gap to flag) | none (`createFrameElement` stays eager — it is `@experimental` public API, re-attribution §5.3 item 5) | +| **C4** | **Regions tier.** `tier-regions.js` = `#bindRegions` / `#regionsFor` / `#discoverRegions` / `collectRegionElements` / `disposeRegions` / `makeFrameElement` / `isFrameRef`, the `{$frame}` arm of `#resolveArgs`, the `resolveSlot` / `resolveSlotRecord` / `removeSlotRecord` thread-up, `tableFor`'s prefix walk, `drainRecords`' `sc:region:` arm. The eager client keeps the `{$frame}` detection in `#resolveArgs` (one `isFrameRef` test → hold, registered). The rename machinery (`renameRegion` / `#reconcileRegions`, D) deletes outright — it is not moved. **Landed (2026-10-06, `feat/frames-regions-tier`, measured before written on edited dist copies per re-attribution §7).** The chunk is `@solidjs/web/frames/regions` (frames/src/regions-tier.ts — a NEW `@solidjs/web` export path): the per-frame region cache (a `WeakMap` the tier keeps, not a frame field), discovery in an adopted interior (`collectRegionElements`, the range walk), the `{$frame}` arm of arg resolution (`resolve`: mint / reuse / rename), the bind of a frame over each region element (`bind`: `createFrame` from the eager entry with the parent linked as `options.parent`, so the slot-resolution thread-up is private access across instances — no closure per region), disposal, and the staged preview's two region reads (`changed` / `frames`). The module's exports ARE its appliers: `prepareTier` records the installed module in a dispatch table (`tierModules`) and the frame calls through it; no `install()`. The eager client keeps: `needsRegions` (one test on the host's `record.regions` note → the fresh-mount hold under 3.1, AND the same wait at the update site — a mounted occurrence's new record naming a region while the tier is absent stays pending in the store, the live binding keeps its args; the check starts the load), the `sc:region:` drain arm in client.ts (an occluded region's html lands in the store regardless; the frame the tier binds seeds from it), the `tierModules` table, the loader entry, an `options` getter on the frame (`@internal`). Already gone on the base: `isFrameRef` (the host's inline `$frame` note is the detection), `tableFor`'s prefix walk and `#reconcileRegions` (A1b). **The rename machinery was NOT deleted — moved into the tier (`rename`, the rename arm of `resolve`), flagged:** it is live, not dead — a single-flight response renders a boundary's regions under the call's address while a direct response and the document render them under the function id, so a flight refresh of a shown boundary renames every region (`#regionsChange`/`preview` anticipates exactly this); two pins assert the rebind (`lifecycle-matrix/call-driven-args` › regions, `frames-optimistic-hold` › the renamed region). Deleting it would leave the refreshed region's content stale; it deletes with S7's normalization (principles §5.3), which is not this step. Eager cost of keeping it: 0 (it rides the chunk). | **−489 / ≈ −480 / ≈ −480 / 0** (measured on frames: `T+regions` → `L8`; pages `est.` at the same cut) **Measured: −237 / −281 / −261 / 0** br (−1,078 / −1,083 / −1,083 / 0 min) against C3's head; the deletion alone −423 / −484 / −346 br (−1,596 min) on an edited copy — less than the −489 because the `sc:region:` drain arm and B's predicate stay and A1b had already taken the prefix walk and `#reconcileRegions`; the glue +518 min / +186 br on frames (the edited-copy cut measured +498 / +173 and landed at −250 br; TypeScript's TS18030 — no private name in an optional chain — forced the longer `#outer && #outer.#x()` thread-up form, +33 min). Glue ≈ ×3.3 the ≈ 150-min line, reported; it is 44% of the deletion (the brief's "not more than half"). Vs `next` 9d89df731: −158 / −6,565 / −6,709 br. | `tier-regions.js` ≈ 1,900 min / **≈ 540 br** (`est.`) **Measured: `regions.js` 1,872 min / 805 br** (page base; 802 on live) — min on the estimate, brotli higher: a 1.9 KB standalone chunk has little to share against. It bundles its own copy of frame-client's pure DOM helpers (`eachInRange`, `makeFrameElement`, `isFrameElement`, ≈ 300 min) and carries a bare `import "solid-js"` (frame-client's one import, kept by Rollup as an external side effect; the app has it loaded). | `tier-regions-hold.spec` (new); `frames-regions-*`, lifecycle matrix region rows green; principles §4 row 19 (the rename compensations) deleted with D. Size: frames eager ≤ 10.75. **Landed:** new `consistency/tier-regions-hold.spec` (4: un-announced `{$frame}` in an adopted record → held under 3.1, interior and region content on screen, mounts with the held record, the ADOPTED region element discovered and bound (a later chunk to its wire id morphs it), no TypeError, hydration-done after; announced → the import starts at install; an occluded region's `sc:region:` record drained before the tier seeds the frame the tier binds — shows the html when placed, nothing re-delivered; stream face, a MOUNTED occurrence's region-naming record waits and applies at the install into the live binding). `tier-prepare`'s regions test releases the real module (the fake `{ install }` cannot resolve a region). Resident cells warm with `prepareTier("regions")`: `lifecycle-matrix/call-driven-args`, `frames-hn-client`, `frames-occlusion-client`, `frames-optimistic-hold`, `frames-used-region-client`; `test/server/frame-hn` (the runtime without the entry) registers the loader itself. Harness 500 × 2 seeds: SC 0, generic (`C1,C9,C19,E` ignored) 0. Caps lowered (the ratchet): frames eager 13.79 → **13.64 KB** (first time under the cap since Phase A), page base 38.61 → **38.33 KB**, live 42.16 → **41.90 KB**. Principles §4 row 19 NOT removed — re-dispositioned (above). | B, A2 (the adopt-path hold registers — no 3.1 gap to flag) | none (`createFrameElement` stays eager — it is `@experimental` public API, re-attribution §5.3 item 5) **Landed:** new export path `@solidjs/web/frames/regions` (`@experimental`; exports `resolve` / `bind` / `unmount` / `changed` / `frames` — the appliers; no `install`); `InstallOptions.tiers`' module type widened to the new exported `TierModule` (`{ install?(): void; [applier: string]: unknown }` — the old `{ install?(): void }` is a TypeScript weak type a real tier module cannot satisfy); `FrameImpl` gains an `@internal` `options` getter (not on `Frame`); frame-client's `eachInRange` / `makeFrameElement` / `isFrameElement` exported `@internal` for the tier (not on the public entry); the internal `resolveSlot` / `resolveSlotRecord` / `removeSlotRecord` options (never on `FrameOptions`) replaced by `parent`. `createFrameElement` unchanged. Wire unchanged; 0 of 150 artifacts changed. | | **C5** | **Assets tier.** `tier-assets.js` = `ensureStylesheet` / `ensurePreload` / `ensureModulePreload` / `applyInlineStyles` / `qualifierValue` / `findHeadElement` / `PRELOAD_QUALIFIERS` / `#processedAssets` / `#styleFlush` / the assets pass; the eager client keeps `chunkToRecords`' `assets` case, `host.write`'s `seg::assets` accumulate, and the `#segmentReady` term. **Pin the two untested functions first** (`ensureStylesheet`, `applyInlineStyles` — the audit's 0-coverage gap) in the same PR. Alternative under decision 2: S10's route-through-`web` instead of a tier. | **−684 / ≈ −665 / ≈ −665 / 0** (measured on frames: `T+assets` → `L8`; the `noassets` full-client cut −665) | `tier-assets.js` ≈ 2,400 min / **≈ 760 br** (`est.`) | `tier-assets-ready.spec` (new, the FOUC guard); `frames-assets-*` green; the two new coverage pins. Size: frames eager ≤ 10.05. | B | none | | **C6** | **Binding-slot tier** (E.c; largest, last of the tiers — its fallback needs the 3.1 hold for the event-replay window, which A2 provides). `tier-bind.js` = `bindDataOccurrence` (+ `valuesFor` / `write` / `release` / `writeText`; its second diff layer above `assign` — ≈ 300 B, D — deletes rather than moves), `slotPositions` / `slotEntry` / `textPosition` / `consumersOf` / `consumersEqual` / `ownedPositions` / `morphOwnedClass` / `morphOwnedStyle` / `applyOwned`, the `_s:` branch of `collectSlots`, the consumer-rebind arm of `#syncSlots`, the owned-position arms of `morphAttributes` / `reconcileChildren` / `#applyAttrs`, the `ctx.positions` branch of `slotsFor`; **`assign` leaves the eager frames client with it** (the page then keeps `assign` only through `dynamic`'s string tag — B.3, D). | **−1,546 / −2,504 / −2,542 / 0** (frames measured `T+bind` → `L8`; pages: the audit's E.c measurement — `assign` leaves on the page too) | `tier-bind.js` ≈ 5,000 min / **≈ 1,650 br** on frames (`est.`); on a page it carries `assign` as well (≈ +3,000 min / +900 br) unless B.3 has already made it lazy | `tier-bind-hold.spec` (new, incl. the click-replay arm); `frames-binding-slot-*`, `slot-positions-*`, #3704 / #3714 suites green. Size: frames eager ≤ 8.5 (both readings), page base ≤ 32.35, live ≤ 36.1. | B, **A2** (the hold registers; the replay window stays open); C3 (the `installTier` shape proven on the biggest chunk first) | none public (the `_s:` marker grammar is unchanged; `bindDataOccurrence` was never exported) | | **D** | **Packaging remnants from the SC audit, if still relevant after tiering.** **S2 / C** `preserveModules` for `solid-js` / `@solidjs/web` (0 on single-entry scenarios; the enabler): lets the store **hydration adapters** (≈ 2.6 KB min, the ≈ 1.3 KB br S1 fell short of B.2's floor by) follow the engine into `container-trace.js`, and lets **B.3** (`dynamic`'s string-tag branch lazy, `staticElement` behind the seam) take `assign` off the page. **B.3:** page −2,372 / −2,391 br (audit measured), frames 0. **E.b** (sf natural-encoding bodies, codec-args message, `Retry-After` / trailer parsing lazy): −65 frames / −476 base / −519 live (audit floor). **E.c's other half** is C6. **Lazy codec:** already a chunk (22,986 / 6,074) — nothing to do. **Claims + event** (F.claims, F.event, 331 br): not a frames tier — they ride the router's chunk (the router installs `CLAIM_SEAM`); the frames client keeps the ≈ 60-B seam. | **≈ −400 / ≈ −4,100 / ≈ −4,200 / 0** (`est.`: claims+event −331 frames; B.3 −2,372, the adapters ≈ −1,300, E.b −476 on page base) | `dynamic-static.js` ≈ 8,000 min / ≈ 2.4 KB br; the sf natural-body chunk ≈ 1,600 min / ≈ 480 br; the router's claims chunk ≈ 900 min / ≈ 330 br | the audit's S2 band (single-entry scenarios ≤ ±50 B); B.3's hydration specs; `CLAIM_SEAM` tests with the router. Size: frames eager ≤ 8.1, page base ≤ 28.2, live ≤ 31.8. | C3, C6 (so what leaves with the engine and with `assign` is known) | B.3: `dynamic`'s string-tag branch becomes async-loading on first use (behaviour change accepted in audit §7 Q5 / B.3); the `CLAIM_SEAM` install moves to the router | diff --git a/documentation/server-components/server-components-principles.md b/documentation/server-components/server-components-principles.md index d5405ef05..391ebb670 100644 --- a/documentation/server-components/server-components-principles.md +++ b/documentation/server-components/server-components-principles.md @@ -719,7 +719,7 @@ exists to undo another mechanism's consequences; deletes with its cause. | 16 | `#refArgsUnchanged` value-compare | A5 | **Done (Stage 2):** the #547 `$frame`-addition leniency deleted with unified records; the plain value-compare stays (it is the dedupe, not the patch). | | 17 | `$ref`/`$frame` arg resolution + per-stream tables | A1/A3 | Derived; table scoping revisited under per-address stores (§5.2). | | 18 | Region discovery from markup (`#discoverRegions`) | A5 | **Done (Stage 2, first half):** with A5, used regions have records on every transport; discovery remains only as claim wiring — and membership is now structural (outermost dotted id in this interior), not producer-prefix-matched, so address-keyed mounts adopt fn-id-prefixed markup. | -| 19 | Region bind/rebind/`renameRegion` (wire-id renames) | A3 | Compensatory: regions become store substructure keyed `(parent address, occurrence, arg)` (§5.3); wire-relative renames delete. | +| 19 | Region bind/rebind/`renameRegion` (wire-id renames) | A3 | Compensatory: regions become store substructure keyed `(parent address, occurrence, arg)` (§5.3); wire-relative renames delete **with that normalization — not before it.** Frames savings pass C4 (2026-10-06) found the rename LIVE, not dead: a single-flight response renders a shown boundary's regions under the call's address while a direct response and the document render them under the function id, so a flight refresh renames every region of the boundary (`preview`'s region check anticipates it; `lifecycle-matrix/call-driven-args` › regions and `frames-optimistic-hold` pin the rebind). C4 moved bind / rename into the regions tier chunk (`@solidjs/web/frames/regions`: `bind`, `rename`, the rename arm of `resolve`) — 0 eager bytes — and left the deletion to S7's store-boundary normalization. | | 20 | `hy.r` occlusion absorption (adopt-time fake chunks) | A5/A6 | Compensatory. Deletes: occluded content is ordinary records in the one buffer, drained by the one consumer (DR-4). | | 21 | Segment reveal + placeholder discovery (`#revealSegment`) | A6 | Derived — and becomes the only implementation (DR-4). | | 22 | Stylesheet gating + modulepreload | A6/L1 | Derived — unchanged, one instance instead of two. |