From 5f3094de90f87bfc3aa57c239fc06dcb90e8e56c Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 17:07:12 -0700 Subject: [PATCH 1/2] =?UTF-8?q?frames:=20C3=20=E2=80=94=20traces=20tier:?= =?UTF-8?q?=20the=20lazy=20store=20materializer=20on=20the=20tier=20mechan?= =?UTF-8?q?ism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container-trace materializer is the store engine's one edge into a server-component page (signals `store/*`, the projection/reconcile machinery, ~8 KB brotli) — and the frames client imported it from `solid-js/internal` and installed it at module load. It is now the frames client's TRACES TIER (frames savings pass §3 row C3 — S1 re-based onto Phase B's tier mechanism, not merged as built), loaded through `prepareTier("trace")`: - `solid-js/internal/container-trace` — a NEW `solid-js` dist entry carrying `materializeContainerTrace(marker, claiming?)` (S1 commit 1's entry, with commit 3's `claiming`). Its own entry because the main build is one flat module: any binding in it that reaches the engine welds the engine to whoever imports the binding. It reaches `createProjection` through `@solidjs/signals` and reads the store wrappers' hydration dispatch (`withStoreHydration`, new, `@internal`), `applyPatches` and `forwardIteratorReturn` back from `solid-js` by name; the server mirrors the three as inert stubs. The materializer leaves `solid-js`'s main and `solid-js/internal` entries. - `@solidjs/web/frames/trace` — a NEW `@solidjs/web` export path, the tier module (`frames/src/trace-tier.ts`): solid's entry plus the plugin's client half (`frame-container-plugin.js`: the shared hook state, the memo, the marker test, the deep revive walk). Its `install()` — B's `install()` shape, unchanged — sets the materializer on the plugin's registered state and the shared host's `revive` (`getFrameHost().revive`, assignable; the eager client wires no reviver). In the dist the tier's import of the client entry is externalized to `@solidjs/web/frames` (instance identity). - The eager client keeps the trigger alone: the loader entry (`tierLoaders.trace`), the container probe read off the plugin's registered state (no copy of the plugin rides the eager client), and the held-set predicate — `needsTrace`: a `{ $tr }` marker anywhere in an adopt-time record's literal args while the tier is not resident holds the occurrence (its server interior on screen; the frame's hold registered as a pending boundary under frames-rulings 3.1, hydration-done waits) and starts the load. The walk runs only while the tier is absent: once it is resident a decoded arg may be a live container whose traps throw, and before it is resident no live container can exist. - S1 commit 3's two frame-side fixes ported: the `claiming` hint (`#invokeSlot`'s `adopted` → `#resolveArgs` → `FrameHostOptions.revive (value, claiming?)` → `reviveContainerTraces` → `materialize`) keys the materializer's parked backlog on the claim again (rulings 3.6 "Landed": the port's unconditional park made every fresh mount pay one beat; now a fresh mount reads the fold of its whole backlog at once); the held-record mount (`#heldRecords`): an adopt-path occurrence held — on its tier, on its record's reads — mounts with the record it was held on (what the server interior was rendered from) and applies a record that replaced it meanwhile as the args change it is. Generalized: every adopt-path hold (`bind`, `regions`, `trace`) uses it. - Not shipped: S1's `FrameHostOptions.prepareData(chunk)` / `prepareArgs` / `#argsUnprepared` (B's `prepareTier` + the held set replace them) and S1's codec-face node scan — B's in-band `tiers` rides the very `data` chunk that carries the plugin's node, so the transport awaits the tier before the chunk decodes without a scan (measured +79 B min / +25 B br to keep; dropped). An un-announced chunk from a producer that predates the tier decodes the inert marker — a skew one package never ships. Measured before written (edited dist copies through scripts/size's bundler, re-attribution §7; then the real build) against Phase B's head e05ba0283: frames eager 43,452 / 13,949 -> 42,968 / 13,866 -484 min / -83 br page base 146,097 / 45,210 -> 122,028 / 38,598 -24,069 / -6,612 page live 158,060 / 48,873 -> 133,899 / 42,147 -24,161 / -6,726 app hydrating (no stores), + stores, compiled hydrating, CSR: 0 / 0 trace.js (lazy, reported not counted) 25,409 min / 8,170 br The deletion alone (F.trace's eager half out of the frames client, the engine + materializer out of the pages) measured -951 / -239 on frames and -28,763 / -8,016 on page base; the trigger left behind +467 min / +156 br on frames (loader entry + probe +160 / +45, the held-set predicate +126 / +67, the claiming thread +6 / -9, the held-record mount +175 / +53) — about 3x the plan's ~150-min estimate, which counted the loader alone. Against `next` @ 9d89df731: frames -446 min / +79 br (Phase A's and B's bytes), page base -6,284 br, page live -6,448 br. `@solidjs/web` server floor +4 B min (`materialize`'s second parameter in the codec plugin the SSR runtime bundles; brotli -9). Caps lowered (the ratchet): page base 44.89 -> 38.61 KB, page live 48.60 -> 42.16 KB; frames eager's cap stays 13.79 KB (76 B over by the earlier steps' bytes; its recorded minified lowered to 42,968). No cap raised. Pins — S1's seven ported through the general seam (the hold re-armed per test by dropping the tier's load through `tierLoads`, the runtime's test seam): `frames-container-lazy-codec` (announced form: the `data` chunk's `tiers` is the trigger), `frames-container-lazy-document` (the production loader), `hydration/welcome-status-lazy` (the artifact's `sc:tiers` record starts the import at install), `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}`; `container-trace-hold-hydration- end` RE-PINNED under 3.1 — hydration waits for the load, the mount claims before done (S1 asserted the opposite order). New `consistency/tier-trace- hold.spec` (§1's pin: the un-announced hold with the held-record mount, the announced start-at-install, the codec wait). C3 (b) asserts the hold and the 3.1 order (it was never `.fails` on `next`). The id-drift `.fails` pin stays red under rulings 3.4 (unfixed; a keyed sibling after the frame). The resident cells (`container-args`, C11, C19, the harness) warm the tier with `prepareTier("trace")`. Solid's `container-trace.spec` pins the keyed park. Artifacts: 0 of 150 changed (no wire change). Harness 500 cases, seeds 3289 / 91501: SC arm 0 / 0; generic arm with CONSISTENCY_IGNORE= C1,C9,C19,E 0 / 0. Co-authored-by: Claude via Cursor --- .changeset/frames-traces-tier.md | 6 + packages/solid/package.json | 4 + packages/solid/rollup.config.js | 17 +- packages/solid/src/client/container-trace.ts | 258 +++++++++++++++ packages/solid/src/client/hydration.ts | 240 +++----------- packages/solid/src/index.ts | 8 +- packages/solid/src/internal.ts | 11 +- packages/solid/src/server/index.ts | 24 +- packages/solid/test/container-trace.spec.ts | 35 +- packages/solid/test/internal-surface.spec.ts | 20 +- packages/web/frames/src/client.ts | 62 ++-- packages/web/frames/src/frame-client.ts | 123 +++++-- .../web/frames/src/frame-container-plugin.ts | 51 ++- packages/web/frames/src/trace-tier.ts | 51 +++ packages/web/package.json | 4 + packages/web/rollup.config.js | 53 ++- .../c03-hydration-done-counts-holds.spec.tsx | 18 +- .../c11-trace-equals-oracle.spec.tsx | 17 +- .../c19-claim-reads-snapshot.spec.tsx | 14 +- packages/web/test/consistency/harness/run.tsx | 14 +- .../test/consistency/tier-trace-hold.spec.tsx | 246 ++++++++++++++ .../test/frames-container-lazy-codec.spec.tsx | 216 ++++++++++++ .../frames-container-lazy-document.spec.tsx | 181 ++++++++++ .../container-trace-hold-helpers.tsx | 313 ++++++++++++++++++ ...ontainer-trace-hold-hydration-end.spec.tsx | 146 ++++++++ ...ntainer-trace-hold-id-determinism.spec.tsx | 157 +++++++++ ...container-trace-hold-interruption.spec.tsx | 179 ++++++++++ ...ainer-trace-hold-record-retention.spec.tsx | 138 ++++++++ .../container-trace-hold-snapshot.spec.tsx | 197 +++++++++++ .../hydration/welcome-status-lazy.spec.tsx | 23 ++ .../test/hydration/welcome-status-parity.tsx | 62 +++- packages/web/test/lifecycle-matrix/MATRIX.md | 15 +- .../lifecycle-matrix/container-args.spec.tsx | 13 +- packages/web/tsconfig.build.json | 3 +- packages/web/vite.config.hydrate.mjs | 3 + packages/web/vite.config.mjs | 6 +- scripts/size/floor-caps.json | 8 +- scripts/size/scenarios.js | 58 +++- 38 files changed, 2672 insertions(+), 322 deletions(-) create mode 100644 .changeset/frames-traces-tier.md create mode 100644 packages/solid/src/client/container-trace.ts create mode 100644 packages/web/frames/src/trace-tier.ts create mode 100644 packages/web/test/consistency/tier-trace-hold.spec.tsx create mode 100644 packages/web/test/frames-container-lazy-codec.spec.tsx create mode 100644 packages/web/test/frames-container-lazy-document.spec.tsx create mode 100644 packages/web/test/hydration/container-trace-hold-helpers.tsx create mode 100644 packages/web/test/hydration/container-trace-hold-hydration-end.spec.tsx create mode 100644 packages/web/test/hydration/container-trace-hold-id-determinism.spec.tsx create mode 100644 packages/web/test/hydration/container-trace-hold-interruption.spec.tsx create mode 100644 packages/web/test/hydration/container-trace-hold-record-retention.spec.tsx create mode 100644 packages/web/test/hydration/container-trace-hold-snapshot.spec.tsx create mode 100644 packages/web/test/hydration/welcome-status-lazy.spec.tsx diff --git a/.changeset/frames-traces-tier.md b/.changeset/frames-traces-tier.md new file mode 100644 index 000000000..2e7ac1597 --- /dev/null +++ b/.changeset/frames-traces-tier.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +frames: the container-trace materializer is the frames client's traces tier — `@solidjs/web/frames/trace`, loaded through the server-announced tier mechanism (`prepareTier("trace")`), so the store engine leaves every server-component page that never meets a trace (page base −6.6 KB brotli, page live −6.7 KB; frames eager −83 B). `solid-js/internal/container-trace` is a new `solid-js` entry carrying `materializeContainerTrace(marker, claiming?)` (the store engine reached through `@solidjs/signals`, the hydration dispatch `withStoreHydration` and the patch protocol read back from `solid-js`); the materializer leaves `solid-js`'s main and `solid-js/internal` entries. The eager frames client keeps the trigger: the loader entry, the held-set predicate (an adopt-time record whose args carry a `{ $tr }` marker while the tier is absent is held under frames-rulings 3.1 — its server interior on screen, hydration-done waits — and mounts with the record it was held on, a replacement applying as an args change), and the `claiming` hint (`FrameHostOptions.revive(value, claiming?)`), which keys the materializer's parked backlog on the claim again: a fresh mount reads the fold of its whole backlog at once. diff --git a/packages/solid/package.json b/packages/solid/package.json index 3386e2b38..ae10e76d4 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -104,6 +104,10 @@ "types": "./types/internal.d.ts", "default": "./dist/internal.js" }, + "./internal/container-trace": { + "types": "./types/client/container-trace.d.ts", + "default": "./dist/container-trace.js" + }, "./package.json": "./package.json" }, "scripts": { diff --git a/packages/solid/rollup.config.js b/packages/solid/rollup.config.js index df2edad6e..ebf72d22f 100644 --- a/packages/solid/rollup.config.js +++ b/packages/solid/rollup.config.js @@ -85,5 +85,20 @@ export default [ // protocol is `@solidjs/signals` (external, the app's one instance) and the // server-scope seams are read back from "solid-js" (external, so the // platform/tier conditions pick the same main build the app runs). - build("src/internal.ts", "internal", ["solid-js", "@solidjs/signals"], false, false) + build("src/internal.ts", "internal", ["solid-js", "@solidjs/signals"], false, false), + // `solid-js/internal/container-trace`: the container-trace materializer, + // its own entry because the main build is one flat module — any binding in + // it that reaches the store engine welds the engine to whoever imports the + // binding. This entry reaches `createProjection` through `@solidjs/signals` + // (external, per-module files) and reads the hydration dispatch and the + // patch protocol back from "solid-js" (external), so an app bundler can + // give the engine to the lazy chunk `@solidjs/web/frames`' traces tier + // loads it in. No tier-specific code of its own, so one build. + build( + "src/client/container-trace.ts", + "container-trace", + ["solid-js", "@solidjs/signals"], + false, + false + ) ]; diff --git a/packages/solid/src/client/container-trace.ts b/packages/solid/src/client/container-trace.ts new file mode 100644 index 000000000..8d1875fca --- /dev/null +++ b/packages/solid/src/client/container-trace.ts @@ -0,0 +1,258 @@ +/** + * `solid-js/internal/container-trace` — the client half of the container tier + * at the slot border (DR-2 case 3): a server projection crosses a + * serialization boundary as its TRACE and materializes back into a live local + * projection. NOT public API (the `solid-js/internal` namespace): consumed by + * `@solidjs/web/frames`' traces tier (`@solidjs/web/frames/trace`), which the + * frames client loads lazily — the server announces the tier when it + * serializes a trace, and an un-announced marker in a record's args starts + * the load itself (frames savings pass §2) — so the store engine + * (`createProjection` and everything `@solidjs/signals/store` drags in, ~8 KB + * brotli) stays out of a server-component page that never meets one. + * + * Its own dist entry, not a member of `solid-js`: that build is one flat + * module, so any binding in it that references the engine welds the engine to + * the eager chunk the moment anything imports the binding — a lazy + * `import("solid-js/internal")` would split off a facade and leave the engine + * where it was. This module reaches `createProjection` through + * `@solidjs/signals` (per-module files the app bundler can assign to this + * chunk) and takes the hydration dispatch and the patch protocol from + * `solid-js` by name, so it behaves exactly as the wrapper did at every call + * site while retaining nothing of the engine on the eager side. + */ +import { + createProjection as coreProjection, + createRoot as coreRoot, + createSignal as coreSignal, + getOwner, + NotReadyError, + runWithOwner, + type Store +} from "@solidjs/signals"; +// Read back from the main entry (external: the app's one instance, whose +// `enableHydration()` filled the adapter slot `withStoreHydration` reads). +// Property reads, not named imports, so a server-tier resolution of this +// entry (which has none of these) stays inert until something calls it. +import * as core from "solid-js"; + +// The seams, typed here because the main entry marks them `@internal` +// and strips them from its declarations — `sharedConfig` is public, listed +// for the same member-read discipline (the same arrangement as +// src/internal.ts). Each is a member read ON THE NAMESPACE BINDING at the +// call — never `const x = core` — so the bundler rewrites them to named +// imports of the one `solid-js` instance; a namespace that escapes into a +// variable retains every export of the flat main module (measured: +32 KB +// minified on the page, the store wrappers' engine edge included). +interface Seams { + withStoreHydration( + coreFn: (fn: any, seed: any, options?: any) => T, + fn: any, + seed: any, + options?: any + ): T; + applyPatches(target: any, patches: any[]): void; + forwardIteratorReturn(it: any, value?: any): any; + sharedConfig: { onHydrationEnd?: (callback: () => void) => void }; +} +const applyPatches = (target: any, patches: any[]) => + (core as unknown as Seams).applyPatches(target, patches); +const forwardIteratorReturn = (it: any, value?: any) => + (core as unknown as Seams).forwardIteratorReturn(it, value); + +/** The projection constructor with solid's hydration dispatch — what `createProjection` from `solid-js` does, minus the wrapper's own engine edge. */ +const createProjection = (fn: (draft: any) => any, seed: any): Store => + (core as unknown as Seams).withStoreHydration(coreProjection as any, fn, seed); + +/** + * A root with NO parent. Materialization runs at arg-read, under whatever + * owner is reading — during hydration an id-carrying one — and a root + * created there inherits the next child id, shifting every key the reader + * mints after it: a trace revived at t=0 consumed one root id while one + * revived by a late claim (no ambient owner) consumed none, and a keyed + * sibling after the frame hydrated under different keys in the two runs. + * The store is shared and memoized per trace; it belongs to no reader's id + * space. + */ +const detachedRoot = (init: () => T): T => runWithOwner(null, () => coreRoot(init))!; + +/** Run `callback` once hydration has completed — now (a microtask) when none is in progress. */ +function afterHydration(callback: () => void) { + const onHydrationEnd = (core as unknown as Seams).sharedConfig.onHydrationEnd; + onHydrationEnd ? onHydrationEnd(callback) : queueMicrotask(callback); +} + +/** + * Materialize a container TRACE — snapshot then patch batches, the + * continuation protocol a server projection serializes as when it crosses a + * boundary (hydration resume in solid-js; the slot border via the serializer's + * container plugin) — into a live local projection. The result reads like + * the server value did: not-ready until the snapshot lands, then a + * read-only store the batches keep updating, done when the trace ends. + * + * Created under a detached root (see `detachedRoot`): revival can run inside + * a render effect's owner, and the store is memoized per trace (see the + * plugin's WeakMap) — a store owned by its first reader would be disposed by + * that reader's re-render while other readers still hold it, and one rooted + * under it would take a hydration id from it. Consumption is pull-driven and + * the trace is response-bounded, so the projection settles on its own; GC + * collects the pair with the trace. + * + * Materialized for a CLAIM (`claiming` — the reader is about to hydrate + * server markup rendered from this value: a frame's adopt-time mount, at + * t=0 or deferred under its hold, frames-rulings 3.1 / 3.2), a replayed + * backlog beyond the snapshot is PARKED until hydration ends + * (`onHydrationEnd`; the next microtask when no pass is in progress — what + * a claim made after hydration-done gets). The snapshot is the state the + * server's markup shows; the claim renders the fill against that markup and + * trusts it — a text hole is never rewritten during a claim — so a store + * already past the markup left the DOM diverged from it for good (the trace + * had nothing further to emit). Applied after the claim, the backlog re-runs + * the fill's reads outside hydration and the DOM catches up: the same + * parking solid's store-shaped async-iterable hydration applies to a + * buffered backlog (frames-rulings 3.6 (iii), "the consumer parks"). The + * release order is the one 3.2 pins: claim, the frame's hold release, done, + * then the backlog. Materialized for a FRESH mount (no server markup to + * agree with — a stream re-call, a codec-face decode), nothing is parked: + * the first read is the fold of the whole backlog. Live emissions land + * after the claim by construction either way. A failure applies in order, + * after everything queued before it, so it, too, waits on a parked backlog. + * + * Consumed by the serialization layer (`@solidjs/web/frames`). Declared, not + * stripped: this entry's whole surface is the seam, and the subpath's + * namespace is what makes it non-public. + */ +export function materializeContainerTrace( + marker: { + $tr: AsyncIterable | { __SEROVAL_STREAM__: true }; + $ta?: number; + }, + claiming?: boolean +): Store { + const src = marker.$tr as any; + // Raw seroval stream (the wire shape since the stream-mint protocol): + // `.on()` replays buffered emissions SYNCHRONOUSLY, so a snapshot the + // document already delivered is applied before the first read — the store + // reads as READY during hydration's synchronous claim walk, matching the + // page's settled markup. The async-iterable branch below (pre-stream + // payloads) can only surface its buffer through microtasks, which made a + // settled-inline boundary suspend at the walk and hydrate a phantom + // fallback over settled markup (the chat welcome/status meter miss). + if (src != null && src.__SEROVAL_STREAM__ === true) { + const queue: any[] = []; + let failed: { error: any } | undefined; + let cursor = 0; + let first = true; + // How far into the queue a compute may apply: everything, except a + // claim's replayed backlog beyond the snapshot, parked until hydration + // ends (see above). + let limit = Infinity; + // Everything lives under the root (see the block comment below): + // materialization runs at arg-read inside a reader's render scope, and + // a version signal owned by that reader would be disposed by its + // re-render while the memoized store lives on. + return detachedRoot(() => { + const [version, setVersion] = coreSignal(0); + // Subscribe before creating the projection: the buffered replay runs + // synchronously inside on(), filling the queue the first compute + // drains. Replayed values must NOT bump the version — the replay can + // run inside an owned render scope where reactive writes are illegal, + // and the projection doesn't exist yet to need waking. Only live + // emissions (stream callbacks on later tasks) bump. + let live = false; + const bump = () => live && setVersion(n => n + 1); + src.on({ + next(value: any) { + queue.push(value); + bump(); + }, + // The trace ended: the last applied state latches (same contract as + // the iterable path's `done`). + return() {}, + throw(error: any) { + failed = { error }; + bump(); + } + }); + live = true; + // The park (see above): decided here, because the projection's first + // compute runs at creation. Released at hydration end with a version + // bump, so the compute drains the backlog as one ordinary update. + if (claiming && queue.length > 1) { + limit = 1; + afterHydration(() => { + limit = Infinity; + bump(); + }); + } + return createProjection( + (draft: any) => { + version(); + while (cursor < queue.length && cursor < limit) { + const value = queue[cursor++]; + if (first) { + first = false; + // Full authoritative snapshot into a fresh {}/[] seed — pure + // writes, no draft reads (see the iterable branch below). + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) draft[i] = value[i]; + draft.length = value.length; + } else { + Object.assign(draft, value); + } + } else { + applyPatches(draft, value); + } + } + // In order: after everything queued before it has applied. + if (failed && cursor === queue.length) throw failed.error; + // Nothing buffered yet (revival raced ahead of the record's data + // script): pending until the snapshot lands, marked on the + // projection's own node — the version bump reruns this compute. + if (first) throw new NotReadyError(getOwner()); + }, + (marker.$ta ? [] : {}) as any + ); + })!; + } + // A root, not a bare null owner: the projection's async machinery routes + // its pending/error states through the owner's queue, and with no owner + // at all the internal NotReadyError (the "pending until snapshot" mark) + // surfaces as an unhandled error in dev. The root is never disposed — + // the projection settles itself when the trace ends and is collected + // with the store. + return detachedRoot(() => + createProjection( + (draft: any) => ({ + [Symbol.asyncIterator]() { + const srcIt = src[Symbol.asyncIterator](); + let first = true; + return { + next: () => + Promise.resolve(srcIt.next()).then((res: any) => { + if (res.done) return { done: true as const, value: undefined }; + if (first) { + first = false; + // The first yield is the full authoritative snapshot. The + // seed is a fresh empty {}/[] minted here, so this is pure + // writes — no reads of the draft, which is still PENDING + // (reading a pending proxy throws NotReadyError, which + // would reject this step and error the projection). + if (Array.isArray(res.value)) { + for (let i = 0; i < res.value.length; i++) draft[i] = res.value[i]; + draft.length = res.value.length; + } else { + Object.assign(draft, res.value); + } + } else { + applyPatches(draft, res.value); + } + return { done: false as const, value: undefined }; + }), + return: (value?: any) => forwardIteratorReturn(srcIt, value) + }; + } + }), + (marker.$ta ? [] : {}) as any + ) + )!; +} diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 0bc296868..1c5441ddc 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -762,7 +762,14 @@ function hasLoadingWindow(options: any): boolean { ); } -function forwardIteratorReturn(it: any, value?: any) { +/** + * Forward an iterator's `return()`: the source's own answer when it is a + * promise, otherwise a synchronously-settling thenable of the done result + * (the hydration resume path must not wait a microtask for it). + * + * @internal — shared with `solid-js/internal/container-trace`. + */ +export function forwardIteratorReturn(it: any, value?: any) { const returned = it.return?.(value); return returned && typeof returned.then === "function" ? returned @@ -819,7 +826,16 @@ function normalizeIterator(it: any, deferFirst?: boolean) { }; } -function applyPatches(target: any, patches: any[]) { +/** + * Apply a projection's recorded patch batch — `[path, value]` writes, + * `[path]` deletes, `[path, value, 1]` splice-inserts — to a draft. The + * continuation protocol a server projection serializes as (hydration + * resume below; a container trace at the slot border, consumed by + * `solid-js/internal/container-trace`). + * + * @internal + */ +export function applyPatches(target: any, patches: any[]) { for (const patch of patches) { const path = patch[0]; let current = target; @@ -1261,198 +1277,6 @@ function hydrateStoreFromAsyncIterable( ); } -/** - * Materialize a container TRACE — snapshot then patch batches, the - * continuation protocol a server projection serializes as when it crosses a - * boundary (hydration resume above; the slot border via the serializer's - * container plugin) — into a live local projection. The result reads like - * the server value did: not-ready until the snapshot lands, then a - * read-only store the batches keep updating, done when the trace ends. - * - * Created under a DETACHED root (see `detachedRoot`): revival can run inside - * a render effect's owner, and the store is memoized per trace (see the - * plugin's WeakMap) — a store owned by its first reader would be disposed by - * that reader's re-render while other readers still hold it, and one rooted - * under it would take a hydration id from it. Consumption is pull-driven and - * the trace is response-bounded, so the projection settles on its own; GC - * collects the pair with the trace. - * - * A replayed backlog beyond the snapshot is PARKED until hydration ends - * (frames-rulings 3.6 (iii), "the consumer parks"): the first reads see the - * snapshot alone. A trace is materialized at a fill's arg-read, and when - * that fill CLAIMS adopted markup — the document's pass, a frame's deferred - * claim under its hold (3.1 / 3.2), a claim at a fragment's reveal or by a - * frame adopted after done — the snapshot is the state the server rendered - * that markup from; the claim renders against it and trusts it — a text - * hole is never rewritten during a claim — so a store already past the - * markup left the DOM diverged from it for good (the trace had nothing - * further to emit). Applied after the claim, the backlog re-runs the fill's - * reads outside hydration and the DOM catches up: the same parking - * `hydrateStoreFromAsyncIterable` gives a buffered backlog. The release - * order is the one 3.2 pins: claim, the frame's hold release, done, then - * the backlog — and the next microtask when no hydration is in progress, - * which is what a claim made after hydration-done gets, and what a FRESH - * mount pays for not being told apart: its backlog lands one beat after - * its snapshot, before any paint. Live emissions land after the claim by - * construction. A failure applies in order, after everything queued before - * it, so it, too, waits on a parked backlog. - * - * @internal — consumed by the serialization layer (@solidjs/web). - */ -export function materializeContainerTrace(marker: { - $tr: AsyncIterable | { __SEROVAL_STREAM__: true }; - $ta?: number; -}): Store { - const src = marker.$tr as any; - // Raw seroval stream (the wire shape since the stream-mint protocol): - // `.on()` replays buffered emissions SYNCHRONOUSLY, so a snapshot the - // document already delivered is applied before the first read — the store - // reads as READY during hydration's synchronous claim walk, matching the - // page's settled markup. The async-iterable branch below (pre-stream - // payloads) can only surface its buffer through microtasks, which made a - // settled-inline boundary suspend at the walk and hydrate a phantom - // fallback over settled markup (the chat welcome/status meter miss). - if (src != null && src.__SEROVAL_STREAM__ === true) { - const queue: any[] = []; - let failed: { error: any } | undefined; - let cursor = 0; - let first = true; - // How far into the queue a compute may apply: everything, except a - // claim's replayed backlog beyond the snapshot, parked until hydration - // ends (see above). - let limit = Infinity; - // Everything lives under the detached root (see the block comment - // below): materialization runs at arg-read inside a reader's render - // scope, and a version signal owned by that reader would be disposed by - // its re-render while the memoized store lives on. - return detachedRoot(() => { - const [version, setVersion] = coreSignal(0); - // Subscribe before creating the projection: the buffered replay runs - // synchronously inside on(), filling the queue the first compute - // drains. Replayed values must NOT bump the version — the replay can - // run inside an owned render scope where reactive writes are illegal, - // and the projection doesn't exist yet to need waking. Only live - // emissions (stream callbacks on later tasks) bump. - let live = false; - const bump = () => live && setVersion(n => n + 1); - src.on({ - next(value: any) { - queue.push(value); - bump(); - }, - // The trace ended: the last applied state latches (same contract as - // the iterable path's `done`). - return() {}, - throw(error: any) { - failed = { error }; - bump(); - } - }); - live = true; - // The park (see above). Decided here, unconditionally: the - // projection's first compute runs at creation, so the decision cannot - // wait for the first read, and materialization runs at arg-read — - // before the frame opens its claim window and, for a claim made after - // hydration-done (an occurrence inside a server `` whose - // fragment reveals after done; a frame adopted late), with no - // hydration state that says "claim" at all. Serving the snapshot - // first costs a fresh mount one beat (the next microtask, before any - // paint) and nothing else. Released at hydration end with a version - // bump, so the compute drains the backlog as one ordinary update. - if (queue.length > 1) { - limit = 1; - onHydrationEnd(() => { - limit = Infinity; - bump(); - }); - } - return createProjection( - (draft: any) => { - version(); - while (cursor < queue.length && cursor < limit) { - const value = queue[cursor++]; - if (first) { - first = false; - // Full authoritative snapshot into a fresh {}/[] seed — pure - // writes, no draft reads (see the iterable branch below). - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) draft[i] = value[i]; - draft.length = value.length; - } else { - Object.assign(draft, value); - } - } else { - applyPatches(draft, value); - } - } - // In order: after everything queued before it has applied. - if (failed && cursor === queue.length) throw failed.error; - // Nothing buffered yet (revival raced ahead of the record's data - // script): pending until the snapshot lands, marked on the - // projection's own node — the version bump reruns this compute. - if (first) throw new NotReadyError(getOwner()); - }, - (marker.$ta ? [] : {}) as any - ); - }); - } - // A root, not a bare null owner: the projection's async machinery routes - // its pending/error states through the owner's queue, and with no owner - // at all the internal NotReadyError (the "pending until snapshot" mark) - // surfaces as an unhandled error in dev. The root is never disposed — - // the projection settles itself when the trace ends and is collected - // with the store. - return detachedRoot(() => - createProjection( - (draft: any) => ({ - [Symbol.asyncIterator]() { - const srcIt = src[Symbol.asyncIterator](); - let first = true; - return { - next: () => - Promise.resolve(srcIt.next()).then((res: any) => { - if (res.done) return { done: true as const, value: undefined }; - if (first) { - first = false; - // The first yield is the full authoritative snapshot. The - // seed is a fresh empty {}/[] minted here, so this is pure - // writes — no reads of the draft, which is still PENDING - // (reading a pending proxy throws NotReadyError, which - // would reject this step and error the projection). - if (Array.isArray(res.value)) { - for (let i = 0; i < res.value.length; i++) draft[i] = res.value[i]; - draft.length = res.value.length; - } else { - Object.assign(draft, res.value); - } - } else { - applyPatches(draft, res.value); - } - return { done: false as const, value: undefined }; - }), - return: (value?: any) => forwardIteratorReturn(srcIt, value) - }; - } - }), - (marker.$ta ? [] : {}) as any - ) - ); -} - -/** - * A root with NO parent, for the container-trace materializer. It runs at - * arg-read, under whatever owner is reading — during hydration an - * id-carrying one — and a root created there inherits the next child id, - * shifting every key the reader mints after it: a trace revived at t=0 - * consumed one root id while one revived by a late claim (no ambient owner) - * consumed none, and a keyed sibling after the frame hydrated under - * different keys in the two runs. The store is shared and memoized per - * trace; it belongs to no reader's id space. - */ -function detachedRoot(init: () => T): T { - return runWithOwner(null, () => coreRoot(init))!; -} - // --- Hydration-aware implementations --- // The shared pre-hydration gate lifecycle for the ssrSource branches @@ -2319,6 +2143,34 @@ export const createProjection: ( type NoFn = T extends Function ? never : T; +/** + * The hydration dispatch the store-family wrappers above share + * (`createProjection`, `createStore`, `createOptimisticStore`), for a caller + * that brings its own core primitive: under hydration the generic store + * adapter runs with `coreFn`, otherwise `coreFn` runs directly. Exists so a + * store-family node can be built from a module that must NOT reference the + * wrappers — `solid-js/internal/container-trace` reaches `createProjection` + * through `@solidjs/signals` so the store engine stays in its lazy chunk + * (every wrapper here is welded to the engine by its core import, and this + * dist is one flat module) while behaving under hydration exactly as the + * wrapper does. `coreFn` is the caller's, so referencing this retains the + * adapter, never the engine (the same retention story as the slot itself). + * + * @internal + */ +export function withStoreHydration( + coreFn: (fn: any, seed: any, options?: any) => T, + fn: any, + seed: any, + options?: any +): T { + // `hydrating` can only be true once enableHydration() installed the + // adapter slot (see createOptimistic above for the retention story). + return sharedConfig.hydrating + ? _hydrateStoreLike!(coreFn, fn, seed, options) + : coreFn(fn, seed, options); +} + /** * Creates a deeply-reactive store backed by a Proxy. Reads track each * property accessed; only the parts that change trigger updates. diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 279fff7c4..c3a7e63d3 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -115,7 +115,13 @@ export { // (src/internal.ts): exported here at runtime so that entry shares this // module's state, `@internal` so they are stripped from the declarations. /** @internal */ -export { materializeContainerTrace, sharedConfig } from "./client/hydration.js"; +export { sharedConfig } from "./client/hydration.js"; +// The container-trace materializer's seams (`solid-js/internal/container-trace`, +// a separate dist entry so the store engine it builds on stays in a lazy +// chunk): the store wrappers' hydration dispatch and the projection patch +// protocol. Runtime exports so that entry shares this module's state. +/** @internal */ +export { withStoreHydration, applyPatches, forwardIteratorReturn } from "./client/hydration.js"; /** @internal */ export { $DEVCOMP } from "./client/core.js"; // The boundary primitives behind `Errored`, `Loading` and `Reveal`: exported diff --git a/packages/solid/src/internal.ts b/packages/solid/src/internal.ts index 2e8a49c7c..761e98c01 100644 --- a/packages/solid/src/internal.ts +++ b/packages/solid/src/internal.ts @@ -26,6 +26,11 @@ * read back the same way (real on both entries), for renderers that * build boundaries without the components, and so are `sharedConfig` * (the hydration/SSR coordination object) and the `$DEVCOMP` brand. + * + * Not here: the container-trace materializer. It lives in its own entry, + * `solid-js/internal/container-trace`, so the store engine it builds on can + * be a lazy chunk — a re-export from this (eagerly imported) module would + * weld the engine back into the main chunk. */ import * as core from "solid-js"; import type { Accessor, RevealOrder, ServerErrorHook, ServerErrorSite } from "solid-js"; @@ -122,12 +127,6 @@ export const getProjectionTrace: ( export const shareAsyncIterable: (source: AsyncIterable) => AsyncIterable = core.shareAsyncIterable; -/** Client: rebuild a store from a serialized container-trace marker. Server: stub. */ -export const materializeContainerTrace: (marker: { - $tr: AsyncIterable | { __SEROVAL_STREAM__: true }; - $ta?: number; -}) => unknown = core.materializeContainerTrace as any; - /** The primitive behind ``: `fn()`, or `fallback(error, reset)` once something under it throws. */ export const createErrorBoundary: ( fn: () => T, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 285e68c92..568291479 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -162,12 +162,24 @@ export { ssrHandleError, ssrScope } from "./hydration.js"; // is unchanged. import { installServerWithOrigin } from "./shared.js"; -/** - * @internal — client-only (see client/hydration.ts). The server stub is - * inert: nothing delivers a trace TO a server, so a marker passes through. - */ -export function materializeContainerTrace(marker: unknown): unknown { - return marker; +// The container-trace materializer's seams (client/hydration.ts; consumed by +// `solid-js/internal/container-trace`, a client-only entry). Mirrored here +// for export parity, inert: nothing delivers a trace TO a server, so the +// dispatch is the core primitive and nothing applies a patch batch. +/** @internal */ +export function withStoreHydration( + coreFn: (fn: any, seed: any, options?: any) => T, + fn: any, + seed: any, + options?: any +): T { + return coreFn(fn, seed, options); +} +/** @internal */ +export function applyPatches(_target: any, _patches: any[]): void {} +/** @internal */ +export function forwardIteratorReturn(it: any, value?: any): any { + return Promise.resolve(it.return ? it.return(value) : { done: true, value }); } // Observe / dev — same shape as the client entry. Both literals are replaced diff --git a/packages/solid/test/container-trace.spec.ts b/packages/solid/test/container-trace.spec.ts index 9f50d61aa..9d9a3fa39 100644 --- a/packages/solid/test/container-trace.spec.ts +++ b/packages/solid/test/container-trace.spec.ts @@ -8,7 +8,8 @@ import { afterEach, describe, expect, test } from "vitest"; import { createOwner } from "@solidjs/signals"; import { createRoot, createRenderEffect, flush } from "../src/index.js"; -import { enableHydration, materializeContainerTrace, sharedConfig } from "../src/index.js"; +import { enableHydration, sharedConfig } from "../src/index.js"; +import { materializeContainerTrace } from "../src/client/container-trace.js"; /** * A hand-cranked RAW seroval stream (the wire shape since the stream-mint @@ -215,11 +216,14 @@ describe("materializeContainerTrace — id neutrality", () => { }); }); -// The park (frames-rulings 3.6 (iii), "the consumer parks"): a replayed +// The park (frames-rulings 3.6 (iii), "the consumer parks"): materialized for +// a CLAIM (`claiming`, the frames client's adopt-time mount), a replayed // backlog beyond the snapshot applies after hydration ends — the first reads // see the snapshot, what the server's markup was rendered from — so a claim // pass over that markup reads the state it shows, and the backlog lands -// after the claim as the update it is. +// after the claim as the update it is. Keyed on the claim since the traces +// tier (plan step C3): a fresh mount — nothing on screen to agree with — +// reads the fold of its whole backlog at once and pays no beat. describe("materializeContainerTrace — the parked backlog", () => { afterEach(() => { sharedConfig.hydrating = false; @@ -237,11 +241,11 @@ describe("materializeContainerTrace — the parked backlog", () => { return stream; }; - test("during hydration the snapshot serves; the backlog lands at hydration end, as one update", () => { + test("materialized for a claim during hydration: the snapshot serves; the backlog lands at hydration end, as one update", () => { enableHydration(); (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; sharedConfig.hydrating = true; - const store: any = materializeContainerTrace({ $tr: ahead(), $ta: 0 } as any); + const store: any = materializeContainerTrace({ $tr: ahead(), $ta: 0 } as any, true); const reads: string[] = []; createRoot(() => { createRenderEffect( @@ -259,8 +263,8 @@ describe("materializeContainerTrace — the parked backlog", () => { expect(reads).toEqual(["Ada/0", "Ada (edited)/2"]); }); - test("with no hydration in progress the backlog lands on the next microtask", async () => { - const store: any = materializeContainerTrace({ $tr: ahead(), $ta: 0 } as any); + test("a claim with no hydration in progress (a frame's late claim): the snapshot serves; the backlog lands on the next microtask", async () => { + const store: any = materializeContainerTrace({ $tr: ahead(), $ta: 0 } as any, true); expect(store.name).toBe("Ada"); expect(store.edits).toBe(0); await Promise.resolve(); @@ -269,6 +273,21 @@ describe("materializeContainerTrace — the parked backlog", () => { expect(store.edits).toBe(2); }); + test("a fresh mount parks nothing: the first read is the fold of the whole backlog", () => { + const store: any = materializeContainerTrace({ $tr: ahead(), $ta: 0 } as any); + expect(store.name).toBe("Ada (edited)"); + expect(store.edits).toBe(2); + }); + + test("a fresh mount during hydration parks nothing either (the park is the claim's, not the pass's)", () => { + enableHydration(); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; + sharedConfig.hydrating = true; + const store: any = materializeContainerTrace({ $tr: ahead(), $ta: 0 } as any); + expect(store.name).toBe("Ada (edited)"); + expect(store.edits).toBe(2); + }); + test("a snapshot alone is not a backlog: live emissions apply as they land", () => { const stream = makeStream(); stream.next({ name: "Ada" }); @@ -282,7 +301,7 @@ describe("materializeContainerTrace — the parked backlog", () => { test("a failure in the backlog applies in order, after the parked patches", async () => { const stream = ahead(); stream.throw(new Error("boom")); - const store: any = materializeContainerTrace({ $tr: stream, $ta: 0 } as any); + const store: any = materializeContainerTrace({ $tr: stream, $ta: 0 } as any, true); // Parked: the snapshot reads, the failure has not surfaced. expect(store.name).toBe("Ada"); await Promise.resolve(); diff --git a/packages/solid/test/internal-surface.spec.ts b/packages/solid/test/internal-surface.spec.ts index b64530640..224f5a668 100644 --- a/packages/solid/test/internal-surface.spec.ts +++ b/packages/solid/test/internal-surface.spec.ts @@ -36,7 +36,6 @@ const INTERNAL = [ "inServerComponentScope", "creationStamp", "getProjectionTrace", - "materializeContainerTrace", // boundary primitives behind Errored/Loading/Reveal (#3709) "createErrorBoundary", "createLoadingBoundary", @@ -46,6 +45,12 @@ const INTERNAL = [ "$DEVCOMP" ]; +// The container-trace materializer's seams: exported from the client entry at +// runtime (so `solid-js/internal/container-trace` shares this module's +// state), `@internal`, and declared NOWHERE public — that entry types them +// itself. Checked against the main declarations only. +const CONTAINER_TRACE_SEAMS = ["withStoreHydration", "applyPatches", "forwardIteratorReturn"]; + const typesDir = resolve(import.meta.dirname, "../types"); const read = (file: string) => readFileSync(resolve(typesDir, file), "utf8"); // `\b` cannot bound a name that starts with `$`; bound on identifier characters. @@ -57,7 +62,9 @@ test.each([ ["server", "server/index.d.ts"] ])("no internal name reaches the %s entry's declarations", (_tier, file) => { const declarations = read(file); - const leaked = INTERNAL.filter(name => mentions(declarations, name)); + const leaked = [...INTERNAL, ...CONTAINER_TRACE_SEAMS, "materializeContainerTrace"].filter(name => + mentions(declarations, name) + ); expect(leaked).toEqual([]); }); @@ -66,3 +73,12 @@ test("solid-js/internal's declarations carry the protocol and the seams", () => const missing = INTERNAL.filter(name => !mentions(declarations, name)); expect(missing).toEqual([]); }); + +// The materializer is its own entry (the store engine it builds on must be +// assignable to a lazy chunk, which a re-export from the flat main module or +// from `solid-js/internal` would prevent); the subpath declares it, and +// neither eager entry does. +test("solid-js/internal/container-trace declares the materializer; solid-js/internal does not", () => { + expect(mentions(read("client/container-trace.d.ts"), "materializeContainerTrace")).toBe(true); + expect(mentions(read("internal.d.ts"), "materializeContainerTrace")).toBe(false); +}); diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 0b70a6e89..356b8fd45 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -51,22 +51,27 @@ import { stagedContent, type ServerComponentHandlerOptions } from "./frame-transport.js"; +import { createLoadingBoundary, sharedConfig } from "solid-js/internal"; + // The container tier (DR-2 case 3): server projections cross the border as // TRACES (snapshot + patch batches) and materialize back into live local -// projections. The materializer is solid's (it owns the patch protocol); -// this entry installs it and wires the host's literal-arg reviver (document -// face). The seroval plugin itself needs no wiring — it rides the codec's -// default plugin set, in the lazy codec chunk. These named imports pull -// only the eager core (hooks + revive walk + the WeakSet probe); the -// plugin object tree-shakes away. -import { - isMaterializedContainer, - reviveContainerTraces, - setContainerTraceMaterializer -} from "./frame-container-plugin.js"; -import { createLoadingBoundary, materializeContainerTrace, sharedConfig } from "solid-js/internal"; - -setContainerTraceMaterializer(materializeContainerTrace); +// projections. The materializer is solid's (it owns the patch protocol) and +// it is the store engine's one edge into a server-component page — so the +// whole client half is a TIER (frames savings pass §3 row C3), the chunk +// `@solidjs/web/frames/trace` (trace-tier.ts: solid's materializer + the +// plugin's revive walk, memo and marker test), loaded through the tier +// mechanism: the server announces `trace` where it serializes a trace, and +// a marker met in an adopt-time record's args while the tier is absent +// holds the occurrence and starts the load (frame-client.ts, `needsTrace`). +// This entry imports NOTHING of the plugin's client half; it keeps the +// loader entry and reads the container probe off the plugin's registered +// state object (the protocol endpoint every copy shares — undefined until +// some copy loaded, and no container can exist before one did). The tier's +// install wires the shared host's `revive` (getFrameHost). The seroval +// plugin itself needs no wiring — it rides the codec's default plugin set, +// in the lazy codec chunk. +const TRACE_STATE = Symbol.for("solid.container-trace-state"); +tierLoaders.trace = () => import("@solidjs/web/frames/trace"); // Build-time literal (see diagnostics.ts): dev-only guidance folds out of prod. const IS_DEV = "_SOLID_DEV_" as unknown as boolean; @@ -307,10 +312,13 @@ export function getFrameHost() { prepareData: loadCodec, applyData: (c: any, current?: number) => tableFor(c.id, c.version, current)?.apply(c), resolve: (ref: any, id: string, version: number, current?: number) => - tableFor(id, version, current)?.resolve(ref), - // Document-face container traces ride slot records as inline literals - // (never `{$ref}`s); this revives them into live stores at arg-read. - revive: reviveContainerTraces + tableFor(id, version, current)?.resolve(ref) + // No `revive` here: document-face container traces ride slot records + // as inline literals (never `{$ref}`s) and are revived into live + // stores at arg-read by the traces tier, whose install sets this + // host's `revive` (trace-tier.ts). Until then no record that carries + // one mounts (the frame holds it on the tier), so nothing reads a + // marker inert. }); } return sharedHost; @@ -497,12 +505,16 @@ function slotArgsProxy(args: () => Record) { // `.then` probe would detonate a pending one (property reads // throw not-ready), so it is classified before the probe and // held BOXED (see `Boxed`). Mirrors the server sink's - // classification order. + // classification order. The probe is the plugin's WeakSet of + // materialized values, read off its registered state (see + // TRACE_STATE): trap-safe, and absent until a copy of the plugin + // loaded — before which no container can exist. const make = () => createMemo( () => { const raw = (args() as any)[key]; - if (isMaterializedContainer(raw)) return new Boxed(raw); + if ((globalThis as any)[TRACE_STATE]?.materializedValues.has(raw)) + return new Boxed(raw); if (raw != null && typeof raw.then === "function") { if (raw.s === 1) return raw.v; if (raw.s === 2) throw raw.v; @@ -1638,8 +1650,9 @@ 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). See - * `installServerComponents`. + * 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`. */ tiers?: Record Promise<{ install?(): void }>>; } @@ -1665,8 +1678,9 @@ export interface InstallOptions { * 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. Nothing is tiered yet; the map is the seam a tier - * plugs into. + * 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). * @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 6627c9915..70bbe505b 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -306,8 +306,12 @@ export interface FrameHost { */ landing(id: string): Promise | undefined; serialize(value: unknown): { $ref: string }; - /** See FrameHostOptions.revive. */ - revive?(value: unknown): unknown; + /** + * See FrameHostOptions.revive. Assignable after creation: the frames + * client's traces tier installs the container-trace reviver on the + * shared host when its chunk loads (`@solidjs/web/frames/trace`). + */ + revive?(value: unknown, claiming?: boolean): unknown; } /** @@ -351,8 +355,12 @@ export interface FrameHostOptions { * revived value as the markup was rendered from it. Document-face * container traces ride this way — inline in the record, revived by the * integration (`reviveContainerTraces`) into live local containers. + * `claiming` marks the args of an adopt-time mount — the occurrence is + * about to hydrate server markup rendered from these values (the + * materializer parks a trace's backlog beyond the snapshot the markup + * shows until hydration ends — frames-rulings 3.6 (iii)). */ - revive?(value: unknown): unknown; + revive?(value: unknown, claiming?: boolean): unknown; } /** @@ -710,7 +718,7 @@ export function createFrameHost(options?: FrameHostOptions): FrameHost; * resolve?: (ref: { $ref: string }, frameId: string, version: number) => unknown, * applyData?: (chunk: object) => void, * prepareData?: () => Promise, - * revive?: (value: unknown) => unknown + * revive?: (value: unknown, claiming?: boolean) => unknown * }} [options] * `serialize`/`resolve` back slot data refs (response-scoped table); * `applyData` receives each `data` chunk whole — keyed codec records @@ -1101,17 +1109,20 @@ export const FRAME_APPLIED_EVENT = "frame:applied"; // readiness check that finds a tier absent starts the load itself and // holds, so an un-announced response converges to the same DOM. // -// Nothing is tiered in this step — every capability is eager, so no name -// has a loader and every tier is resident by definition. `tierLoaders` is -// the seam a tier plugs into (`installServerComponents({ tiers })`, or the -// built-in table once a tier's chunk exists): `name -> () => import(...)`, +// `tierLoaders` is the seam a tier plugs into: `name -> () => import(...)`, // the module exporting an `install()` that registers its appliers into -// this runtime's dispatch. -/** @type {Record Promise<{ install?(): void }>>} */ -export const tierLoaders = {}; +// 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 }>> = {}; // `name -> the load`, a promise stamped `r` (resident) once the module has // installed. One per name for the page's lifetime: tiers never uninstall. -const tierLoads = {}; +// 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 = {}; // 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. @@ -1152,6 +1163,26 @@ export function prepareTier(name) { */ const tierReady = name => !tierLoaders[name] || prepareTier(name).r; +// 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 +// the tier that materializes it is absent. The marker can sit at any depth +// of an argument (`{ filters: { user: proj } }` is one arg), so the test is +// a walk; it runs ONLY while the tier is not resident — once it is, a +// decoded arg may already be a live container, whose property traps throw +// not-ready on a pending one, and nothing can be a marker any more (the +// codec materializes at decode, the revive walk at the mount). Before the +// tier is resident no live container can exist (only the tier's install +// makes one), so the walk is trap-safe. A record whose literal args carry +// a marker found while the tier is absent starts the load (`tierReady`) +// and holds the occurrence: its server interior stays on screen, the +// frame's hold registers (3.1), the install's flush mounts it. +const carriesTrace = value => + value != null && + typeof value === "object" && + (value.$tr != null || Object.values(value).some(carriesTrace)); +const needsTrace = args => !tierLoads.trace?.r && carriesTrace(args) && !tierReady("trace"); + class FrameImpl { // A frame renders either into an element (element boundary: #start/#end // null) or between two comment markers within some parent (range boundary). @@ -1205,6 +1236,13 @@ class FrameImpl { // The release of the frame's hold with the integration while a sync // leaves an occurrence waiting to mount (see #syncSlots' end). #hold; + // Adopt path: the record an unmounted occurrence was first HELD with (for + // its tier, for its record's reads — see #syncSlots). The adopted range's + // server interior was rendered from that record; should the store move on + // while it waits (a refetch, a rebind), the mount still claims with it + // and the current record applies as the args change it is + // (frames-rulings 3.6: a claim reads what the markup was rendered from). + #heldRecords = new Map(); #disposed = false; // Stable identity so a pending stylesheet holds at most one waiter per // frame across repeated readiness checks. @@ -1644,21 +1682,33 @@ 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`. Resident tiers (every tier, - // until one is cut) 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 screen, its positions at the server's - // values — until the install's flush re-syncs. On the adopt path this - // wait is one more reason in the frame's registered hold (3.1): the - // delegated-event replay window stays open, hydration-done waits. - // (A trace in the args is the trace tier's reason — it lands with the - // tier's cut, where the marker scan is.) + // record names a region needs `regions`, 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 + // screen, its positions at the server's values — until the install's + // flush re-syncs. On the adopt path this wait is one more reason in + // the frame's registered hold (3.1): the delegated-event replay window + // stays open, hydration-done waits. if ( !mounted && ((record && record.pending) || - (consumers ? !tierReady("bind") : record && record.regions && !tierReady("regions"))) + (consumers + ? !tierReady("bind") + : record && ((record.regions && !tierReady("regions")) || needsTrace(record.args)))) ) { waiting = true; + // Remember what the adopted interior was rendered from. A hold is + // the t=0 mount deferred: when it lifts, the mount must do what t=0 + // would have — claim with THIS record — even if a later write has + // since replaced it in the store (the mount below falls through to + // the args-change path for the replacement). A claim with the + // replacement's args instead would trust markup rendered from the + // old ones and leave every differing text hole stale: a claim pass + // never rewrites text (frames-rulings 3.6). + if (this.#options.adopt) + this.#heldRecords.has(occurrence) || this.#heldRecords.set(occurrence, record); continue; } if (!mounted) { @@ -1687,7 +1737,19 @@ class FrameImpl { // 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); - const nodes = this.#invokeSlot(occurrence, callback, record, start, this.#options.adopt); + // 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. + const held = this.#heldRecords.get(occurrence); + this.#heldRecords.delete(occurrence); + const mountRecord = held || record; + const nodes = this.#invokeSlot( + occurrence, + callback, + mountRecord, + start, + this.#options.adopt + ); // A data occurrence's nodes are its consuming elements (so the // zombie check above sees a morph that replaced them all); its mount // never returns nodes to place. @@ -1710,7 +1772,7 @@ class FrameImpl { // large adopted tree). if (!this.#options.adopt || nodes) this.#discoverRegions(occurrence, start); this.#bindRegions(occurrence); - continue; + if (mountRecord === record || !record || record.kind !== "slot") continue; } // A mounted data occurrence whose CONSUMERS changed — a morph replaced // one of its elements, a response added or dropped a bound position @@ -1861,7 +1923,10 @@ class FrameImpl { // 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). - const props = record && record.kind === "slot" ? this.#resolveArgs(occurrence, record) : {}; + // `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) : {}; // 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 @@ -1891,6 +1956,7 @@ class FrameImpl { this.#slotArgs.delete(key); this.#slotUpdaters.delete(key); this.#slotRebinders.delete(key); + this.#heldRecords.delete(key); this.#removeSlotRecord(key); this.#runSlotCleanups(key); const regions = this.#slotRegions.get(key); @@ -1927,7 +1993,8 @@ class FrameImpl { * 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)). + * (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 @@ -1937,14 +2004,14 @@ class FrameImpl { * 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) { + #resolveArgs(slotKey, record, claiming) { const { args, regions, decoded } = record; const revive = this.#options.host && this.#options.host.revive; const props = {}; for (const key in args) { if (regions && key in regions) continue; const value = args[key]; - props[key] = revive && !(decoded && key in decoded) ? revive(value) : value; + props[key] = revive && !(decoded && key in decoded) ? revive(value, claiming) : value; } if (regions) { const cache = this.#regionsFor(slotKey); diff --git a/packages/web/frames/src/frame-container-plugin.ts b/packages/web/frames/src/frame-container-plugin.ts index 6d284c614..ef48e6194 100644 --- a/packages/web/frames/src/frame-container-plugin.ts +++ b/packages/web/frames/src/frame-container-plugin.ts @@ -46,19 +46,22 @@ export interface ContainerTraceMarker { // for integrations to wire, and its weight stays in the lazy codec graph. // Hook state is shared ACROSS MODULE COPIES, like the TRACE symbol below: -// integration bundles carry this module once per entry (the frames client +// integration bundles carry this module once per entry (the frames client's +// TRACES TIER chunk — `@solidjs/web/frames/trace`, loaded on demand — // installs the materializer on its copy; the LAZY CODEC chunk's copy is the // one whose plugin deserializes stream data), and module-local state would // leave the codec copy hookless — deserialize falls back to the inert // marker, the arg reads as a plain object, and the meter just renders // nothing (chat example, 2026-08-10). One registered global carries the // hooks and the materialization memo, so every copy is the same protocol -// endpoint. +// endpoint. The EAGER frames client carries no copy at all: it reads the +// container probe (`materializedValues`) off this object by its registered +// symbol, and nothing else of the client half until the tier installs. /** * @type {{ * resolveTrace?: (value: unknown) => ({ subscribe(): AsyncIterable, array: boolean } | undefined), * shareIterable?: (source: AsyncIterable) => AsyncIterable, - * materializeTrace?: (marker: { $tr: any, $ta?: number }) => unknown, + * materializeTrace?: (marker: { $tr: any, $ta?: number }, claiming?: boolean) => unknown, * streamOf?: (iterable: AsyncIterable) => any, * materialized: WeakMap, * materializedValues: WeakSet @@ -92,9 +95,16 @@ export function setAsyncIterableSharer( export function setAsyncIterableSharer(fn) { state.shareIterable = fn; } /** Client half: install the reactive core's trace materializer. */ -export function setContainerTraceMaterializer(fn: (marker: ContainerTraceMarker) => unknown): void; +export function setContainerTraceMaterializer( + fn: (marker: ContainerTraceMarker, claiming?: boolean) => unknown +): void; -/** Client half: install the reactive core's trace materializer. */ +/** + * Client half: install the reactive core's trace materializer. Installed by + * the frames client's traces tier (`@solidjs/web/frames/trace`) when it + * loads — never at the eager client's module load. `claiming` is the + * revival site's hint (see reviveContainerTraces). + */ export function setContainerTraceMaterializer(fn) { state.materializeTrace = fn; } @@ -219,10 +229,10 @@ function isShareableIterable(value) { // across independent revival sites (an eval-face marker read by two // occurrences, a codec node re-resolved per record — possibly by DIFFERENT // copies of this module). -function materialize(marker) { +function materialize(marker, claiming) { let value = state.materialized.get(marker.$tr); if (value === undefined) { - value = state.materializeTrace(marker); + value = state.materializeTrace(marker, claiming); state.materialized.set(marker.$tr, value); if (value !== null && typeof value === "object") state.materializedValues.add(value); } @@ -259,23 +269,29 @@ export function isContainerTraceMarker(value) { const tr = value.$tr; return tr.__SEROVAL_STREAM__ === true || typeof tr[Symbol.asyncIterator] === "function"; } /** Deep-revive trace markers inside a decoded value (document-face slot args). */ -export function reviveContainerTraces(value: unknown): unknown; +export function reviveContainerTraces(value: unknown, claiming?: boolean): unknown; /** * Deep-revive trace markers inside a decoded value (document-face slot args * arrive as literals, and a container can sit at ANY depth of an argument — * `{ filters: { user: proj } }` is one arg). In-place: args records are * per-record decoded copies. No-op until the materializer is installed. + * The traces tier installs this as the shared frame host's `revive`, so it + * runs at the mount's arg-read (`FrameHostOptions.revive`). `claiming`: the + * value's reader is about to hydrate server markup rendered from it (a + * frame's adopt-time mount) — the materializer parks a trace's backlog + * beyond the snapshot the markup shows until hydration ends + * (frames-rulings 3.6 (iii)). */ -export function reviveContainerTraces(value) { +export function reviveContainerTraces(value, claiming) { if (!state.materializeTrace || value == null || typeof value !== "object") return value; - if (isContainerTraceMarker(value)) return materialize(value); + if (isContainerTraceMarker(value)) return materialize(value, claiming); // Plain containers only — anything exotic was either produced by the // codec plugin (already materialized) or is an app value not ours to walk. if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) value[i] = reviveContainerTraces(value[i]); + for (let i = 0; i < value.length; i++) value[i] = reviveContainerTraces(value[i], claiming); } else if (Object.getPrototypeOf(value) === Object.prototype) { - for (const key of Object.keys(value)) value[key] = reviveContainerTraces(value[key]); + for (const key of Object.keys(value)) value[key] = reviveContainerTraces(value[key], claiming); } return value; } @@ -334,10 +350,13 @@ export const ContainerTracePlugin = { deserialize(node, ctx) { const iterable = ctx.deserialize(node.i); const marker = { $tr: iterable, $ta: node.a }; - // Codec face: the decode runs where the reactive core is resident (the - // frames client installs the materializer at module load, before any - // response can decode), so the value leaves the table already live. The - // marker fallback keeps a hookless decode inert instead of broken. + // Codec face: the decode runs where the materializer is resident — the + // `data` chunk that carries this node announces the traces tier + // (`chunk.tiers`, frame-sink.ts), and the transport awaits the tier's + // install before it lets the chunk decode — so the value leaves the + // table already live. A fresh value, never a claim's: no `claiming`. + // The marker fallback keeps a hookless decode (an un-announced chunk + // from a producer that predates the tier) inert instead of broken. return state.materializeTrace ? materialize(marker) : marker; } }; diff --git a/packages/web/frames/src/trace-tier.ts b/packages/web/frames/src/trace-tier.ts new file mode 100644 index 000000000..554dee178 --- /dev/null +++ b/packages/web/frames/src/trace-tier.ts @@ -0,0 +1,51 @@ +/** + * `@solidjs/web/frames/trace` — the frames client's TRACES tier (frames + * savings pass §3 row C3): the container tier's client half, loaded on + * demand through the tier mechanism (`prepareTier("trace")`, frame-client.ts). + * + * What rides in this chunk, and so leaves the eager frames client: solid's + * container-trace materializer (`solid-js/internal/container-trace` — the + * store engine's one edge into a server-component page, ~8 KB brotli with + * the projection/reconcile machinery it builds on) and the plugin's client + * half that reaches it (`frame-container-plugin.js`: the shared hook state, + * the materialization memo, the marker test, the deep arg-revive walk, the + * container probe). The eager client keeps only the trigger: the loader + * entry (`tierLoaders.trace`), the held-set predicate (a `{ $tr }` marker in + * an adopt-time record's args while this tier is absent — the occurrence is + * HELD, its server interior on screen, the frame's hold registered under + * frames-rulings 3.1), and the container probe through the plugin's + * registered state object. + * + * `install()` is the tier's one export, called once by `prepareTier` when + * the import resolves — before the install's flush re-syncs every live + * frame, so a held occurrence mounts with the materializer in place: + * - the materializer goes onto the plugin's shared state + * (`setContainerTraceMaterializer`), where the lazy codec's own copy of + * the plugin reads it at decode (a `data` chunk that carries a trace + * awaits this tier through its `tiers` announcement, so the decode + * finds it installed); + * - the shared host's `revive` becomes the deep arg-revive walk + * (`reviveContainerTraces`): document-face markers in a record's + * literal args materialize at the mount's arg-read, with the mount's + * `claiming` hint — an adopt-time mount's trace parks its backlog + * beyond the snapshot until hydration ends (frames-rulings 3.6 (iii)). + * + * The server announces this tier where it serializes a trace (`sink.needs + * ("trace")`: `X-Frame-Tiers` / `chunk.tiers` on a stream, `_$HY.r["sc: + * tiers"]` + a `modulepreload` on a document — frame-sink.ts), so the load + * is a warm start; an un-announced marker starts it from the readiness + * check and holds (the same DOM, later). + * @experimental + */ +import { materializeContainerTrace } from "solid-js/internal/container-trace"; +// 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 wire a host nobody mounts in). +import { getFrameHost } from "./client.js"; +import { reviveContainerTraces, setContainerTraceMaterializer } from "./frame-container-plugin.js"; + +/** Install the traces tier into the frames client (called by `prepareTier`). */ +export function install(): void { + setContainerTraceMaterializer(materializeContainerTrace); + getFrameHost().revive = reviveContainerTraces; +} diff --git a/packages/web/package.json b/packages/web/package.json index cdadf2670..492ba604a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -333,6 +333,10 @@ "types": "./types/frames/client.d.ts", "default": "./frames/dist/client.js" }, + "./frames/trace": { + "types": "./types/frames/trace-tier.d.ts", + "default": "./frames/dist/trace.js" + }, "./types/*": "./types/*" }, "scripts": { diff --git a/packages/web/rollup.config.js b/packages/web/rollup.config.js index 9886f8441..3bac9f3ea 100644 --- a/packages/web/rollup.config.js +++ b/packages/web/rollup.config.js @@ -106,6 +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. +const externalizeFramesClient = { + name: "externalize-frames-client", + resolveId(source, importer) { + if (!importer || !/[\\/]trace-tier\.(js|ts)$/.test(importer)) return null; + if (source === "./client.js") return { id: "@solidjs/web/frames", external: true }; + return null; + } +}; + // The frames SERVER entry's counterpart of externalizeSharedTransport. The // frame sink and transport lean on three server-side modules that carry // module state the rest of the app writes through the PUBLIC entries: @@ -374,7 +392,13 @@ export default [ "@solidjs/web/server-functions/client", // Lazily imported (`prepareData`): the codec loads only when a `data` // chunk actually arrives, so the frames client ships seroval-free. - "@solidjs/web/serialization/decode" + "@solidjs/web/serialization/decode", + // Lazily imported (`tierLoaders.trace`, through `prepareTier`): the + // traces tier — solid's container-trace materializer (the store + // 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" ], // Prod build: strip `_SOLID_DEV_` like the main `dist/web.js` entry, so the // frame runtime's dev checks/warnings (marker-integrity diagnostics) do @@ -396,7 +420,8 @@ export default [ "seroval", "seroval-plugins/web", "@solidjs/web/server-functions/client", - "@solidjs/web/serialization/decode" + "@solidjs/web/serialization/decode", + "@solidjs/web/frames/trace" ], plugins: [replaceFlags(false, true), externalizeSharedTransport] .concat(plugins) @@ -414,12 +439,34 @@ export default [ "seroval", "seroval-plugins/web", "@solidjs/web/server-functions/client", - "@solidjs/web/serialization/decode" + "@solidjs/web/serialization/decode", + "@solidjs/web/frames/trace" ], plugins: [replaceDev(true), externalizeSharedTransport] .concat(plugins) .concat(assertFramesClientTransport) }, + { + // The traces tier (`@solidjs/web/frames/trace`, frames/src/trace-tier.ts): + // the container tier's client half as a lazy chunk the frames client + // loads through `prepareTier("trace")`. Bundles the plugin's client half + // (frame-container-plugin.js: the shared hook state, the memo, the + // marker test, the revive walk); solid's materializer stays the external + // `solid-js/internal/container-trace` entry so the app's bundler can + // give the store engine to this chunk (bundled here it would be a second + // engine). The eager client entry is external by instance (see + // externalizeFramesClient). No `_SOLID_DEV_` gates of its own, so one + // build serves every condition. + input: "frames/src/trace-tier.ts", + output: { file: "frames/dist/trace.js", format: "es" }, + external: [ + "solid-js", + "solid-js/internal", + "solid-js/internal/container-trace", + "@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/c03-hydration-done-counts-holds.spec.tsx b/packages/web/test/consistency/c03-hydration-done-counts-holds.spec.tsx index 466e86e5f..6e3418f34 100644 --- a/packages/web/test/consistency/c03-hydration-done-counts-holds.spec.tsx +++ b/packages/web/test/consistency/c03-hydration-done-counts-holds.spec.tsx @@ -92,11 +92,14 @@ describe("C3 — hydration-done counts every hold", () => { // Arm (b): a container-trace arg present at adoption. The record and its // trace snapshot are in the page when the boundary adopts; the fill reads - // `props.data.n` through the revived projection. On `next` the materializer - // is installed at module load, so the claim runs in the adopt pass and done - // implies claimed. (On `size/s1-lazy-store-materializer` the materializer - // loads lazily and `prepareArgs` HOLDS the occurrence until it lands — a - // hold hydration does not count; this arm is the S1 probe.) + // `props.data.n` through the revived projection. Since the traces tier + // (plan step C3) the materializer loads LAZILY (`@solidjs/web/frames/ + // trace`, through `prepareTier("trace")`), so the adopt pass finds it + // absent and HOLDS the occurrence — S1's `prepareArgs` hold, re-based onto + // A2's registered held set: the hold is a pending boundary (3.1), so done + // waits for the late claim. This is S1's C3 (b) arm, green by the ruling + // (it was the probe that would have been red on S1 as built, where the + // hold registered with nothing hydration counted). test("(b) container-trace arg present at adoption: the occurrence has claimed by hydration end", async () => { const fid = freshFid("c3b"); page = bootPage( @@ -122,6 +125,11 @@ describe("C3 — hydration-done counts every hold", () => { onHydrationEnd(() => { invocationsAtEnd = invocations.length; }); + // The adopt pass HELD the occurrence on its tier: no fill yet, the + // server's interior on screen, hydration not done (the hold counts). + expect(invocations.length).toBe(0); + expect(page.container.textContent).toBe("1"); + expect(hydrationInProgress()).toBe(true); await quiesce(); await quiesce(); expect(invocations.length).toBe(1); diff --git a/packages/web/test/consistency/c11-trace-equals-oracle.spec.tsx b/packages/web/test/consistency/c11-trace-equals-oracle.spec.tsx index 098344099..9a2e205ad 100644 --- a/packages/web/test/consistency/c11-trace-equals-oracle.spec.tsx +++ b/packages/web/test/consistency/c11-trace-equals-oracle.spec.tsx @@ -21,14 +21,18 @@ * The oracle is `applyPatches` reproduced here over a plain value: the wire * shape is a tuple per patch — `[path, value]` sets, `[path]` deletes * (`splice(i, 1)` on arrays), `[path, value, 1]` inserts (`splice(i, 0, v)`). - * The materializer under test is whichever the branch ships: resident at - * module load on `next`, loaded lazily behind `host.prepareArgs` on - * `size/s1-lazy-store-materializer` — `readyMaterializer` covers both. + * The materializer is the frames client's traces tier (`@solidjs/web/frames/ + * trace`, loaded through `prepareTier("trace")` — plan step C3); these cells + * run with it RESIDENT (`readyMaterializer` warms it as the production host + * has it once that load has settled). The load itself, and the hold while + * it pends, are `tier-trace-hold.spec` and the `container-trace-hold-*` + * specs under test/hydration. */ import { afterEach, describe, expect, test } from "vitest"; import { createRoot, flush, NotReadyError } from "solid-js"; import { hydrate } from "@solidjs/web"; -import { getFrameHost, installServerComponents } from "../../frames/src/client.js"; +import { installServerComponents } from "../../frames/src/client.js"; +import { prepareTier } from "../../frames/src/frame-client.js"; import { isMaterializedContainer, reviveContainerTraces @@ -84,11 +88,10 @@ function read(store: any): unknown { } } -/** The branch's materializer, resident. */ +/** The traces tier, resident (the production load, settled). */ async function readyMaterializer() { installServerComponents(); - const host: any = getFrameHost(); - await host.prepareArgs?.({ probe: traceMarker().marker }); + await prepareTier("trace"); } /** A small deterministic PRNG (mulberry32) for the partition arm. */ diff --git a/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx b/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx index 431f865d4..a03b0c2f5 100644 --- a/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx +++ b/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx @@ -21,10 +21,20 @@ * * Release order pinned (3.2, "ordering to pin with it"): the claim, then * the frame's hold release, then done, then the backlog. + * + * The materializer is the frames client's traces tier (plan step C3), loaded + * through `prepareTier("trace")`; these cells run with it RESIDENT (warmed + * once below, as the production host has it once that load has settled), so + * the t=0 claim is synchronous. The park is keyed on the claim since C3 + * (`revive(value, claiming)`): a fresh mount pays no beat — pinned in + * solid's `container-trace.spec`; the late claim UNDER THE TIER'S OWN HOLD + * is `container-trace-hold-snapshot.spec` (test/hydration). */ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { createSignal, flush, untrack } from "solid-js"; import { hydrate } from "@solidjs/web"; +import { prepareTier } from "../../frames/src/frame-client.js"; +import "../../frames/src/client.js"; import { bootPage, fillHtml, @@ -45,6 +55,8 @@ afterEach(async () => { await page?.cleanup(); page = undefined; }); +// The traces tier, resident before any page boots (see the module doc). +beforeAll(() => prepareTier("trace")); /** A trace the server rendered at `n = snapshot` and then moved past. */ function movedTrace(snapshot: number, ...patches: number[]) { diff --git a/packages/web/test/consistency/harness/run.tsx b/packages/web/test/consistency/harness/run.tsx index ba036149b..2a5fa9c83 100644 --- a/packages/web/test/consistency/harness/run.tsx +++ b/packages/web/test/consistency/harness/run.tsx @@ -11,7 +11,8 @@ import { vi } from "vitest"; import { createSignal, flush, untrack } from "solid-js"; import { hydrate } from "@solidjs/web"; -import { getFrameHost, installServerComponents } from "../../../frames/src/client.js"; +import { installServerComponents } from "../../../frames/src/client.js"; +import { prepareTier } from "../../../frames/src/frame-client.js"; import { bootPage, fillHtml, @@ -50,17 +51,18 @@ let runCounter = 0; let holeCounter = 0; /** - * On a branch whose materializer loads lazily, warm it once so trace args - * read synchronously at claim (the pins do the same; see C11's - * `readyMaterializer`). A no-op where the materializer is resident. + * The materializer is the frames client's traces tier, loaded on demand + * (`prepareTier("trace")`, plan step C3); warm it once so trace args read + * synchronously at claim (the pins do the same; see C11's + * `readyMaterializer`). The load and the hold while it pends have their own + * pins (`tier-trace-hold.spec`, the `container-trace-hold-*` specs). */ let materializerReady: Promise | undefined; async function readyMaterializer() { if (!materializerReady) { materializerReady = (async () => { installServerComponents(); - const host: any = getFrameHost(); - await host.prepareArgs?.({ probe: traceMarker().marker }); + await prepareTier("trace"); delete (globalThis as any)._$SC; })(); } diff --git a/packages/web/test/consistency/tier-trace-hold.spec.tsx b/packages/web/test/consistency/tier-trace-hold.spec.tsx new file mode 100644 index 000000000..bbb094abe --- /dev/null +++ b/packages/web/test/consistency/tier-trace-hold.spec.tsx @@ -0,0 +1,246 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * The traces tier's timing pin (frames savings pass §1, row "traces"; + * §3 row C3). The race: an adopt-time record whose literal args carry a + * `{ $tr, $ta }` marker — or a `data` chunk whose node tree holds the trace + * plugin's node — before `@solidjs/web/frames/trace` (the materializer + the + * store engine) has loaded. Lost, the fill would run with an inert marker + * object (a `TypeError` or wrong content). The bound: ANNOUNCE + HOLD. + * + * - Document face, un-announced (the fallback): the adopt-time sync finds + * the marker (`needsTrace`), starts the load and HOLDS the occurrence — + * its server interior on screen, the frame's hold registered under + * frames-rulings 3.1 (hydration-done waits). It mounts with the record + * it was held on (S1 commit 3's held-record mount, generalized): a + * record that replaced it meanwhile applies as the args change it is. + * No `TypeError`, nothing read inert. + * - Document face, announced: `_$HY.r["sc:tiers"]` names `trace`; + * `installServerComponents` starts the import before any boundary + * adopts (the warm start the `modulepreload` made a cache hit). + * - Codec face: a `data` chunk that names `trace` (the sink stamps it on + * the chunk carrying the node) awaits the tier before it decodes — + * frames-container-lazy-codec.spec has the full arm; here the chunk's + * wait alone. + * + * 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 { hydrate } from "@solidjs/web"; +import { applyFrameResponse, installServerComponents } from "../../frames/src/client.js"; +import { tierLoads } from "../../frames/src/frame-client.js"; +import { createChunk } from "../../server-functions/src/shared.js"; +import { + bootPage, + fillHtml, + frameHtml, + freshFid, + hydrationInProgress, + makeHost, + onHydrationEnd, + pump, + quiesce, + slotRange, + traceMarker, + type Page +} from "./support.js"; + +/** The traces tier's load, gated by the test; `release()` installs the real module. */ +function gatedTrace() { + delete (tierLoads as any).trace; + let resolve!: (m: any) => void; + const loader = vi.fn(() => new Promise(r => (resolve = r))); + return { + tiers: { trace: loader }, + loader, + release: async () => resolve(await import("../../frames/src/trace-tier.js")) + }; +} +const resident = () => !!(tierLoads as any).trace?.r; + +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 traces tier — document face", () => { + test("un-announced: a marker in an adopted record's args before the load holds the occurrence (interior on screen, hydration waits); it mounts with the record it was held on and applies a replacement as an args change", async () => { + const gate = gatedTrace(); + const fid = freshFid("tier-trace-a"); + page = bootPage( + frameHtml(fid, `
    ${slotRange("item#0", fillHtml(fid, "item#0", "2"))}
`), + { tiers: gate.tiers } + ); + const t0 = traceMarker(); + t0.snapshot({ n: 2 }); + page.slotRecord(fid, "item#0", { label: "first", data: t0.marker }); + // 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 mounted: string[] = []; + let readN: (() => number) | undefined; + let mountedAtEnd = -1; + let inProgressAtEnd: boolean | undefined; + const dispose = hydrate( + () => ( + { + mounted.push(p.label); + readN = () => p.data.n; + return
  • {p.data.n}
  • ; + }} + /> + ), + page.container + ); + disposers.push(dispose); + onHydrationEnd(() => { + mountedAtEnd = mounted.length; + inProgressAtEnd = hydrationInProgress(); + }); + await quiesce(); + // The adopt-time sync found the marker with the tier absent: it started + // the load (once) and HELD — no fill ran, the server's node and text are + // untouched, and hydration is not done (3.1: the hold is a pending + // boundary). + expect(gate.loader).toHaveBeenCalledTimes(1); + expect(mounted).toEqual([]); + expect(page.container.querySelector("li")).toBe(li); + expect(li.textContent).toBe("2"); + expect(hydrationInProgress()).toBe(true); + expect(mountedAtEnd).toBe(-1); + expect(page.errors).toEqual([]); + + // Meanwhile the record is REPLACED — a refetch of the same call (the + // next version's write; the store is one response's). The interior on + // screen was rendered from the FIRST record. + const t1 = traceMarker(); + t1.snapshot({ n: 7 }); + page.host.apply({ + type: "slot", + id: fid, + version: 1, + key: "item#0", + args: { label: "second", data: t1.marker } + }); + await quiesce(); + expect(mounted).toEqual([]); + + // The tier lands: its install, then one flush per live frame. The + // occurrence mounts ONCE, with the record it was held on (the claim — + // the markup shows `2`), and the replacement applies as an args change: + // the live mount's props move to the second record's values. + await gate.release(); + await quiesce(); + await quiesce(); + expect(resident()).toBe(true); + expect(mounted).toEqual(["first"]); + expect(page.container.querySelector("li")).toBe(li); + expect(readN!()).toBe(7); + expect(li.textContent).toBe("7"); + // Hydration-done came after the mount, not before; nothing warned, no + // `TypeError` from a marker read inert. + 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([]); + }); + + test('announced: `_$HY.r["sc:tiers"]` names `trace` — the import starts at install, before any boundary adopts; the held occurrence mounts on the install', async () => { + const gate = gatedTrace(); + const fid = freshFid("tier-trace-b"); + page = bootPage( + frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "1"))}
    `), + { tiers: gate.tiers, records: { "sc:tiers": ["trace"] } } + ); + // Started at install (the record), ahead of the adopt-time sync. + expect(gate.loader).toHaveBeenCalledTimes(1); + expect(resident()).toBe(false); + const trace = traceMarker(); + trace.snapshot({ n: 1 }); + page.slotRecord(fid, "item#0", { data: trace.marker }); + const Comp = (globalThis as any)._$SC.r(fid); + let mounts = 0; + const dispose = hydrate( + () => ( + { + mounts++; + return
  • {p.data.n}
  • ; + }} + /> + ), + 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("1"); + expect(hydrationInProgress()).toBe(false); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + }); +}); + +describe("the traces tier — codec face", () => { + test("a `data` chunk that names `trace` waits for the tier before it decodes", async () => { + const gate = gatedTrace(); + const applied: string[] = []; + const { host } = makeHost({ applyData: (c: any) => applied.push(c.key) }); + installServerComponents(host, { tiers: gate.tiers }); + const WIRE = "tier-trace/data-wire"; + let controller!: ReadableStreamDefaultController; + const response = new Response( + new ReadableStream({ + start(c) { + controller = c; + } + }), + { headers: { "X-Frame-Stream": WIRE } } + ); + const send = (chunk: any) => controller.enqueue(createChunk(JSON.stringify(chunk))); + const done = applyFrameResponse(response, host, { as: WIRE, version: 1 }); + send({ type: "start", id: WIRE, version: 1 }); + // The chunk the sink stamps `trace` on: the one whose node is the + // trace plugin's (an inert node here — the wait is what is pinned). + send({ + type: "data", + id: WIRE, + version: 1, + key: "arg:user", + node: null, + initial: true, + tiers: ["trace"] + }); + send({ type: "html", id: WIRE, version: 1, html: "

    after

    " }); + await pump(); + expect(gate.loader).toHaveBeenCalledTimes(1); + expect(applied).toEqual([]); + expect(host.get(WIRE)).toBeUndefined(); + await gate.release(); + await pump(); + expect(resident()).toBe(true); + expect(applied).toEqual(["arg:user"]); + send({ type: "complete", id: WIRE, version: 1 }); + controller.close(); + await done; + }); +}); diff --git a/packages/web/test/frames-container-lazy-codec.spec.tsx b/packages/web/test/frames-container-lazy-codec.spec.tsx new file mode 100644 index 000000000..cf19d43c7 --- /dev/null +++ b/packages/web/test/frames-container-lazy-codec.spec.tsx @@ -0,0 +1,216 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// The traces tier — codec face. The container-trace materializer is solid's +// projection machinery, the store engine's one edge into a server-component +// page, so the frames client no longer installs it at module load: it is the +// `trace` TIER (`@solidjs/web/frames/trace`, frames savings pass §3 row C3), +// loaded through the tier mechanism. On the stream face the SERVER announces +// it: the sink mints `trace` where it serializes a trace, and the `data` +// chunk that carries the plugin's node leaves with `tiers: ["trace"]` in-band +// (test/server/tier-announce.spec pins the sink's half). The transport +// starts the load at that chunk and AWAITS it before the chunk decodes (the +// plugin materializes AT decode, so the install has to come first; nothing +// after a decode revives), as it awaits the codec itself. A response whose +// data never carries a trace announces nothing and loads nothing. +// +// S1's node scan (`prepareData` inspecting each chunk's tree) is not kept: +// the announcement rides the very chunk the scan would have found the node +// in, from the same site that serialized it, so the scan is unreachable +// behind it (the plan's §3 row C3 / §5 — measured +79 B min / +25 B br on +// the eager client to keep; dropped). An un-announced chunk from a producer +// that predates the tier decodes the inert marker (the plugin's hookless +// fallback) — a skew the frames server and client, one package, never ship. +// +// A tier, once resident, stays so for the worker: the gated loader is +// installed per test (`installServerComponents({ tiers })` replaces the +// built-in entry) and the load dropped between tests (`tierLoads`, the +// runtime's test seam). The behaviour of a resident tier is pinned in +// lifecycle-matrix/container-args.spec.tsx; the document face in +// frames-container-lazy-document.spec.tsx. +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createRoot, Loading, untrack } from "solid-js"; +import { dynamic } from "../src/index.js"; +import { getFrameHost, installServerComponents } from "../frames/src/client.js"; +import { tierLoads } from "../frames/src/frame-client.js"; +import { createServerReference } from "../server-functions/src/client.js"; +import { + isMaterializedContainer, + setContainerTraceResolver, + toBorderForm +} from "../frames/src/frame-container-plugin.js"; +import { frameResponse, createDataSource, pump } from "./lifecycle-matrix/harness.js"; + +const getPlain = createServerReference("lazy/containers/plain"); +const getTraced = createServerReference("lazy/containers/traced"); +const traceTierResident = () => !!(tierLoads as any).trace?.r; + +const slotArticle = + "

    T

    "; + +// Harness-side server half: the sink envelopes any value the resolver claims. +const traces = new WeakMap(); +setContainerTraceResolver((v: unknown) => + typeof v === "object" && v !== null ? traces.get(v) : undefined +); +function traceProducer() { + const queue: IteratorResult[] = []; + const waiters: ((r: IteratorResult) => void)[] = []; + const put = (r: IteratorResult) => { + const w = waiters.shift(); + w ? w(r) : queue.push(r); + }; + const iterate = () => ({ + [Symbol.asyncIterator]() { + return { + next: () => { + const b = queue.shift(); + return b + ? Promise.resolve(b) + : new Promise>(res => waiters.push(res)); + } + }; + } + }); + return { + trace: { array: false, subscribe: iterate }, + push: (value: any) => put({ done: false, value }), + end: () => put({ done: true, value: undefined }) + }; +} + +/** The tier's load, gated by the test; `release()` installs the real module. */ +function gateTraceTier() { + delete (tierLoads as any).trace; + let resolve!: (m: any) => void; + const loader = vi.fn(() => new Promise(r => (resolve = r))); + installServerComponents(undefined, { tiers: { trace: loader } }); + return { loader, release: async () => resolve(await import("../frames/src/trace-tier.js")) }; +} + +function mount(Comp: any, props: Record) { + const container = document.createElement("div"); + document.body.appendChild(container); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + shell-fallback}> + + +
    ; + container.appendChild(div); + return d; + }); + return { + div, + cleanup() { + dispose(); + container.remove(); + } + }; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("codec face: the traces tier loads behind the data chunk that announces it", () => { + test("a data chunk WITHOUT a trace announces nothing and decodes with nothing loaded", async () => { + const gated = gateTraceTier(); + const data = createDataSource(); + const initial = data.chunks("plain", 1, { user: { name: "Ada" } }); + vi.stubGlobal("fetch", async () => + frameResponse("plain", [ + { type: "start", id: "plain", version: 1 }, + ...initial, + { + type: "slot", + id: "plain", + version: 1, + key: "comment#0", + args: { user: { $ref: "user" } } + }, + { type: "html", id: "plain", version: 1, html: slotArticle }, + { type: "complete", id: "plain", version: 1 } + ]) + ); + const Page = dynamic(() => getPlain() as any); + const m = mount(Page, { comment: (p: any) =>
  • {p.user.name}
  • }); + await pump(3); + expect(m.div.querySelector("li")!.textContent).toBe("Ada"); + // The codec loaded (the data decoded); the tier was never asked for. + expect(gated.loader).not.toHaveBeenCalled(); + expect((tierLoads as any).trace).toBeUndefined(); + m.cleanup(); + }); + + test("a data chunk WITH a trace announces the tier and awaits it before it decodes: the {$ref} resolves to a live store", async () => { + const gated = gateTraceTier(); + const producer = traceProducer(); + const user = {}; + traces.set(user, producer.trace); + const late: any[] = []; + const data = createDataSource(); + const initial = data.chunks("traced", 1, { user: toBorderForm(user, true) }, c => late.push(c)); + // What the sink emits for this chunk: the trace's announcement rides it. + initial[0].tiers = ["trace"]; + vi.stubGlobal("fetch", async () => + frameResponse("traced", [ + { type: "start", id: "traced", version: 1 }, + ...initial, + { + type: "slot", + id: "traced", + version: 1, + key: "comment#0", + args: { user: { $ref: "user" } } + }, + { type: "html", id: "traced", version: 1, html: slotArticle }, + { type: "complete", id: "traced", version: 1 } + ]) + ); + let received: any; + let mounts = 0; + const Page = dynamic(() => getTraced() as any); + const m = mount(Page, { + comment: (p: any) => { + mounts++; + received = untrack(() => p.user); + return ( + fill-wait}> +
  • {p.user.name}
  • +
    + ); + } + }); + await pump(3); + // The announcement started the load at the chunk — and the chunk is + // held behind it: nothing decoded, the record and the shell behind it + // in the sequential drain wait too (chunk ORDER is the contract). + expect(gated.loader).toHaveBeenCalledTimes(1); + expect(traceTierResident()).toBe(false); + expect(m.div.querySelector("article")).toBeNull(); + expect(mounts).toBe(0); + + // The load settles: the install, then the drain resumes — the data + // decodes with the materializer resident. + await gated.release(); + await pump(3); + expect(traceTierResident()).toBe(true); + expect(mounts).toBe(1); + // Decoded AFTER the load: a live (pending) container, not an inert marker. + expect(isMaterializedContainer(received)).toBe(true); + expect(m.div.textContent).toContain("fill-wait"); + + // The snapshot lands through the (lazily loaded) codec's data table: + // the fill's suspended read settles on the live store. The shared host + // routes data by the call's ADDRESS (the transport rewrote the stream's + // root id to it; an argless call's address is its function id). + producer.push({ name: "Ada" }); + await pump(); + for (const c of late.splice(0)) getFrameHost().apply({ ...c, id: "lazy/containers/traced" }); + await pump(); + expect(m.div.querySelector("li")!.textContent).toBe("Ada"); + m.cleanup(); + producer.end(); + }); +}); diff --git a/packages/web/test/frames-container-lazy-document.spec.tsx b/packages/web/test/frames-container-lazy-document.spec.tsx new file mode 100644 index 000000000..9adfd9c5e --- /dev/null +++ b/packages/web/test/frames-container-lazy-document.spec.tsx @@ -0,0 +1,181 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// The traces tier — document face. A t=0 record (`_$HY.r["sc:slot:: +// "]`) carries the container as a `{ $tr, $ta }` marker literal that the +// host revives at arg-read — but the frames client no longer installs the +// materializer at module load (it is the `trace` tier, `@solidjs/web/frames/ +// trace`), so the first such record finds the tier absent. The adopt-time +// sync's held-set predicate (`needsTrace`: a marker in the record's args +// while the tier is not resident) starts the load — the PRODUCTION loader, +// here through the test alias to the tier's source — and HOLDS the +// occurrence: its server-rendered interior stays on screen, no fill mounts, +// and when the load settles the install's flush re-syncs the frame and +// mounts the fill with the live store (nested references sharing it, as the +// resident path pins). A re-sent record after that revives synchronously. +// +// This page carries no `sc:tiers` record — the un-announced fallback: the +// readiness check itself starts the load, the same DOM, later (the +// announced path, where `installServerComponents` starts the import from the +// record before any boundary adopts, is hydration/welcome-status-lazy.spec). +// +// Its own spec file: a tier, once resident, stays so for the worker, and +// the "not loaded" half is observable only before the first install (see +// frames-container-lazy-codec.spec.tsx for the codec face). +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { createMemo, createRoot, flush, Loading, untrack } from "solid-js"; +import { dynamic } from "../src/index.js"; +import { getFrameHost, installServerComponents } from "../frames/src/client.js"; +import { tierLoads } from "../frames/src/frame-client.js"; +import { createServerReference } from "../server-functions/src/client.js"; +import { pump } from "./lifecycle-matrix/harness.js"; + +const getAdopt = createServerReference("lazy/containers/adopt"); +const traceTierResident = () => !!(tierLoads as any).trace?.r; +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +/** A hand-cranked trace: an async iterable whose yields the test pushes. */ +function traceProducer() { + const queue: IteratorResult[] = []; + const waiters: ((r: IteratorResult) => void)[] = []; + const put = (r: IteratorResult) => { + const w = waiters.shift(); + w ? w(r) : queue.push(r); + }; + return { + subscribe: () => ({ + [Symbol.asyncIterator]() { + return { + next: () => { + const b = queue.shift(); + return b + ? Promise.resolve(b) + : new Promise>(res => waiters.push(res)); + } + }; + } + }), + push: (value: any) => put({ done: false, value }), + end: () => put({ done: true, value: undefined }) + }; +} + +const adoptFid = "lazy/containers/adopt"; +const producer = traceProducer(); +beforeAll(() => { + const marker = { $tr: producer.subscribe(), $ta: 0 }; + document.body.innerHTML = + '
    ' + + `` + + "

    Adopt

      " + + '
    • server-rendered-fill
    • ' + + "
    " + + "
    "; + (window as any)._$HY = { + done: true, + r: { + [`sc:slot:${adoptFid}:comment#c1`]: { + cid: "c1", + user: marker, + filters: { deep: { user: marker } } + } + } + }; +}); + +afterAll(() => { + delete (window as any)._$HY; + document.body.innerHTML = ""; +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("document face: an adopted record's marker holds the occurrence until the traces tier loads", () => { + test("held with the server interior on screen, then mounted with the live store (nested references share it)", async () => { + installServerComponents(); + // Nothing announced, nothing loaded yet. + expect((tierLoads as any).trace).toBeUndefined(); + expect(getFrameHost().revive).toBeUndefined(); + vi.stubGlobal("fetch", async () => { + throw new Error("t=0 adoption must not fetch"); + }); + + let mounts = 0; + let same: boolean | undefined; + const names: string[] = []; + const Page = dynamic(() => getAdopt() as any); + const container = document.createElement("div"); + document.body.appendChild(container); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + shell-fallback}> + { + mounts++; + same = untrack(() => p.user === p.filters.deep.user); + const name = createMemo(() => { + const v = p.user.name; + names.push(v); + return v; + }); + return ( + fill-wait}> +
  • {name()}
  • +
    + ); + }} + /> +
    +
    ; + container.appendChild(div); + return d; + }); + // The local answer resolves over microtasks; the module load needs a + // task. Settle the former only, so the hold is observable. + const frame = document.querySelector(`[data-fid="${adoptFid}"]`)!; + for (let i = 0; i < 20; i++) { + flush(); + await Promise.resolve(); + } + // Adopted in place (the frame is bound under the call's address — an + // argless call's address is its function id), and HELD: the sync found + // the marker and started the load (detection, the un-announced + // fallback); no fill mounted, the server-rendered interior untouched, + // the tier still loading. + expect((tierLoads as any).trace).toBeTruthy(); + expect(traceTierResident()).toBe(false); + expect(getFrameHost().get(adoptFid)).toBeTruthy(); + expect(frame).not.toBe(null); + expect(mounts).toBe(0); + expect(frame.querySelector("li.ssr-fill")!.textContent).toBe("server-rendered-fill"); + + // The load settles; the install wires the shared host's reviver and + // flushes the frame, which re-syncs and mounts the occurrence. + for (let i = 0; i < 200 && !traceTierResident(); i++) await sleep(10); + expect(traceTierResident()).toBe(true); + expect(getFrameHost().revive).toBeTypeOf("function"); + await pump(); + expect(mounts).toBe(1); + // ONE store for both references; uninitialized until the trace feeds + // it, so the fill's own boundary covers the read. + expect(same).toBe(true); + expect(div.textContent).toContain("fill-wait"); + + producer.push({ name: "Ada" }); + await pump(); + expect(div.querySelector("li.live-fill")!.textContent).toBe("Ada"); + expect(names).toEqual(["Ada"]); + + producer.push([[["name"], "Grace"]]); + await pump(); + expect(div.querySelector("li.live-fill")!.textContent).toBe("Grace"); + expect(names).toEqual(["Ada", "Grace"]); + expect(mounts).toBe(1); + + producer.end(); + dispose(); + container.remove(); + }); +}); diff --git a/packages/web/test/hydration/container-trace-hold-helpers.tsx b/packages/web/test/hydration/container-trace-hold-helpers.tsx new file mode 100644 index 000000000..12bb87e85 --- /dev/null +++ b/packages/web/test/hydration/container-trace-hold-helpers.tsx @@ -0,0 +1,313 @@ +/** + * @jsxImportSource @solidjs/web + * + * Shared fixture for the container-trace HOLD specs (the traces tier, + * document face — frames savings pass §3 row C3, S1's hold re-based onto the + * tier mechanism): a server component whose slot calls interleave two + * trace-carrying fills with two ordinary ones, followed by a keyed sibling. + * + * The page as the server left it — `renderToStream` with the frame sink, + * `frameTransformDirectResult(thread, { id: FID })` rendered inline at t=0: + * + * ```tsx + * const thread = (props) => { + * const u1 = createProjection(async function* (d) { d.name = "Ada"; yield; }, { name: "" }); + * const u2 = createProjection(async function* (d) { d.name = "Grace"; yield; }, { name: "" }); + * return ( + *
      + *
    • + *
    • + *
    • + *
    • + *
    + * ); + * }; + * // page + * <> + * + * + * + * + * ``` + * + * `$key` names the calls because the sink's structural-repeat probe reads a + * pending projection's keys (a pre-existing server-side finding, noted in + * the review report). The fills below are the exact JSX the server ran, so + * the `_hk` chains in FRAME_HTML are what they mint. Records are rebuilt + * from the server's data script by hand (seroval streams for the traces, + * settled `_fr` stamps for the fills' inline boundaries) so the specs can + * decide WHEN each trace emits. + * + * The HOLD under test is the tier mechanism's: the adopt-time sync finds a + * `{ $tr }` marker in a record's args while the `trace` tier is absent, + * starts the load (`prepareTier("trace")`, the test's gated loader) and + * holds the occurrence; the frame's hold registers as a pending boundary + * (frames-rulings 3.1 / 3.2). `gateTraceTier` owns the load; `release()` + * settles it with the REAL tier module (`frames/src/trace-tier.ts`), so what + * installs is production's. A tier, once resident, stays so for the + * worker; the gate re-arms it by dropping the load (`tierLoads`, the + * runtime's test seam), the way S1's `force` re-held through its host. + */ +import { expect, vi } from "vitest"; +import { createStream } from "seroval"; +import { createSignal, flush, Loading, untrack } from "solid-js"; +import { getFrameHost, installServerComponents } from "../../frames/src/client.js"; +import { prepareTier, tierLoads } from "../../frames/src/frame-client.js"; + +/** + * The server rendered under `hold/thread`; a spec picks its own fid per + * test (the frames client remembers claimed boundaries per worker, and + * solid's fragment ledger keys by hydration id), and every key below is + * rebuilt from it — fids only ever appear inside prefix-scoped keys. + */ +export const FID = "hold/thread"; + +/** + * The server's markup, in the three pieces the page emitted (scripts + * stripped): a keyed sibling BEFORE the frame (`_hk=0`), the frame, and a + * keyed sibling AFTER it. The server consumed two root ids for the + * component (`NoHydration`'s owner and the slot props' zone), so the + * trailing sibling is `_hk=3`. Specs compose the page they need; the frame's + * own keys are prefix-scoped (`sc---`) and the same in any + * composition. + */ +export const P_BEFORE = `

    after

    `; +export const frameHtml = (fid: string = FID) => + `
      ` + + `
    • c1Ada
    • ` + + `
    • n1
    • ` + + `
    • c2Grace
    • ` + + `
    • n2
    • ` + + `
    `; +export const FRAME_HTML = frameHtml(); +export const P_AFTER = `

    after

    `; + +export const traceState = () => + (globalThis as any)[Symbol.for("solid.container-trace-state")] as + | { materializeTrace?: Function } + | undefined; + +/** Whether the traces tier is resident in this worker (the runtime's own stamp). */ +export const traceTierResident = () => !!(tierLoads as any).trace?.r; + +export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +/** A settled `_fr` stamp, as the document's data script leaves one. */ +function settledFragment() { + const fr: any = Promise.resolve(true); + fr.s = 1; + fr.v = true; + return fr; +} + +export interface Streams { + c1: any; + c2: any; +} + +/** + * Install `_$HY` with the page's records. The traces are the caller's + * streams: `emit` decides which snapshots the document already delivered + * before hydration starts (the default: both, as the server's script does). + */ +export function installRecords( + options: { fid?: string; emit?: (s: Streams) => void; hy?: Record } = {} +): Streams { + const fid = options.fid ?? FID; + const streams: Streams = { c1: createStream(), c2: createStream() }; + (globalThis as any)._$HY = { + events: [], + completed: new WeakSet(), + fe() {}, + ...options.hy, + r: { + [`sc:slot:${fid}:comment#c1`]: { cid: "c1", user: { $tr: streams.c1, $ta: 0 } }, + [`sc:slot:${fid}:note#0`]: { text: "n1" }, + [`sc:slot:${fid}:comment#c2`]: { cid: "c2", user: { $tr: streams.c2, $ta: 0 } }, + [`sc:slot:${fid}:note#1`]: { text: "n2" }, + [`sc-${fid}-comment#c1-2_fr`]: settledFragment(), + [`sc-${fid}-comment#c2-2_fr`]: settledFragment() + } + }; + if (options.emit) options.emit(streams); + else { + streams.c1.next({ name: "Ada" }); + streams.c2.next({ name: "Grace" }); + streams.c1.return(undefined); + streams.c2.return(undefined); + } + return streams; +} + +/** + * A stand-in for the hydration runtime's fragment ledger (`_$HY.fr`): what + * the frames client subscribes to for reveals. Two uses here — a reveal + * re-indexes boundary elements (the frames client indexes the document ONCE + * per worker, so a second page in the same worker is invisible to + * `findBoundaryElement` until a reveal rescans), and a reveal re-drains the + * adopted boundary's records (the #2978 cascade), which record retention + * exercises. + */ +export function fakeLedger() { + const subs: ((id: string, parent?: ParentNode) => void)[] = []; + return { + ledger: { + subscribe(cb: (id: string, parent?: ParentNode) => void) { + subs.push(cb); + return () => { + const i = subs.indexOf(cb); + if (i >= 0) subs.splice(i, 1); + }; + }, + pending: () => false, + claim() {}, + release() {} + }, + reveal(parent: ParentNode) { + for (const cb of [...subs]) cb("reveal", parent); + }, + get subscribers() { + return subs.length; + } + }; +} + +export function mountShell(html: string = P_BEFORE + FRAME_HTML): HTMLDivElement { + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + return container; +} + +/** The fills, verbatim from the server render (see the header). */ +export const mounts: { comment: string[]; note: string[]; stores: any[] } = { + comment: [], + note: [], + stores: [] +}; +export const commentFill = (p: any) => { + mounts.comment.push(untrack(() => p.cid)); + mounts.stores.push(untrack(() => p.user)); + const [n, setN] = createSignal(0); + return ( +
    + {p.cid} + …}> + {p.user.name} + + +
    + ); +}; +export const noteFill = (p: any) => { + mounts.note.push(untrack(() => p.text)); + return {p.text}; +}; +export const After = (props: { text: string }) =>

    {props.text}

    ; + +export function resetMounts() { + mounts.comment.length = 0; + mounts.note.length = 0; + mounts.stores.length = 0; +} + +/** + * The production frames client with the traces tier's LOAD gated by the + * test: `installServerComponents` with a `trace` loader whose import + * settles when `release()` says so — with the real tier module, so the + * install (the materializer onto the plugin's shared state, the shared + * host's `revive`) is production's. The frame holds a trace-carrying + * occurrence on this load exactly as it would on the chunk's fetch. + * + * Re-arms the tier if an earlier test in this worker installed it (one + * load per name per page in the runtime; the test seam drops it), so every + * spec file can hold as often as it needs. `loader` counts the asks: one + * per hold however many occurrences wait on it. + */ +export function gateTraceTier() { + delete (tierLoads as any).trace; + let resolve!: (m: { install(): void }) => void; + const promise = new Promise<{ install(): void }>(r => (resolve = r)); + const loader = vi.fn(() => promise); + installServerComponents(undefined, { tiers: { trace: loader } }); + const host = getFrameHost(); + const release = async () => { + resolve(await import("../../frames/src/trace-tier.js")); + // The install and the frames' re-sync run on the load's continuation; + // give them the microtasks and a flush. + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + flush(); + } + }; + return { host, loader, release }; +} + +/** + * The resident configuration: the traces tier installed before `hydrate()`, + * as the production host has it once the load has settled (the real + * loader, or an earlier gate's module — either installs production's). + */ +export async function residentTraceTier() { + installServerComponents(); + if (!traceTierResident()) { + // A previous gate may have left a pending loader entry; the real module + // settles it either way. + delete (tierLoads as any).trace; + installServerComponents(undefined, { + tiers: { trace: () => import("../../frames/src/trace-tier.js") } + }); + await prepareTier("trace"); + } + return getFrameHost(); +} + +export function captureWarnings() { + const warnings: string[] = []; + vi.spyOn(console, "warn").mockImplementation((...args: any[]) => { + warnings.push(args.map(String).join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...args: any[]) => { + warnings.push(args.map(String).join(" ")); + }); + return warnings; +} + +/** Every server node of the page, in document order, for identity checks. */ +export function snapshotNodes(container: HTMLElement, fid: string = FID) { + return { + c1: container.querySelector(`[_hk="sc-${fid}-comment#c1-0"]`)!, + c1name: container.querySelector(`[_hk="sc-${fid}-comment#c1-2000"]`)!, + c2: container.querySelector(`[_hk="sc-${fid}-comment#c2-0"]`)!, + c2name: container.querySelector(`[_hk="sc-${fid}-comment#c2-2000"]`)!, + n0: container.querySelector(`[_hk="sc-${fid}-note#0-0"]`)!, + n1: container.querySelector(`[_hk="sc-${fid}-note#1-0"]`)!, + before: container.querySelector("p.after")! + }; +} + +/** The claim held: the same nodes, once each, nothing fresh beside them. */ +export function expectClaimed( + container: HTMLElement, + before: ReturnType, + fid: string = FID +) { + const now = snapshotNodes(container, fid); + for (const key of Object.keys(before) as (keyof typeof before)[]) { + expect(now[key], key).toBe(before[key]); + } + expect(container.querySelectorAll(".c").length).toBe(2); + expect(container.querySelectorAll("em").length).toBe(2); + expect(container.querySelectorAll("span.name").length).toBe(2); + expect(container.querySelector("i")).toBe(null); +} + +export function cleanupHold() { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete (globalThis as any)._$HY; + delete (globalThis as any)._$SC; + delete (globalThis as any).$R; + document.body.innerHTML = ""; + resetMounts(); +} diff --git a/packages/web/test/hydration/container-trace-hold-hydration-end.spec.tsx b/packages/web/test/hydration/container-trace-hold-hydration-end.spec.tsx new file mode 100644 index 000000000..bd9bb8bdb --- /dev/null +++ b/packages/web/test/hydration/container-trace-hold-hydration-end.spec.tsx @@ -0,0 +1,146 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * The traces tier's HOLD and hydration end — RE-PINNED under frames-rulings + * 3.1 (ruled 2026-10-05): hydration WAITS for the load; the mount claims + * BEFORE done. + * + * The frame holds a trace-carrying occurrence (server interior on screen) + * while the tier's chunk loads. The hold is one more reason in the frame's + * registered hold (3.2 — one `initBoundaryResume` registration per frame + * while a sync leaves an adopt-time occurrence waiting), so from solid's + * point of view hydration is still in progress: the root pass is over, but + * a pending boundary stands. `onHydrationEnd` does not fire, `_$HY.done` + * does not latch. The load settles, the late mount CLAIMS the server markup + * (the frames' scoped re-entry, `claimRender` — never a fresh render beside + * it), the hold releases, and only then is hydration done; the fill is + * live: the materialized store readable, handlers bound, the claimed + * sibling after the frame hydrated as normal. + * + * S1's original pin (`9927ddddd`) asserted the opposite order — done + * before the claim, the hold "a frame's business" — the private notion of + * done 3.1 rejects; the claim assertions are kept, the order inverted + * (3.1 "Consequences"; the plan's §3 row C3 and §5). + * + * The load is the test's (`gateTraceTier`); the production loader's path + * is `welcome-status-lazy.spec`. This spec pins the ORDERING against + * hydration end. + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createSignal, flush } from "solid-js"; +import { sharedConfig } from "solid-js/internal"; +import { hydrate } from "@solidjs/web"; +import { + After, + captureWarnings, + cleanupHold, + commentFill, + expectClaimed, + FID, + gateTraceTier, + installRecords, + mounts, + mountShell, + noteFill, + sleep, + snapshotNodes, + traceTierResident +} from "./container-trace-hold-helpers.jsx"; + +describe("container-trace hold vs hydration end (frames-rulings 3.1)", () => { + afterEach(cleanupHold); + + test("hydration waits for the load; the late mount claims the server markup before done", async () => { + installRecords(); + const container = mountShell(); + const before = snapshotNodes(container); + const warnings = captureWarnings(); + vi.stubGlobal("fetch", () => { + throw new Error("fetch must not be called"); + }); + const gated = gateTraceTier(); + expect(traceTierResident()).toBe(false); + + const [label, setLabel] = createSignal("after"); + const Thread = (globalThis as any)._$SC.r(FID); + let mountsAtEnd = -1; + let inProgressAtEnd: boolean | undefined; + const dispose = hydrate( + () => ( + <> + + + + ), + container + ); + sharedConfig.onHydrationEnd!(() => { + mountsAtEnd = mounts.comment.length; + inProgressAtEnd = sharedConfig.isHydrationInProgress!(); + }); + flush(); + + // The root pass is over; the ordinary fills claimed in it; the two + // trace-carrying occurrences are HELD on the tier's load — asked once. + expect(gated.loader).toHaveBeenCalledTimes(1); + expect(mounts.note).toEqual(["n1", "n2"]); + expect(mounts.comment).toEqual([]); + expect(sharedConfig.hydrating).toBe(false); + // 3.1: the hold is a pending boundary — hydration is NOT done. + expect(sharedConfig.isHydrationInProgress!()).toBe(true); + await sleep(0); + flush(); + expect(sharedConfig.isHydrationInProgress!()).toBe(true); + expect(mountsAtEnd).toBe(-1); + expect((globalThis as any)._$HY.done).toBeUndefined(); + expect(warnings).toEqual([]); + // Still held: server interior untouched, the tier not resident. + expect(mounts.comment).toEqual([]); + expect(container.querySelector(`[_hk="sc-${FID}-comment#c1-0"]`)).toBe(before.c1); + expect(traceTierResident()).toBe(false); + + // The load settles: the install, one flush per live frame, the mounts. + await gated.release(); + await sleep(10); + flush(); + + // Mounted once each, as CLAIMS: every server node is still the node. + expect(traceTierResident()).toBe(true); + expect(mounts.comment).toEqual(["c1", "c2"]); + expect(mounts.note).toEqual(["n1", "n2"]); + expectClaimed(container, before); + expect(warnings).toEqual([]); + + // The order 3.2 pins: the claims, then the hold's release, then done. + // At the end callback both fills had mounted; done came after them. + expect(mountsAtEnd).toBe(2); + expect(inProgressAtEnd).toBe(false); + expect(sharedConfig.isHydrationInProgress!()).toBe(false); + expect(sharedConfig.done).toBe(true); + expect((globalThis as any)._$HY.done).toBe(true); + + // The materialized stores read (snapshot replayed from the stream). + expect(before.c1name.textContent).toBe("Ada"); + expect(before.c2name.textContent).toBe("Grace"); + + // Interactive: the claimed buttons' handlers are bound; the claimed + // sibling beside the frame is live. + const buttons = container.querySelectorAll("button"); + expect(buttons.length).toBe(2); + buttons[0].click(); + flush(); + buttons[1].click(); + flush(); + buttons[1].click(); + flush(); + expect(buttons[0].textContent).toBe("1"); + expect(buttons[1].textContent).toBe("2"); + setLabel("later"); + flush(); + expect(before.before.textContent).toBe("later"); + + dispose(); + container.remove(); + }); +}); diff --git a/packages/web/test/hydration/container-trace-hold-id-determinism.spec.tsx b/packages/web/test/hydration/container-trace-hold-id-determinism.spec.tsx new file mode 100644 index 000000000..35e7b2a27 --- /dev/null +++ b/packages/web/test/hydration/container-trace-hold-id-determinism.spec.tsx @@ -0,0 +1,157 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Hydration-id determinism across the HOLD. Two trace-carrying fills + * interleaved with two ordinary ones: the ordinary fills claim in the root + * pass, the trace fills after the tier's load. Every fill must claim its own + * markup, and the page must hydrate identically whether the traces tier was + * resident at t=0 or arrived late — fills claim under prefix-scoped owners + * (`sc---`, `claimRender`), so their keys never depend on + * the ambient counter; what COULD differ is what the component consumes + * from the ambient owner, which is the keyed siblings' business. + * + * The materializer's root used to be created under the reviving owner + * (`createRoot` inherits a child id), so a resident revival at t=0 consumed + * one root id PER TRACE and a late revival (no ambient owner) none — the + * sibling after the frame hydrated under a different key in the two runs. + * The root is detached (A2b's port of S1's fix): id-neutral at any time. + * + * Lazy and resident interleave freely here: the gate re-arms the tier. + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createSignal, flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { + After, + captureWarnings, + cleanupHold, + commentFill, + expectClaimed, + fakeLedger, + FID, + frameHtml, + gateTraceTier, + installRecords, + mounts, + mountShell, + noteFill, + P_AFTER, + P_BEFORE, + residentTraceTier, + sleep, + snapshotNodes +} from "./container-trace-hold-helpers.jsx"; + +describe("container-trace hold: id determinism", () => { + afterEach(cleanupHold); + + let n = 0; + async function run(mode: "lazy" | "resident", tail: string, page: (T: any) => any) { + const fid = `${FID}/${mode}${++n}`; + const ledger = fakeLedger(); + installRecords({ fid, hy: { fr: ledger.ledger } }); + const container = mountShell(P_BEFORE + frameHtml(fid) + tail); + const before = snapshotNodes(container, fid); + const warnings = captureWarnings(); + vi.stubGlobal("fetch", () => { + throw new Error("fetch must not be called"); + }); + const gated = mode === "lazy" ? gateTraceTier() : undefined; + if (!gated) await residentTraceTier(); + // A second page in this worker: let the boundary index see it. + ledger.reveal(container); + const T = (globalThis as any)._$SC.r(fid); + const dispose = hydrate(() => page(T), container); + flush(); + if (gated) { + // The pass claimed the ordinary fills and held the trace fills. + expect(mounts.note).toEqual(["n1", "n2"]); + expect(mounts.comment).toEqual([]); + expect(gated.loader).toHaveBeenCalledTimes(1); + await gated.release(); + } + await sleep(10); + flush(); + return { container, before, warnings, dispose, fid }; + } + + const [label] = createSignal("after"); + + test("lazy: ordinary fills claim in the pass, trace fills claim after the load", async () => { + const r = await run("lazy", "", T => ( + <> + + + + )); + expect(mounts.comment).toEqual(["c1", "c2"]); + expect(mounts.note).toEqual(["n1", "n2"]); + expectClaimed(r.container, r.before, r.fid); + expect(r.before.before.textContent).toBe("after"); + expect(r.warnings).toEqual([]); + r.dispose(); + }); + + test("resident at t=0: the same claims, the same sibling key", async () => { + const r = await run("resident", "", T => ( + <> + + + + )); + expect(mounts.comment).toEqual(["c1", "c2"]); + expect(mounts.note).toEqual(["n1", "n2"]); + expectClaimed(r.container, r.before, r.fid); + expect(r.warnings).toEqual([]); + r.dispose(); + }); + + test("the materializer consumes no ambient id: a sibling AFTER the frame keys the same in both runs", async () => { + // One frame, two traces revived at t=0: with the old owned root the + // trailing sibling landed two ids further on than with no trace at + // all. Both runs must agree with each other; what they agree ON is the + // component's own consumption (see the `.fails` pin below). + const keys: string[] = []; + for (const mode of ["lazy", "resident"] as const) { + const r = await run(mode, P_AFTER, T => ( + <> + + + + + )); + const miss = r.warnings.find(w => w.includes("Hydration key miss")); + keys.push(miss ? miss.match(/key miss for "([^"]+)"/)![1] : "claimed"); + r.dispose(); + cleanupHold(); + } + expect(keys[0]).toBe(keys[1]); + }); + + // Frames-rulings 3.4 ("the client consumes what the server consumed"), the + // reading the plan's step C3 hands this pin to: pre-existing, independent + // of the hold — the server consumes root ids for the component + // (`NoHydration`'s owner in `serverOwned`, and more depending on position + // — one when the component is the first child, two after a keyed + // sibling), while the adopting client component consumes none, so a + // keyed sibling AFTER a document boundary misses its key. 3.4 decides it + // as a C10 parity bug to fix without a new ruling — once the server's + // consumption is pinned (it varies by position), possibly as server-side + // normalization. Not this step's; stays `.fails` until that fix (3c). + test.fails( + "a keyed sibling after the frame claims the server's node (frames-rulings 3.4)", + async () => { + const r = await run("resident", P_AFTER, T => ( + <> + + + + + )); + expect(r.warnings).toEqual([]); + expect(r.container.querySelectorAll("p.after").length).toBe(2); + r.dispose(); + } + ); +}); diff --git a/packages/web/test/hydration/container-trace-hold-interruption.spec.tsx b/packages/web/test/hydration/container-trace-hold-interruption.spec.tsx new file mode 100644 index 000000000..2d58145cb --- /dev/null +++ b/packages/web/test/hydration/container-trace-hold-interruption.spec.tsx @@ -0,0 +1,179 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Interruptions DURING the hold. The frame holds a trace-carrying occurrence + * until the traces tier's load settles, then re-syncs (the install's flush of + * every live frame → `#syncSlots`). Three things can happen in between: + * + * (a) the occurrence's record is REPLACED — a refetch of the same call + * (a higher-version slot write), or an address switch re-binding the + * frame to another call's store. The hold is the t=0 mount deferred, and + * the outcome must be what a resident run shows: ONE mount — the claim, + * with the record the server interior was rendered from (the held one, + * `#heldRecords`) — followed by the replacement applied as the args + * change it is, so the DOM ends on the new values. (Claiming with the + * NEW args instead would trust markup rendered from the old ones; a + * claim never rewrites a text hole, so every differing text would stay + * stale for good — frames-rulings 3.6.); + * (b) the frame is DISPOSED — the install's flush skips it (it left the + * live set) and the settlement is a no-op (no mount, no error); + * (c) two occurrences wait on the same load — one load per name + * (`prepareTier` is idempotent), one re-sync, both mount. + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createSignal, flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { createStream } from "seroval"; +import { + After, + captureWarnings, + cleanupHold, + commentFill, + fakeLedger, + FID, + frameHtml, + gateTraceTier, + installRecords, + mounts, + mountShell, + noteFill, + P_BEFORE, + sleep, + snapshotNodes, + type Streams +} from "./container-trace-hold-helpers.jsx"; + +describe("container-trace hold: interruptions", () => { + afterEach(cleanupHold); + + let n = 0; + function setup(emit?: (s: Streams) => void) { + const fid = `${FID}/int${++n}`; + const ledger = fakeLedger(); + const streams = installRecords({ fid, emit, hy: { fr: ledger.ledger } }); + const container = mountShell(P_BEFORE + frameHtml(fid)); + const before = snapshotNodes(container, fid); + const warnings = captureWarnings(); + vi.stubGlobal("fetch", () => { + throw new Error("fetch must not be called"); + }); + const gated = gateTraceTier(); + ledger.reveal(container); + const T = (globalThis as any)._$SC.r(fid); + const [label] = createSignal("after"); + const dispose = hydrate( + () => ( + <> + + + + ), + container + ); + flush(); + // Held: ordinary fills claimed, trace fills waiting, the load asked once. + expect(mounts.note).toEqual(["n1", "n2"]); + expect(mounts.comment).toEqual([]); + expect(gated.loader).toHaveBeenCalledTimes(1); + return { fid, container, before, warnings, gated, dispose, streams }; + } + + test("(a) a refetch replaces the record while held: one mount (the claim, with the held args), then the replacement applies", async () => { + const s = setup(); + // The refetch's stream (same call, next version — the store is one + // response's, frames-rulings 1.1 / 1.4: the bump replaces the records + // wholesale, so the response carries every occurrence's) — a new record + // for `comment#c1` (new args, a new trace), the others re-sent equal. + const v2 = createStream(); + v2.next({ name: "Ada (v2)" }); + const refetch = [ + { key: "comment#c1", args: { cid: "c1v2", user: { $tr: v2, $ta: 0 } } }, + { key: "note#0", args: { text: "n1" } }, + { key: "comment#c2", args: { cid: "c2", user: { $tr: s.streams.c2, $ta: 0 } } }, + { key: "note#1", args: { text: "n2" } } + ]; + for (const r of refetch) + s.gated.host.apply({ type: "slot", id: s.fid, version: 1, ...r } as any); + flush(); + expect(mounts.comment).toEqual([]); + + await s.gated.release(); + await sleep(10); + flush(); + // One mount per occurrence, with the HELD args (the claim); the + // replacement arrived as a props update, not a second mount. + expect(mounts.comment.sort()).toEqual(["c1", "c2"]); + // Claimed (the markup is the range's), updated to the new record's + // values by the live mount. + expect(s.container.querySelector(`[_hk="sc-${s.fid}-comment#c1-0"]`)).toBe(s.before.c1); + expect(s.before.c1.querySelector("b")!.textContent).toBe("c1v2"); + expect(s.before.c1name.textContent).toBe("Ada (v2)"); + expect(s.before.c2name.textContent).toBe("Grace"); + expect(s.container.querySelectorAll(".c").length).toBe(2); + expect(s.warnings).toEqual([]); + s.dispose(); + }); + + test("(a') an address switch re-binds the frame while held: the new address's record mounts, once", async () => { + const s = setup(); + const address = `${s.fid}:switched`; + // The other call's store, warm: the full record set under its address. + const sw = createStream(); + sw.next({ name: "Switched" }); + const seed = [ + { key: "comment#c1", args: { cid: "c1s", user: { $tr: sw, $ta: 0 } } }, + { key: "note#0", args: { text: "n1" } }, + { key: "comment#c2", args: { cid: "c2", user: { $tr: s.streams.c2, $ta: 0 } } }, + { key: "note#1", args: { text: "n2" } } + ]; + for (const r of seed) + s.gated.host.apply({ type: "slot", id: address, version: 0, ...r } as any); + const frame = s.gated.host.get(s.fid)!; + expect(frame).toBeTruthy(); + frame.rebind(address); + flush(); + expect(s.gated.host.get(address)).toBe(frame); + expect(s.gated.host.get(s.fid)).toBeUndefined(); + expect(mounts.comment).toEqual([]); + + await s.gated.release(); + await sleep(10); + flush(); + expect(mounts.comment.sort()).toEqual(["c1", "c2"]); + expect(s.container.querySelector(`[_hk="sc-${s.fid}-comment#c1-0"]`)).toBe(s.before.c1); + expect(s.before.c1.querySelector("b")!.textContent).toBe("c1s"); + expect(s.before.c1name.textContent).toBe("Switched"); + expect(s.container.querySelectorAll(".c").length).toBe(2); + expect(s.warnings).toEqual([]); + s.dispose(); + }); + + test("(b) the frame is disposed while held: the settlement is a no-op", async () => { + const s = setup(); + s.dispose(); + expect(s.gated.host.get(s.fid)).toBeUndefined(); + await s.gated.release(); + await sleep(10); + flush(); + expect(mounts.comment).toEqual([]); + expect(s.warnings).toEqual([]); + s.container.remove(); + }); + + test("(c) two occurrences on one load: one import, one re-sync, both mount", async () => { + const s = setup(); + // Both occurrences were found held on every sync that saw them (the + // adopt sync and the registration-flush drain); `prepareTier` answered + // each ask with the same load. + expect(s.gated.loader).toHaveBeenCalledTimes(1); + await s.gated.release(); + await sleep(10); + flush(); + expect(mounts.comment).toEqual(["c1", "c2"]); + // Nothing asks again once the tier is resident. + expect(s.gated.loader).toHaveBeenCalledTimes(1); + expect(s.warnings).toEqual([]); + s.dispose(); + }); +}); diff --git a/packages/web/test/hydration/container-trace-hold-record-retention.spec.tsx b/packages/web/test/hydration/container-trace-hold-record-retention.spec.tsx new file mode 100644 index 000000000..f22c8ed63 --- /dev/null +++ b/packages/web/test/hydration/container-trace-hold-record-retention.spec.tsx @@ -0,0 +1,138 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Record retention across the hold. The adopted boundary drains the + * document's `sc:slot:` records into the frame's store once per key + * (`adoptBoundary.drainRecords`, `appliedRecords`), and the frame keeps the + * record under `slot:` until the occurrence mounts or leaves. + * While an occurrence is HELD on the traces tier, the document keeps doing + * what it does: + * + * - reveals re-drain (`fr.subscribe` → `drainRecords`) — the held key is + * already applied, so the re-drain must neither re-apply nor drop it; + * - a later data script pushes a NEW key into `_$HY.r` — the re-drain + * applies that one, and only that one; + * - the live channel (`sc:live`) re-sends the occurrence's record with the + * same marker — a store write with equal args, which must not disturb + * the hold or double-mount at release. + * + * At release the held occurrence mounts exactly once, from its record. + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createSignal, flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { + After, + captureWarnings, + cleanupHold, + commentFill, + expectClaimed, + fakeLedger, + FID, + frameHtml, + gateTraceTier, + installRecords, + mounts, + mountShell, + noteFill, + P_BEFORE, + sleep, + snapshotNodes +} from "./container-trace-hold-helpers.jsx"; + +describe("container-trace hold: record retention", () => { + afterEach(cleanupHold); + + test("re-drains and a live re-send during the hold apply the held record once", async () => { + const fid = `${FID}/retain`; + const ledger = fakeLedger(); + // The live channel, as the document's first script creates it: a + // ReadableStream of ops the client pumps. + let liveController!: ReadableStreamDefaultController; + const live = new ReadableStream({ + start(c) { + liveController = c; + } + }); + installRecords({ fid, hy: { fr: ledger.ledger } }); + const hy = (globalThis as any)._$HY; + hy.r["sc:live"] = live; + const container = mountShell(P_BEFORE + frameHtml(fid)); + const before = snapshotNodes(container, fid); + const warnings = captureWarnings(); + vi.stubGlobal("fetch", () => { + throw new Error("fetch must not be called"); + }); + const gated = gateTraceTier(); + // Count the slot applies the boundary routes into the store, by key. + const applied: Record = {}; + const origApply = gated.host.apply.bind(gated.host); + gated.host.apply = (chunk: any) => { + if (chunk.type === "slot") applied[chunk.key] = (applied[chunk.key] ?? 0) + 1; + return origApply(chunk); + }; + ledger.reveal(container); + const T = (globalThis as any)._$SC.r(fid); + const [label] = createSignal("after"); + const dispose = hydrate( + () => ( + <> + + + + ), + container + ); + flush(); + expect(mounts.note).toEqual(["n1", "n2"]); + expect(mounts.comment).toEqual([]); + const drained = { ...applied }; + expect(drained["comment#c1"]).toBe(1); + expect(drained["comment#c2"]).toBe(1); + + // A reveal elsewhere in the page: the boundary re-drains. Nothing new + // for it, so nothing re-applies. + ledger.reveal(container); + flush(); + expect(applied).toEqual(drained); + + // A later data script lands a new key, and a reveal drains it: only + // the new key applies (it names no occurrence in the markup; it just + // sits in the store). + hy.r[`sc:slot:${fid}:comment#c9`] = { cid: "c9" }; + ledger.reveal(container); + flush(); + expect(applied["comment#c9"]).toBe(1); + expect(applied["comment#c1"]).toBe(1); + expect(applied["comment#c2"]).toBe(1); + + // The live channel re-sends `comment#c1` with the same marker (the + // producer's end-of-stream flush does this on every page). Another + // store write — equal args — while the occurrence is still held. + const record = hy.r[`sc:slot:${fid}:comment#c1`]; + liveController.enqueue({ type: "slot", fid, key: "comment#c1", args: { ...record } }); + liveController.close(); + await sleep(10); + flush(); + expect(applied["comment#c1"]).toBe(2); + expect(mounts.comment).toEqual([]); + expect(container.querySelector(`[_hk="sc-${fid}-comment#c1-0"]`)).toBe(before.c1); + + // Release: each held occurrence mounts exactly once, from its record. + await gated.release(); + await sleep(10); + flush(); + expect(mounts.comment).toEqual(["c1", "c2"]); + expectClaimed(container, before, fid); + expect(before.c1name.textContent).toBe("Ada"); + expect(before.c2name.textContent).toBe("Grace"); + // Settled: no further writes, no second mount. + await sleep(10); + flush(); + expect(mounts.comment).toEqual(["c1", "c2"]); + expect(warnings).toEqual([]); + dispose(); + container.remove(); + }); +}); diff --git a/packages/web/test/hydration/container-trace-hold-snapshot.spec.tsx b/packages/web/test/hydration/container-trace-hold-snapshot.spec.tsx new file mode 100644 index 000000000..fcc4b2b47 --- /dev/null +++ b/packages/web/test/hydration/container-trace-hold-snapshot.spec.tsx @@ -0,0 +1,197 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Snapshot consistency across the hold. A trace is a seroval stream whose + * `.on()` replays its buffer synchronously at subscribe, so the store a late + * materialization builds is the fold of EVERYTHING the document delivered + * so far — the snapshot and every patch that landed while the load was + * pending — exactly the state a resident materializer would hold at the + * same point. Patches after that are live emissions either way. + * + * On SCREEN the two must agree too. The claim pass trusts the server's + * markup (a text hole is never rewritten during a claim), and the markup + * shows the SNAPSHOT — so the materializer, told it is read for a CLAIM + * (`revive(value, claiming)` from the adopt-time mount, frames-rulings 3.6 + * (iii)), applies the snapshot at once and parks a replayed backlog beyond + * it until hydration ends, where the fill's reads re-run outside hydration + * and the DOM catches up (the store-shaped async-iterable hydration does the + * same with its buffered backlog). Both the late claim (under the tier's + * hold — the park releases after the hold, 3.2's order) and the resident + * t=0 claim go through that, so a trace past the markup renders its current + * state in either run. + * + * Two timelines, each run lazy (held) and resident, store state compared: + * - "during": snapshot before hydrate; patches arrive during the wait + * (resident: the same beats after its t=0 mount) — a live update there; + * - "ahead": snapshot AND patches already delivered before hydrate (the + * trace moved past the markup before the client booted). + */ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createSignal, flush } from "solid-js"; +import { materializeContainerTrace } from "solid-js/internal/container-trace"; +import { hydrate } from "@solidjs/web"; +import { createStream } from "seroval"; +import { + After, + captureWarnings, + cleanupHold, + commentFill, + expectClaimed, + fakeLedger, + FID, + frameHtml, + gateTraceTier, + installRecords, + mounts, + mountShell, + noteFill, + P_BEFORE, + residentTraceTier, + sleep, + snapshotNodes, + type Streams +} from "./container-trace-hold-helpers.jsx"; + +describe("container-trace hold: snapshot consistency", () => { + afterEach(cleanupHold); + + let n = 0; + async function run( + mode: "lazy" | "resident", + emit: (s: Streams) => void, + duringWait: (s: Streams) => void + ) { + const fid = `${FID}/snap-${mode}${++n}`; + const ledger = fakeLedger(); + const streams = installRecords({ fid, emit, hy: { fr: ledger.ledger } }); + const container = mountShell(P_BEFORE + frameHtml(fid)); + const before = snapshotNodes(container, fid); + const warnings = captureWarnings(); + vi.stubGlobal("fetch", () => { + throw new Error("fetch must not be called"); + }); + const gated = mode === "lazy" ? gateTraceTier() : undefined; + if (!gated) await residentTraceTier(); + ledger.reveal(container); + const T = (globalThis as any)._$SC.r(fid); + const [label] = createSignal("after"); + const dispose = hydrate( + () => ( + <> + + + + ), + container + ); + flush(); + if (gated) expect(mounts.comment).toEqual([]); + else expect(mounts.comment).toEqual(["c1", "c2"]); + // The wait: emissions land in the stream's buffer (lazy: nothing has + // subscribed yet; resident: the live store folds them as they come). + duringWait(streams); + flush(); + if (gated) { + await gated.release(); + } + await sleep(10); + flush(); + expect(mounts.comment).toEqual(["c1", "c2"]); + expectClaimed(container, before, fid); + const store = mounts.stores[0]; + return { fid, container, before, warnings, dispose, store, streams }; + } + + const snapshotOnly = (s: Streams) => { + s.c1.next({ name: "Ada", edits: 0 }); + s.c2.next({ name: "Grace" }); + s.c2.return(undefined); + }; + const twoPatches = (s: Streams) => { + s.c1.next([[["edits"], 1]]); + s.c1.next([ + [["name"], "Ada (edited)"], + [["edits"], 2] + ]); + }; + + test("patches arriving during the wait replay fully: the late store equals the resident one", async () => { + const lazy = await run("lazy", snapshotOnly, twoPatches); + expect(lazy.store.name).toBe("Ada (edited)"); + expect(lazy.store.edits).toBe(2); + expect(lazy.before.c1name.textContent).toBe("Ada (edited)"); + expect(lazy.warnings).toEqual([]); + // A patch after the mount is a live update on the same store. + lazy.streams.c1.next([[["name"], "Ada (live)"]]); + flush(); + expect(lazy.store.name).toBe("Ada (live)"); + expect(lazy.before.c1name.textContent).toBe("Ada (live)"); + lazy.dispose(); + cleanupHold(); + + const resident = await run("resident", snapshotOnly, twoPatches); + expect(resident.store.name).toBe("Ada (edited)"); + expect(resident.store.edits).toBe(2); + expect(resident.before.c1name.textContent).toBe("Ada (edited)"); + expect(resident.warnings).toEqual([]); + resident.dispose(); + }); + + test("a trace already past the markup: the late store is the fold of all of it", async () => { + const ahead = (s: Streams) => { + snapshotOnly(s); + twoPatches(s); + s.c1.return(undefined); + }; + const lazy = await run("lazy", ahead, () => {}); + expect(lazy.store.name).toBe("Ada (edited)"); + expect(lazy.store.edits).toBe(2); + // Mounted under the hold as a CLAIM: the fill read the snapshot ("Ada", + // the markup's text), the park released after the hold (3.2's order) + // and the hole caught up. + expect(lazy.before.c1name.textContent).toBe("Ada (edited)"); + expect(lazy.warnings).toEqual([]); + lazy.dispose(); + cleanupHold(); + + const resident = await run("resident", ahead, () => {}); + expect(resident.store.name).toBe("Ada (edited)"); + expect(resident.store.edits).toBe(2); + // The resident t=0 claim ran against markup showing "Ada"; the parked + // backlog applied at hydration end and the hole caught up. + expect(resident.before.c1name.textContent).toBe("Ada (edited)"); + expect(resident.warnings).toEqual([]); + resident.dispose(); + }); + + // The seam itself: only a CLAIM parks (keyed on the claim since the traces + // tier — plan step C3; frames-rulings 3.6 "Landed"). A trace materialized + // for a fresh mount (no server markup to agree with) is the fold of its + // whole backlog at once, no beat paid; one materialized for a claim + // outside the document's pass (a frame's late claim) shows the snapshot + // and folds on the next microtask. + test("the backlog parks for a claim only", async () => { + const ahead = () => { + const s = createStream(); + s.next({ name: "Ada", edits: 0 }); + s.next([[["edits"], 1]]); + s.next([ + [["name"], "Ada (edited)"], + [["edits"], 2] + ]); + return s; + }; + const fresh = materializeContainerTrace({ $tr: ahead() as any }); + expect(fresh.name).toBe("Ada (edited)"); + expect(fresh.edits).toBe(2); + + const claimed = materializeContainerTrace({ $tr: ahead() as any }, true); + expect(claimed.name).toBe("Ada"); + expect(claimed.edits).toBe(0); + await sleep(0); + flush(); + expect(claimed.name).toBe("Ada (edited)"); + expect(claimed.edits).toBe(2); + }); +}); diff --git a/packages/web/test/hydration/welcome-status-lazy.spec.tsx b/packages/web/test/hydration/welcome-status-lazy.spec.tsx new file mode 100644 index 000000000..f3b4b5e50 --- /dev/null +++ b/packages/web/test/hydration/welcome-status-lazy.spec.tsx @@ -0,0 +1,23 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Lazy: the loaded document, hydrated with the traces tier NOT resident — + * the production wiring end to end: the shell's data script announces the + * tier (`_$HY.r["sc:tiers"] = ["trace"]`), `installServerComponents` starts + * the import from the record, the adopted boundary holds the `status#0` + * occurrence (its server-rendered interior stays on screen, the hold a + * pending boundary under frames-rulings 3.1) until the load settles, then + * mounts the fill, which claims the same server nodes in place: a late + * attach, never a re-render, and no key misses. One configuration per spec + * file — see welcome-status-parity.tsx for why (and a tier, once resident, + * stays so for the worker — the resident specs would pre-empt the hold). + */ +import { afterEach, describe, test } from "vitest"; +import { cleanupWelcomeStatusParity, runWelcomeStatusParity } from "./welcome-status-parity.jsx"; + +describe("welcome/status parity — hydration (loaded, the traces tier loads lazily)", () => { + afterEach(cleanupWelcomeStatusParity); + test("the adopted fill is held until the tier loads, then claims the server nodes with no key misses", () => + runWelcomeStatusParity("loaded", { lazy: true })); +}); diff --git a/packages/web/test/hydration/welcome-status-parity.tsx b/packages/web/test/hydration/welcome-status-parity.tsx index b574aa815..09ecf1a8a 100644 --- a/packages/web/test/hydration/welcome-status-parity.tsx +++ b/packages/web/test/hydration/welcome-status-parity.tsx @@ -16,18 +16,39 @@ * paths the client allocates must be byte-identical to what the ssr compile * minted — any extra reactive scope the client wraps an arg read in shows up * as a hydration key miss and a re-rendered (or blank) range. + * + * The `usage` arg is a container trace, and the frames client loads the + * materializer as its TRACES TIER (`@solidjs/web/frames/trace`, through + * `prepareTier("trace")` — plan step C3). Two configurations, one per spec + * file: + * - resident (default): the tier is installed before `hydrate()`, as the + * production host has it once that load has settled — the claim walk + * revives synchronously and the fill claims in the root pass; + * - `lazy`: the production host wiring with nothing installed, the page + * as the browser runs it — the shell's data script ANNOUNCES the tier + * (`_$HY.r["sc:tiers"] = ["trace"]`, the artifact's), `installServer + * Components` starts the import from the record before any boundary + * adopts, and the adopt-time sync HOLDS the `status#0` occurrence + * (server interior on screen; the frame's hold registered, hydration + * not done — frames-rulings 3.1) until the load settles, then mounts + * the fill, which claims the same nodes in place. Same assertions: the + * attach is late, never a re-render. */ import { expect, vi } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { flush } from "solid-js"; +import { sharedConfig } from "solid-js/internal"; import { hydrate } from "@solidjs/web"; import { installServerComponents, createFrameHost } from "../../frames/src/client.js"; +import { prepareTier, tierLoads } from "../../frames/src/frame-client.js"; import { createJSONDataTable } from "../../serialization/src/serializer.js"; import { reviveContainerTraces } from "../../frames/src/frame-container-plugin.js"; import { FID, statusFill } from "../harness/frames-welcome.jsx"; +const traceTierResident = () => !!(tierLoads as any).trace?.r; + const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); @@ -81,7 +102,10 @@ export function cleanupWelcomeStatusParity() { document.body.innerHTML = ""; } -export async function runWelcomeStatusParity(mode: "loaded" | "streamed") { +export async function runWelcomeStatusParity( + mode: "loaded" | "streamed", + options: { lazy?: boolean } = {} +) { const { shell, rest } = loadArtifact(mode); const fid = FID(mode); const container = document.createElement("div"); @@ -90,19 +114,39 @@ export async function runWelcomeStatusParity(mode: "loaded" | "streamed") { vi.stubGlobal("fetch", () => { throw new Error("fetch must not be called"); }); - installServerComponents(makeHost()); const warnings: string[] = []; vi.spyOn(console, "warn").mockImplementation((...args: any[]) => { warnings.push(args.map(String).join(" ")); }); + // The shell (its data scripts included) parses before the client entry + // runs, as in a browser; the lazy configuration depends on that order — + // the entry's `installServerComponents` reads the announcement the + // shell's script wrote. applyChunk(container, shell, true); if (mode === "loaded") applyChunk(container, rest, false); + if (options.lazy) { + expect(traceTierResident()).toBe(false); + // The document announced the tier (the artifact's record, minted where + // the sink serialized the trace — B's re-recorded fixture). + expect((globalThis as any)._$HY.r["sc:tiers"]).toEqual(["trace"]); + installServerComponents(); + // The import started at install, from the record — before any boundary + // adopted (the production loader, through the test alias). + expect((tierLoads as any).trace).toBeTruthy(); + expect(traceTierResident()).toBe(false); + } else { + installServerComponents(makeHost()); + await prepareTier("trace"); + expect(traceTierResident()).toBe(true); + } + const frame = container.querySelector(`solid-frame[data-fid="${fid}"]`)!; const ssrStatus = container.querySelector(".status"); expect(ssrStatus).toBeTruthy(); + const ssrText = frame.textContent; const SC = (globalThis as any)._$SC.r(fid); const dispose = hydrate(() => , container); @@ -110,8 +154,22 @@ export async function runWelcomeStatusParity(mode: "loaded" | "streamed") { await Promise.resolve(); flush(); + if (options.lazy) { + // The hold: the root pass adopted the boundary with the tier absent, so + // `status#0` is not mounted yet and the server-rendered interior stands + // untouched — exactly what was on screen; the frame's hold is a pending + // boundary, so hydration is not done (3.1). + expect(frame.textContent).toBe(ssrText); + expect(sharedConfig.isHydrationInProgress!()).toBe(true); + // The load settles on its own schedule; wait for the install, then for + // the flush it triggers. + for (let i = 0; i < 200 && !traceTierResident(); i++) await sleep(10); + expect(traceTierResident()).toBe(true); + } + if (mode === "streamed") applyChunk(container, rest, false); await settle(); + if (options.lazy) expect(sharedConfig.isHydrationInProgress!()).toBe(false); if (process.env.DEBUG_DOM) process.stdout.write( diff --git a/packages/web/test/lifecycle-matrix/MATRIX.md b/packages/web/test/lifecycle-matrix/MATRIX.md index b635160cc..51c6d49a8 100644 --- a/packages/web/test/lifecycle-matrix/MATRIX.md +++ b/packages/web/test/lifecycle-matrix/MATRIX.md @@ -79,10 +79,23 @@ classification must never probe a pending container's properties (they throw not-ready) — containers test FIRST, by WeakSet, on both faces. The producer and server faces are pinned in `test/server/container-traces.spec.tsx`, the client faces in `container-args.spec.tsx`, and the materializer's unit semantics -in solid `test/container-trace.spec.ts`. +in solid `test/container-trace.spec.ts`. The materializer is the store engine's one +edge into a server-component page, so the frames client carries the whole client half +as its TRACES TIER (`@solidjs/web/frames/trace` — solid's materializer through its own +`solid-js/internal/container-trace` entry, plus the plugin's revive walk), loaded +through the tier mechanism (`prepareTier("trace")`): announced by the server where it +serializes a trace, held on by the adopt-time sync otherwise (frames savings pass §3 +row C3). Those load paths are pinned in `test/frames-container-lazy-*.spec.tsx`, +`test/hydration/welcome-status-lazy.spec.tsx`, the `test/hydration/container-trace-hold-*` +specs and `test/consistency/tier-trace-hold.spec.tsx`; the cells below run with the +tier resident. | Cell | Spec / test | Status | | --- | --- | --- | +| codec face, tier not resident: the `data` chunk whose node tree carries the trace plugin's node announces `trace` (`chunk.tiers`) and the transport awaits the tier BEFORE the chunk decodes (the `{$ref}` resolves to a live store); a `data` chunk without one announces and loads nothing | `frames-container-lazy-codec` | pass | +| document face, tier not resident: an adopted record's marker holds the occurrence (server interior on screen, no fill; the frame's hold a pending boundary — frames-rulings 3.1) until the load settles, then the fill mounts with the live store; nested references share it | `frames-container-lazy-document`, `consistency/tier-trace-hold` | pass | +| document face under `hydrate()`, tier not resident, announced (`sc:tiers`): the import starts at install; the fill claims the server nodes in place after the load — no key misses, no re-render; hydration-done after the claim | `hydration/welcome-status-lazy` | pass | +| held occurrence whose record a refetch replaced meanwhile: one mount, with the record it was HELD on (the claim), then the replacement as an args change | `hydration/container-trace-hold-interruption` (a, a'), `consistency/tier-trace-hold` | pass | | container `{$ref}` arg materializes live: reference synchronous, reads suspend until the snapshot, patch batches update granularly (sibling reads don't re-fire), trace end latches | `container-args` › `call-driven/args/containers` (materializes live) | pass — closed gap: the client's arg classification (`slotArgsProxy` async probe, `#refArgsUnchanged` compare) detonated pending containers; both now classify containers first, trap-safe | | updates flow through the store — never a re-call, node identity survives | `container-args` › `call-driven/args/containers` (materializes live) | pass | | one container, many references: every `{$ref}` to the same trace resolves to the SAME live store instance | `container-args` › `call-driven/args/containers` (two arg positions) | pass | diff --git a/packages/web/test/lifecycle-matrix/container-args.spec.tsx b/packages/web/test/lifecycle-matrix/container-args.spec.tsx index f4b9c61f3..c7ed75780 100644 --- a/packages/web/test/lifecycle-matrix/container-args.spec.tsx +++ b/packages/web/test/lifecycle-matrix/container-args.spec.tsx @@ -18,12 +18,19 @@ // // The producer halves (classification, envelope, wire shape) and real-core // server faces are pinned in `test/server/container-traces.spec.tsx`; the -// materializer's -// unit semantics in solid `test/container-trace.spec.ts`. See MATRIX.md. +// materializer's unit semantics in solid `test/container-trace.spec.ts`; the +// LOAD of the materializer — the frames client's traces tier +// (`@solidjs/web/frames/trace`), fetched through `prepareTier("trace")` +// behind the server's announcement or the first adopt-time record whose +// args carry a trace — in `test/frames-container-lazy-*.spec.tsx` and the +// `test/hydration/container-trace-hold-*` specs. These cells run with it +// RESIDENT (warmed below, as the production host has it after that first +// load) and pin what the two faces do with it. See MATRIX.md. import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { createMemo, createRoot, 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 { toBorderForm, @@ -113,6 +120,8 @@ function mountUnderLoading(Comp: any, props: Record = {}) { // before any runtime is resident). const adoptFid = "matrix/containers/adopt"; const adoptProducer = traceProducer(); +// The traces tier, resident before any cell runs (see the header). +beforeAll(() => prepareTier("trace")); beforeAll(() => { const marker = { $tr: adoptProducer.trace.subscribe(), $ta: 0 }; document.body.innerHTML = diff --git a/packages/web/tsconfig.build.json b/packages/web/tsconfig.build.json index 87cdf33a6..5e9b6fa08 100644 --- a/packages/web/tsconfig.build.json +++ b/packages/web/tsconfig.build.json @@ -12,7 +12,8 @@ "@solidjs/web": ["./src/index.ts"], "@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/serialization/decode": ["./serialization/src/serializer-decode.ts"], + "@solidjs/web/frames/trace": ["./frames/src/trace-tier.ts"] } }, "include": [ diff --git a/packages/web/vite.config.hydrate.mjs b/packages/web/vite.config.hydrate.mjs index a62ff1ec8..e4f8d119e 100644 --- a/packages/web/vite.config.hydrate.mjs +++ b/packages/web/vite.config.hydrate.mjs @@ -46,6 +46,9 @@ export default defineConfig({ 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/frames/trace": resolve(rootDir, "frames/src/trace-tier.ts"), "@solidjs/web": resolve(rootDir, "src/index.ts") } } diff --git a/packages/web/vite.config.mjs b/packages/web/vite.config.mjs index c8bd5ca9b..e70188111 100644 --- a/packages/web/vite.config.mjs +++ b/packages/web/vite.config.mjs @@ -62,7 +62,11 @@ export default defineConfig({ "@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") } } }); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 035853ebf..c2fb32f32 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": "44.89 KB", - "minified": 145757 + "cap": "38.61 KB", + "minified": 122028 }, "page: live server components (base + live/GET + action + isPending/latest)": { - "cap": "48.60 KB", - "minified": 157720 + "cap": "42.16 KB", + "minified": 133899 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index e19fc133c..ce54078af 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -69,9 +69,18 @@ const floorMinified = Object.fromEntries( // Rolldown splits it the same way, so it shows up in the report as a lazy // chunk at its true size and stays out of the cap. (Under size-limit the // specifiers resolved to a stub because esbuild did not split there.) The -// "frames: eager client consumer" scenario measures the package; these -// measure the page. Subpath aliases first (see above). +// frames client's TRACES TIER (`@solidjs/web/frames/trace` — plan step C3, +// 2026-10-06: solid's container-trace materializer, reached through its own +// `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. const pageAlias = { + "@solidjs/web/frames/trace": "../../packages/web/frames/dist/trace.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", "@solidjs/web/frames": "../../packages/web/frames/dist/client.js", @@ -111,6 +120,11 @@ const framesAlias = { const framesExternal = [ "solid-js", "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. + "solid-js/internal/container-trace", + "@solidjs/web/frames/trace", "@solidjs/web", "@solidjs/web/serialization", "@solidjs/web/serialization/decode" @@ -3409,8 +3423,23 @@ module.exports = [ // 10 B; recorded minified 43,414 B. Accepted by the maintainer // (2026-10-06, "pay the cost for correctness"). The cap is frozen again // at 13.79 KB. + // Frames savings pass C3 — the traces tier (2026-10-06): measured at + // 13,866 B against the Phase B head e05ba0283's 13,949 (-83 B; -484 B + // minified, 43,452 -> 42,968) and `next` @ 9d89df731's 13,787 (+79 B; + // -446 B minified). The container tier's client half left for the lazy + // `@solidjs/web/frames/trace` chunk (solid's materializer + the plugin's + // revive walk, memo and marker test: -951 B minified / -239 B brotli, + // measured on an edited dist copy); the trigger left behind — the loader + // entry and the container probe, the held-set predicate (`needsTrace`: + // the marker walk while the tier is absent), the `claiming` thread and + // the held-record mount — costs +467 / +156. Still 76 B over the 13.79 KB + // cap by Phase A's and B's own bytes (their notes); the minified size is + // below the recorded 43,414 B, so the gate passes by its minified rule. + // 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: 43414, + capMinified: 42968, alias: framesAlias, external: framesExternal }, @@ -3582,6 +3611,20 @@ module.exports = [ // at or below measured + 10 B; recorded minified 145,757 B. Accepted by // the maintainer (2026-10-06, "pay the cost for correctness"). The cap is // frozen again at 44.89 KB. + // Frames savings pass C3 — the traces tier (2026-10-06): 44.89 -> 38.61 KB + // (floor-caps.json), measured at 38,598 B against the Phase B head + // e05ba0283's 45,210 (-6,612 B; -24,069 B minified, 146,097 -> 122,028) + // and `next` @ 9d89df731's 44,882 (-6,284 B). The store engine (signals + // `store/*`), solid's container-trace materializer (its own entry, + // `solid-js/internal/container-trace`) and the plugin's client half leave + // this page's eager chunk for `trace.js` — reported above as lazy, not + // counted, 25,409 B minified / 8,170 B brotli — which the frames client + // fetches when the document announces the tier (`_$HY.r["sc:tiers"]`) or + // an adopt-time record's args carry a trace. What stays: the store + // hydration adapters (`enableHydration`'s on every hydrating page, as + // 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. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -3703,6 +3746,15 @@ module.exports = [ // at or below measured + 10 B; recorded minified 157,720 B. Accepted by // the maintainer (2026-10-06, "pay the cost for correctness"). The cap is // frozen again at 48.60 KB. + // Frames savings pass C3 — the traces tier (2026-10-06): 48.60 -> 42.16 KB + // (floor-caps.json), measured at 42,147 B against the Phase B head + // e05ba0283's 48,873 (-6,726 B; -24,161 B minified, 158,060 -> 133,899) + // and `next` @ 9d89df731's 48,595 (-6,448 B). The same split as the base + // page (its note): the store engine, the materializer and the plugin's + // client half leave for `trace.js` (lazy, not counted, 25,409 B minified + // / 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. 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 89954fa1bdf02fb2947886bfc6fd8101c8b3d466 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 17:07:21 -0700 Subject: [PATCH 2/2] =?UTF-8?q?docs(plans,=20server-components):=20frames?= =?UTF-8?q?=20savings=20pass=20=E2=80=94=20C3=20landed=20(measured=20?= =?UTF-8?q?=E2=88=9283=20/=20=E2=88=926,612=20/=20=E2=88=926,726=20br;=20g?= =?UTF-8?q?lue=20+467=20min=20vs=20the=20=E2=89=88=20150=20estimate),=20?= =?UTF-8?q?=C2=A75=20S1=20re-based;=20rulings=203.6=20the=20park=20keyed?= =?UTF-8?q?=20on=20the=20claim=20again,=203.4=20the=20id-drift=20pin=20on?= =?UTF-8?q?=20the=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude via Cursor --- documentation/plans/frames-savings-pass.md | 67 ++++++++++++------- .../server-components/frames-rulings.md | 29 +++++++- 2 files changed, 72 insertions(+), 24 deletions(-) diff --git a/documentation/plans/frames-savings-pass.md b/documentation/plans/frames-savings-pass.md index 03168446c..eed06d25f 100644 --- a/documentation/plans/frames-savings-pass.md +++ b/documentation/plans/frames-savings-pass.md @@ -349,24 +349,24 @@ live +120 br with −138 min (over their caps by 143 / 115 B brotli, held by the gate's minified rule). Frames eager measures 14 B over its 13.79 KB cap on the same terms; no frames / page cap moved. -| # | step | Δ br (frames eager / page base / page live / compiled hydrating) | chunks created | gates (pins flip; size) | depends on | surface | -| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **A0** | **C18 — classification waits for the drain** (rulings step 1, 3d / 3.5; **in flight**). The predicate is one term in `adoptBoundary.recordsPending`: a `prop#n` occurrence is classified only after every delivered record has drained. The only page-halting red; lands as the rulings specified it. A1 then makes the mechanism moot (one write per drain; `#` decides the class — C18 becomes unrepresentable) and the pins stay as the assertion of the pending read. | **≈ +15 / +15 / +15 / 0** (the rulings' ≈ +50 min / +15 br; ≈ +25 br with the batched drain, which A1 supersedes) | none | **flip:** C18 ×3. Size: ±0.02 KB on every scenario (frames eager ≤ 13.8). | #3813 landed | none | -| **A1** | **S-flush + the R deletions it unlocks.** `content = createMemo(() => host.landing(binding()))` — one reactive node per bound address resolved at the version's first root / error write (the host's `landing(address)`: a promise for a cold store, the value for a warm one); the enclosing `` pends on it, a switch is a new flight (`_inFlight` supersession, 1.6 (i) by construction), a refetch's landing is staged by the Transaction that read it (G7 closes). Deletes: **R.gate** (`arm`/`release`/`settle`/`setGate`/`mountGate`, the adopted twin), **R.stage** (`stage` / `stageTables` / `stagedContent` / `CONTENT_TOKEN` / `STAGED_DATA` / `FrameImpl#preview` / `#regionsChange` / `host.preview`; the chunk buffer-until-`complete` stays, one write), **R.version** (2a: one applied record keyed by identity; `#appliedRoot`), **R.dedupe** (per-prop memos in `slotArgsProxy`; `argsEquivalent` / `#refArgsUnchanged` / `#slotResolvedRefs` go), **R.error**'s latch. Files: `frames/src/client.ts` (`boundaryComponent`, `adoptBoundary`, `followAddress`), `frame-transport.ts` (`stage*`, `handle`), `frame-client.ts` (`#apply`, `#flush`, `preview`, `#syncSlots`' dedupe arms). | **≈ −1,150 / ≈ −1,150 / ≈ −1,150 / 0** (`est.` from the measured R total −1,863 with every feature kept, scaled to the 4,170 of 6,655 R-min these groups are; S-flush's own glue ≈ +110–190 min / +40 br is inside this) | none | **flip:** C5 (a, b, e) with the per-response data cell (1.2), C6 (b2), C7 (c), C17 (a); **C17 (c) re-pins** to 1.6 (i) `waiting → B`; C6 (b1) inverts (asserts the opposite of A0). Size: frames eager ≤ 12.7 KB (from 13.78), page base ≤ 43.7, live ≤ 47.5. | A0 | **removed / changed:** `ServerComponentHandlerOptions.onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost.preview` / `Frame.preview`, `STAGED_DATA` — the rulings' step-4 list. **New:** `FrameHost.landing(address)` (internal). | -| **A2** | **C3 via `initBoundaryResume` — S-hold** (the rulings' 3a, pulled forward: **this is what lets any later hold register**). `hydrateWindow(id, fn, roots?)` factored out of `resumeBoundaryHydration`; `initBoundaryResume`'s registration reachable from the adopter (`sharedConfig.resumeBoundary`); `adoptBoundary` registers the adopted frame's owner while `#syncSlots` leaves any adopt-time occurrence deferred (the held set — one registration per frame, 3.2) and releases when a sync leaves none or the frame disposes. The #2968 `setTimeout` poll becomes the registration with the drain's end as its bound (3.5); the resumed fill re-enters hydration through the window and claims under the producer's keys (C1 / C9 stay green). **With it, 3e ported onto `next`** (the detached root + the parked backlog beyond the snapshot, 3.6 (iii), without S1's `claiming` plumbing — the rulings' "else ≈ +90 / +25" arm, because S1 no longer lands first and the Phase A gate counts C19), and 3.2's release order (claim → hold release → done → backlog) pinned. Deletes **R.claim** (the range-scoped registry beside `gatherHydratable(el, root)`) and the counter half of **R.drain**. **Landed in two parts** — #3837 (`holdBoundary`) and #3840 (`hydrateWindow` + the R.claim deletion; the park unconditional, rulings 3.6 "Landed") — measured **frames −109 br / hydrating +105 br** against the −130 / +40 estimate; the maintainer accepted the hydrating cost (2026-10-06; caps raised under a Size-Exception at the next integration PR), and **every further solid-side seam (S-adopted next) is to be measured on an edited dist copy before it is written.** | **≈ −130 / ≈ −130 / ≈ −130 / ≈ +40** (`est.`: R.claim ≈ 472 min + R.drain's defer ≈ 270 min ≈ −215 br of cuts; the registration ≈ +100 min frames ≈ +60 br incl. the `hold` option; the 3e port ≈ +90 min / +25 br; solid `hydrateWindow` + the reach ≈ +40–65 min ≈ +12–20 br, the detached root ≈ +20 br) | none | **flip:** C3 (a) + the harness's C3 replay; C19 ×2 (3e); S1's C3 (b) flips at C3 (the traces tier). Size: frames eager ≤ 12.55; **app hydrating / compiled hydrating +≈ 40 br — the first cap raise, the maintainer's** (compiled hydrating is at its cap on this head: 30,943 vs 30.93 KB). | A1 (the deferred set is right only once the drain is one write and the landing node exists) | **solid:** `sharedConfig.resumeBoundary` or an `internal` export of the registration — no new counter, no new done path (3.1 ruled; the draft's `holdHydration` withdrawn); the detached projection root (3e). **frames:** `FrameOptions.hold(): () => void` (internal, wired by `adoptBoundary`). | -| **A3** | **C2 / C4 — a reveal is an apply (S-reveal, interim 2b).** `fr.subscribe((_, parent) => el.contains(parent) && frame.sync(parent))` — a document `$df` into adopted content syncs the frame (2.3, 2.4); a bare `children` mounts at the revealed range (C2 b); a `#`-named occurrence found recordless is a pending read (C2 a2, through A2's hold). Deletes **R.reveal**'s readiness / retry model (`#segmentReady`'s retry loop, the `#revealed` / `#fallbackShown` second set) — the segment swap's DOM half stays (T.morph). DR-4's structural form (2c, the document fragment as a store write) is its own plan and not this step. **Landed** (2026-10-06, `fix/frames-a7-a3-error-throws-reveal-deletion`, with A7): the document-face half was #3837's (the empty write from the reveal cascade); the stream face's own half here — a revealed segment's content is applied as it is revealed, nested segments included (`#revealSegments(root)`), so `#flush` makes one pass and the second ledger (`#revealed` / `#fallbackShown` / `isRevealed`) deletes; the applied state is the content record / fallback gate by identity in the one applied map. **Measured −45 B min / ±0 br** on the frames eager client against A7's head: the fallback pass and the style gate — half the estimate's bytes — stay (F.assets keeps the gate; the pass keeps the reveal-before-fallback order). Pinned: two nested arms in the lifecycle matrix, one inside a pending boundary's detached content (which never revealed before). | **≈ −115 / ≈ −115 / ≈ −115 / 0** (`est.`: R.reveal 467 min ≈ −140 br; the one-liner +58 min / +23 br, measured as `Tglue-reveal` − `L8`) | none | **flip:** C2 (a2, b) + the harness's C2 replay; C4 (d) (the ledger is the store; the drain is one write). Size: frames eager ≤ 12.45. | A1, A2 | a `Frame` sync hook for the document reveal — internal, through the spread-cast options seam `adoptBoundary` already uses (rulings' list) | -| **A4** | **C5 / C6 / C17 residue — S-record, S-ref.** **S-ref:** the codec table answers an undelivered `{$ref}` with a pending promise rejected at `complete` / `:error` (L1 — closes the silent-ref hole, re-attribution §5.3 item 1); a record's refs resolve through the table current at its apply (1.3) — the per-response data cell (1.2, ≈ 140 min, replacing `stageTables`) that A1 left as the C5 condition. **S-record:** the server half — the document sink writes `sc:slot::` as a **declared** pending ref at the marker (as `registerFragment` writes `_fr`) and settles it with the args, so `readHydratedValue`'s `.then` path carries the wait — or the solid write hook on `_$HY.r` (+40 B); either removes the poll's last reason. Deletes **R.refwait** (`#refsUnresolved`, the threaded `resolve`) and the poll half of **R.drain**. | **≈ −15 / ≈ −15 / ≈ −15 / 0** (`est.`: R.refwait 145 min + the poll ≈ 120 min ≈ −75 br; the cell ≈ +45 br + reject-at-complete ≈ +15; decode chunk +≈ 60 B min for pending-on-missing — lazy, not counted) | `decode.js` +≈ 60 B (S-ref) | **flip:** C6 (a1); C5 (a, b, e) if A1 shipped them conditional; C17 (c) confirmed under 1.6 (i). Size: frames eager ≤ 12.45 (±). | A1 (the landing node), A2 (a pending read is a hold) | **server:** the declared slot record (output shape, +≈ 30 B/record) **or solid:** the `_$HY.r` write hook (+40 B) — one of the two, the maintainer's pick (the declared record is recommended: it is A5's shape). | -| **A5** | **C12 (c) client half + `claimRegionFragments` — S-adopted, S-key.** **S-adopted:** `_adoptedRoots: Set` in `hydration.ts`; `fragmentPolicy` swaps an unclaimed fragment after `_hydrationDone` when its `pl-` placeholder is inside an adopted root (`_$HY.fr.adopt(el)` / `unadopt(el)` from `adoptBoundary`, ≈ 30 B frames) — G4 closes and **`claimRegionFragments`** (R.claimant) deletes. **S-key:** `whenRevealed` published on `_$HY.fr` (+≈ 15 B solid) and the SC reference carries its covering fragment key (+≈ 30 B server); `installRevealHook`'s rescan and `boundaryWaiters` (D) collapse into `whenRevealed(key).then(...)`. **C12 (c) client half:** the adopted face shows what the server rendered (A0 withdraws the pin's expectation, 3.3); post-done the swap goes through S-adopted rather than freezing the fallback — the pin's **server half** (the sink's error markup) is A6's draft. **Landed as A5′ (2026-10-06, ruled 12:55: _a placeholder inside a server component's element is the frame's content by rendering, not by adoption_).** Measured before written: A5 as specified came in at **+504 min** solid on hydrating (no stores) (S-adopted +221 / S-key +283), so the shape changed — **(h)** an ownership predicate `_$HY.fa(placeholder)` the ledger's `fragmentPolicy` asks (geometry: the `pl-*` inside a live `data-fid` element), no `_adoptedRoots`, no claim, no replay, `_$HY.fr.claim`/`release` removed; **G9** by collapsing `documentBoundary`'s wait onto the intercept's `awaitBoundary` (`boundaryWaiters` deleted); the exhaustion fix (`fragmentPending` reads a revealed fragment from `_$HY.v` before its `_fr` stamp — the real producer order); a C14 guard (`disposedFrames`, a boundary disposed in place disowns its placeholders). **Measured:** hydrating (no stores) **+50 min / +5 br** (est. ≈ +20 min solid), frames eager **−204 / −51** (est. −135 min / ≈ −65 br; the pre-guard (h)+G9 edit measured −320 — the C14 guard is the ≈ +116 between), page base **−156 / −81**, live **−156 / +3**. **S-key not built** (reachability: the hydrating flow never reaches the late-boundary wait — a client `` twin's resume gates it; the intercept has no key to collapse onto; nested splices need covering-chain semantics; measured frames half −18 min for +283 solid). | **≈ −65 / ≈ −65 / ≈ −65 / ≈ +5** (`est.`: R.claimant 153 min + `boundaryWaiters` ≈ 110 min + the rescan's rebind ≈ 80 min ≈ −95 br; frames glue ≈ +30 br; solid `_adoptedRoots` + `whenRevealed` ≈ +20 min net of the detached root already paid in A2) | none | **flip:** C12 (c) client arm (shows the server's outcome; swaps post-done); the G4 and G9 timing pins (new: `adopted-swap-post-done.spec`, `boundary-arrival.spec`). Size: frames eager ≤ 12.35; hydrating scenarios +≈ 5 (inside A2's raise). | A2 (the adopted frame's registration is what `fr.adopt` keys off), A3 | **solid:** `_$HY.fr.adopt/unadopt`, `whenRevealed` on `_$HY.fr`. **server:** the fragment key on the SC reference (+30 B of output). | -| **A6** | **Server-half drafts — design, no wire change in this step.** (i) **C13's sweep delimiter** (R7): a multi-record `FrameChunk` member `{ type: "ops", ops: [...] }` the sink emits per sweep and the client applies as one write — the only wire item in the rulings' list, drafted as an RFC 11 addendum with the client's one-write apply (A1's shape already applies a write atomically). (ii) **The plain-response streaming bound** (§6 decision 4): `complete` gains `bound: "yields" \| "time"`; the sink ends a plain response at the bound. (iii) **C12 (c)'s error template**: the document face renders a rejected server ``'s error outcome into the fragment (3.3's server half) instead of the blank. Each is a design note + a `test.fails` pin written against the draft; the server PRs follow the drafts and flip C13 (a, b) and C12 (c)'s server arm — Phase A is taken as done when the drafts are reviewed and those PRs are open. | 0 (design) | — | the three drafts reviewed; pins written (`.fails`). **Phase A gate taken here:** 22 reds green / unrepresentable except the three server-half arms, drafts attached; harness clean on two seeds; frames eager ≈ 12.3 < 13.77. | A1–A5 | **wire (drafted, not shipped):** the `ops` chunk member; `complete.bound`. Decision 4. | -| **B** | **The tier mechanism** (§2): `sink.needs(tier)` at the five mint sites; `X-Frame-Tiers` at first flush; `sc:tiers` record + `modulepreload` links on the document face; `prepareTier(name)` + `installTier`; the **held set is A2's registered set** — a tier's adopt-path hold is one more reason an occurrence is deferred, so it registers under 3.1 from day one. No tier is cut yet — this step is the seam alone, measured. S1's `prepareData` / `prepareArgs` are not in the tree (S1 has not merged); the general seam is built directly and S1 re-bases onto it at C3. **Landed** (2026-10-06, `feat/frames-tier-mechanism`, measured before written — §2 "Landed"): frames eager **+473 min / +145 br** (≤ the +150 budget; est. ≈ +100, ×2.5 pre-estimate ≈ +250), pages +478 min, non-SC scenarios 0; frames server dist +793 min. `installServerComponents` gained `{ tiers }` (the client's loader map); the in-band form is `FrameChunk.tiers` on the next chunk out; the document face writes `sc:tiers` cumulative and a `modulepreload` only when `frameTransformDirectResult` is given `tierUrls` (decision 3 sub-item). Pins `tier-announce.spec` (14) / `tier-prepare.spec` (8); 5 of 150 artifacts re-recorded. | **≈ +100 / ≈ +100 / ≈ +100 / 0** gross (`est.`); server ≈ +300–450 min. S1's two faces (+543 min / +134 br) are never shipped — the ≈ −35 net the earlier draft credited here appears at C3 instead, as "S1 re-based costs less than S1 as built". **Measured: +145 / +177 / +158 / 0** br (+473 / +478 / +478 / 0 min); server +793 min. | none new | `tier-announce.spec`, `tier-prepare.spec` (new); artifacts re-recorded once. Size: frames eager ≤ 12.45. **Landed:** both specs green; 5 artifacts; frames eager 13,949 br (the 12.45 gate assumed Phase A savings that did not materialize — the cap is the maintainer's). | **the Phase A gate**; A2 (the holds register), A1 (the `landing` node is what an installed tier's `flush()` wakes) | **wire (additive):** `X-Frame-Tiers`, `_$HY.r["sc:tiers"]`, the links. Decision 3. **Landed:** also `installServerComponents(host?, { tiers })`, `frameTransformDirectResult`'s `tierUrls`, `FRAME_TIERS_HEADER` (server entry), `FrameChunk.tiers` (`TierAnnouncement`). | -| **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. | **≈ −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) | `container-trace.js` 24.3 KB / **7.86 KB br measured** (S1) + the eager half (≈ +700 min / +200 br → ≈ 8.1 KB br) | 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). | 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 | -| **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) | -| **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 | -| **E** | **Budget restatement — principles §6 as per-tier lines** (§4's table is the draft). One line per eager default (both readings written, one picked), one per tier chunk, the page lines, the ratchet rule unchanged ("a ceiling increase requires a new mechanism row citing its axiom"), `floor-caps.json` gains the tier chunks as reported-not-counted lines with their own caps. | 0 | — | `check-floor-caps` clean on `next` | all | — | +| # | step | Δ br (frames eager / page base / page live / compiled hydrating) | chunks created | gates (pins flip; size) | depends on | surface | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A0** | **C18 — classification waits for the drain** (rulings step 1, 3d / 3.5; **in flight**). The predicate is one term in `adoptBoundary.recordsPending`: a `prop#n` occurrence is classified only after every delivered record has drained. The only page-halting red; lands as the rulings specified it. A1 then makes the mechanism moot (one write per drain; `#` decides the class — C18 becomes unrepresentable) and the pins stay as the assertion of the pending read. | **≈ +15 / +15 / +15 / 0** (the rulings' ≈ +50 min / +15 br; ≈ +25 br with the batched drain, which A1 supersedes) | none | **flip:** C18 ×3. Size: ±0.02 KB on every scenario (frames eager ≤ 13.8). | #3813 landed | none | +| **A1** | **S-flush + the R deletions it unlocks.** `content = createMemo(() => host.landing(binding()))` — one reactive node per bound address resolved at the version's first root / error write (the host's `landing(address)`: a promise for a cold store, the value for a warm one); the enclosing `` pends on it, a switch is a new flight (`_inFlight` supersession, 1.6 (i) by construction), a refetch's landing is staged by the Transaction that read it (G7 closes). Deletes: **R.gate** (`arm`/`release`/`settle`/`setGate`/`mountGate`, the adopted twin), **R.stage** (`stage` / `stageTables` / `stagedContent` / `CONTENT_TOKEN` / `STAGED_DATA` / `FrameImpl#preview` / `#regionsChange` / `host.preview`; the chunk buffer-until-`complete` stays, one write), **R.version** (2a: one applied record keyed by identity; `#appliedRoot`), **R.dedupe** (per-prop memos in `slotArgsProxy`; `argsEquivalent` / `#refArgsUnchanged` / `#slotResolvedRefs` go), **R.error**'s latch. Files: `frames/src/client.ts` (`boundaryComponent`, `adoptBoundary`, `followAddress`), `frame-transport.ts` (`stage*`, `handle`), `frame-client.ts` (`#apply`, `#flush`, `preview`, `#syncSlots`' dedupe arms). | **≈ −1,150 / ≈ −1,150 / ≈ −1,150 / 0** (`est.` from the measured R total −1,863 with every feature kept, scaled to the 4,170 of 6,655 R-min these groups are; S-flush's own glue ≈ +110–190 min / +40 br is inside this) | none | **flip:** C5 (a, b, e) with the per-response data cell (1.2), C6 (b2), C7 (c), C17 (a); **C17 (c) re-pins** to 1.6 (i) `waiting → B`; C6 (b1) inverts (asserts the opposite of A0). Size: frames eager ≤ 12.7 KB (from 13.78), page base ≤ 43.7, live ≤ 47.5. | A0 | **removed / changed:** `ServerComponentHandlerOptions.onStream`, `FrameHostOptions.resolve` / `FrameHost.resolve`, `FrameHost.preview` / `Frame.preview`, `STAGED_DATA` — the rulings' step-4 list. **New:** `FrameHost.landing(address)` (internal). | +| **A2** | **C3 via `initBoundaryResume` — S-hold** (the rulings' 3a, pulled forward: **this is what lets any later hold register**). `hydrateWindow(id, fn, roots?)` factored out of `resumeBoundaryHydration`; `initBoundaryResume`'s registration reachable from the adopter (`sharedConfig.resumeBoundary`); `adoptBoundary` registers the adopted frame's owner while `#syncSlots` leaves any adopt-time occurrence deferred (the held set — one registration per frame, 3.2) and releases when a sync leaves none or the frame disposes. The #2968 `setTimeout` poll becomes the registration with the drain's end as its bound (3.5); the resumed fill re-enters hydration through the window and claims under the producer's keys (C1 / C9 stay green). **With it, 3e ported onto `next`** (the detached root + the parked backlog beyond the snapshot, 3.6 (iii), without S1's `claiming` plumbing — the rulings' "else ≈ +90 / +25" arm, because S1 no longer lands first and the Phase A gate counts C19), and 3.2's release order (claim → hold release → done → backlog) pinned. Deletes **R.claim** (the range-scoped registry beside `gatherHydratable(el, root)`) and the counter half of **R.drain**. **Landed in two parts** — #3837 (`holdBoundary`) and #3840 (`hydrateWindow` + the R.claim deletion; the park unconditional, rulings 3.6 "Landed") — measured **frames −109 br / hydrating +105 br** against the −130 / +40 estimate; the maintainer accepted the hydrating cost (2026-10-06; caps raised under a Size-Exception at the next integration PR), and **every further solid-side seam (S-adopted next) is to be measured on an edited dist copy before it is written.** | **≈ −130 / ≈ −130 / ≈ −130 / ≈ +40** (`est.`: R.claim ≈ 472 min + R.drain's defer ≈ 270 min ≈ −215 br of cuts; the registration ≈ +100 min frames ≈ +60 br incl. the `hold` option; the 3e port ≈ +90 min / +25 br; solid `hydrateWindow` + the reach ≈ +40–65 min ≈ +12–20 br, the detached root ≈ +20 br) | none | **flip:** C3 (a) + the harness's C3 replay; C19 ×2 (3e); S1's C3 (b) flips at C3 (the traces tier). Size: frames eager ≤ 12.55; **app hydrating / compiled hydrating +≈ 40 br — the first cap raise, the maintainer's** (compiled hydrating is at its cap on this head: 30,943 vs 30.93 KB). | A1 (the deferred set is right only once the drain is one write and the landing node exists) | **solid:** `sharedConfig.resumeBoundary` or an `internal` export of the registration — no new counter, no new done path (3.1 ruled; the draft's `holdHydration` withdrawn); the detached projection root (3e). **frames:** `FrameOptions.hold(): () => void` (internal, wired by `adoptBoundary`). | +| **A3** | **C2 / C4 — a reveal is an apply (S-reveal, interim 2b).** `fr.subscribe((_, parent) => el.contains(parent) && frame.sync(parent))` — a document `$df` into adopted content syncs the frame (2.3, 2.4); a bare `children` mounts at the revealed range (C2 b); a `#`-named occurrence found recordless is a pending read (C2 a2, through A2's hold). Deletes **R.reveal**'s readiness / retry model (`#segmentReady`'s retry loop, the `#revealed` / `#fallbackShown` second set) — the segment swap's DOM half stays (T.morph). DR-4's structural form (2c, the document fragment as a store write) is its own plan and not this step. **Landed** (2026-10-06, `fix/frames-a7-a3-error-throws-reveal-deletion`, with A7): the document-face half was #3837's (the empty write from the reveal cascade); the stream face's own half here — a revealed segment's content is applied as it is revealed, nested segments included (`#revealSegments(root)`), so `#flush` makes one pass and the second ledger (`#revealed` / `#fallbackShown` / `isRevealed`) deletes; the applied state is the content record / fallback gate by identity in the one applied map. **Measured −45 B min / ±0 br** on the frames eager client against A7's head: the fallback pass and the style gate — half the estimate's bytes — stay (F.assets keeps the gate; the pass keeps the reveal-before-fallback order). Pinned: two nested arms in the lifecycle matrix, one inside a pending boundary's detached content (which never revealed before). | **≈ −115 / ≈ −115 / ≈ −115 / 0** (`est.`: R.reveal 467 min ≈ −140 br; the one-liner +58 min / +23 br, measured as `Tglue-reveal` − `L8`) | none | **flip:** C2 (a2, b) + the harness's C2 replay; C4 (d) (the ledger is the store; the drain is one write). Size: frames eager ≤ 12.45. | A1, A2 | a `Frame` sync hook for the document reveal — internal, through the spread-cast options seam `adoptBoundary` already uses (rulings' list) | +| **A4** | **C5 / C6 / C17 residue — S-record, S-ref.** **S-ref:** the codec table answers an undelivered `{$ref}` with a pending promise rejected at `complete` / `:error` (L1 — closes the silent-ref hole, re-attribution §5.3 item 1); a record's refs resolve through the table current at its apply (1.3) — the per-response data cell (1.2, ≈ 140 min, replacing `stageTables`) that A1 left as the C5 condition. **S-record:** the server half — the document sink writes `sc:slot::` as a **declared** pending ref at the marker (as `registerFragment` writes `_fr`) and settles it with the args, so `readHydratedValue`'s `.then` path carries the wait — or the solid write hook on `_$HY.r` (+40 B); either removes the poll's last reason. Deletes **R.refwait** (`#refsUnresolved`, the threaded `resolve`) and the poll half of **R.drain**. | **≈ −15 / ≈ −15 / ≈ −15 / 0** (`est.`: R.refwait 145 min + the poll ≈ 120 min ≈ −75 br; the cell ≈ +45 br + reject-at-complete ≈ +15; decode chunk +≈ 60 B min for pending-on-missing — lazy, not counted) | `decode.js` +≈ 60 B (S-ref) | **flip:** C6 (a1); C5 (a, b, e) if A1 shipped them conditional; C17 (c) confirmed under 1.6 (i). Size: frames eager ≤ 12.45 (±). | A1 (the landing node), A2 (a pending read is a hold) | **server:** the declared slot record (output shape, +≈ 30 B/record) **or solid:** the `_$HY.r` write hook (+40 B) — one of the two, the maintainer's pick (the declared record is recommended: it is A5's shape). | +| **A5** | **C12 (c) client half + `claimRegionFragments` — S-adopted, S-key.** **S-adopted:** `_adoptedRoots: Set` in `hydration.ts`; `fragmentPolicy` swaps an unclaimed fragment after `_hydrationDone` when its `pl-` placeholder is inside an adopted root (`_$HY.fr.adopt(el)` / `unadopt(el)` from `adoptBoundary`, ≈ 30 B frames) — G4 closes and **`claimRegionFragments`** (R.claimant) deletes. **S-key:** `whenRevealed` published on `_$HY.fr` (+≈ 15 B solid) and the SC reference carries its covering fragment key (+≈ 30 B server); `installRevealHook`'s rescan and `boundaryWaiters` (D) collapse into `whenRevealed(key).then(...)`. **C12 (c) client half:** the adopted face shows what the server rendered (A0 withdraws the pin's expectation, 3.3); post-done the swap goes through S-adopted rather than freezing the fallback — the pin's **server half** (the sink's error markup) is A6's draft. **Landed as A5′ (2026-10-06, ruled 12:55: _a placeholder inside a server component's element is the frame's content by rendering, not by adoption_).** Measured before written: A5 as specified came in at **+504 min** solid on hydrating (no stores) (S-adopted +221 / S-key +283), so the shape changed — **(h)** an ownership predicate `_$HY.fa(placeholder)` the ledger's `fragmentPolicy` asks (geometry: the `pl-*` inside a live `data-fid` element), no `_adoptedRoots`, no claim, no replay, `_$HY.fr.claim`/`release` removed; **G9** by collapsing `documentBoundary`'s wait onto the intercept's `awaitBoundary` (`boundaryWaiters` deleted); the exhaustion fix (`fragmentPending` reads a revealed fragment from `_$HY.v` before its `_fr` stamp — the real producer order); a C14 guard (`disposedFrames`, a boundary disposed in place disowns its placeholders). **Measured:** hydrating (no stores) **+50 min / +5 br** (est. ≈ +20 min solid), frames eager **−204 / −51** (est. −135 min / ≈ −65 br; the pre-guard (h)+G9 edit measured −320 — the C14 guard is the ≈ +116 between), page base **−156 / −81**, live **−156 / +3**. **S-key not built** (reachability: the hydrating flow never reaches the late-boundary wait — a client `` twin's resume gates it; the intercept has no key to collapse onto; nested splices need covering-chain semantics; measured frames half −18 min for +283 solid). | **≈ −65 / ≈ −65 / ≈ −65 / ≈ +5** (`est.`: R.claimant 153 min + `boundaryWaiters` ≈ 110 min + the rescan's rebind ≈ 80 min ≈ −95 br; frames glue ≈ +30 br; solid `_adoptedRoots` + `whenRevealed` ≈ +20 min net of the detached root already paid in A2) | none | **flip:** C12 (c) client arm (shows the server's outcome; swaps post-done); the G4 and G9 timing pins (new: `adopted-swap-post-done.spec`, `boundary-arrival.spec`). Size: frames eager ≤ 12.35; hydrating scenarios +≈ 5 (inside A2's raise). | A2 (the adopted frame's registration is what `fr.adopt` keys off), A3 | **solid:** `_$HY.fr.adopt/unadopt`, `whenRevealed` on `_$HY.fr`. **server:** the fragment key on the SC reference (+30 B of output). | +| **A6** | **Server-half drafts — design, no wire change in this step.** (i) **C13's sweep delimiter** (R7): a multi-record `FrameChunk` member `{ type: "ops", ops: [...] }` the sink emits per sweep and the client applies as one write — the only wire item in the rulings' list, drafted as an RFC 11 addendum with the client's one-write apply (A1's shape already applies a write atomically). (ii) **The plain-response streaming bound** (§6 decision 4): `complete` gains `bound: "yields" \| "time"`; the sink ends a plain response at the bound. (iii) **C12 (c)'s error template**: the document face renders a rejected server ``'s error outcome into the fragment (3.3's server half) instead of the blank. Each is a design note + a `test.fails` pin written against the draft; the server PRs follow the drafts and flip C13 (a, b) and C12 (c)'s server arm — Phase A is taken as done when the drafts are reviewed and those PRs are open. | 0 (design) | — | the three drafts reviewed; pins written (`.fails`). **Phase A gate taken here:** 22 reds green / unrepresentable except the three server-half arms, drafts attached; harness clean on two seeds; frames eager ≈ 12.3 < 13.77. | A1–A5 | **wire (drafted, not shipped):** the `ops` chunk member; `complete.bound`. Decision 4. | +| **B** | **The tier mechanism** (§2): `sink.needs(tier)` at the five mint sites; `X-Frame-Tiers` at first flush; `sc:tiers` record + `modulepreload` links on the document face; `prepareTier(name)` + `installTier`; the **held set is A2's registered set** — a tier's adopt-path hold is one more reason an occurrence is deferred, so it registers under 3.1 from day one. No tier is cut yet — this step is the seam alone, measured. S1's `prepareData` / `prepareArgs` are not in the tree (S1 has not merged); the general seam is built directly and S1 re-bases onto it at C3. **Landed** (2026-10-06, `feat/frames-tier-mechanism`, measured before written — §2 "Landed"): frames eager **+473 min / +145 br** (≤ the +150 budget; est. ≈ +100, ×2.5 pre-estimate ≈ +250), pages +478 min, non-SC scenarios 0; frames server dist +793 min. `installServerComponents` gained `{ tiers }` (the client's loader map); the in-band form is `FrameChunk.tiers` on the next chunk out; the document face writes `sc:tiers` cumulative and a `modulepreload` only when `frameTransformDirectResult` is given `tierUrls` (decision 3 sub-item). Pins `tier-announce.spec` (14) / `tier-prepare.spec` (8); 5 of 150 artifacts re-recorded. | **≈ +100 / ≈ +100 / ≈ +100 / 0** gross (`est.`); server ≈ +300–450 min. S1's two faces (+543 min / +134 br) are never shipped — the ≈ −35 net the earlier draft credited here appears at C3 instead, as "S1 re-based costs less than S1 as built". **Measured: +145 / +177 / +158 / 0** br (+473 / +478 / +478 / 0 min); server +793 min. | none new | `tier-announce.spec`, `tier-prepare.spec` (new); artifacts re-recorded once. Size: frames eager ≤ 12.45. **Landed:** both specs green; 5 artifacts; frames eager 13,949 br (the 12.45 gate assumed Phase A savings that did not materialize — the cap is the maintainer's). | **the Phase A gate**; A2 (the holds register), A1 (the `landing` node is what an installed tier's `flush()` wakes) | **wire (additive):** `X-Frame-Tiers`, `_$HY.r["sc:tiers"]`, the links. Decision 3. **Landed:** also `installServerComponents(host?, { tiers })`, `frameTransformDirectResult`'s `tierUrls`, `FRAME_TIERS_HEADER` (server entry), `FrameChunk.tiers` (`TierAnnouncement`). | +| **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) | +| **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 | +| **E** | **Budget restatement — principles §6 as per-tier lines** (§4's table is the draft). One line per eager default (both readings written, one picked), one per tier chunk, the page lines, the ratchet rule unchanged ("a ceiling increase requires a new mechanism row citing its axiom"), `floor-caps.json` gains the tier chunks as reported-not-counted lines with their own caps. | 0 | — | `check-floor-caps` clean on `next` | all | — | **Dependency check under the new order.** S-hold (A2) now precedes everything that holds: B's held set is A2's registered set; C3's, C4's and @@ -490,11 +490,11 @@ this plan's tier model: it built the lazy chunk, the hold, and the consistency fixes for the one tier whose chunk is big enough to have forced the question. Under the general mechanism: -| commit | survives as-is | re-shapes under the general mechanism (B / C3) | pins | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **1 — `8629a93be`** the lazy materializer via `solid-js/internal/container-trace` | the **entry** (its own dist entry, the `@internal` seams it reads back from the main module — `withStoreHydration`, `applyPatches`, `forwardIteratorReturn`; the server's inert stubs); `loadContainers` as the dynamic import kept external; the frames container plugin materializing at decode | `FrameHostOptions.prepareData(chunk)` → `needsContainerTraceMaterializer(chunk) && prepareTier("trace")` (the node scan stays as the un-announced fallback; the header flag makes it a confirmation); `prepareArgs(record)` → the general **held set** predicate (a marker literal is one of the three hold reasons: trace / `{$frame}` / `positions`); `#argsUnprepared` → the held set; the eager half of F.trace (`reviveContainerTraces`, `materialize`, the marker tests) moves into the chunk's `installTier` | `frames-container-lazy-codec`, `frames-container-lazy-document`, `hydration/welcome-status-lazy` — unchanged in assertion; their load trigger becomes `prepareTier` | -| **2 — `ef6147557`** caps lowered to S1's measured + 10 B | the **ratchet itself** (lowering at landing) | the values re-set at each later landing; the frames-eager +134 exception S1 could not take is **never granted**: B's shared seam lands before S1 merges and costs less than S1's two faces (≈ −35 net, credited at C3) | `check-floor-caps` clean on `next` | -| **3 — `9927ddddd`** a held container-trace fill hydrates like a resident one | **all three fixes**: the detached root for the projection (solid; id determinism); the parked backlog beyond the snapshot until `onHydrationEnd` with the `claiming` hint (`revive(value, claiming?)` → `reviveContainerTraces(value, claiming?)` → `materializer(marker, claiming?)`) — this is ruling 3.6 (iii), the rulings' step "3e" — the detached root and the backlog are ported onto `next` in A2, the `claiming` hint lands with S1 at C3; the held-record mount (an occurrence held on an unresolved ref or unprepared arg mounts with the record it was held on and applies a replacement as the args change) | the held-record mount **generalizes**: it is the mechanism every announced tier's adopt-path hold uses (regions, bind), not trace-specific — `#heldRecords` keyed by occurrence becomes the held set of B (A2's registered set); the park's release order (claim → hold release → done → backlog) is **pinned with 3a** (rulings 3.2 "Ordering to pin with it") | `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}` — survive as-is; **`container-trace-hold-hydration-end` re-pins under 3.1** at C3's merge (A2 already in) (_hydration waits for the load; the mount claims before done_ — same claim assertions, opposite order); the `.fails` id-drift pin (a keyed sibling after a frame) is ruling 3.4's and flips with 3c | +| commit | survives as-is | re-shapes under the general mechanism (B / C3) | pins | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1 — `8629a93be`** the lazy materializer via `solid-js/internal/container-trace` | the **entry** (its own dist entry, the `@internal` seams it reads back from the main module — `withStoreHydration`, `applyPatches`, `forwardIteratorReturn`; the server's inert stubs); `loadContainers` as the dynamic import kept external; the frames container plugin materializing at decode | `FrameHostOptions.prepareData(chunk)` → **nothing** (landed at C3: B's `chunk.tiers` on the `data` chunk that carries the node is the codec face's whole trigger; the node scan was measured and dropped — see row C3); `prepareArgs(record)` → the general **held set** predicate (a marker literal is one of the three hold reasons: trace / `{$frame}` / `positions`); `#argsUnprepared` → the held set; the eager half of F.trace (`reviveContainerTraces`, `materialize`, the marker tests) moves into the chunk's `installTier` | `frames-container-lazy-codec`, `frames-container-lazy-document`, `hydration/welcome-status-lazy` — unchanged in assertion; their load trigger becomes `prepareTier` | +| **2 — `ef6147557`** caps lowered to S1's measured + 10 B | the **ratchet itself** (lowering at landing) | the values re-set at each later landing; the frames-eager +134 exception S1 could not take is **never granted**: B's shared seam lands before S1 merges and costs less than S1's two faces (≈ −35 net, credited at C3) | `check-floor-caps` clean on `next` | +| **3 — `9927ddddd`** a held container-trace fill hydrates like a resident one | **all three fixes**: the detached root for the projection (solid; id determinism); the parked backlog beyond the snapshot until `onHydrationEnd` with the `claiming` hint (`revive(value, claiming?)` → `reviveContainerTraces(value, claiming?)` → `materializer(marker, claiming?)`) — this is ruling 3.6 (iii), the rulings' step "3e" — the detached root and the backlog are ported onto `next` in A2, the `claiming` hint lands with S1 at C3; the held-record mount (an occurrence held on an unresolved ref or unprepared arg mounts with the record it was held on and applies a replacement as the args change) | the held-record mount **generalizes**: it is the mechanism every announced tier's adopt-path hold uses (regions, bind), not trace-specific — `#heldRecords` keyed by occurrence becomes the held set of B (A2's registered set); the park's release order (claim → hold release → done → backlog) is **pinned with 3a** (rulings 3.2 "Ordering to pin with it") | `container-trace-hold-{id-determinism, interruption, record-retention, snapshot}` — survive as-is; **`container-trace-hold-hydration-end` re-pins under 3.1** at C3's merge (A2 already in) (_hydration waits for the load; the mount claims before done_ — same claim assertions, opposite order); the `.fails` id-drift pin (a keyed sibling after a frame) is ruling 3.4's and flips with 3c | **When it merges: at C3 — re-based, after Phases A and B. S1 as built is NOT merged first.** The reason is the one the ruling turns on. S1's @@ -519,6 +519,27 @@ What S1 **does not** become: the general mechanism's model. S1 detects stays as the fallback, so S1's behaviour on an un-announced response is exactly the plan's fallback behaviour, already pinned. +**Landed (2026-10-06, C3 — `feat/frames-traces-tier`).** S1 re-based as +above: commit 1's entry shipped unchanged (`solid-js/internal/container-trace`, +the three `@internal` seams on the main entry, the server stubs) and its two +host faces did not (`prepareData(chunk)` dropped with the node scan; +`prepareArgs` / `#argsUnprepared` became `needsTrace` in A2's held set); +commit 2's ratchet re-applied at the landing (page caps 38.61 / 42.16 KB); +commit 3's `claiming` hint and held-record mount ported (`#heldRecords` +generalizes to every adopt-path hold — `bind`, `regions`, `trace`), its +hydration-end pin re-pinned under 3.1, its other four pins green with the +hold re-armed per test through the runtime's `tierLoads` seam instead of +S1's `force`. The plugin's client half rides the tier chunk, not the eager +client: S1's eager half (`reviveContainerTraces`, `materialize`, the marker +test, `isMaterializedContainer`, the module-load install) is what the −239 +br on frames eager is. S1 re-based costs **−83 br** on frames against B's +head where S1 as built cost +134 against its own — the "≈ −35 net" this +section credited came in at −217, because B's seam carried only the +`bind` / `regions` predicates and the trace predicate's +67 br landed here +with the −239 cut. One pin of S1's seven changed in assertion +(`hydration-end`); the un-announced codec face is no longer convergent +(the node scan is gone) and is flagged as such in row C3. + --- ## 6. Open decisions for the maintainer diff --git a/documentation/server-components/frames-rulings.md b/documentation/server-components/frames-rulings.md index 5600b993a..3d6b6b1e2 100644 --- a/documentation/server-components/frames-rulings.md +++ b/documentation/server-components/frames-rulings.md @@ -1055,7 +1055,11 @@ server's node.** child, two after a keyed sibling — per S1's note); the client's `adoptBoundary` consumes none. S1's `.fails` pin: `container-trace-hold-id-determinism` "a keyed sibling after the frame claims the server's node". Independent of any - hold; pre-existing on `next`. + hold; pre-existing on `next`. **The pin is on the tree since C3** + (2026-10-06, `test/hydration/container-trace-hold-id-determinism.spec.tsx`, + ported with S1's hold specs): still red on a resident run, still `.fails`, + with this ruling named as the reading it waits on (3c) — C3 changed + nothing in what the server or the adopter consumes. - **Decides.** That pin. A parity bug under C10's rule read one level up (the frame's own ids, not the fill's) — fix without a new ruling, but it needs the server's consumption pinned first (it varies by position), and the fix may be @@ -1185,6 +1189,29 @@ update it is. The claim pass never rewrites a hole.** `claiming` hint (S1's `revive(value, claiming?)`, threaded from the adopt-time mount) arrives at plan step C3 with S1 and converts the park back to keyed-on-claim then. +- **Landed (2026-10-06, C3 — `feat/frames-traces-tier`): the park is + keyed on the claim again.** The hint is threaded as S1 had it — + `#invokeSlot`'s `adopted` → `#resolveArgs(occurrence, record, claiming)` → + `FrameHostOptions.revive(value, claiming)` → `reviveContainerTraces(value, +claiming)` → `materialize(marker, claiming)` → `materializeContainerTrace +(marker, claiming)` (now `solid-js/internal/container-trace`, the traces + tier's entry) — and the materializer parks only when `claiming` is true: + every adopt-time mount of an adopt frame (t = 0, under the tier's hold, + at a fragment's reveal, on a frame adopted after done — `ctx.adopted` is + the one invocation a consumer may answer with a claim, so it is exactly + the set of claims; the "no hydration state says claim" problem the + unconditional port worked around does not arise, since the hint is the + frame's, not the pass's). A fresh mount — a stream re-call, a codec-face + decode — reads the fold of its whole backlog at once and pays no beat. + The release order 3.2 pins holds through the tier's hold: the late claim + under `needsTrace` materializes with `claiming`, the frame's hold releases + after that sync, done after the hold, the backlog after done + (`container-trace-hold-{snapshot,hydration-end}`, `tier-trace-hold`; + solid's `container-trace.spec` pins the keyed park itself: a fresh mount + during hydration parks nothing either). The +4 B this costs on the + `@solidjs/web` server floor is `materialize`'s second parameter in the + codec plugin the SSR runtime bundles (within the minified allowance; + brotli −9). - **Mechanism today.** `web/src/client.ts:insertExpression` under hydration is a claim pass, not a mutation pass (C1/C9: nothing moves);