From ef6596c3b3c16f15f4787d9057fae2c1250a9957 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 12:01:23 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(web/frames):=20A1b=20+=20A4=20S-ref=20?= =?UTF-8?q?=E2=80=94=20refs=20settle=20at=20the=20write=20through=20the=20?= =?UTF-8?q?response's=20table;=20the=20dedupe,=20the=20latches,=20stageTab?= =?UTF-8?q?les/STAGED=5FDATA/onStream=20and=20the=20threaded=20resolver=20?= =?UTF-8?q?deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-ref (frames-rulings 1.3, L1). `createFrameHost.apply` settles a `slot` chunk's `{$ref}` args at the write, through the integration's table for the chunk's RESPONSE (`FrameHostOptions.resolve(ref, frameId, version, current)`), so the record the store holds — and every mount reads — carries values, never refs, and a later response's data can answer none of them. A key the response has not delivered yet resolves to a pending read owned by the store (keyed by version and key; stamped `s`/`v` as a serialized promise once it settles): its `data` chunk settles it and re-applies the records it was the last open read of, the stream's `complete` or `error` rejects it (L1 — a value that never comes surfaces where it is read, instead of leaving the record silently unapplied), a bump drops the superseded response's waits unanswered. A FRESH mount waits for its record to settle (`record.pending`), so the frame's shell shows at its landing with the range as the server left it and the mount's covering boundary pends on the landing alone (A0, corollary 4 — a fill whose first read pended into that boundary would hold the frame's own address follow behind the fallback); a MOUNTED occurrence takes the read as it is and its prop holds what it shows (A17, the DR-2 value tier). The frame no longer resolves refs: `#refsUnresolved`, `#resolveRef`, `#slotResolvedRefs`, `#refArgsUnchanged`, `#reconcileRegions` and the `resolve` parameter threaded through `preview` delete; the host notes a record's `{$frame}` args (`record.regions`) and decoded args (`record.decoded`) so the frame never probes a value (a decoded value may be a live container). Codec data tables are per RESPONSE (frames-rulings 1.2): `client.ts` keys them by frame id and version — the shown response's and a staged refetch's coexist — and drops versions below the store's `current` at the next use. `stageTables`, `STAGED_DATA`, `beginStream`, the prefix walk and `ServerComponentHandlerOptions.onStream` delete; a staged response's data chunks write through to the host as they arrive (into their own table). R.dedupe. A re-sent record always updates the live occurrence's props; the fill's props read through one memo per prop whose `equals` (`sameArg`: identity, then structural for decoded plain data; containers, async values and nodes by identity) is the dedupe — a record whose refs decode to equal values churns no reader. A live container is held boxed in its memo (the core probes a memo's result for `.then`; a pending container's trap throws). `FrameHostOptions.isContainer` / `FrameHost.isContainer` have no caller and delete. R.version / R.error. The applied root and the notified error are keyed by record identity (`#appliedRoot`, `#appliedError`; frames-rulings 2.1/2.2); `Frame.rebase()` (no caller since #3830) deletes. NOT deleted — the gap, reported: `stage`'s buffer + `CONTENT_TOKEN` + `stagedContent.preview/commit` + `FrameImpl#preview`/`host.preview` (reduced). The token is how a refetch of the address a mount SHOWS enters the reactive graph at all (`dynamic` delivers a kept resolution only when its address differs), and the compute-half preview is what stages the refetch's slot args with the transaction that read it: without it a fill deriving optimistic intent over an arg re-derives from the OLD arg in the pass that dissolves the intent (`frames-optimistic-hold` ×3, principles §9.2.2) — one flush before the effect-half commit pushes the new one. C15 itself stays green either way (the DOM never shows the tear; a memo does). Pins: C5 (b, c) re-pinned to observe the rotation through the fill (the host's resolver is no longer a surface); `frames-flight-delivery`'s `onStream` probe → a probe registration seeded from the resident store. --- .changeset/frames-a1b-stage-deletion-s-ref.md | 5 + packages/web/frames/src/client.ts | 179 +++--- packages/web/frames/src/frame-client.ts | 588 +++++++++--------- packages/web/frames/src/frame-transport.ts | 125 ++-- .../c05-data-response-scoped.spec.tsx | 65 +- .../c06-stale-wait-never-lands.spec.tsx | 32 +- packages/web/test/consistency/support.ts | 7 +- .../web/test/frames-flight-delivery.spec.tsx | 25 +- .../hydration/document-live-channel.spec.tsx | 8 +- .../test/hydration/welcome-status-parity.tsx | 8 +- 10 files changed, 507 insertions(+), 535 deletions(-) create mode 100644 .changeset/frames-a1b-stage-deletion-s-ref.md diff --git a/.changeset/frames-a1b-stage-deletion-s-ref.md b/.changeset/frames-a1b-stage-deletion-s-ref.md new file mode 100644 index 0000000000..cb3e81bb05 --- /dev/null +++ b/.changeset/frames-a1b-stage-deletion-s-ref.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: A1b + A4 (S-ref) — a slot record's `{$ref}`s settle at the host's write, through the response's own data table (`FrameHostOptions.resolve(ref, frameId, version, current)`); an undelivered key is a pending read the response's `data` chunk settles and its `complete`/`error` rejects (L1 — a value that never comes is an error, not a silence); a fresh mount waits for the record to settle, a mounted occurrence's prop pends and holds its value. Codec data tables are per response (keyed by frame id and version). Deleted: `ServerComponentHandlerOptions.onStream`, `STAGED_DATA` and the staged tables, `FrameHost.resolve`, `FrameHostOptions.isContainer`/`FrameHost.isContainer`, `Frame.rebase`, the frame's record dedupe (`#refArgsUnchanged`, `#slotResolvedRefs`) — a re-sent record's equality is the fill's per-prop memo's — and the frame's error/root value latches (applied state keyed by record identity). `FrameHost.preview`/`Frame.preview` lose their `resolve` parameter and stay: the compute-half preview is what stages a refetch's args with the transaction that read it. diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 3d250df639..d85654dd61 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -38,7 +38,6 @@ import { insert, assign } from "@solidjs/web"; import { createFrame, createFrameElement, createFrameHost, FRAME_ID_ATTR } from "./frame-client.js"; import { COMPONENT_BINDING, - STAGED_DATA, contentAddress, createServerComponentHandler, stagedContent, @@ -119,17 +118,24 @@ export function asyncArg(value: PromiseLike | AsyncIterable): T { } // One host per app is the norm: one chunk router, with codec data tables -// rotated PER RESPONSE — the deserializer's cross-reference space is +// kept PER RESPONSE — the deserializer's cross-reference space is // stream-scoped by contract, so each stream into a boundary gets a fresh -// table (routed by root frame id; nested region ids prefix-match to their -// root's table). Apps needing isolation pass their own host. +// table. A response is one version of one root frame id (the transport +// stamps every chunk of it), so tables are keyed by the id and the +// version: a chunk or a record of a response reads and writes its own +// response's table and no other's (frames-rulings 1.2, 1.3) — the shown +// response's and a staged refetch's coexist, each its own — and a version +// the address has moved past (`current`, the host's store version) has no +// reader left, so its table is dropped at the next use. Data and slot +// chunks both carry the ROOT id (a nested region's records live on the +// root sink), so no prefix routing is needed. Apps needing isolation pass +// their own host. // -// Tables materialize lazily: `beginStream` only REGISTERS the stream (the -// prefix routing needs the root id), and the table itself is created at -// first use once the codec module is resident — `prepareData` guarantees -// that before any `data` chunk delivers. A `resolve` ahead of the codec -// (a record's `$ref` sighted before its data) returns undefined, which is -// already the "not delivered yet" state the held-record contract covers. +// Tables materialize lazily, at first use once the codec module is +// resident — `prepareData` guarantees that before any `data` chunk +// delivers. A `resolve` ahead of the codec (a record's `$ref` sighted +// before its data) answers undefined — "not delivered" — and the host hands +// the fill a pending read the data chunk settles (see `createFrameHost`). let sharedHost: any; let codec: any; let codecLoading: Promise | undefined; @@ -140,33 +146,14 @@ function loadCodec() { codec = m; })); } -const tables = new Map(); -function ensureTable(root: string, map = tables) { - let table = map.get(root); - if (!table && codec) map.set(root, (table = codec.createJSONDataTable())); - return table; -} -function tableFor(id: string, map = tables) { - if (map.has(id)) return ensureTable(id, map); - for (const root of map.keys()) if (id.startsWith(root + ".")) return ensureTable(root, map); - return undefined; -} -/** Rotate in a fresh response-scoped data table for a boundary's stream. */ -function beginStream(frameId: string) { - tables.set(frameId, undefined); -} -/** - * A staged response's tables (STAGED_DATA): routed like `tables`, decoded - * as the response arrives, installed over the shown response's at commit. - */ -function stageTables() { - const staged = new Map(); - return { - begin: (id: string) => staged.set(id, undefined), - apply: (c: any) => tableFor(c.id, staged)?.apply(c), - resolve: (ref: any, id: string) => tableFor(id, staged)?.resolve(ref), - commit: () => staged.forEach((table, id) => tables.set(id, table)) - }; +const tables = new Map>(); +function tableFor(id: string, version: number, current: number | undefined) { + let byVersion = tables.get(id); + if (!byVersion) tables.set(id, (byVersion = new Map())); + else for (const v of byVersion.keys()) if (v < current!) byVersion.delete(v); + let t = byVersion.get(version); + if (!t && codec) byVersion.set(version, (t = codec.createJSONDataTable())); + return t; } /** * The render effect that follows a mount's address accessor. `dynamic` @@ -177,16 +164,20 @@ function stageTables() { * * The compute half is plumbing: a content TOKEN (a refetch of the address * shown, see createServerComponentHandler) has its slot args previewed into - * the live fills (`stagedContent.preview`) — held with the transaction, so - * a fill deriving optimistic intent over an arg never reads the old arg - * once the intent ends. + * the live fills (`stagedContent.preview`) — staged with the transaction, + * so a fill deriving optimistic intent over an arg re-derives from the new + * arg in the pass that dissolves the intent, never from the old one a + * flush behind it (principles §9.2.2, `frames-optimistic-hold`). This is + * the one write the token carries that the landing node (`landing` below) + * does not: the landing is per address and reads warm for a refetch; the + * fills' args are the record's, and the record is the token's. * * The effect half is display. It commits the token's content * (`stagedContent.commit`: the markup, the store, the mounts) and re-binds * the frame to the address — a warm store re-materializes at once; the same * address under a new version is not a switch and `rebind` no-ops. Both * wait for the commit so the region's answer never lands beside siblings - * the transaction still holds (`frames-morph-in-transition`). + * the transaction still holds (`frames-morph-in-transition`, C15). * * Ruling (maintainer, 2026-10-04, #3759 on L2): the switch IS display — * one reveal. The rebind morphs the DOM, so it runs in the effect half at @@ -244,14 +235,12 @@ export function getFrameHost() { if (!sharedHost) { sharedHost = createFrameHost({ prepareData: loadCodec, - applyData: (c: any) => tableFor(c.id)?.apply(c), - resolve: (ref: any, id: string) => tableFor(id)?.resolve(ref), + 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, - // Lets the record-dedupe compare identity-test containers instead of - // probing them (a pending container's property reads throw not-ready). - isContainer: isMaterializedContainer + revive: reviveContainerTraces }); } return sharedHost; @@ -329,8 +318,10 @@ function claimRender(prefix: string, existing: Node[], render: () => any, scope? * into the same instance" semantic compiled components already have. */ function liveSlotProps(initial: Record, ctx: any) { - // `ownedWrite`: a staged response's args arrive from the mount's compute - // half (see followAddress), under the transition that delivered them. + // `ownedWrite`: the record's writes arrive from wherever the frame flushes + // — a chunk microtask, the commit of the transaction that delivered a + // refetch (see followAddress), the document's reveal cascade — none of + // them a read of this occurrence's. const [args, setArgs] = createSignal(initial, { ownedWrite: true }); ctx.onUpdate((next: Record) => setArgs(() => next)); return slotArgsProxy(args); @@ -350,31 +341,65 @@ function isAsyncValue(v: any): boolean { } /** - * The props object handed to a render-prop occurrence. Plain values read - * straight through (and stay reactive over `args` for live updates); an - * async value reads through a lazily-created async memo, so the prop read - * follows the normal async read path — it suspends into the reading - * component's nearest `Loading` (the reveal seam's reconstructed boundary - * when the fill has none of its own) and settles to the value when the - * server's data chunk lands. Memos are created under the occurrence's owner - * (not the reader's), so they live as long as the occurrence: a read from a - * later effect or event handler reuses the same source. + * Whether two values of one slot arg are the same value — the per-prop + * memo's equality, and so the whole dedupe of a re-sent record (a record + * whose refs decode to equal values churns no reader; one with a changed + * arg moves exactly that arg's readers). Identity first; then structural + * for the plain data the codec decodes (every prop of a record is a fresh + * decode, so two equal records are never `===`). A live container (DR-2's + * container tier) compares by identity ONLY — its reads carry the async + * semantics and a pending one throws not-ready on any property probe, so + * the container test comes before the async probe — and so does an async + * value (two pending promises stringify alike and are different values) + * and a DOM node (a region element; the frame caches those per arg, so an + * unchanged one IS identical). + */ +function sameArg(a: any, b: any): boolean { + if (a === b) return true; + if (!a || !b || typeof a !== "object" || typeof b !== "object") return false; + if (a instanceof Boxed || b instanceof Boxed) + return a instanceof Boxed && b instanceof Boxed && a.c === b.c; + if (isAsyncValue(a) || isAsyncValue(b) || a instanceof Node || b instanceof Node) return false; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +/** + * A live container as a prop memo's value. The core probes a memo's result + * for `.then`, and a pending container's property trap answers any read + * with not-ready — so the memo holds the container boxed (identity is its + * equality) and the prop read unboxes it. + */ +class Boxed { + constructor(public c: unknown) {} +} + +/** + * The props object handed to a render-prop occurrence. Every prop reads + * through a lazily-created memo over the record's value for it, so a read + * is reactive over `args` (a re-sent record updates the live occurrence) + * and deduped by the memo's equality (`sameArg`: an equal value, however + * it was decoded, moves nothing — the dedupe a frame-side compare used to + * do, now where a memo already does it). An async value makes the memo an + * async one, so the prop read follows the normal async read path — it + * suspends into the reading component's nearest `Loading` (the reveal + * seam's reconstructed boundary when the fill has none of its own) and + * settles to the value when the server's data chunk lands. Memos are + * created under the occurrence's owner (not the reader's), so they live as + * long as the occurrence: a read from a later effect or event handler + * reuses the same source. */ function slotArgsProxy(args: () => Record) { const owner = getOwner(); - const asyncReads = new Map any>(); + const reads = new Map any>(); return new Proxy( {}, { get: (_, key) => { - const v = (args() as any)[key]; - // Containers first (DR-2's container tier): the store IS the live - // value — its own reads carry the async semantics — and the async - // probe below would detonate a pending one (property reads throw - // not-ready). Mirrors the server sink's classification order. - if (isMaterializedContainer(v)) return v; - if (!isAsyncValue(v)) return v; - let read = asyncReads.get(key); + let read = reads.get(key); if (!read) { // TRANSPARENT: an adopted fill invokes during the hydrate window // under the occurrence's claim owner, and a plain memo minted @@ -396,22 +421,31 @@ function slotArgsProxy(args: () => Record) { // claim walk is synchronous: without the sync adopt the fill // renders its fallback branch over a page whose markup settled // before flush — branch mismatch, key misses, dead range. + // + // Containers first (DR-2's container tier): the store IS the live + // value — its own reads carry the async semantics — and the + // `.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. const make = () => createMemo( () => { const raw = (args() as any)[key]; + if (isMaterializedContainer(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; } return raw; }, - { transparent: true } as any + { transparent: true, equals: sameArg } as any ); read = owner ? runWithOwner(owner, make)! : make(); - asyncReads.set(key, read); + reads.set(key, read); } - return read(); + const v = read(); + return v instanceof Boxed ? v.c : v; }, has: (_, key) => key in args(), ownKeys: () => Reflect.ownKeys(args()), @@ -1535,10 +1569,6 @@ export function installServerComponents(host: any = getFrameHost()) { // boundary while one is unclaimed and mount fresh after — always bound // to the delivered call address. component: (fnId: string) => g._$SC.r(fnId), - onStream: (address: string) => beginStream(address), - // Only the shared host routes data through the per-stream `tables`; a - // host of the app's own takes a staged response's data as it arrives. - [STAGED_DATA]: host === sharedHost ? stageTables : undefined, // The page IS the t=0 record: a call whose function has an unclaimed // SSR'd boundary in the document is answered locally — the source // re-runs during hydration per dynamic's contract, but no request @@ -1561,7 +1591,6 @@ export function installServerComponents(host: any = getFrameHost()) { // bundle resolves the transport's wire-layer imports to that external // entry (externalizeSharedTransport in rollup.config.js), so no getter // overrides are needed. - // (Asserted: STAGED_DATA is internal, not part of the options type.) } as ServerComponentHandlerOptions); // Which calls the document is showing: hydration references carry their // call's address (`_$SC.r(id, address)`), and those records — never seen diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 1831cfb2fe..af5c6f9e66 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -192,13 +192,6 @@ export interface Frame { * boundary id. */ rebind(id: string): void; - /** - * Forget the version baseline without touching content — the next write - * is accepted whatever its number. Called by the host after seeding a - * registration from a retained snapshot, whose numbering belongs to a - * different stream space. - */ - rebase(): void; /** * The frame's have-list (RFC 11 §9.5): the server-minted digests of * what this frame currently SHOWS — the root skeleton under `""`, each @@ -214,10 +207,7 @@ export interface Frame { * address, ahead of the records' real apply (see `FrameHost.preview`). * @internal */ - preview?( - records: Record, - resolve?: (ref: { $ref: string }, frameId?: string) => unknown - ): void; + preview?(records: Record): void; /** Tear down: slot cleanups cascade, later chunks are ignored. Idempotent. */ dispose(): void; } @@ -225,7 +215,9 @@ export interface Frame { /** * Routes a flat stream of addressed chunks to frames by id, buffering chunks * for frames that have not registered yet (only the newest version's chunks - * are kept). `data` chunks are response-scoped and go to `applyData`. + * are kept). `data` chunks are response-scoped and go to `applyData`; a + * `slot` chunk's `{$ref}` args are resolved at the write, through the + * response's data (a value, or a pending read the response settles). * * An id may have several frames (the same server component mounted more * than once): chunks fan out to all of them, and a frame registering after @@ -239,14 +231,14 @@ export interface FrameHost { apply(chunk: FrameChunk): void; /** * The reactive half of a staged response (see - * `createServerComponentHandler`): a `slot` chunk's args reach the - * occurrences mounted under its id — re-resolved props into their live - * bindings — while the store, the markup, and every structural change - * wait for the chunk's `apply`. `resolve` reads the staged response's - * data. Other chunk types are ignored. + * `createServerComponentHandler`): a `slot` chunk's args — settled + * through its own response's table, as at a write — reach the + * occurrences mounted under its id as re-resolved props into their live + * bindings, while the store, the markup, and every structural change + * wait for the chunk's `apply`. Other chunk types are ignored. * @internal */ - preview?(chunk: FrameChunk, resolve?: (ref: { $ref: string }, frameId?: string) => unknown): void; + preview?(chunk: FrameChunk): void; /** The first registered frame under the id, if any. */ get(id: string): Frame | undefined; /** @@ -261,12 +253,8 @@ export interface FrameHost { */ landing(id: string): Promise | undefined; serialize(value: unknown): { $ref: string }; - /** `frameId` is the resolving frame's id — route to its stream's table. */ - resolve(ref: { $ref: string }, frameId?: string): unknown; /** See FrameHostOptions.revive. */ revive?(value: unknown): unknown; - /** See FrameHostOptions.isContainer. */ - isContainer?(value: unknown): boolean; } /** @@ -276,18 +264,26 @@ export interface FrameHost { export interface FrameHostOptions { /** * Backs `{$ref}` slot args (typically a codec data table's `resolve`). - * `frameId` identifies the resolving frame — data tables are - * response-scoped, so multi-stream hosts route by it (nested region ids - * prefix-match their root). + * Called at a `slot` chunk's write with the chunk's frame id and version + * — the RESPONSE the record belongs to (data tables are response-scoped; + * one response is one version of one id), so the integration answers from + * that response's table and never a later one's. `undefined` means the + * key has not been delivered: the host then hands the fill a pending read + * that the key's `data` chunk settles and the stream's `complete` or + * `error` rejects (frames-rulings 1.3; the L1 rule — a value that never + * comes is an error, not a silence). `current` is the version the id's + * store is at — every version below it is superseded, and an integration + * keeping a table per response may drop theirs. */ - resolve?(ref: { $ref: string }, frameId?: string): unknown; + resolve?(ref: { $ref: string }, frameId: string, version: number, current?: number): unknown; /** Test/host-side counterpart of `resolve`. */ serialize?(value: unknown): { $ref: string }; /** * Receives each `data` chunk whole. Wire a codec table: - * `applyData: c => table.apply(c)` (see `createJSONDataTable`). + * `applyData: c => table.apply(c)` (see `createJSONDataTable`); a table + * per response keys on `chunk.version` (see `resolve` for `current`). */ - applyData?(chunk: Extract): void; + applyData?(chunk: Extract, current?: number): void; /** * A lazily-loaded deserializer's load, awaited by the transport before it * delivers a `data` chunk — `applyData`/`resolve` can assume the codec is @@ -297,19 +293,13 @@ export interface FrameHostOptions { prepareData?(): Promise; /** * Revive protocol markers inside LITERAL slot args (values that are - * neither `{$ref}` nor `{$frame}`) at arg-resolution time. Document-face + * neither `{$ref}` nor `{$frame}`) at arg-resolution time — the mount's + * read of the record, where an adopted occurrence's claim can read the + * 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. */ revive?(value: unknown): unknown; - /** - * Whether a resolved arg value is a LIVE CONTAINER (a materialized trace — - * see `isMaterializedContainer`). The record-dedupe compare must know: a - * pending container's property reads throw not-ready, so async probes and - * serialization compares would detonate it. Containers compare by - * identity only. - */ - isContainer?(value: unknown): boolean; } /** @@ -667,11 +657,10 @@ export function createFrameHost(options?: FrameHostOptions): FrameHost; * * @param {{ * serialize?: (value: unknown) => { $ref: string }, - * resolve?: (ref: { $ref: string }) => unknown, + * resolve?: (ref: { $ref: string }, frameId: string, version: number) => unknown, * applyData?: (chunk: object) => void, * prepareData?: () => Promise, - * revive?: (value: unknown) => unknown, - * isContainer?: (value: unknown) => boolean + * revive?: (value: unknown) => unknown * }} [options] * `serialize`/`resolve` back slot data refs (response-scoped table); * `applyData` receives each `data` chunk whole — keyed codec records @@ -679,7 +668,8 @@ export function createFrameHost(options?: FrameHostOptions): FrameHost; * `payload` scripts, depending on the producer's serializer. A host whose * deserializer loads lazily exposes the load as `prepareData`: the * transport awaits it before delivering a `data` chunk, so `applyData` - * and `resolve` can assume the codec is resident once data has arrived. + * can assume the codec is resident once data has arrived (a `resolve` + * ahead of it answers `undefined`, and the host waits — see below). */ export function createFrameHost(options = {}) { // One logical stream may feed several mounted boundaries (the same server @@ -706,14 +696,35 @@ export function createFrameHost(options = {}) { // version leaves at the bump — content, segments, slot records, the error // — so nothing a superseded response delivered can land in the frame // that shows the current one (what preserves client state across versions - // is the MOUNT's applied state, FrameImpl's `#slotArgs` value compare, not - // a merge of two responses in one store). The address is an async SOURCE + // is the MOUNT — a re-sent record updates its live occurrence's props, + // whose own equality decides what moved — not a merge of two responses in + // one store). The address is an async SOURCE // over it (`landing` below): a response announces itself with `start` // and is in flight (`open`) until its first flush lands — the root, the // stream's error, or its completion; `shown` is the record set of the // latest version that landed — the source's committed value, what a mount // opened mid-flight seeds from (holds-latest) — and the same object as // `records` once the version in flight has landed. + // + // A `{$ref}` wait is the response's too (frames-rulings 1.1, 1.3): a slot + // record's data refs resolve AT THE WRITE, through the integration's table + // for the chunk's response (`options.resolve(ref, id, version)`), so the + // record the store holds — and every mount reads — carries values, never + // refs, and a later response's data can answer none of them. A key the + // response has not delivered yet resolves to a pending read (`waits`, + // per store) stamped like a serialized promise once it settles (`s`/`v`, + // the marks a fill's prop read adopts synchronously): its `data` chunk + // settles it, the stream's `complete` or `error` rejects it (L1 — a value + // that never comes surfaces where it is read, instead of leaving the + // record silently unapplied), and a bump drops the superseded response's + // waits unanswered (their readers re-derive from the new response's + // record; nothing is left to tell). A record counts its unsettled reads + // (`pending`): a mounted occurrence takes the read as it is — its prop + // pends and holds the value it shows (A17) — while a FRESH mount waits for + // the record to settle (the frame's `#syncSlots`), so the frame's shell + // shows at its landing with the range as the server left it, and the + // mount's covering boundary pends on the landing alone (A0, corollary 4); + // the settle re-applies the record and the mount runs with values. const stores = new Map(); const storeFor = id => { let store = stores.get(id); @@ -732,6 +743,7 @@ export function createFrameHost(options = {}) { if (store.version === undefined || version > store.version) { store.version = version; store.records = {}; + store.waits = undefined; } // Root assets reuse one key for the shell and late chunks. Accumulate // their arrays so frames registered later receive the full snapshot. @@ -749,6 +761,75 @@ export function createFrameHost(options = {}) { } return true; }; + /** The pending read for an undelivered key of a response (its version), + * on behalf of `record` (stored under `recordKey`). Keyed by version and + * key: ids restart per response, and a staged refetch's preview resolves + * through the shown response's store. */ + const pendingRef = (store, version, key, recordKey, record) => { + const waits = (store.waits ??= new Map()); + key = version + "\0" + key; + let w = waits.get(key); + if (!w) { + waits.set(key, (w = { records: [] })); + w.p = new Promise((r, j) => ((w.r = r), (w.j = j))); + // Owned here: a read nobody makes (the occurrence never mounted) must + // not surface the rejection as unhandled; a fill's read attaches its + // own handlers. + w.p.then(undefined, () => {}); + } + w.records.push(recordKey, record); + record.pending = (record.pending || 0) + 1; + return w.p; + }; + /** Settle a wait (`s` 1 fulfilled / 2 rejected, as the hydration + * serializer stamps a promise) and re-apply every record it was the last + * unsettled read of, so a mount that waited runs with values. */ + const settleWait = (id, store, w, s, value) => { + w.p.s = s; + w.p.v = value; + s === 1 ? w.r(value) : w.j(value); + const set = frames.get(id); + for (let i = 0; i < w.records.length; i += 2) { + const record = w.records[i + 1]; + if (--record.pending || !set) continue; + for (const frame of set) + frame.apply({ version: store.version, r: { [w.records[i]]: record } }); + } + }; + /** + * Settle the record's `{$ref}` args into client values through the + * response's table (or a pending read). The record notes what it found + * so the frames never probe a value again (a decoded value may be a live + * container, whose property reads throw not-ready while pending): + * `decoded` names the args that came through the table — the frame + * passes those through untouched — and `regions` the `{$frame}` args + * (arg name -> the region's wire id), which are addressing, not data. A + * literal stays as written: it is revived at the mount (`revive`), where + * a claim can read it as the markup was rendered from it. + */ + const settleArgs = (store, recordKey, record, chunk) => { + const args = record.args; + const out = {}; + let regions, decoded; + for (const key in args) { + const value = args[key]; + if (value && typeof value.$ref === "string") { + const resolved = + options.resolve && options.resolve(value, chunk.id, chunk.version, store.version); + out[key] = + resolved === undefined + ? pendingRef(store, chunk.version, value.$ref, recordKey, record) + : resolved; + (decoded ??= {})[key] = true; + } else { + if (value && typeof value.$frame === "string") (regions ??= {})[key] = value.$frame; + out[key] = value; + } + } + record.args = out; + record.regions = regions; + record.decoded = decoded; + }; return { register(id, frame) { let set = frames.get(id); @@ -816,10 +897,20 @@ export function createFrameHost(options = {}) { // the current response's). if (chunk.type === "data") { const store = stores.get(chunk.id); + const current = store && store.version; // (`n < undefined` is false: a store with no version yet, or a chunk // with none, guards nothing.) - if (store && chunk.version < store.version) return; - options.applyData && options.applyData(chunk); + if (store && chunk.version < current) return; + options.applyData && options.applyData(chunk, current); + // The key a record of this response is waiting on: answered now, + // from the table the chunk just landed in. + const key = chunk.version + "\0" + chunk.key; + const w = chunk.initial && store && store.waits && store.waits.get(key); + if (w) { + store.waits.delete(key); + const value = options.resolve({ $ref: chunk.key }, chunk.id, chunk.version, current); + settleWait(chunk.id, store, w, 1, value); + } return; } // Write through to the resident store first: the store version-guards @@ -827,6 +918,12 @@ export function createFrameHost(options = {}) { const records = chunkToRecords(chunk); const store = storeFor(chunk.id); if (!write(store, chunk.version, records)) return; + // The record's refs resolve through ITS response's data — the table + // current for the version the write just made the store's. + if (chunk.type === "slot") { + const key = `slot:${chunk.key}`; + settleArgs(store, key, records[key], chunk); + } let r = records; // The address as a source: `start` opens a flight; the write that // lands it makes the version the one SHOWN and answers whoever awaited @@ -842,16 +939,27 @@ export function createFrameHost(options = {}) { store.open = false; store.shown = r = store.records; store.shownVersion = chunk.version; - const wait = landings.get(chunk.id); - if (wait) { + const landed = landings.get(chunk.id); + if (landed) { landings.delete(chunk.id); - wait.r(); + landed.r(); } } const set = frames.get(chunk.id); if (set) { for (const frame of set) frame.apply({ version: chunk.version, r }); } + // The response's end: a key it never delivered never comes, and every + // read waiting on one fails where it is read (L1) — a mount that + // waited for the record runs now and its read throws. After the end + // chunk's own apply, so the frame has the response's last word first. + if (store.waits && (":complete" in records || ":error" in records)) { + const error = + records[":error"] || new Error("Frame stream ended without delivering a {$ref}."); + const waits = store.waits; + store.waits = undefined; + for (const w of waits.values()) settleWait(chunk.id, store, w, 2, error); + } }, landing(id) { const store = stores.get(id); @@ -866,12 +974,18 @@ export function createFrameHost(options = {}) { } return wait.p; }, - preview(chunk, resolve) { + preview(chunk) { if (chunk.type !== "slot") return; const set = frames.get(chunk.id); if (!set) return; const records = chunkToRecords(chunk); - for (const frame of set) frame.preview && frame.preview(records, resolve); + const key = `slot:${chunk.key}`; + // Through the STAGED response's table (its version's), with the + // shown response's store as the wait's home — the version in the + // key tells them apart; a wait a bump drops is re-minted by the + // committed write's own settle. + settleArgs(storeFor(chunk.id), key, records[key], chunk); + for (const frame of set) frame.preview && frame.preview(records); }, get(id) { const set = frames.get(id); @@ -881,11 +995,7 @@ export function createFrameHost(options = {}) { if (!options.serialize) throw new Error("host has no serializer"); return options.serialize(value); }, - resolve(ref, frameId) { - return options.resolve ? options.resolve(ref, frameId) : undefined; - }, revive: options.revive, - isContainer: options.isContainer, prepareData: options.prepareData }; } @@ -914,9 +1024,14 @@ class FrameImpl { #options; #version; #store = Object.create(null); - #appliedRootValue; + // The applied state is keyed by RECORD identity (frames-rulings 2.1, + // 2.2): the root record this mount morphed, the error record it notified. + // A new record under the same version applies again; the version bump + // and the rebind reset both (`#resetStreamState`), so a byte-identical + // root under a new version applies as the new version's. + #appliedRoot; + #appliedError; #hasContent = false; - #errorNotified = false; #revealed = new Set(); #fallbackShown = new Set(); // Live-hole apply dedupe: store key -> the record this MOUNT applied @@ -938,7 +1053,6 @@ class FrameImpl { #slotArgs = new Map(); #slotUpdaters = new Map(); #slotRegions = new Map(); - #slotResolvedRefs = new Map(); #slotNodes = new Map(); // Data occurrences (§9.2.3): the consumer set last handed to the mount, // and the mount's rebind callback (`ctx.onRebind`) for when it changes. @@ -1077,9 +1191,9 @@ class FrameImpl { // same name; a root byte-identical to the old one still applies as // the new version's (its placeholders are the new segments'); and a // slot record the old version held unapplied leaves with it. What - // preserves occurrence state is the mount's own applied state - // (`#slotArgs` — the sync's value compare adopts an equal re-sent - // record without a re-call), never a merge of two responses. + // preserves occurrence state is the mount itself — a re-sent record + // updates the live occurrence's props, never re-calls it — not a + // merge of two responses. this.#version = v; this.#resetStreamState(); } @@ -1094,22 +1208,23 @@ class FrameImpl { * its re-resolved args into it, and is recorded as the occurrence's * applied record so the real apply's slot sync finds it adopted. Called * from a mount's compute half — the pass of the transaction that - * delivered the content — so the args are held with that transaction and - * commit in the frame whose landing dissolves its optimistic guesses, - * never a flush behind it. Nothing else moves: the store, the markup, - * mounts, re-calls and unmounts all wait for the apply. Args that add or - * rename a region are structural too — those occurrences wait. Records - * reach the regions below that inherit them (their own store does not - * shadow the key). + * delivered the content — so the args are staged with that transaction + * and the fills' derivations re-derive in the pass that commits it, never + * a flush behind it (principles §9.2.2). Nothing else moves: the store, + * the markup, mounts, re-calls and unmounts all wait for the apply. Args + * that add or rename a region are structural too — those occurrences + * wait. Records reach the regions below that inherit them (their own + * store does not shadow the key). Whether a pushed value CHANGED is the + * props' own equality (the binding's per-prop memo), as for any write. * * An adopted record is also written to the store that owns it — this * frame's, for regions below too — so a flush before the apply (the * apply's own first chunks, which precede the slot records) finds the - * occurrence's record adopted instead of pushing the old args back; the - * apply's record dedupe then keeps it. A record nothing adopted stays out - * of the store: an early flush would apply it against the old markup. + * occurrence's record adopted instead of pushing the old args back. A + * record nothing adopted stays out of the store: an early flush would + * apply it against the old markup. */ - preview(records, resolve?, inherited?) { + preview(records, inherited?) { const adopted = new Set(); if (this.#disposed) return adopted; for (const key in records) { @@ -1118,29 +1233,26 @@ class FrameImpl { if (inherited && key in this.#store) continue; const occurrence = key.slice(5); const update = this.#mountedSlots.has(occurrence) && this.#slotUpdaters.get(occurrence); - if (!update || this.#refsUnresolved(record.args, resolve)) continue; - if (this.#regionsChange(occurrence, record.args)) continue; - const same = this.#refArgsUnchanged(occurrence, record, resolve); - const props = same || this.#resolveArgs(occurrence, record.args, resolve); + if (!update || this.#regionsChange(occurrence, record)) continue; this.#slotArgs.set(occurrence, record); adopted.add(key); - if (!same) update(props); + update(this.#resolveArgs(occurrence, record)); } for (const regions of this.#slotRegions.values()) for (const entry of regions.values()) - if (entry.frame) - for (const key of entry.frame.preview(records, resolve, true)) adopted.add(key); + if (entry.frame) for (const key of entry.frame.preview(records, true)) adopted.add(key); if (!inherited) for (const key of adopted) this.#store[key] = records[key]; return adopted; } - /** Whether `args` add a region to the occurrence or rename one of its - * regions (its chunks ride the new name, so the rename lands with them). */ - #regionsChange(occurrence, args) { + /** Whether the record adds a region to the occurrence or renames one of + * its regions (its chunks ride the new name, so the rename lands with + * them). Read off the host's note of the record's `{$frame}` args. */ + #regionsChange(occurrence, record) { const regions = this.#slotRegions.get(occurrence); - for (const key in args) { + for (const key in record.regions) { const entry = regions && regions.get(key); - if (isFrameRef(args[key]) && !(entry && entry.childId === args[key].$frame)) return true; + if (!(entry && entry.childId === record.regions[key])) return true; } return false; } @@ -1150,19 +1262,18 @@ class FrameImpl { * bump and the rebind replace it wholesale — the store (every record of * the previous response), the root the morph applied (so a byte-identical * root under the new version applies as the new version's — 2.2), the - * reveal and fallback sets, the hole dedupe, the assets, the - * once-per-stream error notification. Nothing applied under the previous - * version is consulted under the next; the DOM keeps showing what it - * showed until the new version's writes morph it. + * error it notified, the reveal and fallback sets, the hole dedupe, the + * assets. Nothing applied under the previous version is consulted under + * the next; the DOM keeps showing what it showed until the new version's + * writes morph it. */ #resetStreamState() { this.#store = Object.create(null); - this.#appliedRootValue = undefined; + this.#appliedRoot = this.#appliedError = undefined; this.#revealed.clear(); this.#fallbackShown.clear(); this.#appliedHoles.clear(); this.#processedAssets = new WeakSet(); - this.#errorNotified = false; } #flush() { @@ -1170,10 +1281,10 @@ class FrameImpl { const version = this.#version; const root = this.#store[""]; - if (root && root.kind === "html" && root.value !== this.#appliedRootValue) { + if (root && root.kind === "html" && root !== this.#appliedRoot) { const reason = this.#hasContent ? "morph" : "materialize"; this.#applyRoot(root.value); - this.#appliedRootValue = root.value; + this.#appliedRoot = root; // The root resets the ledger: everything shown is now this root. this.#have = root.digest === undefined ? undefined : { "": root.digest, ...root.holes }; this.#applied(version, reason); @@ -1182,11 +1293,11 @@ class FrameImpl { // An error record is an APPLY too: a consumer gating on first apply (a // mount holding its covering boundary open until the frame has content) // must release on a failed stream — surfacing the error state beats - // holding a fallback forever. Notified once per stream: later flushes - // (data, complete) don't re-fire, and a new version re-arms (its reset - // clears the record). - if (this.#store[":error"] && !this.#errorNotified) { - this.#errorNotified = true; + // holding a fallback forever. Once per error record: later flushes + // (data, complete) find it applied, and a new version's reset clears it. + const error = this.#store[":error"]; + if (error && error !== this.#appliedError) { + this.#appliedError = error; this.#applied(version, "error"); } @@ -1355,20 +1466,6 @@ class FrameImpl { continue; } const record = this.#resolveSlotRecord(occurrence); - // A record whose data refs have not ARRIVED yet is not applicable: the - // producer emits the slot chunk before the `data` chunks carrying its - // ref'd values (an async arg's promise is created by its data chunk), - // and `#resolveArgs` cannot tell "not here yet" from a real value — it - // resolves the miss to `undefined` and hands that to the consumer as - // the arg. On a live occurrence that lands immediately: the update - // pushes `undefined` into the mounted fill (blanking it), commits the - // record, and no later flush re-resolves it — `data` chunks don't - // re-sync and the committed record no longer differs. So wait: the - // stream's own html/complete chunk flushes again a moment later, by - // which time the table has the value and the SAME record applies with - // real args (an async one then suspends and holds, as the value tier - // intends). A fresh mount is skipped for the same reason — mounting - // with a fabricated `undefined` is what makes it visible. // A mount whose output the morph destroyed (its range was recreated // inside a different server parent — ranges only relocate among // siblings) is a zombie: remount fresh so content stays correct, even @@ -1385,10 +1482,6 @@ class FrameImpl { this.#runSlotCleanups(occurrence); mounted = false; } - if (record && record.kind === "slot" && this.#refsUnresolved(record.args)) { - waiting ||= !mounted; - continue; - } // The occurrence's name decides its class: the producer mints every // CALLED occurrence as `prop#n` and emits its record at the call, // ahead of the markup that reads it; a bare occurrence (the prop @@ -1429,6 +1522,22 @@ class FrameImpl { devSlotOrphan(this, occurrence, consumers, "record"); continue; } + // A record's args are client values by the time it is here: the host + // settled its `{$ref}`s at the write — a delivered value, or a pending + // read its `data` chunk settles (`record.pending` counts those still + // open). A MOUNTED occurrence takes the read as it is: its prop pends + // and holds what it shows until the value lands (DR-2's value tier, + // the path a promise passed whole takes). A fresh mount waits for the + // record to settle — the host re-applies it then — so the frame's + // shell shows at its landing with the range as the server left it, + // instead of a fill whose first read pends into the mount's covering + // boundary (which would hold the frame's own address follow behind + // the fallback). A read the response never answers settles rejected + // at its end: the mount runs then and the read throws (L1). + if (!mounted && record && record.pending) { + waiting = true; + continue; + } if (!mounted) { // Direct-insert occurrences have no `slot:` record and mount with // empty props; render-function occurrences mount with resolved props. @@ -1494,30 +1603,23 @@ class FrameImpl { if (rebind) rebind(consumers); } if (record !== this.#slotArgs.get(occurrence)) { - // A re-sent record differing only in {$ref} identity may carry the - // SAME values (tables rotate per response, so the store-write - // dedupe stays conservative). Value-compare the new refs against - // the cached resolutions: all equal -> adopt the record without - // re-calling, occurrence state intact. Region wire names still - // follow the record (rebind, not re-call) so this stream's region - // chunks reach the live frames. - if (this.#refArgsUnchanged(occurrence, record)) { - this.#slotArgs.set(occurrence, record); - this.#reconcileRegions(occurrence, record); - continue; - } - // Args changed, live binding (the mount registered ctx.onUpdate): - // push the re-resolved props into the LIVE occurrence instead of - // re-calling — the consumer's reactive props update in place, so - // client state on the occurrence (expansion, focus, animation) - // follows the entity across morphs. #resolveArgs reuses/renames the - // cached regions, so `{$frame}` args keep their live elements. + // A re-sent record, live binding (the mount registered + // ctx.onUpdate): push the re-resolved props into the LIVE + // occurrence instead of re-calling — the consumer's reactive props + // update in place, so client state on the occurrence (expansion, + // focus, animation) follows the entity across morphs. Whether a + // value CHANGED is the props' own equality (the binding's per-prop + // memo compares the resolved values — a re-sent record whose refs + // decode to equal values churns nothing), not the frame's: the + // frame records delivery, the reader decides. #resolveArgs + // reuses/renames the cached regions, so `{$frame}` args keep their + // live elements and this stream's region chunks reach them. const update = this.#slotUpdaters.get(occurrence); if (update) { // One record shape (A5): every transport's record carries ALL of // the occurrence's region args as `{$frame}` refs, so the resolved // props are complete — a key the record omits really was removed. - const props = this.#resolveArgs(occurrence, record.args); + const props = this.#resolveArgs(occurrence, record); this.#slotArgs.set(occurrence, record); update(props); this.#bindRegions(occurrence); @@ -1548,11 +1650,11 @@ class FrameImpl { } // The frame's hold (frames-rulings 3.2): ONE registration with the // integration while a sync leaves an occurrence waiting to mount — - // whatever it waits for (the record, the `{$ref}` data, a hold added - // later) — released by the first sync that leaves none, or by - // disposal. The waits are bounded as a `` resume's is: the - // record by the document's records running out (`recordsPending`), - // the ref by the stream's `complete`/`:error`. + // for its record, or for the record's reads to settle — released by + // the first sync that leaves none, or by disposal. The waits are + // bounded as a `` resume's is: the record by the document's + // records running out (`recordsPending`), the read by the stream's + // `complete`/`:error`. if (waiting && !this.#hold) this.#hold = this.#options.hold?.(); else if (!waiting && this.#hold) this.#releaseHold(); } @@ -1636,8 +1738,7 @@ 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.args) : {}; + const props = record && record.kind === "slot" ? this.#resolveArgs(occurrence, record) : {}; // 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 @@ -1667,7 +1768,6 @@ class FrameImpl { this.#slotArgs.delete(key); this.#slotUpdaters.delete(key); this.#slotRebinders.delete(key); - this.#slotResolvedRefs.delete(key); this.#removeSlotRecord(key); this.#runSlotCleanups(key); const regions = this.#slotRegions.get(key); @@ -1684,15 +1784,27 @@ class FrameImpl { for (const fn of cleanups) fn(); } + #regionsFor(slotKey) { + let regions = this.#slotRegions.get(slotKey); + if (!regions) this.#slotRegions.set(slotKey, (regions = new Map())); + return regions; + } + /** - * Resolve a slot's raw args into client-facing props: - * - data ref `{$ref}` -> the response-scoped serialized value. - * - frame ref `{$frame}` -> a nested reconciled region delivered as a - * marker range (no wrapper element), **cached per slot** so a re-call - * reuses the same range and its bound frame. The client places the - * returned fragment; the region's frame renders/reconciles between the - * markers. - * - anything else -> passed through as a literal. + * Resolve a slot record's args into client-facing props. The record's + * data refs are already the client's — the host settled every `{$ref}` + * into a value (or a pending read) at the write and named those args + * (`record.decoded`); they pass through untouched, never probed (a + * decoded value may be a live container). What the FRAME resolves: + * - frame ref `{$frame}` (named in `record.regions`) -> a nested + * reconciled region delivered as a frame ELEMENT the wrapper places, + * **cached per slot** so a re-call reuses the same element and its + * bound frame. + * - a literal -> through the host's `revive` (document-face container + * traces arrive as inline markers), HERE rather than at the write: an + * adopted occurrence's claim must read the container as the markup + * was rendered from it, and the materializer keys that on the claim + * (frames-rulings 3.6 (iii)). * * 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 @@ -1702,72 +1814,31 @@ 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. */ - /** - * Whether any of a record's DATA refs is still unknown to the response's - * table — the record arrived ahead of the `data` chunks carrying its - * values. Only `{$ref}` args can be pending this way: `{$frame}` regions - * are addressing (resolved structurally) and primitives ship inline, so a - * ref that resolves to `undefined` means "not delivered yet", never a real - * value. See the call site in #syncSlots for why applying early is wrong. - */ - #refsUnresolved(args, resolve?) { - if (!this.#options.host) return false; - for (const key in args) { - if (isDataRef(args[key]) && this.#resolveRef(args[key], resolve) === undefined) return true; - } - return false; - } - - /** A data ref through the host's tables, or a staged response's. */ - #resolveRef(ref, resolve) { - const { host, id } = this.#options; - if (resolve) return resolve(ref, id); - return host ? host.resolve(ref, id) : undefined; - } - - #regionsFor(slotKey) { - let regions = this.#slotRegions.get(slotKey); - if (!regions) this.#slotRegions.set(slotKey, (regions = new Map())); - return regions; - } - - #resolveArgs(slotKey, args, resolve?) { - const host = this.#options.host; - const regions = this.#regionsFor(slotKey); + #resolveArgs(slotKey, record) { + 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]; - if (isDataRef(value)) { - // The frame's id rides along so multi-stream hosts can route the - // ref to the right response-scoped data table. The resolution is - // cached per occurrence so a later stream's re-sent ref can be - // VALUE-compared (tables rotate per response, so ref identity - // alone can't prove equivalence). - const resolved = this.#resolveRef(value, resolve); - let cache = this.#slotResolvedRefs.get(slotKey); - if (!cache) this.#slotResolvedRefs.set(slotKey, (cache = {})); - cache[key] = resolved; - props[key] = resolved; - } else if (isFrameRef(value)) { + props[key] = revive && !(decoded && key in decoded) ? revive(value) : value; + } + if (regions) { + const cache = this.#regionsFor(slotKey); + for (const key in regions) { // A nested server-content region: a single frame ELEMENT the wrapper // places. On re-call the wrapper re-places the SAME element (the // platform moves the subtree as one node — no marker range to walk, // no fragment refill), and the bound frame's parent follows live. - let entry = regions.get(key); + const childId = regions[key]; + let entry = cache.get(key); if (!entry) { - const element = makeFrameElement(value.$frame); - entry = { childId: value.$frame, element, frame: undefined }; - regions.set(key, entry); - } else if (entry.childId !== value.$frame) { - renameRegion(entry, value.$frame); - } + cache.set( + key, + (entry = { childId, element: makeFrameElement(childId), frame: undefined }) + ); + } else if (entry.childId !== childId) renameRegion(entry, childId); props[key] = entry.element; - } else { - // Literal args may carry protocol markers the integration knows how - // to revive (document-face container traces arrive as inline - // literals rather than `{$ref}`s — see reviveContainerTraces). The - // host hook keeps this module protocol-agnostic. - props[key] = host && host.revive ? host.revive(value) : value; } } return props; @@ -1792,86 +1863,6 @@ class FrameImpl { eachInRange(start, slotKey, n => collectRegionElements(n, regions)); } - /** - * Whether a re-sent slot record's args are VALUE-equal to the mounted - * occurrence's: primitives structurally, {$frame} refs by position — the - * same arg of the same occurrence IS the same region whatever wire name - * this stream gave it (see #resolveArgs; `#reconcileRegions` follows the - * rename) — and {$ref}s by resolving the incoming ref (current table) - * against the cached resolution from mount. One record shape (A5): every - * transport's record carries the occurrence's full key set, so an added - * or removed key is a REAL args change. Unresolvable or - * non-JSON-comparable values fall back to "changed" (re-call) — the - * conservative default. - */ - #refArgsUnchanged(occurrence, record, resolve?) { - const old = this.#slotArgs.get(occurrence); - if (!record || record.kind !== "slot") return false; - if (old && old.kind !== "slot") return false; - const a = (old && old.args) || {}; - const b = record.args || {}; - const ka = Object.keys(a); - const kb = Object.keys(b); - if (kb.length !== ka.length) return false; - const cache = this.#slotResolvedRefs.get(occurrence); - for (const key of ka) { - const va = a[key]; - const vb = b[key]; - if (va === vb) continue; - if (isFrameRef(va) && isFrameRef(vb)) continue; - if (isDataRef(va) && isDataRef(vb) && cache && key in cache) { - const host = this.#options.host; - const next = this.#resolveRef(vb, resolve); - // A live CONTAINER (DR-2's container tier) must be identity-compared - // BEFORE any probe: a pending container's property reads throw - // not-ready, so the async probe below (or the stringify) would - // detonate it. Same table -> same materialized instance (adopt - // silently); a re-serialized trace materializes a NEW container and - // that IS a change (a fresh generation the live update pushes). - if (host && host.isContainer && (host.isContainer(next) || host.isContainer(cache[key]))) { - if (next === cache[key]) continue; - return false; - } - // An async value (DR-2's value tier: the arg is passed WHOLE and the - // consumer's READ settles) is never serialization-comparable — every - // promise stringifies to `{}`, so two DIFFERENT pending values read - // as equal and the occurrence keeps the PREVIOUS response's value - // forever. Identity is the only sound test, and `va === vb` above - // already made it: from here, an async value on either side is a - // CHANGE, and the live update re-suspends on the new one (holding - // the settled value meanwhile, which is the point of the tier). - if (isAsyncLike(next) || isAsyncLike(cache[key])) return false; - try { - if (JSON.stringify(next) === JSON.stringify(cache[key])) continue; - } catch (e) { - return false; - } - } - return false; - } - return true; - } - - /** - * Follow an adopted record's region wire names without re-calling: for - * each `{$frame}` arg whose childId differs from the cached entry's, - * rebind the live region frame to the new name — the stream that sent - * this record addresses the region's content by it. Runs only on the - * adopt-without-recall path; a re-call reconciles through #resolveArgs. - */ - #reconcileRegions(occurrence, record) { - const args = record && record.args; - if (!args) return; - const regions = this.#slotRegions.get(occurrence); - if (!regions) return; - for (const key in args) { - const value = args[key]; - if (!isFrameRef(value)) continue; - const entry = regions.get(key); - if (entry && entry.childId !== value.$frame) renameRegion(entry, value.$frame); - } - } - #bindRegions(slotKey) { const regions = this.#slotRegions.get(slotKey); if (!regions) return; @@ -1974,17 +1965,6 @@ class FrameImpl { if (host) host.register(id, this); } - /** - * Forget the version baseline without touching content: the store, DOM, - * and slot state stay, but the next write is accepted whatever its number. - * The host calls this after seeding a registration from the resident - * store — the store's version belongs to whatever stream space last wrote - * it, and it must not out-rank the registering mount's live counter. - */ - rebase() { - this.#version = undefined; - } - /** The have-list of what this mount shows (see the `Frame` interface). */ have() { return this.#have; @@ -2369,12 +2349,6 @@ function segmentName(key) { return m ? m[1] : null; } -/** An async value in the DR-2 value-tier sense: passed whole, the consumer's - * READ settles. Never serialization-comparable — see #refArgsUnchanged. */ -function isAsyncLike(v) { - return !!v && (typeof v.then === "function" || typeof v[Symbol.asyncIterator] === "function"); -} - /** The prop name for a slot occurrence id: `comment#0` -> `comment`, `x` -> `x`. */ function propOf(occurrence) { const hash = occurrence.indexOf("#"); @@ -2387,14 +2361,6 @@ function isCalled(occurrence) { return occurrence.indexOf("#") !== -1; } -function isDataRef(value) { - return !!value && typeof value.$ref === "string"; -} - -function isFrameRef(value) { - return !!value && typeof value.$frame === "string"; -} - /** Dispose every bound frame in a slot's region-entry map. */ function disposeRegions(regions) { for (const { frame } of regions.values()) frame?.dispose(); diff --git a/packages/web/frames/src/frame-transport.ts b/packages/web/frames/src/frame-transport.ts index 51c89458a0..92652c2045 100644 --- a/packages/web/frames/src/frame-transport.ts +++ b/packages/web/frames/src/frame-transport.ts @@ -93,12 +93,6 @@ export interface ServerComponentHandlerOptions { * to that address's store. Multi-mount fans out per site. */ component(fnId: string): C; - /** - * A new response is about to stream into an address: rotate - * response-scoped state (codec data tables) here. `version` is the - * client-owned stream counter the chunks will be stamped with. - */ - onStream?(address: string, version: number, response: Response): void; /** * Answer a call before any request is made (t = 0 local answers — a * boundary the document already carries). Returning `undefined` is a @@ -387,7 +381,11 @@ export const COMPONENT_BINDING = /*#__PURE__*/ Symbol.for("solid.component-bindi // separator, the response's version. Mounts receive tokens through their // address accessor (dynamic treats addresses as opaque, so a new token is // delivered like an address switch — inside the transition that read it). -// NUL never occurs in a function id. +// The token is how a refetch of the address a mount SHOWS enters the +// reactive graph at all: `dynamic` delivers a kept resolution only when its +// address differs, so a refetch resolving to the bare address would be a +// write nothing observes, and its content could not be held by the +// transaction that asked for it (C15). NUL never occurs in a function id. const CONTENT_TOKEN = "\u0000"; /** @@ -405,9 +403,11 @@ export function contentAddress(token) { * by token; a plain address, or a token already committed or superseded, * is a no-op. Mounts call both halves from the render effect that follows * their address accessor: `preview` from its compute half — under the - * transition that delivered the token, so the slot args it pushes into the - * live fills (see `FrameHost.preview`) are held with it — and `commit` - * from its effect half, replaying the rest of the response in the commit. + * transaction that delivered the token, so the slot args it pushes into the + * live fills (see `FrameHost.preview`) are staged with it and a fill's + * derivation over an arg re-derives in that pass, never one flush behind + * the intent it held (principles §9.2.2) — and `commit` from its effect + * half, landing the buffered response as the store's writes in the commit. * Installed by the handler (one active handler at a time, as for * `resolveServerComponent` below). * @internal @@ -417,16 +417,6 @@ export const stagedContent: { commit(token: string): void; } = { preview() {}, commit() {} }; -/** - * The handler option (internal) through which an integration that routes - * `data` chunks to per-stream tables stages a response's data: a factory - * for `{ begin(id), apply(chunk), resolve(ref, id), commit() }` — `begin` - * where the integration's `onStream` would rotate, `commit` installing the - * staged tables in its place. - * @internal - */ -export const STAGED_DATA = Symbol("solid.StagedData"); - // The live transport registry's resolver, installed by // createServerComponentHandler. Module state on the config pattern (one // active handler at a time, a later creation replaces the current one): @@ -646,13 +636,7 @@ export function createServerComponentHandler(options: ServerComponentHandlerO * updates on delivery; calling the binding directly (a non-gated mount) * passes the binding's own constant address. */ -export function createServerComponentHandler({ - host, - component, - onStream, - intercept, - [STAGED_DATA]: openData -}) { +export function createServerComponentHandler({ host, component, intercept }) { // Mount components, one per FUNCTION (the equals-gate identity). const byFn = new Map(); const componentFor = fnId => { @@ -681,22 +665,27 @@ export function createServerComponentHandler({ return binding; }; // Content for a call a mount is SHOWING is staged, not written: the - // response's chunks buffer under the address, and the call resolves a - // binding to a content token naming that version. The mount's address - // accessor delivers the token inside the transition that read the call; - // the follow effect's compute half previews the slot args into the live - // fills (held with the transition) and its effect half commits the rest — - // so new content lands in that transition's commit, alongside everything - // else it holds, and not when the body happens to finish arriving. One - // entry per address: - // the newest response is the only one worth committing (versions are - // bumped as responses arrive, so a later stage always supersedes). + // response's chunks buffer under the address until the body ends, and + // the call resolves a binding to a content token naming that version. The + // mount's address accessor delivers the token inside the transition that + // read the call; the follow effect's compute half previews the slot args + // into the live fills (staged with the transition) and its effect half + // commits the rest as one run of writes — so new content lands in that + // transition's commit, alongside everything else it holds, and not when + // the body happens to finish arriving. The response's DATA is the one + // part that writes through as it arrives: tables are per response + // (frames-rulings 1.2 — the host's data path keys them by the chunk's + // version), so the staged response decodes into its own table while the + // shown response's stays in place, and the preview resolves the staged + // args through it. One entry per address: the newest response is the + // only one worth committing (versions are bumped as responses arrive, so + // a later stage always supersedes). const staged = new Map(); // The binding a reference to an address resolves: its newest token once // content was staged for it, so a flight reference in a mutation's // envelope names the version the same response carried. const latest = new Map(); - const stage = (address, base, version, response) => { + const stage = (address, base, version) => { // Content is staged under a token of the address's binding; an address // no binding was minted for has no reader a token could reach. if (!base) return undefined; @@ -705,37 +694,20 @@ export function createServerComponentHandler({ // through: it is now what the mount shows. let committed = false; const chunks = []; - const streams = []; - // The response's data decodes as it arrives, into tables of its own when - // the integration routes data per stream (STAGED_DATA): the preview - // resolves the staged args through them while the shown response's - // tables stay in place, and the commit installs them. A host without - // per-stream tables takes data at once, as it would unstaged. - const data = openData && openData(); const token = address + CONTENT_TOKEN + version; const entry = { token, prepareData: host.prepareData, - stream(id, v) { - if (committed) onStream && onStream(id, v, response); - else if (data) data.begin(id); - else streams.push([id, v]); - }, apply(chunk) { - if (committed) host.apply(chunk); - else if (chunk.type !== "data") chunks.push(chunk); - else if (data) data.apply(chunk); - else host.apply(chunk); + if (committed || chunk.type === "data") host.apply(chunk); + else chunks.push(chunk); }, preview() { - if (host.preview) - for (const chunk of chunks) host.preview(chunk, data ? data.resolve : undefined); + if (host.preview) for (const chunk of chunks) host.preview(chunk); }, commit() { committed = true; staged.delete(address); - if (data) data.commit(); - else if (onStream) for (const [id, v] of streams) onStream(id, v, response); for (const chunk of chunks) host.apply(chunk); } }; @@ -748,6 +720,8 @@ export function createServerComponentHandler({ }; /** The binding a settled call resolves to: its staged version's token. */ const settled = (address, binding) => latest.get(address) || binding; + /** Run a half of the staged entry a token names, while it is still the + * address's (committing removes it). */ /** Run a half of the staged entry a token names, while it is still the * address's (committing removes it). */ const named = (token, half) => { @@ -798,17 +772,16 @@ export function createServerComponentHandler({ }; /** * An unstaged response has begun for an address — at its header, before - * its body is read. The integration rotates its response-scoped state, - * and the address's store moves to the response's version NOW: the - * address is a source (`host.landing`), and from here until the body's - * first flush it reads "in flight" — a mount opened in between pends on - * that landing instead of materializing the superseded one. The body's - * own `start` chunk then writes the same version and nothing. + * its body is read. The address's store moves to the response's version + * NOW: the address is a source (`host.landing`), and from here until the + * body's first flush it reads "in flight" — a mount opened in between + * pends on that landing instead of materializing the superseded one. The + * body's own `start` chunk then writes the same version and nothing. The + * version is the response's identity for its data too (the integration's + * table rotates on it — frames-rulings 1.2), so nothing else announces + * the response. */ - const begin = (address, version, response) => { - if (onStream) onStream(address, version, response); - host.apply({ type: "start", id: address, version }); - }; + const begin = (address, version) => host.apply({ type: "start", id: address, version }); return { intercept: intercept && @@ -880,7 +853,7 @@ export function createServerComponentHandler({ return binding; } const version = bump(address); - begin(address, version, response); + begin(address, version); // The end is judged by the loop from `connection.ended` (set // synchronously by applyFrames); a rejected read is a death it // already sees, not an error record — the loop decides what the @@ -906,9 +879,8 @@ export function createServerComponentHandler({ // needs the binding to place the boundary and the shell gate is its // hold — settling those late would block progressive streaming // behind a completed body. - const entry = host.get(address) ? stage(address, binding, version, response) : undefined; - if (entry) entry.stream(address, version); - else begin(address, version, response); + const entry = host.get(address) ? stage(address, binding, version) : undefined; + if (!entry) begin(address, version); const target = entry || host; const applied = applyFrameResponse(response, target, { as: address, version }).catch(err => target.apply({ @@ -1011,16 +983,9 @@ export function createServerComponentHandler({ const version = bump(frameId); let entry = regionOf(frameId); if (!entry && host.get(frameId)) { - entry = stage( - frameId, - frameId === as ? binding : byAddress.get(frameId), - version, - response - ); + entry = stage(frameId, frameId === as ? binding : byAddress.get(frameId), version); if (entry) regions.set(frameId, entry); } - if (entry) entry.stream(frameId, version); - else if (onStream) onStream(frameId, version, response); return version; }, onOutcome: text => { diff --git a/packages/web/test/consistency/c05-data-response-scoped.spec.tsx b/packages/web/test/consistency/c05-data-response-scoped.spec.tsx index e353a895cd..9dbd5b0229 100644 --- a/packages/web/test/consistency/c05-data-response-scoped.spec.tsx +++ b/packages/web/test/consistency/c05-data-response-scoped.spec.tsx @@ -9,12 +9,13 @@ * response never answers it, in any arrival order of the two responses' * chunks." * - * Mechanism meant to carry it: frames/src/client.ts `beginStream`/`tableFor` - * (the shared host rotates one table per response at the handler's - * `onStream`), frame-transport.ts `createServerComponentHandler.handle` - * (`bump` + `onStream` at header time), frame-client.ts - * `createFrameHost.apply` — a `data` chunk goes straight to `applyData` and - * bypasses the store's version guard. + * Mechanism: frames/src/client.ts `tableFor(id, version)` (the shared host + * keeps one table per RESPONSE — keyed by the frame id, stamped with the + * response's version; a newer version opens a fresh table), frame-client.ts + * `createFrameHost.apply` — a `data` chunk below the store's version lands + * nowhere (frames-rulings 1.2), and a `slot` chunk's `{$ref}`s are settled + * AT THE WRITE through its own response's table (1.3), an undelivered key + * becoming a pending read that response's data settles (A4, S-ref). * * The PRODUCTION shared host is under test (`installServerComponents()` with * no host): `makeHost()` has one table per test and cannot rotate. Data @@ -30,7 +31,6 @@ import { createMemo, createRoot, createSignal, Loading } from "solid-js"; import { dynamic } from "@solidjs/web"; import { getFrameHost, installServerComponents } from "../../frames/src/client.js"; import { createServerReference } from "../../server-functions/src/client.js"; -import { frameAddress } from "../../server-functions/src/shared.js"; import { createDataSource, freshFid, pump, stubHeldFetch } from "./support.js"; const WIRE = "srv"; @@ -143,21 +143,23 @@ describe("C5 — data is response-scoped", () => { expect(container.querySelector("li")!.textContent).toBe("new"); }); - // Arm (b): the table rotation observed directly through the host's - // resolver (what `#refsUnresolved`/`#resolveArgs` call with the frame's - // address) — does v1's late data land in the table v2's refs resolve - // from, and does it overwrite v2's own value once that has landed? + // Arm (b): the table rotation observed through a record that resolves + // AFTER both responses' chunks interleaved — v1's late data after v2's + // header, v2's record, v2's data, then a second v1 straggler after v2's + // value — through the mounted fill (the host's resolver is not a + // surface: a record's refs settle at its write, through the table of + // the response that carried it). // // Was red on `next`: after v2's header, `resolve({$ref:"1"}, A)` read // "old" from v1's late chunk, and a second late v1 chunk overwrote v2's // "new" — one table per ADDRESS at a time, keyed by nothing that named // the response, every `initial` record setting its key. Green: a stale - // response's data chunks are dropped at the host (see a). + // response's data chunks are dropped at the host (see a), and the table + // is the response's (keyed by its version). test("(b) table rotation: a superseded response's late data never lands in the current table", async () => { const fid = freshFid("c5b"); const getX = createServerReference(fid); - const host = await sharedHost(); - const A = frameAddress(fid, [1]); + await sharedHost(); const { held } = stubHeldFetch([WIRE, WIRE]); const [v1, v2] = held; const p1 = getX(1); @@ -167,22 +169,29 @@ describe("C5 — data is response-scoped", () => { v1.send(start(1)); v2.send(start(1)); await pump(1); - expect(host.resolve({ $ref: "1" }, A)).toBeUndefined(); // v1's late data after v2's header. - const v1Data = createDataSource(); - for (const c of v1Data.chunks(WIRE, 1, { "1": "old" })) v1.send(c); + for (const c of createDataSource().chunks(WIRE, 1, { "1": "old" })) v1.send(c); + await pump(1); + v2.send(slot(1, "1")); + v2.send(html(1)); await pump(1); - const afterStaleData = host.resolve({ $ref: "1" }, A); + const { container, seen } = mountSite(p1); + await pump(); + // v2's record, settled at its write through v2's table: "1" is + // undelivered there (v1's chunk never entered it) — a pending read. + expect(seen).toEqual([]); // v2's data lands. for (const c of createDataSource().chunks(WIRE, 1, { "1": "new" })) v2.send(c); - await pump(1); - expect(host.resolve({ $ref: "1" }, A)).toBe("new"); + await pump(); + expect(container.querySelector("li")!.textContent).toBe("new"); // Another straggler from v1 (a re-serialized key) after v2's value. for (const c of createDataSource().chunks(WIRE, 1, { "1": "old" })) v1.send(c); - await pump(1); - const afterSecondStale = host.resolve({ $ref: "1" }, A); - expect(afterStaleData).toBeUndefined(); - expect(afterSecondStale).toBe("new"); + v2.send(complete(1)); + v2.close(); + v1.close(); + await pump(); + expect(seen).toEqual(["new"]); + expect(container.querySelector("li")!.textContent).toBe("new"); }); // Arm (c) (control): the normal order — v1 is complete before v2's header. @@ -191,8 +200,7 @@ describe("C5 — data is response-scoped", () => { test("(c) control: v1 completes before v2's header — v2's {$ref} resolves only to v2's data", async () => { const fid = freshFid("c5c"); const getX = createServerReference(fid); - const host = await sharedHost(); - const A = frameAddress(fid, [1]); + await sharedHost(); const { held } = stubHeldFetch([WIRE, WIRE]); const [v1, v2] = held; const p1 = getX(1); @@ -204,13 +212,12 @@ describe("C5 — data is response-scoped", () => { v1.send(complete(1)); v1.close(); await pump(); - expect(host.resolve({ $ref: "1" }, A)).toBe("old"); - // v2's header: the table rotates; nothing of v1 is reachable. + // v2's header: the version moves on, and with it the table; nothing of + // v1 is reachable from v2's record. const p2 = getX(1); expect(await p2).toBe(await p1); v2.send(start(1)); await pump(1); - expect(host.resolve({ $ref: "1" }, A)).toBeUndefined(); v2.send(slot(1, "1")); v2.send(html(1)); await pump(1); diff --git a/packages/web/test/consistency/c06-stale-wait-never-lands.spec.tsx b/packages/web/test/consistency/c06-stale-wait-never-lands.spec.tsx index 178d50de37..15567bf879 100644 --- a/packages/web/test/consistency/c06-stale-wait-never-lands.spec.tsx +++ b/packages/web/test/consistency/c06-stale-wait-never-lands.spec.tsx @@ -9,12 +9,14 @@ * after an address switch, a refetch that supersedes it, or disposal during * the wait, it never mounts or updates a fill." * - * Mechanism meant to carry it: frames/src/frame-client.ts - * `FrameImpl.#syncSlots` (the `#refsUnresolved` skip — "the stream's own - * next flush retries"), `FrameImpl.rebind` (`#resetStreamState(true)` → - * `clearStreamRecords` drops seg/hole/attr/:error and the root but KEEPS - * `slot:*`; `#resolveRef` then routes by the frame's NEW id), - * `FrameImpl.dispose`, client.ts `followAddress.drop`. + * Mechanism (frames A4, S-ref): `createFrameHost.apply` settles a record's + * `{$ref}`s AT THE WRITE through its own response's table — an undelivered + * key becomes a pending read of that response (settled by its `data` + * chunk, rejected at its end), so the record is never re-resolved later + * through another response's data; a fresh mount waits for the record's + * reads to settle (`record.pending`, `FrameImpl.#syncSlots`), and a version + * bump or rebind replaces the store wholesale (1.4 full), so a superseded + * response's record and its waits leave with it. * * The production shared host is under test (`installServerComponents()`): * the pin is about which response's data answers a held record, and only @@ -109,11 +111,12 @@ describe("C6 — a held record never lands on content it no longer belongs to", // fill MOUNTED with "jB/kB" — A's record `{k:$ref"1", j:$ref"2"}` resolved // against B's table — because `FrameImpl.rebind` kept every `slot:*` // record across the move and `#resolveRef` routed by the frame's NEW id. - // Green under frames-rulings 1.4 (full): the store is one response's — - // the rebind (like a version bump) replaces it wholesale, so A's held - // record leaves with A, and the called occurrence found recordless - // under B WAITS for B's own record rather than mounting (its name, - // `comment#0`, says it has one). + // Green under frames-rulings 1.3 / 1.4 (full): A's refs were settled at + // A's write into pending reads of A's own response, which B's data can + // never answer; the store is one response's — the rebind (like a version + // bump) replaces it wholesale, so A's record leaves with A, and the + // called occurrence found recordless under B WAITS for B's own record + // rather than mounting (its name, `comment#0`, says it has one). test("(a1) switch during the wait, new stream orders data → html → slot: the stale record never mounts with the new data", async () => { const fid = freshFid("c6a1"); const getX = createServerReference(fid); @@ -248,9 +251,10 @@ describe("C6 — a held record never lands on content it no longer belongs to", // replayed chunk (`start`) bumped the version and flushed with v1's // record still in the store (slot records survived the bump by design) // and the staged tables already installed. Green under frames-rulings - // 1.4 (full): the bump replaces the store wholesale, v1's held record - // leaves with v1, and the occurrence waits for v2's own record (replayed - // right after) — one mount, with v2's values. + // 1.3 / 1.4 (full): v1's refs are v1's pending reads, the bump replaces + // the store wholesale (v1's record and its waits leave with v1), and the + // occurrence waits for v2's own record (replayed right after) — one + // mount, with v2's values. test("(b2) refetch during the wait, v1's data never before the commit: the held v1 record never mounts with v2's data", async () => { const { site, sendLateV1, commitV2 } = await refetchDuringWait(freshFid("c6b2")); await commitV2(); diff --git a/packages/web/test/consistency/support.ts b/packages/web/test/consistency/support.ts index d2ac15efa3..5a0cbe6517 100644 --- a/packages/web/test/consistency/support.ts +++ b/packages/web/test/consistency/support.ts @@ -33,10 +33,7 @@ import { vi } from "vitest"; import { enableHydration, flush } from "solid-js"; import { sharedConfig } from "solid-js/internal"; import { installServerComponents, createFrameHost, getFrameHost } from "../../frames/src/client.js"; -import { - reviveContainerTraces, - isMaterializedContainer -} from "../../frames/src/frame-container-plugin.js"; +import { reviveContainerTraces } from "../../frames/src/frame-container-plugin.js"; import { createJSONDataTable } from "../../serialization/src/serializer.js"; import { createChunk } from "../../server-functions/src/shared.js"; @@ -357,7 +354,6 @@ export function bootPage(shellHtml: string, options: { hostOptions?: Record table.apply(c), resolve: (r: any) => table.resolve(r), revive: reviveContainerTraces, - isContainer: isMaterializedContainer, ...options.hostOptions }); installServerComponents(host); @@ -554,7 +550,6 @@ export function makeHost(hostOptions: Record = {}) { applyData: (c: any) => table.apply(c), resolve: (ref: any) => table.resolve(ref), revive: reviveContainerTraces, - isContainer: isMaterializedContainer, ...hostOptions }); return { host, table }; diff --git a/packages/web/test/frames-flight-delivery.spec.tsx b/packages/web/test/frames-flight-delivery.spec.tsx index e385bd48d7..65fc82ac25 100644 --- a/packages/web/test/frames-flight-delivery.spec.tsx +++ b/packages/web/test/frames-flight-delivery.spec.tsx @@ -92,14 +92,23 @@ describe("single-flight delivery over the frames transport (#3638)", () => { }); function handler() { - const streams: string[] = []; + const host = createFrameHost(); const created = createServerComponentHandler({ - host: createFrameHost(), - component: (fnId: string) => fnId as any, - onStream: address => { - streams.push(address); - } + host, + component: (fnId: string) => fnId as any }); + // The addresses whose resident store holds a landing: a region streamed + // into an address nothing shows warms its store, and a mount registered + // afterwards is seeded from it — the one write that seed is, observed + // through a probe registration. + const streams = (...addresses: string[]) => + addresses.filter(address => { + let seeded = false; + const probe: any = { apply: () => (seeded = true) }; + host.register(address, probe); + host.unregister(address, probe); + return seeded; + }); return { handler: created, streams }; } @@ -140,7 +149,7 @@ describe("single-flight delivery over the frames transport (#3638)", () => { // the caller gets the mutation's value, like a plain body expect(result).toBe("ok"); // the region streamed into the address the entry references - expect(streams).toEqual(["view"]); + expect(streams("view", "mutate")).toEqual(["view"]); // the unnamed consumer got ITS slice — not the keyed envelope — with the // component resolved to the call's binding (the value a boundary showing // that call holds, so a cache seeded with it passes the equals-gate) @@ -226,7 +235,7 @@ describe("single-flight delivery over the frames transport (#3638)", () => { ); expect(typeof bound).toBe("function"); expect(bound[COMPONENT_BINDING]).toEqual({ component: "view-1", address: "view-1" }); - expect(streams).toEqual(["view-1"]); + expect(streams("view-1")).toEqual(["view-1"]); expect(unnamed).toHaveBeenCalledWith({ "route-data[]": { title: "x" } }, expect.anything()); // The failure the issue reported, pinned as the contrast: the same body diff --git a/packages/web/test/hydration/document-live-channel.spec.tsx b/packages/web/test/hydration/document-live-channel.spec.tsx index 46cc575d6c..6cbd1f6c6e 100644 --- a/packages/web/test/hydration/document-live-channel.spec.tsx +++ b/packages/web/test/hydration/document-live-channel.spec.tsx @@ -19,10 +19,7 @@ import { flush } from "solid-js"; import { hydrate } from "@solidjs/web"; import { installServerComponents, createFrameHost } from "../../frames/src/client.js"; import { createJSONDataTable } from "../../serialization/src/serializer.js"; -import { - reviveContainerTraces, - isMaterializedContainer -} from "../../frames/src/frame-container-plugin.js"; +import { reviveContainerTraces } from "../../frames/src/frame-container-plugin.js"; import { FID, YIELDS } from "../harness/document-live-channel.jsx"; import { applyChunk, loadArtifact } from "./frame-live-document-helpers.js"; @@ -58,8 +55,7 @@ describe("document live channel — hydration (streamed)", () => { createFrameHost({ applyData: (c: any) => table.apply(c), resolve: (r: any) => table.resolve(r), - revive: reviveContainerTraces, - isContainer: isMaterializedContainer + revive: reviveContainerTraces }) ); const warnings: string[] = []; diff --git a/packages/web/test/hydration/welcome-status-parity.tsx b/packages/web/test/hydration/welcome-status-parity.tsx index f27954c85d..b574aa8150 100644 --- a/packages/web/test/hydration/welcome-status-parity.tsx +++ b/packages/web/test/hydration/welcome-status-parity.tsx @@ -25,10 +25,7 @@ import { flush } from "solid-js"; import { hydrate } from "@solidjs/web"; import { installServerComponents, createFrameHost } from "../../frames/src/client.js"; import { createJSONDataTable } from "../../serialization/src/serializer.js"; -import { - reviveContainerTraces, - isMaterializedContainer -} from "../../frames/src/frame-container-plugin.js"; +import { reviveContainerTraces } from "../../frames/src/frame-container-plugin.js"; import { FID, statusFill } from "../harness/frames-welcome.jsx"; const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); @@ -64,8 +61,7 @@ function makeHost() { resolve: (r: any) => table.resolve(r), // The production host (getFrameHost) wires these; the document-face // container-trace args under test need the same revival at arg-read. - revive: reviveContainerTraces, - isContainer: isMaterializedContainer + revive: reviveContainerTraces }); } From 26bfb9585e54c838aa47b3692c1edbf7a8979dec Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 12:02:33 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(web/frames):=20A4=20S-record=20?= =?UTF-8?q?=E2=80=94=20the=20document=20declares=20a=20slot=20record=20at?= =?UTF-8?q?=20its=20marker;=20the=20#2968=20poll=20deletes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document face (`createDocumentSlotProps`) writes `sc:slot::` as a DECLARED pending value under its key — serialized at the marker, ahead of the fill and of any fragment that carries the range — and settles it with the args once they are classified: the shape a fragment's `_fr` takes, so the adopting client awaits a record that trails its range's reveal through the value's own `.then` instead of polling the registry for a plain write. `adoptBoundary.drainRecords` reads a settled declaration's stamp synchronously (an adopt-time claim must not take a pending beat — readHydratedValue's rule), awaits a pending one, observes a rejected one. A sync render (`renderToString`) has no later script and no serializer for a promise: it writes the settled value, as before. A fill that throws settles its declaration empty so the response is not held open. Output shape: ≈ +32–38 B per document slot record (measured over the eight artifacts carrying one; the resolver/settle helpers are the page's `_fr` declarations' already). The 148 artifacts re-recorded: 8 changed. Client: `#recordRefresh` and the `setTimeout` re-drain poll delete, with `FrameOptions.recordsPending` / `FrameOptions.drainRecords` (no caller); a recordless called occurrence waits — the declaration's settle is the write that re-syncs it. The hold (3.2) is unchanged. No new wire TYPE: the declaration is the hydration-value mechanism the page already uses for fragments, applied to slot records (document face only). Pins: C2 (a2) flips to `test` (the record settled after the reveal, parser done, no fragment pending). The late-record pins re-shaped to the declared protocol (declared at boot/with the fragment, settled where the plain write was): c01 (window roots), c03 (a), c04 (b), c09 (c), c10 (b), c14 (a), c19 (b, c, e), hydration/adopted-slot-late-record; the harness declares a render occurrence's record with the markup that carries its range and settles it at the `record` event. Campaign: 500 cases × seeds 3289, 91501 — 0 findings. --- .changeset/frames-a4-declared-slot-records.md | 5 + packages/web/frames/src/client.ts | 115 ++--- packages/web/frames/src/frame-client.ts | 70 +-- packages/web/frames/src/frame-sink.ts | 481 ++++++++++-------- .../c01-claim-window-roots.spec.tsx | 9 +- .../c02-revealed-occurrence-mounts.spec.tsx | 113 ++-- .../c03-hydration-done-counts-holds.spec.tsx | 7 +- .../c04-record-applies-once.spec.tsx | 4 +- .../test/consistency/c09-no-phantom.spec.tsx | 4 +- .../c10-ids-timing-independent.spec.tsx | 4 +- .../consistency/c14-dispose-clean.spec.tsx | 23 +- .../c19-claim-reads-snapshot.spec.tsx | 16 +- packages/web/test/consistency/harness/run.tsx | 19 +- packages/web/test/consistency/support.ts | 25 +- .../frame-live-document-loaded.json | 2 +- .../frame-live-document-streamed.json | 2 +- .../frame-live-document-switched.json | 2 +- ...e-nonlive-document-3666-inline-loaded.json | 2 +- ...nonlive-document-3666-streamed-loaded.json | 2 +- ...nlive-document-3666-streamed-streamed.json | 2 +- .../__artifacts__/welcome-status-loaded.json | 4 +- .../welcome-status-streamed.json | 4 +- .../adopted-slot-late-record.spec.tsx | 39 +- 23 files changed, 514 insertions(+), 440 deletions(-) create mode 100644 .changeset/frames-a4-declared-slot-records.md diff --git a/.changeset/frames-a4-declared-slot-records.md b/.changeset/frames-a4-declared-slot-records.md new file mode 100644 index 0000000000..8acd67bdd4 --- /dev/null +++ b/.changeset/frames-a4-declared-slot-records.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: A4 (S-record) — the document face declares a slot record at its marker: `_$HY.r["sc:slot::"]` is a pending promise written with the occurrence's markup and settled with the args by the record's data script (the shape a fragment's `_fr` takes), so the adopting client awaits a record that trails its range's reveal through the value's own `.then` instead of polling the registry (C2 (a2) flips). Output shape: ≈ +32–38 B per document slot record (the resolver helpers are shared with the page's fragment declarations); a sync render (`renderToString`) writes the settled value as before. Deleted: the #2968 `setTimeout` re-drain poll and `FrameOptions.recordsPending` / `FrameOptions.drainRecords`. diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index d85654dd61..74f016367f 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -1270,11 +1270,13 @@ function adoptBoundary( // markup. Apply the records BEFORE binding the frame — the host buffers // them per id and drains at registration, so the first slot sync claims // WITH real args and the wrapper can render the occluded region later - // from the frame store. Re-drainable (each key applies once): nothing on - // the wire formally orders a record's data script before the event that - // triggers adoption, so the frame re-drains before classifying a - // recordless occurrence it deferred (#2968 — the frame's recordsPending/ - // drainRecords seam below). + // from the frame store. Re-drainable (each key is taken once): a reveal + // brings its occurrences' records with it (the cascade below re-drains), + // and a record the producer DECLARED but has not settled yet is awaited + // through its `.then` — the record is a pending value under its key at + // the marker, settled with the args (as a fragment's `_fr` is), so + // its arrival is a write the frame sees, never a plain assignment to + // poll for (frames A4, S-record). const appliedRecords = new Set(); // Deferred fragments in the adopted markup (#2978): a that // suspended inside the server component during document SSR left a `pl-*` @@ -1335,13 +1337,25 @@ function adoptBoundary( appliedRecords.add(key); // Slot records land in the ADDRESS's store (where the frame binds); // the wire keys them by function id, the document's producer name. - host.apply({ - type: "slot", - id: address, - version: 0, - key: key.slice(slotPrefix.length), - args: hy.r[key] - }); + const apply = (args: unknown) => + host.apply({ + type: "slot", + id: address, + version: 0, + key: key.slice(slotPrefix.length), + args + }); + const value = hy.r[key]; + // A declared record: settled reads its stamp synchronously (a + // pending beat here would push an adopt-time claim past the + // window — readHydratedValue's rule); pending is awaited; rejected + // is observed (the stamp is the consumption, #2997) and nothing + // applies — the occurrence has no args to run with. + if (value && typeof value.then === "function") { + if (value.s === 1) apply(value.v); + else if (value.s === 2) value.then(undefined, () => {}); + else value.then(apply, () => {}); + } else apply(value); } else if (key.startsWith("sc:region:")) { const childId = key.slice("sc:region:".length); if (childId.startsWith(id + ".")) { @@ -1371,9 +1385,9 @@ function adoptBoundary( if (fr.claim && inside) claimRegionFragments(parent!); // A revealed fragment also brings its occurrences' ARGS RECORDS: a // slot invoked inside a server `` ships its `sc:slot:` - // script with the fragment, ~the async's own delay after this + // declaration with the fragment, ~the async's own delay after this // boundary adopted — long after the adopt-time drain below ran. - // Re-drainable by design (each key applies once), so this is a + // Re-drainable by design (each key is taken once), so this is a // cheap no-op once caught up. drainRecords(); // A reveal is an apply (frames-rulings 2.3): content that becomes @@ -1384,7 +1398,8 @@ function adoptBoundary( // holds for them: a direct-insert range the fragment carried mounts // (C2 b), a record drained before its range was shown takes effect // now (C4 d), and a called occurrence whose record trails the - // reveal waits for it under the poll above (C2 a2). "The record + // reveal waits for the declaration's settle — the drain above + // subscribed to it, and that write re-syncs (C2 a2). "The record // arrived" and "the range is shown" are one event seen from two // sides; either one completes the pair. if (inside && frame) frame.apply({ version: frame.version ?? 0, r: {} }); @@ -1432,51 +1447,31 @@ function adoptBoundary( slots: slotsFor(props, { registry: sc.registry, gather: sc.gather }), ownerScope: boundaryScope(owner), reveal: revealSeam(owner), - // May the document still run scripts that assign records? While the - // parser is running the answer is yes, and a held fragment's replay can - // still deliver one — so a called occurrence found without its record - // re-drains and re-syncs a macrotask later, until the record lands (the - // runtime re-checks until this flips false). Deliberately NOT - // boundaryMayArrive(): its `!_$HY.done` term answers a different - // question (can this boundary's ELEMENT still appear), and holding the - // adopted mount until client hydration completes pushes it past the - // hydrate window — the claim then adopts markup the client's state has - // already moved past (the adopted-slot-live spec pins the working - // ordering). - // Spread-cast: the published FrameOptions predates this seam; a runtime - // without it simply never calls the hooks (drop once the pin catches up). - ...({ - recordsPending: () => { - if (document.readyState === "loading") return true; - const hy = (globalThis as any)._$HY; - return !!(hy && hy.fr && hy.fr.pending()); - }, - drainRecords, - // Hydration-done follows non-SC Solid 2 (frames-rulings 3.1, ruled): - // an adopted occurrence the frame has not claimed yet — waiting for - // its record, for a `{$ref}`'s data — is a pending boundary in - // everything but a resume, and registers as one through the same - // registration a streamed `` takes (`sharedConfig. - // holdBoundary`), under this component's owner so disposal releases - // it, keyed where no fragment is. No parallel accounting, no second - // "done": `onHydrationEnd` and `isHydrationInProgress()` mean the - // same thing with or without server components. Only while hydration - // is in progress: a hold taken on a page that never hydrated (a - // client render adopting server markup) or after it settled is the - // frame's business, not the page's. Untracked: the registration reads - // its trigger once, which is not a read of this component's. - hold: () => - sc.isHydrationInProgress?.() - ? runWithOwner(owner, () => untrack(() => sc.holdBoundary("sc:" + id))) - : () => {}, - // The identity split binds the frame to the call ADDRESS (id + args - // hash), but the document producer stamped `_hk` keys and region fids - // under the wire name — the bare function id. Hydration-claim prefixes - // must derive from what the producer wrote, so thread the wire id down - // as the claim scope; without it every adopted claim misses and the - // occurrence re-renders fresh clones that cannibalize the server DOM. - claimScope: id - } as {}) + // Hydration-done follows non-SC Solid 2 (frames-rulings 3.1, ruled): + // an adopted occurrence the frame has not claimed yet — waiting for + // its record — is a pending boundary in everything but a resume, and + // registers as one through the same registration a streamed + // `` takes (`sharedConfig.holdBoundary`), under this + // component's owner so disposal releases it, keyed where no fragment + // is. No parallel accounting, no second "done": `onHydrationEnd` and + // `isHydrationInProgress()` mean the same thing with or without server + // components. Only while hydration is in progress: a hold taken on a + // page that never hydrated (a client render adopting server markup) or + // after it settled is the frame's business, not the page's. Untracked: + // the registration reads its trigger once, which is not a read of this + // component's. + hold: () => + sc.isHydrationInProgress?.() + ? runWithOwner(owner, () => untrack(() => sc.holdBoundary("sc:" + id))) + : () => {}, + // The identity split binds the frame to the call ADDRESS (id + args + // hash), but the document producer stamped `_hk` keys and region fids + // under the wire name — the bare function id. Hydration-claim prefixes + // must derive from what the producer wrote, so thread the wire id down + // as the claim scope; without it every adopted claim misses and the + // occurrence re-renders fresh clones that cannibalize the server DOM. + // Spread-cast: the published FrameOptions predates this seam. + ...({ claimScope: id } as {}) }); // Follow the live address binding (see boundaryComponent and // followAddress): a kept resolution delivers the new call's address, or a diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index af5c6f9e66..48899e1df2 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -341,28 +341,16 @@ export interface FrameOptions { * for the framework-agnostic imperative swap (no reactive reveal). */ reveal?(seam: { before: Node; fallback: Node[]; content: () => Node | DocumentFragment }): void; - /** - * Document-face record delivery (adopt path only — solidjs/solid#2968). - * Nothing on the wire formally orders an occurrence's args-record data - * script before the event that triggers adoption, and a data script is a - * plain assignment the frame cannot observe. A called occurrence - * (`prop#n`) found without its record WAITS for it; while this returns - * true the frame re-drains the document's records a macrotask later - * (all currently parsed scripts run first — `drainRecords`) and re-syncs, - * until the record lands. Return false once the document can deliver no - * further records. - */ - recordsPending?(): boolean; - /** Re-absorb the document's arrived-by-now records (idempotent per key). */ - drainRecords?(): void; /** * Adopt path only. Called when a sync leaves an adopted occurrence - * waiting — for its args record, for a `{$ref}`'s data — while none was - * before; returns the release, called when a sync leaves none waiting or - * the frame disposes. The integration registers the hold with whatever - * counts its page as not yet settled (hydration-done counts it as a - * pending boundary — frames-rulings 3.1): a claim the frame has not made - * yet is page work still pending. + * waiting for its args record while none was before; returns the release, + * called when a sync leaves none waiting or the frame disposes. The + * integration registers the hold with whatever counts its page as not + * yet settled (hydration-done counts it as a pending boundary — + * frames-rulings 3.1): a claim the frame has not made yet is page work + * still pending. The record's delivery is the integration's to observe + * (the document declares it at the marker and settles it — see + * `adoptBoundary`); the frame only re-syncs on the write. */ hold?(): () => void; } @@ -1059,9 +1047,6 @@ class FrameImpl { #slotConsumers = new Map(); #slotRebinders = new Map(); #processedAssets = new WeakSet(); - // The pending re-check for adopt-time occurrences deferred on a - // still-arriving args record (#2968 — see #syncSlots). - #recordRefresh = null; // The release of the frame's hold with the integration while a sync // leaves an occurrence waiting to mount (see #syncSlots' end). #hold; @@ -1498,27 +1483,19 @@ class FrameImpl { // screen — an adopted occurrence's server-rendered interior is already // in the DOM, and a mounted one shows what it showed. // - // The document face has no write to wait for (a data script is a - // plain assignment into `_$HY.r`), so while the document may still - // deliver records (`recordsPending` — the parser running, a fragment - // held, a record delivered and undrained) the frame re-drains them a - // macrotask later (all currently parsed scripts run first) and - // re-syncs — repeatedly, not after a fixed single beat: a streamed - // document held open on async content keeps records arriving across - // many macrotasks (PR #559). A called occurrence still recordless once - // nothing can deliver its record is the protocol's invariant broken - // (a record dropped, or marker and record minted under different - // ids), never something the fill can fix; dev names it. + // The document face delivers its records as writes too: the producer + // DECLARES each record at the marker (a pending value under its key, + // settled with the args — as a fragment's `_fr`), and the + // adopting integration applies it when it settles, so a record that + // trails the reveal is a write the frame sees, not a plain assignment + // it would have to poll for. A called occurrence still recordless on + // a stream once nothing can deliver its record is the protocol's + // invariant broken (a record dropped, or marker and record minted + // under different ids), never something the fill can fix; dev names + // it there (the document's declaration may still settle). if (record === undefined && isCalled(occurrence)) { waiting ||= !mounted; - if (this.#options.adopt && this.#options.recordsPending?.()) { - this.#recordRefresh ??= setTimeout(() => { - this.#recordRefresh = null; - if (this.#disposed) return; - this.#options.drainRecords?.(); - this.#syncSlots(); - }); - } else if ("_SOLID_DEV_" && consumers && !mounted) + if ("_SOLID_DEV_" && consumers && !mounted && !this.#options.adopt) devSlotOrphan(this, occurrence, consumers, "record"); continue; } @@ -1652,9 +1629,8 @@ class FrameImpl { // integration while a sync leaves an occurrence waiting to mount — // for its record, or for the record's reads to settle — released by // the first sync that leaves none, or by disposal. The waits are - // bounded as a `` resume's is: the record by the document's - // records running out (`recordsPending`), the read by the stream's - // `complete`/`:error`. + // bounded as a `` resume's is: the record by its declared + // value settling, the read by the stream's `complete`/`:error`. if (waiting && !this.#hold) this.#hold = this.#options.hold?.(); else if (!waiting && this.#hold) this.#releaseHold(); } @@ -1991,10 +1967,6 @@ class FrameImpl { const { host, id } = this.#options; if (host && id !== undefined) host.unregister(id, this); this.#disposed = true; - if (this.#recordRefresh) { - clearTimeout(this.#recordRefresh); - this.#recordRefresh = null; - } this.#releaseHold(); for (const key of [...this.#slotCleanups.keys()]) this.#runSlotCleanups(key); // Release this frame's occurrences' records from the store that owns them diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 5ba77b14a0..8a701d36b1 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -1395,243 +1395,272 @@ export function createDocumentSlotProps(clientProps, frameId) { const occurrence = occurrenceId(prop, raw, counts); const slot = clientProps[prop]; if (typeof slot !== "function") return range(occurrence, undefined); - const resolved = {}; - // Usage tracking (dispatch case 3, document face): regions ride as - // THUNKS, so SSR hole resolution evaluating one IS the usage - // signal. A wrapper that never renders an arg (collapsed by - // default) leaves its thunk unevaluated — that content would - // vanish from the page, so after the wrapper's render it FLIPS: - // serialized once as hydration-data records (the occurrence's args - // + the region html, keyed for the adopting frame's store) and the - // client mounts it from there when the wrapper finally renders it. - // Unwrap function-valued args (a function can't be serialized, so - // it is a thunk producing content or a getter producing data), - // then classify the result — region detection and the t=0 arming - // below see the same classified value. This is how top-level - // one-shot reactive control flow (/) reaches the region - // path when it arrives as a thunk/memo. - // - // The evaluator is captured from the property DESCRIPTOR exactly - // as on the stream face (createSlotProps): compiled JSX props are - // getters — the SAME authored shape as a markup hole — and that - // re-runnable handle is what the case-1 ledger sweeps, so an - // expression arg stays as live at t=0 as it is on a call-driven - // stream. A NOT-READY first evaluation is pending per-arg, never - // a hold on the whole occurrence: the retry-loop promise takes - // the value's place and flows down the value-tier path — the - // inline fill's read suspends into the fill's OWN boundary (the - // client read's semantics exactly), the record ships the promise - // (the hydration serializer patches it on settle), and the - // binding opens unsettled, re-armed by the retry's onSettle. - const liveArgs = - sharedConfig.context && sharedConfig.context.live && sharedConfig.context.live.args; - const vals = {}; - const evals = {}; - // Per-key ledger state: `settled` + the equality baseline. Kept - // as the PRE-TAP value — `vals` entries get replaced for tapped - // iterables (the rest-wrapper below), and comparing a - // re-evaluation against the wrapper would re-emit spuriously. - const states = {}; - // Keys whose evaluation minted reactive scopes (scopeStamp moved): - // not re-runnable, so no watched binding opens for them below. - const minted = {}; - for (const key of Object.keys(raw)) { - if (key === "$key") continue; - const desc = Object.getOwnPropertyDescriptor(raw, key); - let evaluate = null; - let value; - if (desc.get) { - const get = desc.get; - evaluate = () => unwrapThunks(get.call(raw)); - } else { - value = desc.value; - if (typeof value === "function") { - const fn = value; - evaluate = () => unwrapThunks(fn); - } - } - if (evaluate) { - evals[key] = evaluate; - const stampBefore = scopeStamp(); - try { - value = evaluate(); - states[key] = { settled: true, last: value }; - } catch (err) { - const blocked = ssrHandleError && ssrHandleError(err); - if (!blocked) throw err; - const state = (states[key] = { settled: false, last: undefined }); - value = retryArgUntilSettled(evaluate, blocked, key, occurrence, v => { - state.settled = true; - state.last = v; - // The settle is a commit: other bindings may read the - // same source. (This binding's own re-emission stays - // gated on inequality with the value just recorded.) - if (liveArgs) liveArgs.commit(); - }); - } - if (scopeStamp() !== stampBefore) minted[key] = true; - } - vals[key] = value; + // The record is DECLARED at the marker (frames A4, S-record): a + // pending value under its key, written now — ahead of the fill + // and of any fragment that carries this range — and settled with + // the args once they are classified below. The same shape a + // fragment's `_fr` takes, so the adopting client awaits a + // record that trails its range's reveal through the value's own + // `.then` instead of polling the registry for a plain write; a + // settled one reads its stamp synchronously at adoption, as it + // always did. (The hydration serializer emits the resolver and + // the settle as one task batch when both happen in one span.) A + // SYNC render (`renderToString`) has no later script to settle a + // declaration in and no serializer for one: its record is the + // value, written after the fill. + const context = sharedConfig.context; + const recordKey = `sc:slot:${frameId}:${occurrence}`; + let settleRecord; + if (context && context.async) { + context.serialize(recordKey, new Promise(resolve => (settleRecord = resolve))); } - const regions = []; - for (const key of Object.keys(vals)) { - const value = vals[key]; - if (isContainerTraced(value)) { - // Container tier (DR-2 case 3): a traced container is DATA - // however object-shaped it is, and the check comes FIRST — the - // classifiers below read properties, and a pending projection - // proxy throws not-ready at any string-key get (isAsyncValue's - // `.then` probe would detonate here). The fill reads the proxy - // itself: settled reads pass through; a pending read throws - // not-ready into the hole machinery — a per-arg suspend, the - // value tier's own behavior. The record ships the proxy, which - // the serializer's trace plugin carries as snapshot + patches. - resolved[key] = value; - } else if (isServerContent(value)) { - const childId = `${frameId}.${occurrence}.${key}`; - const region = { key, childId, value, used: false, locked: false }; - regions.push(region); - resolved[key] = () => { - // Streaming occlusion lock: the usage flip below runs at the - // wrapper's SYNCHRONOUS return, but a wrapper that places this - // region behind an async boundary (a Suspense that resolves - // after the shell flush) calls this thunk LATER — after the - // flip already deemed the region occluded and serialized its - // content once as a data record. Re-emitting it as markup now - // would double-ship the same content (data + markup), the one - // thing single-copy forbids. So a locked region contributes - // nothing — identical to a region the wrapper never placed; the - // client mounts it from the `sc:region:` record on placement. - if (region.locked) return []; - region.used = true; - // A region is a frame ELEMENT the client wrapper adopts — - // the same DOM contract as the boundary, one level down. - return [{ t: frameElementOpen(childId) }, value, { t: FRAME_ELEMENT_CLOSE }]; - }; - } else if (ssrAsyncValue && isAsyncValue(value)) { - // DR-2 value tier, document face: the inline fill's read of an - // async arg must SUSPEND (throw not-ready into the engine's - // hole machinery, which re-pulls on settle), not read the raw - // promise — a raw read renders empty markup the adopted client - // then contradicts (it reads the record's settled value): a - // hydration mismatch instead of a covered pending state. The - // record is untouched — the async value itself still ships - // there and the document's data scripts stream its resolution, - // exactly as before. - // - // An async ITERABLE has two consumers here (this read wants - // the first yield; the record's serialization wants every - // yield) and possibly a third — the server component reading - // the same source — so each takes a seat on the runtime's - // shared multicast of it: the read settles on the first yield - // — markup is the V1 snapshot, later yields are the adopted - // client's story — and the record's seat carries the full - // sequence. - let readable = value; - if (typeof value.then !== "function") { - const { first, rest } = tapFirstYield(value); - readable = first; - vals[key] = rest; + try { + const resolved = {}; + // Usage tracking (dispatch case 3, document face): regions ride as + // THUNKS, so SSR hole resolution evaluating one IS the usage + // signal. A wrapper that never renders an arg (collapsed by + // default) leaves its thunk unevaluated — that content would + // vanish from the page, so after the wrapper's render it FLIPS: + // serialized once as hydration-data records (the occurrence's args + // + the region html, keyed for the adopting frame's store) and the + // client mounts it from there when the wrapper finally renders it. + // Unwrap function-valued args (a function can't be serialized, so + // it is a thunk producing content or a getter producing data), + // then classify the result — region detection and the t=0 arming + // below see the same classified value. This is how top-level + // one-shot reactive control flow (/) reaches the region + // path when it arrives as a thunk/memo. + // + // The evaluator is captured from the property DESCRIPTOR exactly + // as on the stream face (createSlotProps): compiled JSX props are + // getters — the SAME authored shape as a markup hole — and that + // re-runnable handle is what the case-1 ledger sweeps, so an + // expression arg stays as live at t=0 as it is on a call-driven + // stream. A NOT-READY first evaluation is pending per-arg, never + // a hold on the whole occurrence: the retry-loop promise takes + // the value's place and flows down the value-tier path — the + // inline fill's read suspends into the fill's OWN boundary (the + // client read's semantics exactly), the record ships the promise + // (the hydration serializer patches it on settle), and the + // binding opens unsettled, re-armed by the retry's onSettle. + const liveArgs = + sharedConfig.context && sharedConfig.context.live && sharedConfig.context.live.args; + const vals = {}; + const evals = {}; + // Per-key ledger state: `settled` + the equality baseline. Kept + // as the PRE-TAP value — `vals` entries get replaced for tapped + // iterables (the rest-wrapper below), and comparing a + // re-evaluation against the wrapper would re-emit spuriously. + const states = {}; + // Keys whose evaluation minted reactive scopes (scopeStamp moved): + // not re-runnable, so no watched binding opens for them below. + const minted = {}; + for (const key of Object.keys(raw)) { + if (key === "$key") continue; + const desc = Object.getOwnPropertyDescriptor(raw, key); + let evaluate = null; + let value; + if (desc.get) { + const get = desc.get; + evaluate = () => unwrapThunks(get.call(raw)); + } else { + value = desc.value; + if (typeof value === "function") { + const fn = value; + evaluate = () => unwrapThunks(fn); + } } - const read = ssrAsyncValue(readable); - Object.defineProperty(resolved, key, { - get: read, - enumerable: true, - configurable: true - }); - } else { - // What hydration will read: a stand-in anywhere in the arg is - // `undefined` in the record (argBorderForm), so the t=0 fill - // takes the same value — the one-record shape holds for the - // args the fill saw, not only the ones it shipped. - resolved[key] = vals[key] = withoutStandIns(value, key, occurrence); + if (evaluate) { + evals[key] = evaluate; + const stampBefore = scopeStamp(); + try { + value = evaluate(); + states[key] = { settled: true, last: value }; + } catch (err) { + const blocked = ssrHandleError && ssrHandleError(err); + if (!blocked) throw err; + const state = (states[key] = { settled: false, last: undefined }); + value = retryArgUntilSettled(evaluate, blocked, key, occurrence, v => { + state.settled = true; + state.last = v; + // The settle is a commit: other bindings may read the + // same source. (This binding's own re-emission stays + // gated on inequality with the value just recorded.) + if (liveArgs) liveArgs.commit(); + }); + } + if (scopeStamp() !== stampBefore) minted[key] = true; + } + vals[key] = value; } - } - const out = suppressedFill(() => - scoped(occurrence, () => range(occurrence, slot(resolved))) - ); - const unused = regions.filter(r => !r.used); - if (sharedConfig.context) { - // One record shape (A5, server-components-principles.md): the - // t=0 document emits the record a stream would — every invoked - // occurrence gets one, and EVERY region arg rides as its - // `{$frame}` address ref, used or not. The ref is addressing, - // not content: a used region's content ships once as page - // markup (the adopting client resolves the ref to the element - // already in the interior), an occluded one ships once as its - // `sc:region:` record. Primitive args always ship — a scalar - // the client needs AS DATA to re-invoke the wrapper is not a - // single-copy concern (value-from-page recovery is a - // template-mode question, never a substring guess: the old - // heuristic dropped correct args on any markup coincidence). - const args = {}; + const regions = []; for (const key of Object.keys(vals)) { const value = vals[key]; - const region = regions.find(r => r.key === key); - if (region) { - args[key] = { $frame: region.childId }; - continue; + if (isContainerTraced(value)) { + // Container tier (DR-2 case 3): a traced container is DATA + // however object-shaped it is, and the check comes FIRST — the + // classifiers below read properties, and a pending projection + // proxy throws not-ready at any string-key get (isAsyncValue's + // `.then` probe would detonate here). The fill reads the proxy + // itself: settled reads pass through; a pending read throws + // not-ready into the hole machinery — a per-arg suspend, the + // value tier's own behavior. The record ships the proxy, which + // the serializer's trace plugin carries as snapshot + patches. + resolved[key] = value; + } else if (isServerContent(value)) { + const childId = `${frameId}.${occurrence}.${key}`; + const region = { key, childId, value, used: false, locked: false }; + regions.push(region); + resolved[key] = () => { + // Streaming occlusion lock: the usage flip below runs at the + // wrapper's SYNCHRONOUS return, but a wrapper that places this + // region behind an async boundary (a Suspense that resolves + // after the shell flush) calls this thunk LATER — after the + // flip already deemed the region occluded and serialized its + // content once as a data record. Re-emitting it as markup now + // would double-ship the same content (data + markup), the one + // thing single-copy forbids. So a locked region contributes + // nothing — identical to a region the wrapper never placed; the + // client mounts it from the `sc:region:` record on placement. + if (region.locked) return []; + region.used = true; + // A region is a frame ELEMENT the client wrapper adopts — + // the same DOM contract as the boundary, one level down. + return [{ t: frameElementOpen(childId) }, value, { t: FRAME_ELEMENT_CLOSE }]; + }; + } else if (ssrAsyncValue && isAsyncValue(value)) { + // DR-2 value tier, document face: the inline fill's read of an + // async arg must SUSPEND (throw not-ready into the engine's + // hole machinery, which re-pulls on settle), not read the raw + // promise — a raw read renders empty markup the adopted client + // then contradicts (it reads the record's settled value): a + // hydration mismatch instead of a covered pending state. The + // record is untouched — the async value itself still ships + // there and the document's data scripts stream its resolution, + // exactly as before. + // + // An async ITERABLE has two consumers here (this read wants + // the first yield; the record's serialization wants every + // yield) and possibly a third — the server component reading + // the same source — so each takes a seat on the runtime's + // shared multicast of it: the read settles on the first yield + // — markup is the V1 snapshot, later yields are the adopted + // client's story — and the record's seat carries the full + // sequence. + let readable = value; + if (typeof value.then !== "function") { + const { first, rest } = tapFirstYield(value); + readable = first; + vals[key] = rest; + } + const read = ssrAsyncValue(readable); + Object.defineProperty(resolved, key, { + get: read, + enumerable: true, + configurable: true + }); + } else { + // What hydration will read: a stand-in anywhere in the arg is + // `undefined` in the record (argBorderForm), so the t=0 fill + // takes the same value — the one-record shape holds for the + // args the fill saw, not only the ones it shipped. + resolved[key] = vals[key] = withoutStandIns(value, key, occurrence); } - // Container check first for exactness: a store whose STATE has - // a `t` key would satisfy isServerContent's shape probe. - if (!isContainerTraced(value) && isServerContent(value)) continue; - // Containers (at any depth) ride the record as trace envelopes; - // everything else passes through by reference. - args[key] = argBorderForm(value, key, occurrence); } - // A CLONE serializes; `args` stays canonical for the ledger - // below — re-emissions mutate it and clone again, so the - // initial record can never change under a consumer. - sharedConfig.context.serialize(`sc:slot:${frameId}:${occurrence}`, { ...args }); - for (const region of unused) { - // Lock BEFORE returning: the content is now committed to the data - // channel, so any later async placement of this region must - // suppress its markup (see the thunk above) — that is what makes - // "serialize once at flush" a guarantee rather than a race. - region.locked = true; - // Resolve the region's server content through the live render - // context. Sync content serializes directly; async content - // serializes as a PROMISE of its final html — the hydration - // serializer holds the stream and patches the record when it - // settles (resolveRegionHtml re-pulls holes as their promises - // land, the resolveRootHoles shape). - sharedConfig.context.serialize( - `sc:region:${region.childId}`, - resolveRegionHtml(sharedConfig.context, region.value) - ); - } - // The document arg ledger (DR-2 case 1 at t=0): every - // re-runnable arg that classified as DATA opens a watched - // binding AFTER its record emitted, mirroring the stream face — - // the same authored getter shape stays live on both faces. Only - // on an armed document (live.args): the hostless fallback - // latches, like everything else at t=0. - if (liveArgs) { - for (const key of Object.keys(evals)) { - if (regions.some(r => r.key === key)) continue; - if (minted[key]) continue; // scope-minting eval: latched - const ledgerKey = `${frameId}:${occurrence}:${key}`; - openArgBinding( - liveArgs, - ledgerKey, - occurrence, - key, - evals[key], - states[key], - value => { - args[key] = argBorderForm(value, key, occurrence); - liveArgs.slot(frameId, occurrence, { ...args }); - } + const out = suppressedFill(() => + scoped(occurrence, () => range(occurrence, slot(resolved))) + ); + const unused = regions.filter(r => !r.used); + if (context) { + // One record shape (A5, server-components-principles.md): the + // t=0 document emits the record a stream would — every invoked + // occurrence gets one, and EVERY region arg rides as its + // `{$frame}` address ref, used or not. The ref is addressing, + // not content: a used region's content ships once as page + // markup (the adopting client resolves the ref to the element + // already in the interior), an occluded one ships once as its + // `sc:region:` record. Primitive args always ship — a scalar + // the client needs AS DATA to re-invoke the wrapper is not a + // single-copy concern (value-from-page recovery is a + // template-mode question, never a substring guess: the old + // heuristic dropped correct args on any markup coincidence). + const args = {}; + for (const key of Object.keys(vals)) { + const value = vals[key]; + const region = regions.find(r => r.key === key); + if (region) { + args[key] = { $frame: region.childId }; + continue; + } + // Container check first for exactness: a store whose STATE has + // a `t` key would satisfy isServerContent's shape probe. + if (!isContainerTraced(value) && isServerContent(value)) continue; + // Containers (at any depth) ride the record as trace envelopes; + // everything else passes through by reference. + args[key] = argBorderForm(value, key, occurrence); + } + // A CLONE settles the declaration (or IS the record, on a sync + // render); `args` stays canonical for the ledger below — + // re-emissions mutate it and clone again, so the initial + // record can never change under a consumer. + settleRecord ? settleRecord({ ...args }) : context.serialize(recordKey, { ...args }); + for (const region of unused) { + // Lock BEFORE returning: the content is now committed to the data + // channel, so any later async placement of this region must + // suppress its markup (see the thunk above) — that is what makes + // "serialize once at flush" a guarantee rather than a race. + region.locked = true; + // Resolve the region's server content through the live render + // context. Sync content serializes directly; async content + // serializes as a PROMISE of its final html — the hydration + // serializer holds the stream and patches the record when it + // settles (resolveRegionHtml re-pulls holes as their promises + // land, the resolveRootHoles shape). + context.serialize( + `sc:region:${region.childId}`, + resolveRegionHtml(context, region.value) ); } + // The document arg ledger (DR-2 case 1 at t=0): every + // re-runnable arg that classified as DATA opens a watched + // binding AFTER its record emitted, mirroring the stream face — + // the same authored getter shape stays live on both faces. Only + // on an armed document (live.args): the hostless fallback + // latches, like everything else at t=0. + if (liveArgs) { + for (const key of Object.keys(evals)) { + if (regions.some(r => r.key === key)) continue; + if (minted[key]) continue; // scope-minting eval: latched + const ledgerKey = `${frameId}:${occurrence}:${key}`; + openArgBinding( + liveArgs, + ledgerKey, + occurrence, + key, + evals[key], + states[key], + value => { + args[key] = argBorderForm(value, key, occurrence); + liveArgs.slot(frameId, occurrence, { ...args }); + } + ); + } + } } + // The fill ran and its return is classified: a keyed call + // registers for repeats whatever its face, an un-keyed one only + // as data (two identical placements are two ranges). + if (rk !== undefined && (rk === occurrence || out.$face === SLOT_FACE_DATA)) + repeats.has(rk) || repeats.set(rk, out); + return out; + } catch (error) { + // The fill (or an arg's evaluation) threw: the record has no args to + // settle with, and a declaration left pending would hold the + // response open. Settle it empty — the range it would have named + // was never rendered, so nothing on the client reads it. + settleRecord && settleRecord(undefined); + throw error; } - // The fill ran and its return is classified: a keyed call - // registers for repeats whatever its face, an un-keyed one only - // as data (two identical placements are two ranges). - if (rk !== undefined && (rk === occurrence || out.$face === SLOT_FACE_DATA)) - repeats.has(rk) || repeats.set(rk, out); - return out; }; // A slot getter placed directly as a child (`{props.children}`) is a // function-shaped hole; the tag opts it out of live-hole marking the diff --git a/packages/web/test/consistency/c01-claim-window-roots.spec.tsx b/packages/web/test/consistency/c01-claim-window-roots.spec.tsx index 3e2aaf3f8f..1974e8594f 100644 --- a/packages/web/test/consistency/c01-claim-window-roots.spec.tsx +++ b/packages/web/test/consistency/c01-claim-window-roots.spec.tsx @@ -37,6 +37,9 @@ describe("C1 — an adopted occurrence's claim window gathers against its own ro page = bootPage( frameHtml(fid, `
    ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
`) ); + // Declared at the marker (S-record); its settle is the script the + // parser is still owed. + const record = page.declareSlotRecord(fid, "item#0"); const Comp = (globalThis as any)._$SC.r(fid); const li = page.container.querySelector("li")!; const invocations: number[] = []; @@ -61,9 +64,9 @@ describe("C1 — an adopted occurrence's claim window gathers against its own ro document.body.appendChild(other); const disposeOther = hydrate(() =>

other

, other); await quiesce(); - // The record the parser was still owed: the deferred claim gathers - // against the frame's root, claims the server's node in place. - page.slotRecord(fid, "item#0", { text: "one" }); + // The record's settle the parser was still owed: the deferred claim + // gathers against the frame's root, claims the server's node in place. + record.settle({ text: "one" }); await quiesce(); await quiesce(); expect(invocations).toEqual([1]); diff --git a/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx b/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx index 5df319d1ea..d2c351fc81 100644 --- a/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx +++ b/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx @@ -171,64 +171,65 @@ describe("C2 — no inert server content", () => { dispose(); }); - // Arm (a2): the record lands AFTER the reveal. - test.fails( - "(a2) render-prop occurrence revealed after adoption, record after the reveal: mounted and live", - async () => { - const fid = freshFid("c2a2"); - const frag = "c2a2"; - page = bootPage(pendingShell(fid, frag)); - page.declareFragment(frag); - const Comp = (globalThis as any)._$SC.r(fid); - const [tick, setTick] = createSignal(0); - const invocations: number[] = []; - const dispose = hydrate( - () => ( - { - invocations.push(1); - return ( -
  • - {p.text} - {tick()} -
  • - ); - }} - /> - ), - page.container - ); - await quiesce(); - expect(invocations.length).toBe(0); + // Arm (a2): the record lands AFTER the reveal. Under the declared-record + // protocol (frames A4, S-record) the producer writes the record at the + // occurrence's marker as a PENDING value — with the fragment, ahead of + // its swap — and settles it with the args when they are known; here the + // settle trails the reveal by two quiescences, with the parser done and + // no fragment pending (the shape no poll could cover). + // + // Was red on `next`: the record was a plain property write to `_$HY.r` + // observed by nothing — the reveal's drain ran before it, and the + // `#recordRefresh` poll armed only while `recordsPending()`. Green: the + // reveal's drain finds the declaration and awaits it (`.then`); the + // settle is a write the frame sees, re-syncs on, and the deferred mount + // claims the revealed markup. + test("(a2) render-prop occurrence revealed after adoption, record settled after the reveal: mounted and live", async () => { + const fid = freshFid("c2a2"); + const frag = "c2a2"; + page = bootPage(pendingShell(fid, frag)); + page.declareFragment(frag); + const Comp = (globalThis as any)._$SC.r(fid); + const [tick, setTick] = createSignal(0); + const invocations: number[] = []; + const dispose = hydrate( + () => ( + { + invocations.push(1); + return ( +
  • + {p.text} + {tick()} +
  • + ); + }} + /> + ), + page.container + ); + await quiesce(); + expect(invocations.length).toBe(0); - page.revealFragment(frag, slotRange("item#0", liveFillHtml(fid, "item#0", "one"))); - await quiesce(); - page.slotRecord(fid, "item#0", { text: "one" }); - await quiesce(); - await quiesce(); - // Observed on next: the server
  • is in the page (text "one0") but - // the fill was never invoked (invocations 0) and the bump below leaves - // the DOM at "one0"; nothing is logged. Expected: one invocation, the - // hole follows the signal. Where it goes wrong: client.ts - // adoptBoundary — the only post-adopt drains are the `fr.subscribe` - // callback (runs AT the reveal, finds no `sc:slot:` key yet) and - // frame-client.ts #syncSlots' `#recordRefresh` timer, which arms only - // when a sync discovers a recordless occurrence while - // `recordsPending()`; no sync ever runs over the revealed range (the - // reveal applied nothing, so no `#flush`), so the record's later - // arrival — a plain property write to `_$HY.r` — is observed by - // nothing and the range stays inert. - expect(page.container.textContent).toBe("one0"); - expect(invocations.length).toBe(1); + // The fragment carries the declaration (pending), then the swap. + const record = page.declareSlotRecord(fid, "item#0"); + page.revealFragment(frag, slotRange("item#0", liveFillHtml(fid, "item#0", "one"))); + await quiesce(); + expect(invocations.length).toBe(0); + expect(page.container.textContent).toBe("one0"); + record.settle({ text: "one" }); + await quiesce(); + await quiesce(); + expect(page.container.textContent).toBe("one0"); + expect(invocations.length).toBe(1); - setTick(1); - flush(); - expect(page.container.textContent).toBe("one1"); - expect(page.warnings).toEqual([]); - expect(page.errors).toEqual([]); - dispose(); - } - ); + setTick(1); + flush(); + expect(page.container.textContent).toBe("one1"); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + dispose(); + }); // Arm (b): a direct-insert occurrence (`children`) is recordless by design. // Revealed into the adopted region, it must mount all the same. Was red 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 e4724619ca..466e86e5f1 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 @@ -51,6 +51,9 @@ describe("C3 — hydration-done counts every hold", () => { page = bootPage( frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `) ); + // Declared at the marker (S-record), settled by the script the parser + // is still owed. + const record = page.declareSlotRecord(fid, "item#0"); const Comp = (globalThis as any)._$SC.r(fid); const invocations: number[] = []; let invocationsAtEnd = -1; @@ -71,8 +74,8 @@ describe("C3 — hydration-done counts every hold", () => { inProgressAtEnd = hydrationInProgress(); }); await quiesce(); - // The record script the parser was still owed. - page.slotRecord(fid, "item#0", { text: "one" }); + // The record's settle script the parser was still owed. + record.settle({ text: "one" }); await quiesce(); await quiesce(); // The occurrence did claim in the end (the deferral is invisible)… diff --git a/packages/web/test/consistency/c04-record-applies-once.spec.tsx b/packages/web/test/consistency/c04-record-applies-once.spec.tsx index 4fdf3810e8..76792454d7 100644 --- a/packages/web/test/consistency/c04-record-applies-once.spec.tsx +++ b/packages/web/test/consistency/c04-record-applies-once.spec.tsx @@ -143,13 +143,15 @@ describe("C4 — a record applies exactly once, in any drain order", () => { ) ); page.declareFragment(frag); + // Declared at the marker (S-record); settled after adoption. + const record = page.declareSlotRecord(fid, "item#0"); const Comp = (globalThis as any)._$SC.r(fid); const invocations: number[] = []; const pushes: string[] = []; const dispose = hydrate(() => , page.container); await quiesce(); expect(invocations.length).toBe(0); - page.slotRecord(fid, "item#0", { text: "one" }); + record.settle({ text: "one" }); await quiesce(); await quiesce(); expect(invocations.length).toBe(1); diff --git a/packages/web/test/consistency/c09-no-phantom.spec.tsx b/packages/web/test/consistency/c09-no-phantom.spec.tsx index cf90eb007c..55197a6815 100644 --- a/packages/web/test/consistency/c09-no-phantom.spec.tsx +++ b/packages/web/test/consistency/c09-no-phantom.spec.tsx @@ -146,6 +146,8 @@ describe("C9 — no phantom", () => { page = bootPage( frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `) ); + // Declared at the marker (S-record), settled a beat after adoption. + const record = page.declareSlotRecord(fid, "item#0"); const Comp = (globalThis as any)._$SC.r(fid); const serverLi = page.container.querySelector("li")!; const frames = watchFrames(page.container); @@ -165,7 +167,7 @@ describe("C9 — no phantom", () => { ); await quiesce(); expect(invocations.length).toBe(0); - page.slotRecord(fid, "item#0", { text: "one" }); + record.settle({ text: "one" }); await quiesce(); await quiesce(); frames.sample(); diff --git a/packages/web/test/consistency/c10-ids-timing-independent.spec.tsx b/packages/web/test/consistency/c10-ids-timing-independent.spec.tsx index 9b2e3df80d..9e146a608d 100644 --- a/packages/web/test/consistency/c10-ids-timing-independent.spec.tsx +++ b/packages/web/test/consistency/c10-ids-timing-independent.spec.tsx @@ -76,13 +76,15 @@ describe("C10 — hydration ids are timing-independent", () => { page = bootPage( frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `) ); + // Declared at the marker (S-record), settled a beat after adoption. + const record = page.declareSlotRecord(fid, "item#0"); const Comp = (globalThis as any)._$SC.r(fid); const serverLi = page.container.querySelector("li")!; const keys: (string | null)[] = []; const dispose = hydrate(() => , page.container); await quiesce(); expect(keys).toEqual([]); - page.slotRecord(fid, "item#0", { text: "one" }); + record.settle({ text: "one" }); await quiesce(); await quiesce(); expect(keys).toEqual([fillKey(fid, "item#0")]); diff --git a/packages/web/test/consistency/c14-dispose-clean.spec.tsx b/packages/web/test/consistency/c14-dispose-clean.spec.tsx index 3db9f8701f..1daa840630 100644 --- a/packages/web/test/consistency/c14-dispose-clean.spec.tsx +++ b/packages/web/test/consistency/c14-dispose-clean.spec.tsx @@ -90,16 +90,17 @@ function mountSite(getX: () => unknown, log: ReturnType) { } describe("C14 — disposal leaves nothing", () => { - // Arm (a): document face, disposed during the #2968 record defer. The - // parser is "still running" (readyState loading) and the occurrence's - // record has not executed; the frame armed its `#recordRefresh` timer. - // Dispose before it fires; then the record lands. - test("(a) dispose during the record defer: the late record never invokes the fill", async () => { + // Arm (a): document face, disposed during the record wait. The parser is + // "still running" (readyState loading); the occurrence's record is + // DECLARED at its marker (S-record) but its settle has not executed, so + // the frame waits on it. Dispose before the settle; then the record lands. + test("(a) dispose during the record wait: the late record never invokes the fill", async () => { const fid = freshFid("c14a"); vi.spyOn(document, "readyState", "get").mockReturnValue("loading"); page = bootPage( frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "one"))}
    `) ); + const record = page.declareSlotRecord(fid, "item#0"); const Comp = (globalThis as any)._$SC.r(fid); const log = freshLog(); const li = page.container.querySelector("li")!; @@ -110,9 +111,9 @@ describe("C14 — disposal leaves nothing", () => { await microtasks(2); expect(log.invocations).toBe(0); dispose(); - // The record script the parser was owed, then every beat the defer - // loop would have used. - page.slotRecord(fid, "item#0", { text: "one" }); + // The record's settle script the parser was owed, then every beat a + // re-sync would have used. + record.settle({ text: "one" }); await quiesce(); await quiesce(); expect(log.invocations).toBe(0); @@ -122,8 +123,10 @@ describe("C14 — disposal leaves nothing", () => { }); // Arm (b): stream face, disposed during a `{$ref}` wait. The slot record - // references data that has not arrived (the mount is held); dispose; then - // the data, a `complete`, and the body's end. + // references data that has not arrived: the host settled the ref into a + // pending read at the write (frames A4, S-ref) and the fresh mount waits + // for it to settle; dispose; then the data, a `complete`, and the body's + // end — the settle re-applies the record to no frame. test("(b) dispose during a {$ref} wait: the data's arrival never invokes the fill", async () => { const id = freshFid("c14b"); const { host } = makeHost(); 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 51ffc6bdc2..431f865d4a 100644 --- a/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx +++ b/packages/web/test/consistency/c19-claim-reads-snapshot.spec.tsx @@ -116,6 +116,9 @@ describe("C19 — a claim reads the snapshot; the backlog lands after the claim" frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "2"))}
    `) ); const trace = movedTrace(2); + // Declared at the marker (S-record); settled by the script the parser + // is still owed. + const record = page.declareSlotRecord(fid, "item#0"); const Comp = (globalThis as any)._$SC.r(fid); const li = page.container.querySelector("li")!; const reads: number[] = []; @@ -138,9 +141,9 @@ describe("C19 — a claim reads the snapshot; the backlog lands after the claim" // The trace moves while the occurrence waits for its record. trace.patch([[["n"], 3]]); trace.patch([[["n"], 5]]); - // The record the parser was still owed; the poll drains it and the - // deferred mount claims. - page.slotRecord(fid, "item#0", { data: trace.marker }); + // The record's settle the parser was still owed; the declaration's + // `.then` applies it and the deferred mount claims. + record.settle({ data: trace.marker }); await quiesce(); await quiesce(); expect(reads).toEqual([2]); @@ -163,6 +166,7 @@ describe("C19 — a claim reads the snapshot; the backlog lands after the claim" frameHtml(fid, `
      ${slotRange("item#0", fillHtml(fid, "item#0", "2"))}
    `) ); const trace = movedTrace(2); + const record = page.declareSlotRecord(fid, "item#0"); const Comp = (globalThis as any)._$SC.r(fid); const li = page.container.querySelector("li")!; const order: string[] = []; @@ -184,7 +188,7 @@ describe("C19 — a claim reads the snapshot; the backlog lands after the claim" onHydrationEnd(() => order.push(`done:${store.n}:${li.textContent}`)); await quiesce(); trace.patch([[["n"], 5]]); - page.slotRecord(fid, "item#0", { data: trace.marker }); + record.settle({ data: trace.marker }); await quiesce(); await quiesce(); order.push(`settled:${store.n}:${li.textContent}`); @@ -268,6 +272,8 @@ describe("C19 — a claim reads the snapshot; the backlog lands after the claim" ); const trace = movedTrace(2, 5); page.slotRecord(fid, "item#0", { data: trace.marker }); + // item#1's record: declared at its marker, settled a beat later. + const record1 = page.declareSlotRecord(fid, "item#1"); const Comp = (globalThis as any)._$SC.r(fid); let store: any; const dispose = hydrate( @@ -287,7 +293,7 @@ describe("C19 — a claim reads the snapshot; the backlog lands after the claim" // backlog is parked, the store reads the snapshot. expect(hydrationInProgress()).toBe(true); expect(store.n).toBe(2); - page.slotRecord(fid, "item#1", { text: "one" }); + record1.settle({ text: "one" }); await quiesce(); await quiesce(); expect(hydrationInProgress()).toBe(false); diff --git a/packages/web/test/consistency/harness/run.tsx b/packages/web/test/consistency/harness/run.tsx index 76444d3f45..ba036149b4 100644 --- a/packages/web/test/consistency/harness/run.tsx +++ b/packages/web/test/consistency/harness/run.tsx @@ -93,6 +93,19 @@ export async function runScenario(scenario: Scenario): Promise { const hole = scenario.liveHole ? `

    ${holeHtml(holeId, "hole-v0")}

    ` : ""; const page = bootPage(frameHtml(fid, `
      ${rootRanges}${placeholders}
    ${hole}`)); scenario.fragments.forEach((_, i) => page.declareFragment(fragKey(i))); + // The producer DECLARES a render occurrence's record at its marker (frames + // A4, S-record): a pending value under the record's key, written with the + // markup that carries the range — the shell for a root occurrence, the + // fragment for one inside it — and settled by the record's own data + // script. The `record` event is that settle; a record event ahead of its + // fragment's reveal (an order the client tolerates, never the producer's) + // declares and settles in one script. + const declared = new Map>(); + const declare = (i: number) => { + if (scenario.occurrences[i].kind !== "render" || declared.has(i)) return; + declared.set(i, page.declareSlotRecord(fid, scenario.occurrences[i].name)); + }; + scenario.occurrences.forEach((o, i) => o.inFragment === null && declare(i)); const traces = new Map>(); scenario.occurrences.forEach((o, i) => { if (o.arg.kind !== "trace") return; @@ -233,7 +246,8 @@ export async function runScenario(scenario: Scenario): Promise { const o = scenario.occurrences[e.occ]; const args = o.arg.kind === "plain" ? { text: `p${e.occ}` } : { data: traces.get(e.occ)!.marker }; - page.slotRecord(fid, o.name, args); + declare(e.occ); + declared.get(e.occ)!.settle(args); owedDone(); break; } @@ -241,6 +255,9 @@ export async function runScenario(scenario: Scenario): Promise { const html = scenario.occurrences .map((o, i) => (o.inFragment === e.frag ? rangeHtml(i) : "")) .join(""); + // The fragment carries its occurrences' declarations (ahead of the + // swap, as the producer orders its one task batch). + scenario.occurrences.forEach((o, i) => o.inFragment === e.frag && declare(i)); page.revealFragment(fragKey(e.frag), html); world.revealed.set(e.frag, world.step); collectBootNodes(page.container); diff --git a/packages/web/test/consistency/support.ts b/packages/web/test/consistency/support.ts index 5a0cbe6517..d6ee7efa81 100644 --- a/packages/web/test/consistency/support.ts +++ b/packages/web/test/consistency/support.ts @@ -287,8 +287,24 @@ export interface Page { errors: string[]; /** `_$HY.r[key] = value` — a data script executing. */ record(key: string, value: unknown): void; - /** The slot record for an occurrence (`sc:slot::`). */ + /** + * The slot record for an occurrence (`sc:slot::`) as the + * producer writes it when the args are known at the marker: declared + * and settled in one script (a promise stamped `s = 1`, `v = args` — the + * shape the hydration serializer emits for a promise resolved in the + * same span, see frame-sink's `createDocumentSlotProps`). + */ slotRecord(fid: string, occurrence: string, args: Record): void; + /** + * The slot record DECLARED at its marker and settled later (frames A4, + * S-record): `_$HY.r[key]` is a pending promise from this call on; + * `settle(args)` is the producer's data script resolving it (the client + * awaits it through `.then`, the way it awaits a fragment's `_fr`). + */ + declareSlotRecord( + fid: string, + occurrence: string + ): ReturnType>>; /** The region record for a nested region (`sc:region:`). */ regionRecord(childId: string, html: string | Promise): void; /** Declare a deferred fragment: `K_fr` pending until `settle`/`reject`. */ @@ -395,7 +411,12 @@ export function bootPage(shellHtml: string, options: { hostOptions?: Record>(); + hy.r[`sc:slot:${fid}:${occurrence}`] = record.promise; + return record; }, regionRecord(childId, html) { hy.r[`sc:region:${childId}`] = html; diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json index 899fe0dd82..1198e07488 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-loaded", - "shell": "

    shell-fallback

    ", + "shell": "

    shell-fallback

    ", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json b/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json index af02723cf2..425d5129e8 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-streamed.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-streamed", - "shell": "

    shell-fallback

    ", + "shell": "

    shell-fallback

    ", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-live-document-switched.json b/packages/web/test/harness/__artifacts__/frame-live-document-switched.json index fe2a0f7fbe..394a680a4d 100644 --- a/packages/web/test/harness/__artifacts__/frame-live-document-switched.json +++ b/packages/web/test/harness/__artifacts__/frame-live-document-switched.json @@ -1,5 +1,5 @@ { "name": "frame-live-document-switched", - "shell": "

    shell-fallback

    ", + "shell": "

    shell-fallback

    ", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-inline-loaded.json b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-inline-loaded.json index f1e7c9c496..2bdaec9d6c 100644 --- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-inline-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-inline-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-nonlive-document-3666-inline-loaded", - "shell": "

    note v1

    ", + "shell": "

    note v1

    ", "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json index 480d94e8e3..d3ed14ef58 100644 --- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json +++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-loaded.json @@ -1,5 +1,5 @@ { "name": "frame-nonlive-document-3666-streamed-loaded", "shell": "

    shell-fallback

    ", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json index 785528a447..3e54cef752 100644 --- a/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json +++ b/packages/web/test/harness/__artifacts__/frame-nonlive-document-3666-streamed-streamed.json @@ -1,5 +1,5 @@ { "name": "frame-nonlive-document-3666-streamed-streamed", "shell": "

    shell-fallback

    ", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json index ff82a9b908..7201e0bd7e 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json @@ -1,5 +1,5 @@ { "name": "welcome-status-loaded", - "shell": "
    1…
    ", - "rest": "" + "shell": "
    1…
    ", + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json index d2b2507635..d48a7bb804 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json @@ -1,5 +1,5 @@ { "name": "welcome-status-streamed", - "shell": "
    1…
    ", - "rest": "" + "shell": "
    1…
    ", + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/hydration/adopted-slot-late-record.spec.tsx b/packages/web/test/hydration/adopted-slot-late-record.spec.tsx index 6fe39b134b..2a52b5b3ea 100644 --- a/packages/web/test/hydration/adopted-slot-late-record.spec.tsx +++ b/packages/web/test/hydration/adopted-slot-late-record.spec.tsx @@ -10,14 +10,15 @@ * zero-argument accessor. A callback that reads `props.id` then halted the * reactive system (`TypeError: Cannot read properties of undefined`). * - * The fix: while the document may still deliver records (parser running — - * `document.readyState === "loading"` — or fragments still pending), a - * recordless adopt-time occurrence defers, the boundary re-drains `_$HY.r` - * each beat, and classification happens with the record present. The - * server-rendered DOM stays in place across the deferral, so the wait is - * invisible. NOT gated on `_$HY.done`: holding classification until client - * hydration completes pushes adopted mounts past the hydrate window (see - * adopted-slot-live.spec). + * The fix (as it stands after frames A4, S-record): the occurrence's name + * decides its class — a called occurrence (`button#0`) found without its + * record WAITS, never classifies as direct-insert — and the document + * DECLARES the record at the marker: `_$HY.r["sc:slot:…"]` is a pending + * value from the shell's data script on, settled with the args by the + * script the parser is still owed (the shape a fragment's `_fr` + * takes). The adopting boundary awaits the declaration through `.then`, + * so the settle is a write the frame re-syncs on — no poll, no beat. The + * server-rendered DOM stays in place across the wait, so it is invisible. */ import { afterEach, describe, expect, test, vi } from "vitest"; import { flush } from "solid-js"; @@ -62,6 +63,18 @@ describe("adopted invoked slot whose record script runs after adoption", () => { ""; document.body.appendChild(container); (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + // The record, DECLARED at the marker by the shell's data script: a + // pending promise under its key, settled — and stamped `s`/`v` as the + // hydration serializer's resolve helper does — by the later script. + let settleRecord!: (args: unknown) => void; + const record: any = new Promise(resolve => { + settleRecord = (args: unknown) => { + record.s = 1; + record.v = args; + resolve(args); + }; + }); + (globalThis as any)._$HY.r[`sc:slot:${FID}:button#0`] = record; vi.stubGlobal("fetch", () => { throw new Error("fetch must not be called"); }); @@ -85,14 +98,14 @@ describe("adopted invoked slot whose record script runs after adoption", () => { ); flush(); - // The race moment: adoption ran, the record hasn't. Nothing may have - // invoked the callback yet — the server-rendered button is still the - // range's content. + // The race moment: adoption ran, the record's settle hasn't. Nothing may + // have invoked the callback yet — the server-rendered button is still + // the range's content. expect(reads).toEqual([]); expect(container.textContent).toContain("Click me 10"); - // The data script the parser was still owed. - (globalThis as any)._$HY.r[`sc:slot:${FID}:button#0`] = { id: 10 }; + // The data script the parser was still owed: the declaration settles. + settleRecord({ id: 10 }); await settle(); flush(); From d9d2179592414c51f89a6cd7cbd58318b235d5c7 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 12:05:05 -0700 Subject: [PATCH 3/3] =?UTF-8?q?docs(server-components):=20frames=20rulings?= =?UTF-8?q?=20=E2=80=94=201.2/1.3's=20carriers=20and=20the=20A1b=20removal?= =?UTF-8?q?s=20landed=20(A1b=20+=20A4);=20the=20preview=20kept=20as=20the?= =?UTF-8?q?=20Transaction's=20carrier;=20public=20surface=20updated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server-components/frames-rulings.md | 321 ++++++++++-------- 1 file changed, 184 insertions(+), 137 deletions(-) diff --git a/documentation/server-components/frames-rulings.md b/documentation/server-components/frames-rulings.md index f0e6546137..a6eb864004 100644 --- a/documentation/server-components/frames-rulings.md +++ b/documentation/server-components/frames-rulings.md @@ -3,9 +3,9 @@ **Status: ruled.** **3.1, ruled 2026-10-05** (hydration-done follows non-SC Solid 2) and the **Principle** below, which is the maintainer's (three statements, 2026-10-05); every other ruling was re-derived from it and -marked *recommended-by-principle* where the principle decides a reading — -and on **2026-10-06** the maintainer nodded the lot (*"other than that lets -do your recommendations"*): **1.3, 1.4 full, 1.6 (i), 2.3, 3.3** are ruled +marked _recommended-by-principle_ where the principle decides a reading — +and on **2026-10-06** the maintainer nodded the lot (_"other than that lets +do your recommendations"_): **1.3, 1.4 full, 1.6 (i), 2.3, 3.3** are ruled as recommended; **3.6 is ruled (iii)** (the consumer parks); the one wire fact (whether `slot` may trail `html`) is ruled by leaving the order unspecified and pinning the client's tolerance; **3.5 is closed**, @@ -74,11 +74,11 @@ is a response too — version 0, the t = 0 frame (DR-4). **A server component's output — its frame markup, its records, its traces — is rendered data like any other async data in Solid 2, and follows the rules Solid 2 already has for async data; the frames layer adds a transport, never -a second model.** The maintainer's three statements, verbatim: *"SCs are no -different than other rendered data."* *"Hydration ending should follow our -Solid 2 non-SC."* *"SCs participate in `` until their first flush +a second model.** The maintainer's three statements, verbatim: _"SCs are no +different than other rendered data."_ _"Hydration ending should follow our +Solid 2 non-SC."_ _"SCs participate in `` until their first flush the same way [as any async data], and can have their own internal loading -states that the client doesn't care about."* Every ruling below is therefore +states that the client doesn't care about."_ Every ruling below is therefore one of two things — the frames **form of a rule the core already has** (`Restates:` names it: the L2 rulings 1–9 and A-rules of `packages/signals/docs/SPEC-ASYNC-SEMANTICS.md`; the `` rules of @@ -97,29 +97,29 @@ Four corollaries, one per seam and one for the boundary the seams meet at: 1. **Response identity IS async supersession.** An address is a source; a response is a flight answering one question on it; a refetch or a switch - is a **new question** on the same source. L2 ruling 5 (provenance): *"a + is a **new question** on the same source. L2 ruling 5 (provenance): _"a landing asking an older question than the guess it lands beneath is not - its answer … nothing moves on screen."* A18 (supersession, 2026-09-10): - *"a slow source shouldn't leak back in like that"* — only the question's + its answer … nothing moves on screen."_ A18 (supersession, 2026-09-10): + _"a slow source shouldn't leak back in like that"_ — only the question's own answer, a later question's, or mainline supersedes; a store's - *"projection landing still consumes the whole layer (fresh authority - supersedes every tentative write)."* So: a late chunk of a superseded + _"projection landing still consumes the whole layer (fresh authority + supersedes every tentative write)."_ So: a late chunk of a superseded response is dropped, never merged; the store holds the **latest answer**, not a merge of answers; an answer resolves its parts (`{$ref}`) through its own question's context, never the current one's. Seam 1. 2. **Applied state per version IS "a landing replaces the value wholesale".** L2 ruling 1 (one frame concept — a node is committed or staged, a flush - lands or parks), A15 (*"lanes settle as one reveal"*; a stale reader is + lands or parks), A15 (_"lanes settle as one reveal"_; a stale reader is re-derived at the landing), A30 (a frame is replaced by its landing, not by the pass that asked). A landing is applied as a whole and every reader of it re-derives; nothing of the previous frame is consulted. So: a version bump re-applies even a byte-identical root (an equal landing is - still a landing — A18 (a): *"a landing that equals … confirms"*, the frame + still a landing — A18 (a): _"a landing that equals … confirms"_, the frame is the new question's); a **reveal is a landing** (content becoming shown is the moment readers of it re-derive — A15's reveal corollary); "applied" is a cache of the store keyed by the landing. Seam 2. -3. **Hydration-done IS non-SC hydration-done — ruled.** *"Hydration ending - should follow our Solid 2 non-SC."* Done is what +3. **Hydration-done IS non-SC hydration-done — ruled.** _"Hydration ending + should follow our Solid 2 non-SC."_ Done is what `hydration.ts:checkHydrationComplete` says: the root pass over and `_pendingBoundaries === 0`; every hold the frames client takes registers **as a pending boundary**, the way a `` resume does @@ -128,20 +128,20 @@ Four corollaries, one per seam and one for the boundary the seams meet at: mean the same thing with or without SC. Seam 3 (3.1, with 3.2 as its mechanism). 4. **A frame is one async value outward; its inner boundaries are the - server's.** *"SCs participate in `` until their first flush the + server's.** _"SCs participate in `` until their first flush the same way, and can have their own internal loading states that the client - doesn't care about."* **Outward:** to its surroundings a frame is one + doesn't care about."_ **Outward:** to its surroundings a frame is one async source. The enclosing `` — and hydration-done, per 3 — waits for the frame's **first flush** exactly as it waits for any async source's first landing (`05-async-data.md` "`Loading` is the UI boundary": - *"branch readiness … after that branch has produced content, subsequent - revalidation should not kick you back into the fallback"*; A29's boundary + _"branch readiness … after that branch has produced content, subsequent + revalidation should not kick you back into the fallback"_; A29's boundary exemption, #3540: an unrevealed boundary shows its fallback now, a - revealed one holds; A33: *"a `` boundary showing its fallback is - the display of everything under it"*), and for **nothing inside it**. A + revealed one holds; A33: _"a `` boundary showing its fallback is + the display of everything under it"_), and for **nothing inside it**. A refetch or switch is a new question on that source — the boundary's - retain/`on` behaviour applies as for any memo. *This is what the shell - gate is* (1.5, 1.6): the boundary's pending state for the frame's first + retain/`on` behaviour applies as for any memo. _This is what the shell + gate is_ (1.5, 1.6): the boundary's pending state for the frame's first flush under the bound address. **Inward:** a server component's own ``/`` are the server's. Their fallbacks, reveals and error outcomes arrive **as markup and segments** (`seg:`/`reveal`/the @@ -152,13 +152,13 @@ Four corollaries, one per seam and one for the boundary the seams meet at: server rendered nothing for it, that is a server-half gap to report, not a client state to invent. **The inward face exempts nothing of the client's:** a fill waiting for its chunk (S1's `prepareArgs`), a record - defer, a `{$ref}` wait are client-side waits *inside* a frame that has + defer, a `{$ref}` wait are client-side waits _inside_ a frame that has had its first flush — the client's own fills — and register under 3 as pending boundaries (3.2). The principle decides five readings the draft left to the maintainer — 1.3 (yes), 1.4 (full), 1.6 (per-address), 2.3 (yes), 3.3 (yes, re-shaped by -corollary 4) — each marked *recommended-by-principle* below; it supports 3.6 +corollary 4) — each marked _recommended-by-principle_ below; it supports 3.6 (iii) through the hydration adoption rule and asks only that 3.5's sentence be confirmed. It argues **against** two things as drafted: 3.3's client error arm (a client `` for a server boundary's failure) and the @@ -167,12 +167,12 @@ position". Code sites corollary 4 says to change are listed under 3.3. ## The seams, the reds, the duplicates -| seam | question | reds (pins that fail on `next`) | duplicates (audit §4) | -| --- | --- | --- | --- | -| **1. Response identity** | which response owns a record, a `{$ref}` wait, a data table, the shell gate | **C5** (a, b, e) a superseded response's late `data` lands in the current table — R4; **C6** (a1, b2) a held `slot:*` record outlives its response and resolves through the next one's data — R5; **C17** (a, c) the shell gate answers to the frame's registered address, which lags the binding — R8 | two table spaces (`tables` + `stageTables`' `staged`, 183 B); two ref-resolution paths (`host.resolve(ref, frameId)` + the `resolve` parameter threaded through `preview` → `#refsUnresolved`/`#refArgsUnchanged`/`#resolveArgs`/`#resolveRef`); two dedupes (`argsEquivalent` 217 B at apply, `#refArgsUnchanged` 534 B at sync — the first exists because refs are response-scoped and the store is not); two shell gates (`boundaryComponent` + the adopted face in `adoptBoundary`, ≈ 150 B duplicated); `preview`'s data half (the `resolve` it threads). _No red, same question:_ the `_$SC` bootstrap twice — the document's t = 0 address record reaches the mount by `documentAddress` scanning `_$SC.a` (audit S9) | -| **2. Applied state per version** | what a version bump resets; when a reveal is an apply | **C7** (c) a byte-identical v2 root never re-applies, so v2's segment waits for a placeholder v1 removed — R2; **C2** (a2, b; and the harness's C2 replay — R3 rediscovered: record before adoption, fragment revealed after) and **C4** (d) a fragment reveal into adopted content is not a sync trigger — R3 | two version spaces (`FrameImpl.#version` beside `store.version`; `rebase`, ≈ 60–100 B); applied state in seven fields reset at three sites (`#resetStreamState` ×5, `rebind` ×2, the root apply ×1), one of them (`#appliedRootValue`) reset at only one; two reveal engines (`web.js`'s `$df`/`$dfl` and the frame's `#revealSegment`/`#showFallback`, ≈ 1.3 KB on one side — DR-4); the #2978 cascade (`claimRegionFragments` + the `fr.subscribe` body ≈ 250 B) as the document face's half of a sync | -| **3. Hydration-done accounting** | what `done` counts; when a hold lifts; what a claim owes and reads | **C3** (a; and the harness's C3 replay — R1 rediscovered) hydration reports done while an adopted occurrence is still deferred — R1; **C18** (×3: two records drained after the parser finished, a live op before the drain, the live pump's catch-up read) a recordless occurrence is classified while the document still holds its record undrained, its render prop evaluated argless → `TypeError` → `REACTIVITY_HALTED` — R9, **page-halting**; **C19** (×2: patch before claim, two orders) a trace patch delivered before the fill's claim is never shown; heals only on the next distinct patch — R10 (green on S1); **C12** (c) a rejected server `` the adoption claimed swaps to a blank, unsurfaced — R6; S1's **C3b** shape (the `prepareArgs` wait — held by S1's own pin as the *expected* order); S1's `.fails` (a keyed sibling after a document boundary misses its key) | the #2968 deferral (`recordsPending` + `#recordRefresh` arm + the drain hook ≈ 300 B) — whose bound is the parser's state while the records it waits for sit in a ledger (`_$HY.r`) nothing consults; S1's `#argsRefresh` + `#heldRecords`, and the `{$ref}` wait's non-carrier: three hold kinds, two carriers, no shared accounting; `drainRecords` + `appliedRecords` (DR-4 row 20) — one `host.apply` per record, a sync per apply; the trace's claim reading (`materializeContainerTrace`'s synchronous replay) and the claim pass's non-mutation (`insertExpression`) each right alone, wrong together. _No red, same question:_ two late-boundary waiters (`boundaryWaiters` + `arrivals`, ≈ 150 B duplicated, resolved from one subscription — a hold already counted through the covering ``'s `_fr`; audit S9) | -| outside | — | **C13** (a, b) one sweep, two frames — R7, **confirmed** by `c13-sweep-atomic` on both faces (`a0\|b0 → a1\|b0 → a1\|b1`, identical through `frame:applied` and a `MutationObserver`); the delimiter's shape is in "The server half / wire" below | the asset-loader mirror (audit S10), the three region-rename sites (audit S7) | +| seam | question | reds (pins that fail on `next`) | duplicates (audit §4) | +| -------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1. Response identity** | which response owns a record, a `{$ref}` wait, a data table, the shell gate | **C5** (a, b, e) a superseded response's late `data` lands in the current table — R4; **C6** (a1, b2) a held `slot:*` record outlives its response and resolves through the next one's data — R5; **C17** (a, c) the shell gate answers to the frame's registered address, which lags the binding — R8 | two table spaces (`tables` + `stageTables`' `staged`, 183 B); two ref-resolution paths (`host.resolve(ref, frameId)` + the `resolve` parameter threaded through `preview` → `#refsUnresolved`/`#refArgsUnchanged`/`#resolveArgs`/`#resolveRef`); two dedupes (`argsEquivalent` 217 B at apply, `#refArgsUnchanged` 534 B at sync — the first exists because refs are response-scoped and the store is not); two shell gates (`boundaryComponent` + the adopted face in `adoptBoundary`, ≈ 150 B duplicated); `preview`'s data half (the `resolve` it threads). _No red, same question:_ the `_$SC` bootstrap twice — the document's t = 0 address record reaches the mount by `documentAddress` scanning `_$SC.a` (audit S9) | +| **2. Applied state per version** | what a version bump resets; when a reveal is an apply | **C7** (c) a byte-identical v2 root never re-applies, so v2's segment waits for a placeholder v1 removed — R2; **C2** (a2, b; and the harness's C2 replay — R3 rediscovered: record before adoption, fragment revealed after) and **C4** (d) a fragment reveal into adopted content is not a sync trigger — R3 | two version spaces (`FrameImpl.#version` beside `store.version`; `rebase`, ≈ 60–100 B); applied state in seven fields reset at three sites (`#resetStreamState` ×5, `rebind` ×2, the root apply ×1), one of them (`#appliedRootValue`) reset at only one; two reveal engines (`web.js`'s `$df`/`$dfl` and the frame's `#revealSegment`/`#showFallback`, ≈ 1.3 KB on one side — DR-4); the #2978 cascade (`claimRegionFragments` + the `fr.subscribe` body ≈ 250 B) as the document face's half of a sync | +| **3. Hydration-done accounting** | what `done` counts; when a hold lifts; what a claim owes and reads | **C3** (a; and the harness's C3 replay — R1 rediscovered) hydration reports done while an adopted occurrence is still deferred — R1; **C18** (×3: two records drained after the parser finished, a live op before the drain, the live pump's catch-up read) a recordless occurrence is classified while the document still holds its record undrained, its render prop evaluated argless → `TypeError` → `REACTIVITY_HALTED` — R9, **page-halting**; **C19** (×2: patch before claim, two orders) a trace patch delivered before the fill's claim is never shown; heals only on the next distinct patch — R10 (green on S1); **C12** (c) a rejected server `` the adoption claimed swaps to a blank, unsurfaced — R6; S1's **C3b** shape (the `prepareArgs` wait — held by S1's own pin as the _expected_ order); S1's `.fails` (a keyed sibling after a document boundary misses its key) | the #2968 deferral (`recordsPending` + `#recordRefresh` arm + the drain hook ≈ 300 B) — whose bound is the parser's state while the records it waits for sit in a ledger (`_$HY.r`) nothing consults; S1's `#argsRefresh` + `#heldRecords`, and the `{$ref}` wait's non-carrier: three hold kinds, two carriers, no shared accounting; `drainRecords` + `appliedRecords` (DR-4 row 20) — one `host.apply` per record, a sync per apply; the trace's claim reading (`materializeContainerTrace`'s synchronous replay) and the claim pass's non-mutation (`insertExpression`) each right alone, wrong together. _No red, same question:_ two late-boundary waiters (`boundaryWaiters` + `arrivals`, ≈ 150 B duplicated, resolved from one subscription — a hold already counted through the covering ``'s `_fr`; audit S9) | +| outside | — | **C13** (a, b) one sweep, two frames — R7, **confirmed** by `c13-sweep-atomic` on both faces (`a0\|b0 → a1\|b0 → a1\|b1`, identical through `frame:applied` and a `MutationObserver`); the delimiter's shape is in "The server half / wire" below | the asset-loader mirror (audit S10), the three region-rename sites (audit S7) | Byte figures are the audit's (minified, page base, exact per function; brotli ≈ 0.29× at this layer). Estimates below carry a sign per direction: @@ -183,7 +183,7 @@ brotli ≈ 0.29× at this layer). Estimates below carry a sign per direction: ## Seam 1 — Response identity The question every red here asks: a thing arrived — whose is it? Today the -answer is given by *where it landed* (the address's current table, the frame's +answer is given by _where it landed_ (the address's current table, the frame's current id, whatever gate is armed), and the rotation that makes "current" mean "newest" happens at different moments for different things: the table at the header (`beginStream`), the record never (`slot:*` survives `clearStreamRecords`), @@ -200,16 +200,16 @@ superseded response lands in the frame that shows the current one.** - **Mechanism today.** The table: `client.ts:tables` is a `Map`; `beginStream(address)` rotates by `tables.set(address, undefined)` and - `ensureTable` creates lazily at *first use* — which `createFrameHost.apply`'s + `ensureTable` creates lazily at _first use_ — which `createFrameHost.apply`'s `data` arm performs with no version read (the transport restamped `chunk.version`; `applyData` never looks). The record: owned by the frame store and versioned at the store (`store.version`, `#version`), not per record; `clearStreamRecords` keeps every `slot:*`. The wait: a `continue` in `FrameImpl.#syncSlots` with no carrier; its answer is `#resolveRef(ref)` → - `host.resolve(ref, this.#options.id)` → `tableFor(id)` — the frame's *current* + `host.resolve(ref, this.#options.id)` → `tableFor(id)` — the frame's _current_ id, so a `rebind` re-routes every held record to the new address's data. - **Lives twice in.** `tables` + `stageTables()`'s `staged` (the staged - response's table is the one case that already *is* response-owned — kept in a + response's table is the one case that already _is_ response-owned — kept in a second map because the first is address-owned); `host.resolve` + the `resolve` parameter; `argsEquivalent` + `#refArgsUnchanged`. - **Decides.** The frame of reference for 1.2–1.4; by itself it flips nothing. @@ -242,7 +242,7 @@ becomes the current one.** ruling §3 11 — so the cell's lazy fill is the codec's, not the response's); installed as the address's current at the header (unstaged) or at `commit` (staged). The response's chunks reach the host through a per-response target - whose `apply` routes `data` into *its* cell — the shape `stage`'s entry + whose `apply` routes `data` into _its_ cell — the shape `stage`'s entry already has. Nothing stamps or compares versions on the data path: a late chunk fills a cell nothing reads. - **Restates:** corollary 1 — A18 (2026-09-10): a superseded flight's landing @@ -259,7 +259,7 @@ a later response's values, and the later response's own record replaces it.** - **Mechanism today.** `#resolveRef(ref, resolve?)` — the host path by frame id, or the `resolve` the staged preview threads down. R5: after `rebind`, A's held record resolves through `tableFor(B)`; at a staged commit, `commit` installs - v2's tables *before* replaying v2's chunks, and the replayed `start`'s flush + v2's tables _before_ replaying v2's chunks, and the replayed `start`'s flush resolves v1's held record through them. - **Lives twice in.** The two resolution paths (host-by-frame-id and the threaded `resolve`): `createFrameHost.preview(chunk, resolve)`, @@ -286,13 +286,13 @@ a later response's values, and the later response's own record replaces it.** - **Restates:** corollary 1 — L2 ruling 5: an answer is judged by the question it answers; A29/A15: a pass derives from the world it was served. A record is an answer whose parts (`{$ref}`) are resolved in its own question's - context; resolving them through the frame's *current* address is the + context; resolving them through the frame's _current_ address is the "slow source leaking back in" A18 forbids. **Recommended-by-principle: yes.** Not frames-specific. ### 1.4 A version bump drops what the previous version never applied -**Slot records outlive a bump only as the dedupe for *mounted* occurrences; a +**Slot records outlive a bump only as the dedupe for _mounted_ occurrences; a record no mount applied belongs to its superseded response and leaves with it.** Two forms; the maintainer picks. @@ -304,7 +304,7 @@ Two forms; the maintainer picks. record cannot resolve through anything. ≈ +50 B. - **Full — the store is one response's.** Every record of the previous version leaves at the bump (the host's `write` and the frame's `apply` alike); what - preserves occurrence state across versions is the *mount's* applied state + preserves occurrence state across versions is the _mount's_ applied state (`#slotArgs`, `#slotResolvedRefs`), which the sync's value compare (`#refArgsUnchanged`) already consults. Then the apply-time dedupe (`argsEquivalent` 217 B, the `slot:` arm of `FrameImpl.apply` ≈ 80 B) has @@ -325,11 +325,11 @@ Two forms; the maintainer picks. supersedes every tentative write)"). The narrow form keeps a merge of two responses in one store, which has no analogue in a node's value. **Recommended-by-principle: the full form.** What stays frames-specific is - the *precondition* — that every response carries its full record set (the + the _precondition_ — that every response carries its full record set (the sink's A5 rule; "may `slot` trail `html`" below) — a property of the wire the principle cannot supply; confirm it before taking the full form. - **Open under either form:** a called occurrence (`prop#n`) found recordless - on a *non-adopt* sync is invoked argless today (the #2968 defer is adopt-only; + on a _non-adopt_ sync is invoked argless today (the #2968 defer is adopt-only; `#syncSlots`' comment calls the recordless-called case "the protocol's invariant broken"). RFC 11 fixes no order between `slot` and `html`; the sink emits the record "at the call, ahead of the markup". C6 (a1) orders @@ -381,7 +381,7 @@ releases through the frameless waiter.** flush the same way". A switch is a new question on the source (A18 provenance), so the superseded address's apply is an older question's landing and releases nothing (L2 ruling 5: "nothing moves on screen"). The - frames-specific residue is only *where the binding lives* (the `rebind` at + frames-specific residue is only _where the binding lives_ (the `rebind` at the commit, ruled 2026-10-04) — a transport fact, not a second gate. ### 1.6 A switch keeps on screen what was on screen @@ -394,7 +394,7 @@ and the superseded address never reveals after the switch was delivered.** - **Mechanism today.** `createMemo(() => gatePromise())` is one memo across addresses; R8 (c): A's html released the gate legitimately (A was the bound address, no switch delivered yet), so the memo holds the element; B's delivery - re-arms it, and a memo pending *with* a value shows the value under + re-arms it, and a memo pending _with_ a value shows the value under async-holds-latest — the `` drops its fallback for content that was never on screen, `waiting → A → B`. - **Two readings.** @@ -426,38 +426,49 @@ and the superseded address never reveals after the switch was delivered.** is those two rules applied to the frame-as-one-value: unrevealed → fallback until B's first flush; revealed A → A until B's first flush; A's late landing is an older question's (ruling 5) and never reveals. (ii) - holds-latest is a display rule for a *value already shown* — A was never + holds-latest is a display rule for a _value already shown_ — A was never shown, so (ii) misapplies it. **Recommended-by-principle: (i).** Not frames-specific. - **Decides.** **C17 (c)** under (i). ### Fix shape — seam 1 -| step | collapse (−) | carrier (+) | net (min B, est.) | pins that flip | touches | -| --- | --- | --- | --- | --- | --- | -| 1a — 1.2, data owned by response | `stageTables` 183; `beginStream` 32; `tableFor`/`ensureTable`'s lazy-create ≈ 84; `stage`'s `data ? … : streams` fallbacks ≈ 60 | per-response cell at `bump` + the unstaged per-response target (the staged entry's shape, smaller) ≈ 140 | **≈ −220** | C5 (a), (b), (e) | `ServerComponentHandlerOptions.onStream` (public: the rotation it signalled is now the cell's install — delete or redefine); `STAGED_DATA` (@internal) generalizes to every response | -| 1b — 1.3, record carries its resolver | `host.resolve` 53 + `FrameHostOptions.resolve` wiring ≈ 40; the `resolve` parameter across `preview` ×2, `#refsUnresolved`, `#refArgsUnchanged`, `#resolveArgs`, `#resolveRef` ≈ 70 | the stamp at chunk→records ≈ 40 | **≈ −120** | C6 (a1), (b2) | `FrameHost.resolve(ref, frameId)` / `FrameHostOptions.resolve` (public, experimental — no caller); `FrameHost.preview`/`Frame.preview` signatures (@internal) | -| 1c — 1.4 full, the store is one response's | `argsEquivalent` 217; the `slot:` arm of `FrameImpl.apply` ≈ 80; `clearStreamRecords`' filter + `root` ≈ 60 | — | **≈ −350** (narrow form: ≈ +50) | none directly; closes the C6 class | the sink's A5 rule must be confirmed (no wire change if it holds) | -| 1d — 1.5 + 1.6 (i), the gate | the second gate (`adoptBoundary`'s face) ≈ 150 | `shellGate()` helper's waiter check ≈ 20; per-address gate memo ≈ 80 | **≈ −50** (1.5 alone: ≈ −130) | C17 (a); C17 (c) under (i) | none public | -| **seam 1** | | | **≈ −740** (≈ −215 br) | 7 of the 22 | | +| step | collapse (−) | carrier (+) | net (min B, est.) | pins that flip | touches | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1a — 1.2, data owned by response | `stageTables` 183; `beginStream` 32; `tableFor`/`ensureTable`'s lazy-create ≈ 84; `stage`'s `data ? … : streams` fallbacks ≈ 60 | per-response cell at `bump` + the unstaged per-response target (the staged entry's shape, smaller) ≈ 140 | **≈ −220** | C5 (a), (b), (e) | `ServerComponentHandlerOptions.onStream` (public: the rotation it signalled is now the cell's install — delete or redefine); `STAGED_DATA` (@internal) generalizes to every response | +| 1b — 1.3, record carries its resolver | `host.resolve` 53 + `FrameHostOptions.resolve` wiring ≈ 40; the `resolve` parameter across `preview` ×2, `#refsUnresolved`, `#refArgsUnchanged`, `#resolveArgs`, `#resolveRef` ≈ 70 | the stamp at chunk→records ≈ 40 | **≈ −120** | C6 (a1), (b2) | `FrameHost.resolve(ref, frameId)` / `FrameHostOptions.resolve` (public, experimental — no caller); `FrameHost.preview`/`Frame.preview` signatures (@internal) | +| 1c — 1.4 full, the store is one response's | `argsEquivalent` 217; the `slot:` arm of `FrameImpl.apply` ≈ 80; `clearStreamRecords`' filter + `root` ≈ 60 | — | **≈ −350** (narrow form: ≈ +50) | none directly; closes the C6 class | the sink's A5 rule must be confirmed (no wire change if it holds) | +| 1d — 1.5 + 1.6 (i), the gate | the second gate (`adoptBoundary`'s face) ≈ 150 | `shellGate()` helper's waiter check ≈ 20; per-address gate memo ≈ 80 | **≈ −50** (1.5 alone: ≈ −130) | C17 (a); C17 (c) under (i) | none public | +| **seam 1** | | | **≈ −740** (≈ −215 br) | 7 of the 22 | | The audit's **S8** ("one apply path for staged content", ≈ −0.7 KB) splits -here: 1a/1b are its *data* half (`stageTables` folds into response-owned -cells; `preview`'s `resolve` threading goes). Its *markup* half — a staged +here: 1a/1b are its _data_ half (`stageTables` folds into response-owned +cells; `preview`'s `resolve` threading goes). Its _markup_ half — a staged version held in the one store under a not-shown bit, `preview` becoming the ordinary args-update arm — is S8 proper, is a restatement of rulings §3 71–75, and is not decided here (audit §7 Q2). +**Landed (2026-10-06, A1b + A4 on the A2b branch):** 1.2 and 1.3's carriers +as one mechanism — the response's table is keyed by the host's version +(`tableFor(id, version, current)`) and a record's `{$ref}`s settle at the +host's write through it, an undelivered key becoming the response's own +pending read (S-ref; rejected at the stream's end — L1); 1.4 full was +#3830's. 1b's threaded `resolve`, `stageTables`, `STAGED_DATA`, `onStream` +and `FrameHost.resolve` are gone; the `preview` push itself stays (see "As +landed" below for why: the token is the carrier by which a refetch of a +shown address enters the Transaction, and the compute-half push is what +stages its args in that pass). + --- ## Seam 2 — Applied state per version -The question: a frame applied something under version *n*; version *n*+1 +The question: a frame applied something under version _n_; version _n_+1 arrives — what does the frame still believe? Today "applied" is seven fields reset at three sites, and one of them is reset at only one of the two sites that bump the version. And "applied" is also asked of the wrong event: a record -applies when it *arrives* (the flush after a store write), not when the range -it names *appears* — so a reveal that brings no new record applies nothing. +applies when it _arrives_ (the flush after a store write), not when the range +it names _appears_ — so a reveal that brings no new record applies nothing. ### 2.1 The store is the truth; the applied state is a cache of it, keyed by version @@ -504,7 +515,7 @@ version's segments reveal into.** - **Decides.** **C7 (c)** a v2 root byte-identical to v1's resets the segment. (C7 a — all 720 orders — and b hold; d is the differing-root control.) - **Carrier.** One `#applied` record, `{ version, root, revealed, fallbacks, - holes, assets, errorNotified, have }`, created fresh at every bump +holes, assets, errorNotified, have }`, created fresh at every bump (`this.#applied = applied(v)`) and at `rebind` (`applied(undefined)`, plus the root record dropped — the one thing `rebind` does beyond a bump); there is no second site to forget. `rebase()` becomes `#applied.version = undefined` @@ -532,9 +543,9 @@ event seen from two sides, and either one completes the pair.** seam's `content()`). The document face does not: a `$df` into adopted markup notifies `adoptBoundary`'s `fr.subscribe`, which runs `claimRegionFragments` (#2978) and `drainRecords` (#2968) — a sync happens only if the drain finds a - *new* record (`drainRecords` → `host.apply` → `#flush` → `#syncSlots`). R3: a + _new_ record (`drainRecords` → `host.apply` → `#flush` → `#syncSlots`). R3: a reveal with no new record syncs nothing (C2 b, C2 a2 at reveal time); a record - drained *before* the reveal ran its sync while the range was inside + drained _before_ the reveal ran its sync while the range was inside `