From b4880fc8cd17361a5db73de3ca45207b5490b6b9 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 03:56:56 -0700 Subject: [PATCH 01/17] =?UTF-8?q?fix(web/frames):=20the=20address=20is=20a?= =?UTF-8?q?n=20async=20source=20=E2=80=94=20one=20landing=20per=20bound=20?= =?UTF-8?q?address,=20one=20response=20per=20store=20(S-flush)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A0 (SCs are rendered data): a frame is one async value outward. The mount's covering now pends on the bound address's FIRST FLUSH — content or error — through `FrameHost.landing(address)`, read per bound address through a fresh node (frames-rulings 1.5, 1.6 (i)): a switch is a new question on the source, the superseded address's late writes release nothing, an unrevealed boundary stays on its fallback and a revealed one holds. The two hand-rolled shell gates (`boundaryComponent`'s arm/release/settle/setGate and `adoptBoundary`'s twin), `followAddress`'s re-arm and frameless waiter, and `onApply`-as-release go. An unstaged response announces itself to the host at its header (`start`), so the address reads "in flight" from the header to the landing. The store is one response's (1.4 full; 2.1/2.2): a version bump or rebind replaces the store wholesale — root, segments, slot records, the error — so a byte-identical root under a new version still applies as the new version's (C7 c), a held record leaves with the response that carried it (C6 a1, b2), and `argsEquivalent`/`clearStreamRecords` delete; the mount's `#slotArgs` value compare is what preserves occurrence state. The host keeps the latest landed version as `shown` so a mount opened mid-flight seeds the committed value (holds-latest) and the landing fans out as the version's whole set. The occurrence's name decides its class: a called occurrence (`prop#n`) found without its record waits for it — never evaluated argless (C18 ×3 flip; the #2968 poll stays as the document face's re-sync trigger, scoped to called occurrences) — and a bare occurrence is direct-insert. A reveal is an apply (2.3, interim): the document face's reveal cascade syncs the adopting frame, so a direct-insert range the fragment carried mounts (C2 b) and a record drained before its range was shown takes effect at the reveal (C4 d). Pins flipped to `test`: C2 (b), C4 (d), C6 (a1, b2), C7 (c), C17 (a — re-pinned: B's first flush, not its `start`, releases), C17 (c), harness C2 ×1, C18 ×3. Re-pinned: frames-binding-slots "orphan record … waits" (was "still mounts"), frames-hn-client zero-data occurrence is the bare prop. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/frames-address-source.md | 5 + packages/web/frames/src/client.ts | 292 ++++++---------- packages/web/frames/src/frame-client.ts | 321 ++++++++++-------- packages/web/frames/src/frame-transport.ts | 17 +- .../c02-revealed-occurrence-mounts.spec.tsx | 92 ++--- .../c04-record-applies-once.spec.tsx | 75 ++-- .../c06-stale-wait-never-lands.spec.tsx | 151 ++++---- .../consistency/c07-store-is-truth.spec.tsx | 65 ++-- .../c17-gate-bound-address.spec.tsx | 235 ++++++------- .../test/consistency/harness/replay.spec.tsx | 121 +++---- .../web/test/frames-binding-slots.spec.tsx | 14 +- packages/web/test/frames-hn-client.spec.tsx | 10 +- 12 files changed, 653 insertions(+), 745 deletions(-) create mode 100644 .changeset/frames-address-source.md diff --git a/.changeset/frames-address-source.md b/.changeset/frames-address-source.md new file mode 100644 index 000000000..367b41937 --- /dev/null +++ b/.changeset/frames-address-source.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: the address is an async source (S-flush) — a mount's covering `` pends on the bound address's first flush through `FrameHost.landing(address)` (content or error), per bound address, so a switch is a new question and the superseded address's late writes release nothing (contract C17 a, c); the store is one response's — a version bump or rebind replaces it wholesale, root included (C6 a1, b2; C7 c); a called occurrence (`prop#n`) found without its record waits for it instead of being evaluated argless (C18); the document face's reveal cascade syncs the adopting frame — a reveal is an apply (C2 b, C4 d). diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 6634f8994..ffb2cf770 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -175,73 +175,65 @@ function stageTables() { * this effect run as its work: the compute half in the pass that sees the * value, the effect half at the commit, with everything else it holds. * - * The compute half is plumbing. A content TOKEN (a refetch of the address + * 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. An address SWITCH re-arms the shell gate (#2977: - * the binding resolved at response-header time, which is not an answer — - * until the new address's first content or error arrives the boundary - * still shows the previous call's and the source that drove the switch - * must keep reading pending) and registers a frameless WAITER under the - * new address: the host fans every write under an address out to what is - * registered there — a warm store's synchronously, at the registration — - * so the gate settles on that first write before the frame is bound there. - * Re-armed in the pass, not the run: under the hold model the run is - * stashed with the frame the previous gate holds — behind the very gate it - * would release (a second switch mid-flight, `call-driven-lifecycle`; plan - * sec. 40.3). Only switches with a stream begun gate — nothing else is - * coming to release one. + * once the intent ends. * * The effect half is display. It commits the token's content - * (`stagedContent.commit`: the markup, the store, the mounts), drops the - * waiter 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`). + * (`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`). * * Ruling (maintainer, 2026-10-04, #3759 on L2): the switch IS display — - * one reveal. e133516c8 had called the rebind "plumbing, not display" and - * ran it in the pass beside the re-arm; that wording is superseded. The - * rebind morphs the DOM, so it runs in the effect half at the commit; the - * re-arm stays in the pass; the frameless host waiter is what lets the - * gate settle without the rebind. + * one reveal. The rebind morphs the DOM, so it runs in the effect half at + * the commit. What keeps the boundary pending across a switch is not this + * effect's business: the mount reads the address as a source (`landing` + * below), and a switch is a new question on it. */ -function followAddress( - host: any, - frame: { rebind(address: string): void }, - binding: () => string, - bound: string, - rearm: () => void, - settle: () => void -) { - let waiter: { apply(): void } | undefined; - let at: string; - const drop = () => { - if (waiter) host.unregister(at, waiter); - waiter = undefined; - }; +function followAddress(host: any, frame: { rebind(address: string): void }, binding: () => string) { createRenderEffect( () => { const token = binding(); stagedContent.preview(token); - const address = contentAddress(token); - if (address !== bound && tables.has(address)) { - drop(); - rearm(); - host.register((at = address), (waiter = { apply: settle })); - } - bound = address; return token; }, token => { stagedContent.commit(token); - drop(); frame.rebind(contentAddress(token)); } ); - onCleanup(drop); +} + +/** + * The frame as one async value outward (A0, corollary 4): to its + * surroundings a mount is one async source whose first landing is the + * bound address's first flush — content or error — and whose inside is the + * server's. The enclosing `` pends on that landing exactly as it + * pends on any async source's first landing (`host.landing`: a promise + * while the response is in flight), and on nothing inside the frame — a + * server-rendered `` fallback in the shell IS content. + * + * Per bound address (frames-rulings 1.5, 1.6 (i)): a switch is a new + * question on the source, read here through a FRESH node with no value, so + * an unrevealed boundary stays on its fallback and a revealed one holds what + * it shows until the new address lands (#2977: the binding resolves at + * response-header time, which is not an answer); the superseded address's + * late writes answer only their own question and release nothing — the + * frame may still be bound there (the rebind runs at the commit the + * boundary is holding) and may even morph them into its element; nothing + * shows. Warm — the store shows a landing, or nothing is in flight to + * produce one (a placeholder mount with no call out, the exhausted + * late-boundary waiter, a client-only boot) — reads synchronously as + * `value`: no pending beat, no fallback flicker, and a hydrating consumer + * never sees the node go async. + */ +function landing(host: any, address: string, value: T): T | (() => T) { + const wait = host.landing(address); + return wait ? createMemo(() => wait.then(() => value)) : value; } /** * The app-wide shared frame host (created lazily): one chunk router with @@ -977,49 +969,10 @@ function boundaryComponent(host: any, fnId: string) { // boundary, and streamed chunks — applied from microtasks with no owner // of their own — still claim with the right lifetime. const owner = getOwner(); - // Shell gate: a fresh mount's covering must stay open until the - // frame's FIRST content applies. The binding resolves at response-header - // time while content streams in behind it — ungated, the boundary - // resolves over an empty (a flash), and it has LATCHED by the - // time the shell's fills run, orphaning any pending async slot-arg read - // (with no reveal seam to reconstruct, the mount's own boundary is the - // covering one). Ordering makes the handoff seamless: the frame notifies - // BEFORE it syncs slots, and the release only lands a microtask later — - // by then the fills' pending reads hold the queue open. - // - // Only mounts a stream has BEGUN for gate (the transport rotates the - // address's data table before the binding resolves, so a call-driven - // mount always has one). A placeholder mount with no call in flight — - // the exhausted late-boundary waiter, a client-only boot — must render - // its empty frame NOW, ready for the stream a future call fills it with: - // nothing is coming to release a gate. const id = binding ? contentAddress(binding()) : fnId; - let applied = !tables.has(id); - // The gate is RE-ARMABLE (a signal of the current wait, not a one-shot - // promise): an address SWITCH re-pends this site (#2977, below), so the - // "first apply" question is asked once per bound address, not once per - // mount. - let release: (() => void) | undefined; - const arm = () => new Promise(r => (release = r)); - // Armed BEFORE the frame mounts (a synchronous seed's apply releases - // it), but the SIGNAL is created after: a warm registration fires - // onApply inside this component's own render, where a reactive write is - // illegal — mount-time state reaches the signal through its initial - // value instead. Post-mount releases write through `setGate`: stream - // applies run in ownerless microtasks, a switch's waiter answers in the - // follow effect's pass (`ownedWrite`). - const mountGate = applied ? undefined : arm(); - let setGate: ((v: Promise | undefined) => void) | undefined; - const settle = () => { - if (release) { - release(); - release = undefined; - } - setGate && setGate(undefined); - }; // The boundary is a DOM element (``), not a branded value: // `insert` places it natively in any position (array/fragment/single — - // no #550), and the frame mounts INTO it. Return the element itself. + // no #550), and the frame mounts INTO it. const { element, frame, dispose } = createFrameElement({ host, // The mount binds the ADDRESS's store (content is keyed by call, the @@ -1028,49 +981,30 @@ function boundaryComponent(host: any, fnId: string) { id, slots: slotsFor(props), ownerScope: boundaryScope(owner), - reveal: revealSeam(owner), - // Any apply releases the gate — content ("materialize") is the normal - // path; an error record must release too (surfacing the frame's error - // state beats holding a fallback forever). The error reason requires - // the runtime's error-apply notification; on runtimes without it a - // failed stream holds the fallback. - onApply: () => { - applied = true; - settle(); - } + reveal: revealSeam(owner) }); - // `ownedWrite`: the re-arm is written from a pass (the follow's - // compute) and a warm switch's seed releases the gate from inside it; - // committing a staged response (see followAddress) applies to every - // mount of the address, so another mount's release can land in this - // one's run too. - const [gatePromise, setGatePromise] = createSignal | undefined>( - applied ? undefined : mountGate, - { ownedWrite: true } - ); - setGate = setGatePromise; - if (binding) { - // Follow the live address binding (the identity split's delivery - // path): a `dynamic` site whose call switched arguments keeps this - // instance and pushes the new address through the accessor — the - // frame re-binds its pull to the new address's resident store (warm - // content re-materializes instantly; slot occurrences whose ids - // persist keep their client state) — and a refetch of the address - // shown pushes a content token. Async-holds-latest keeps the old - // content on screen while a re-armed gate pends; a server-rendered - // fallback in the new shell IS content and releases it as - // readily as a client fallback drops isPending. Same rule as the - // mount gate: only switches with a stream begun gate. - followAddress(host, frame, binding, id, () => setGatePromise(arm()), settle); - } onCleanup(dispose); - // A warm DIRECT mount (resident store, registration flushed - // synchronously, no live binding that could ever switch it) has its - // content before we return: no gate, no fallback flicker, no memo. A - // bound mount keeps the gate chain alive for re-arms even when warm. - if (applied && !binding) return element as unknown as SolidElement; - const gate = createMemo(() => gatePromise()); - return createMemo(() => (gate(), element)) as unknown as SolidElement; + // The shell: the covering pends on the bound address's first + // flush (`landing`). The binding resolves at response-header time while + // content streams in behind it — read ungated, the boundary would + // resolve over an empty (a flash) and have LATCHED by the + // time the shell's fills run, orphaning any pending async slot-arg read + // (with no reveal seam to reconstruct, the mount's own boundary is the + // covering one). A warm direct mount has its content before we return + // and IS the element: no memo, no pending beat. + if (!binding) return landing(host, id, element) as unknown as SolidElement; + // Follow the live address binding (the identity split's delivery + // path): a `dynamic` site whose call switched arguments keeps this + // instance and pushes the new address through the accessor — the frame + // re-binds its pull to the new address's resident store (warm content + // re-materializes instantly; slot occurrences whose ids persist keep + // their client state) — and a refetch of the address shown pushes a + // content token (the same address: not a new question, the landing + // reads warm, and the refetch's pending is the transaction's). + followAddress(host, frame, binding); + return createMemo(() => + landing(host, contentAddress(binding()), element) + ) as unknown as SolidElement; }; } @@ -1393,27 +1327,34 @@ function adoptBoundary( }; claimRegionFragments(el); const fr = (globalThis as any)._$HY?.fr; + // The adopting frame, bound below; the reveal cascade syncs it. + let frame: ReturnType | undefined; const unsubscribe = fr ? fr.subscribe((_fragId: string, parent?: ParentNode) => { // The cascade: a reveal into this region can itself carry a pl-* // (nested server async). Scoped to the revealed parent, so each // sweep is proportional to what just landed. - if (fr.claim && parent && el.contains(parent as Node)) claimRegionFragments(parent); + const inside = !!parent && el.contains(parent as Node); + 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 - // boundary adopted — long after the adopt-time drain below ran. The - // reveal is the one moment that record is both present and newly - // relevant, and it is NOT self-healing: the #2968 defer loop is the - // only other re-drain, and it arms on `recordsPending()`, which this - // very reveal flips false (a revealed fragment is no longer - // pending). Without a drain here the record stays stranded in - // hydration data, and the next full sync — a refetch's stream apply - // — finds a recordless occurrence, classifies the render prop as - // direct-insert, and evaluates it as a zero-arg accessor: a props - // read that halts the reactive system. Re-drainable by design (each - // key applies once), so this is a cheap no-op once caught up. + // boundary adopted — long after the adopt-time drain below ran. + // Re-drainable by design (each key applies once), so this is a + // cheap no-op once caught up. drainRecords(); + // A reveal is an apply (frames-rulings 2.3): content that becomes + // shown under a version is synced as content that arrived under it. + // The document face's reveal engine (`$df`) knows nothing of the + // frame, so the frame is told here — an empty write at its version + // re-walks its content for occurrences and applies what the store + // 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 + // 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: {} }); }) : undefined; // Live-hole ops broadcast into this boundary's store at its bound @@ -1448,41 +1389,24 @@ function adoptBoundary( // streamed morphs — bind consumer cleanup to this boundary's owner (see // boundaryScope for the ambient-preserving rule). const owner = getOwner(); - // Switch-gate state (armed on a later address switch, below): declared - // before the frame so its onApply can release, but the SIGNAL is created - // after — a synchronous seed at adopt time fires onApply inside this - // component's own render, where a reactive write is illegal, and at that - // point there is nothing armed to clear anyway. - let release: (() => void) | undefined; - let setGate: ((v: Promise | undefined) => void) | undefined; - // Any apply for the currently bound address — a morph, a reveal, an - // error record — answers an armed switch gate (see below), as does the - // new address's first write while a switch pends (followAddress). - const settle = () => { - if (release) { - release(); - release = undefined; - } - setGate && setGate(undefined); - }; - const frame = createFrame(el, { + frame = createFrame(el, { adopt: true, host, id: address, slots: slotsFor(props), ownerScope: boundaryScope(owner), reveal: revealSeam(owner), - onApply: settle, // 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 recordless occurrence defers instead of - // misclassifying as content (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 classification until client hydration completes - // pushes the adopted mount 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). + // 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). ...({ @@ -1506,25 +1430,23 @@ function adoptBoundary( // content token for the address shown, and the adopted frame re-binds // its pull or commits the content at the delivering transaction's commit. // - // An address SWITCH also arms a gate (#2977, adopted face — the notes- - // search shape: t=0 adopted sidebar, then a search param changes the - // call). Unlike the call-driven mount, this component's return value is - // the raw SSR'd element (hydration must claim it in place), so no reader - // in the render graph would ever see an armed gate — the second effect - // below exists to BE that reader: while its compute pends on the gate, - // the transition that delivered the switch stays open. + // An address SWITCH is a new question on the address source (#2977, + // adopted face — the notes-search shape: t=0 adopted sidebar, then a + // search param changes the call). Unlike the call-driven mount, this + // component's return value is the raw SSR'd element (hydration must + // claim it in place), so no reader in the render graph would ever see + // the source pend — the effect below exists to BE that reader: while its + // compute pends on the new address's landing, the transition that + // delivered the switch stays open (no-op effect half: the pend IS the + // point). if (binding) { - const arm = () => new Promise(r => (release = r)); - // `ownedWrite`: as in the call-driven mount. - const [gatePromise, setGatePromise] = createSignal | undefined>(undefined, { - ownedWrite: true - }); - setGate = setGatePromise; - const gate = createMemo(() => gatePromise()); - followAddress(host, frame, binding, address, () => setGatePromise(arm()), settle); - // The pending observer (no-op effect half: the pend IS the point). + followAddress(host, frame, binding); + const source = createMemo(() => landing(host, contentAddress(binding()), true)); createRenderEffect( - () => (gate(), undefined), + () => { + const landed = source(); + typeof landed === "function" && landed(); + }, () => {} ); } diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index d28248b1f..d711c90c1 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -249,6 +249,17 @@ export interface FrameHost { preview?(chunk: FrameChunk, resolve?: (ref: { $ref: string }, frameId?: string) => unknown): void; /** The first registered frame under the id, if any. */ get(id: string): Frame | undefined; + /** + * The address as an async source: its first landing is the first flush of + * the first response for it — the root content, the stream's error, or + * its completion. A promise resolved at the write that lands it while + * that response is in flight; `undefined` once the address has a landing + * to show (a later response in flight then morphs over it — the + * committed value holds), or when nothing has begun for the address. A + * mount's covering `` pends on this — and on nothing inside the + * frame — exactly as it pends on any async source's first landing. + */ + 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; @@ -341,13 +352,15 @@ export interface FrameOptions { */ reveal?(seam: { before: Node; fallback: Node[]; content: () => Node | DocumentFragment }): void; /** - * Document-face record-race guard (adopt path only — solidjs/solid#2968). + * 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, so a recordless - * occurrence is ambiguous while this returns true: the frame defers its - * mount one macrotask (all currently parsed scripts run first), calls - * `drainRecords`, and classifies with whatever is then resolvable. Return - * false once the document can run no further data scripts. + * 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). */ @@ -677,21 +690,38 @@ export function createFrameHost(options = {}) { // one copy any number of sibling mounts share. Stores live for the // session; eviction policy (data-layer coupling + LRU floor, principles // §5.1) hangs off the purge form of `unregister`. + // + // A store is one response's (frames-rulings 1.4, full form; 2.1): its + // `records` are the latest version's and every record of the previous + // 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 + // 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. const stores = new Map(); const storeFor = id => { let store = stores.get(id); if (!store) stores.set(id, (store = { version: undefined, records: {} })); return store; }; + // Landings awaited per address (see `landing`): the promise handed out + // while the address's first response is in flight, with its resolver. + const landings = new Map(); + const lands = records => "" in records || ":error" in records || ":complete" in records; // Mirrors FrameImpl.apply's version policy (policy A): stale writes drop, - // a newer version is a morph, not a reset — content and slot records - // carry over; per-response segment/error state clears (fragment names - // restart each stream). + // a newer version replaces the records wholesale, the same version + // accumulates. const write = (store, version, records) => { if (store.version !== undefined && version < store.version) return false; if (store.version === undefined || version > store.version) { store.version = version; - clearStreamRecords(store.records); + store.records = {}; } // Root assets reuse one key for the shell and late chunks. Accumulate // their arrays so frames registered later receive the full snapshot. @@ -720,13 +750,13 @@ export function createFrameHost(options = {}) { // slots) between records, so the first record would mount every // discovered occurrence — the rest record-less — and each later // record would look like an args CHANGE, re-calling with incomplete - // args and wiping adopted interiors (the #547 boot face). - frame.apply({ version: store.version, r: store.records }); - // The store's version belongs to whatever stream space last wrote it; - // everything from here on is this registration's own. Rebase so the - // next live write establishes the frame's baseline — the host's own - // version guard (above) is what keeps genuinely stale chunks out. - frame.rebase && frame.rebase(); + // args and wiping adopted interiors (the #547 boot face). A mount + // opened while a response is in flight seeds the source's committed + // value (`shown`, an older version: the in-flight version's writes + // then bump it, as they bump every mount of the address) — a cold + // address seeds nothing and the mount pends on its landing. + if (!store.open) frame.apply({ version: store.version, r: store.records }); + else if (store.shown) frame.apply({ version: store.shownVersion, r: store.shown }); } }, /** @@ -752,11 +782,18 @@ export function createFrameHost(options = {}) { if (html != null) { store.records[""] = { kind: "html", value: html }; if (store.version === undefined) store.version = 0; + // The capture is the document's landing (version 0): what a + // later mount shows, a refetch's flight notwithstanding. + store.shown = store.records; + store.shownVersion = store.version; } } } } - if (!frame) stores.delete(id); + if (!frame) { + stores.delete(id); + landings.delete(id); + } }, apply(chunk) { // Data payloads are response-scoped; apply immediately, no store needed. @@ -767,12 +804,47 @@ export function createFrameHost(options = {}) { // Write through to the resident store first: the store version-guards // once for all mounts, and an unmounted address simply warms. const records = chunkToRecords(chunk); - if (!write(storeFor(chunk.id), chunk.version, records)) return; + const store = storeFor(chunk.id); + if (!write(store, chunk.version, records)) return; + 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 + // the landing — before the frames apply, so a mount gating on it reads + // the content in the beat its frame shows it. The landing fans out as + // the version's WHOLE record set: a mount opened mid-flight seeded the + // committed value and has none of this version's earlier writes (a + // frame already at the version re-receives the same records — a + // no-op). A document-adopted store never opens: its content is page + // markup, written by no `start`. + if (chunk.type === "start") store.open = true; + else if (lands(records)) { + store.open = false; + store.shown = r = store.records; + store.shownVersion = chunk.version; + const wait = landings.get(chunk.id); + if (wait) { + landings.delete(chunk.id); + wait.r(); + } + } const set = frames.get(chunk.id); if (set) { - for (const frame of set) frame.apply({ version: chunk.version, r: records }); + for (const frame of set) frame.apply({ version: chunk.version, r }); } }, + landing(id) { + const store = stores.get(id); + // Nothing to wait for: no response in flight, or the address has a + // landing to show already — the committed value a mount reads + // (holds-latest) while a later response is in flight. + if (!store || !store.open || store.shown) return undefined; + let wait = landings.get(id); + if (!wait) { + landings.set(id, (wait = {})); + wait.p = new Promise(r => (wait.r = r)); + } + return wait.p; + }, preview(chunk, resolve) { if (chunk.type !== "slot") return; const set = frames.get(chunk.id); @@ -966,40 +1038,29 @@ class FrameImpl { return; } else if (v > this.#version) { // Policy A: version only guards against stale (older) writes. A newer - // version is an in-place update, not a reset — the store, applied-root, - // and slot records are kept so the reconciler morphs server content - // while client-owned slots/regions and their state survive (e.g. across - // a client-side navigation). Stale discard is the `v < version` branch; - // a genuine teardown is `dispose()`. + // version is an in-place update of the DOM, not a teardown: the + // element stays, mounted slots and regions keep their client state + // (e.g. across a client-side navigation), and the reconciler morphs + // the new content over the old. Stale discard is the `v < version` + // branch; a genuine teardown is `dispose()`. // - // Segment state, though, is per-response: fragment names restart in - // every stream (`pl-0`, ...), so a new version's placeholder must not - // be skipped because the OLD version's segment of the same name - // already revealed — nor revealed instantly with the old version's - // content. Reveal bookkeeping and seg/error records reset; slot - // records stay (dedupe is what preserves occurrence state). + // The STORE, though, is one response's (frames-rulings 1.4, full + // form; 2.1): what this frame applied under the previous version — + // the root it morphed, the segments it revealed, the slot records it + // mounted — is that landing's, and the new version replaces it + // wholesale. Fragment names and hole ids restart per stream, so a new + // version's placeholder is never skipped for an old reveal of the + // 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. this.#version = v; this.#resetStreamState(); } - for (const key in write.r) { - const incoming = write.r[key]; - // Slot-record dedupe: streams re-send their slot chunks, and re-call - // triggers on record identity — so an equivalent re-sent record keeps - // the existing object (no re-call, occurrence state preserved). - if ( - incoming && - incoming.kind === "slot" && - key.charCodeAt(0) === 115 /* s */ && - key.startsWith("slot:") - ) { - const existing = this.#store[key]; - if (existing && existing.kind === "slot" && argsEquivalent(existing.args, incoming.args)) { - continue; - } - } - this.#store[key] = incoming; - } + for (const key in write.r) this.#store[key] = write.r[key]; this.#flush(); } @@ -1061,19 +1122,23 @@ class FrameImpl { } /** - * Per-stream bookkeeping reset (the version-bump/rebind branch): reveal and - * fallback state, the once-per-stream error notification, and the seg/error - * records — fragment names restart in every stream. `root` additionally - * drops the root record (rebind's case: a flush between the rebind and the - * new stream's html must find no stale shell to re-apply). + * The applied state is one version's (frames-rulings 2.1): the version + * bump and the rebind replace it wholesale — the store (every record of + * 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. */ - #resetStreamState(root) { + #resetStreamState() { + this.#store = Object.create(null); + this.#appliedRootValue = undefined; this.#revealed.clear(); this.#fallbackShown.clear(); this.#appliedHoles.clear(); this.#processedAssets = new WeakSet(); this.#errorNotified = false; - clearStreamRecords(this.#store, root); } #flush() { @@ -1293,54 +1358,46 @@ class FrameImpl { this.#mountedSlots.delete(occurrence); this.#runSlotCleanups(occurrence); } - if (!this.#mountedSlots.has(occurrence)) { - // solidjs/solid#2968 (interim — A5 of the principles doc removes the - // skew): an invoked occurrence's args record rides the document as a - // data script, and nothing formally orders that script before the - // event that triggers adoption. Recordless here is therefore - // ambiguous while records may still arrive: a genuine direct-insert - // position, or an invoked occurrence whose record the parser hasn't - // reached. Guessing "content" evaluates the wrapper's render-prop - // callback as a zero-arg accessor — a props read halts the reactive - // system. So defer this occurrence, re-drain the document's records - // a macrotask later (all currently parsed scripts run first), and - // classify only once `recordsPending` says the document can deliver - // no more — NOT after a fixed single beat: a streamed document held - // open on async content (or slow dev-mode module timing) keeps - // records arriving across many macrotasks, and a one-shot defer - // classified the tail of them as content (PR #559). The wait is - // bounded by the same contract as everything else here: - // recordsPending flips false when the document completes with no - // fragment left to reveal (truncation included — the ledger rejects - // stragglers). Deferral is invisible on screen: an adopted - // occurrence's server-rendered interior is already in the DOM; the - // mount is the hydration attach. Full syncs only: a scoped segment - // fill renders into a detached fragment a later full sync can't - // reach — and its records rode the same stream, ahead of its markup. - if ( - record === undefined && - !root && - this.#options.adopt && - this.#options.recordsPending?.() - ) { + // 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 + // itself) is a direct-insert position and has no record by design. + // So a called occurrence found recordless is one whose record has not + // been DELIVERED yet — the version in flight has not sent it (the + // store is one response's: the previous version's record left at + // the bump), or the document's data script for it has not run + // (#2968) — never a direct-insert position to classify. It waits: a + // fresh mount is not invoked (invoking it argless evaluates a render + // prop as a zero-arg accessor — a props read that halts the reactive + // system, contract C18), a mounted one keeps its applied args; the + // write that delivers the record re-syncs. Waiting is invisible on + // 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. + if (record === undefined && isCalled(occurrence)) { + if (this.#options.adopt && this.#options.recordsPending?.()) { this.#recordRefresh ??= setTimeout(() => { this.#recordRefresh = null; if (this.#disposed) return; this.#options.drainRecords?.(); this.#syncSlots(); }); - continue; - } - // A CALLED occurrence (`prop#n`) always has a record — the producer - // emits it at the call, ahead of the markup that reads it — so - // marked positions with none here, once records can no longer - // arrive, are the protocol's invariant broken (a record dropped, or - // marker and record minted under different ids), never something - // the fill can fix. The mount below still runs, as it always has; - // dev says why its args are empty. A bare occurrence (the prop - // itself) has no record by design. - if ("_SOLID_DEV_" && consumers && record === undefined && occurrence.indexOf("#") !== -1) + } else if ("_SOLID_DEV_" && consumers && !this.#mountedSlots.has(occurrence)) devSlotOrphan(this, occurrence, consumers, "record"); + continue; + } + if (!this.#mountedSlots.has(occurrence)) { // Direct-insert occurrences have no `slot:` record and mount with // empty props; render-function occurrences mount with resolved props. // Mounting replaces the range interior: on a fresh stream it is @@ -1857,22 +1914,16 @@ class FrameImpl { this.#element.setAttribute(FRAME_ID_ATTR, id); } this.#version = undefined; - // Root affinity is per stream, like the version: the new address's html - // may be byte-identical to the old one's (slot-driven content ships its - // differences as records, not markup), and the value-skip must not - // swallow the new stream's morph — consumers gate on `onApply` to learn - // the new call ANSWERED, so an identical shell still has to apply as - // this address's. The old root RECORD leaves with it: a flush between - // this rebind and the new stream's html (its start chunk, a slot write) - // must find no root to re-apply, or the stale shell would morph and - // answer the gate with the PREVIOUS call's content. The DOM keeps - // showing the old content either way (async-holds-latest owns that); - // a warm re-registration re-seeds its own root record and still - // answers synchronously. The reset also re-arms the once-per-stream - // error notification: the record it fired for left with the old stream, - // and the NEW address's error must reach the gate too. - this.#appliedRootValue = undefined; - this.#resetStreamState(true); + // The applied state leaves with the old address (see #resetStreamState): + // the new address's html may be byte-identical to the old one's + // (slot-driven content ships its differences as records, not markup), + // and the value-skip must not swallow the new stream's morph; the old + // root RECORD goes too, so a flush between this rebind and the new + // stream's html finds no stale shell to re-apply. The DOM keeps showing + // the old content either way (async-holds-latest owns that); a warm + // re-registration re-seeds its own root record and still answers + // synchronously. + this.#resetStreamState(); if (host) host.register(id, this); } @@ -2282,6 +2333,12 @@ function propOf(occurrence) { return hash === -1 ? occurrence : occurrence.slice(0, hash); } +/** Whether an occurrence id names a render-prop CALL (`prop#n`, minted with + * a record) rather than the bare prop (a direct-insert position). */ +function isCalled(occurrence) { + return occurrence.indexOf("#") !== -1; +} + function isDataRef(value) { return !!value && typeof value.$ref === "string"; } @@ -2580,44 +2637,6 @@ function eachInRange(start, key, cb) { return n; } -/** - * Delete the per-stream records from a store: seg/hole/error state is - * response-scoped (fragment names and hole ids restart in every stream), - * while slot records persist (dedupe is what preserves occurrence state). - * `root` also drops the root html record — rebind's case only. - */ -function clearStreamRecords(records, root) { - for (const key in records) { - if (/^(seg|hole|attr):/.test(key) || key === ":error" || (root && key === "")) { - delete records[key]; - } - } -} - -/** - * Whether two slot-args objects are equivalent for re-call purposes: - * primitives by value, `{$frame}` region refs by id (region content updates - * flow through the region's own chunks — a re-call is never needed for - * them). `{$ref}` codec refs are response-scoped — the same id can decode - * to a new value on a later stream — so they conservatively count as - * changed. - */ -function argsEquivalent(a, b) { - if (a === b) return true; - if (!a || !b) return false; - const ka = Object.keys(a); - const kb = Object.keys(b); - if (ka.length !== kb.length) return false; - for (const key of ka) { - const va = a[key]; - const vb = b[key]; - if (va === vb) continue; - if (isFrameRef(va) && va.$frame === vb?.$frame) continue; - return false; - } - return true; -} - // --- Morph ----------------------------------------------------------------- // // A zero-allocation, two-cursor server-owned DOM patch path: text/attribute diff --git a/packages/web/frames/src/frame-transport.ts b/packages/web/frames/src/frame-transport.ts index 9977db119..51c89458a 100644 --- a/packages/web/frames/src/frame-transport.ts +++ b/packages/web/frames/src/frame-transport.ts @@ -796,6 +796,19 @@ export function createServerComponentHandler({ if (connections.get(address) === connection) connections.delete(address); }); }; + /** + * 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. + */ + const begin = (address, version, response) => { + if (onStream) onStream(address, version, response); + host.apply({ type: "start", id: address, version }); + }; return { intercept: intercept && @@ -867,7 +880,7 @@ export function createServerComponentHandler({ return binding; } const version = bump(address); - if (onStream) onStream(address, version, response); + begin(address, version, response); // 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 @@ -895,7 +908,7 @@ export function createServerComponentHandler({ // behind a completed body. const entry = host.get(address) ? stage(address, binding, version, response) : undefined; if (entry) entry.stream(address, version); - else if (onStream) onStream(address, version, response); + else begin(address, version, response); const target = entry || host; const applied = applyFrameResponse(response, target, { as: address, version }).catch(err => target.apply({ 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 18ad49e42..5df319d1e 100644 --- a/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx +++ b/packages/web/test/consistency/c02-revealed-occurrence-mounts.spec.tsx @@ -231,54 +231,54 @@ describe("C2 — no inert server content", () => { ); // Arm (b): a direct-insert occurrence (`children`) is recordless by design. - // Revealed into the adopted region, it must mount all the same. - test.fails( - "(b) direct-insert `children` occurrence revealed after adoption: mounted and live", - async () => { - const fid = freshFid("c2b"); - const frag = "c2b"; - page = bootPage(pendingShell(fid, frag)); - page.declareFragment(frag); - const Comp = (globalThis as any)._$SC.r(fid); - const [tick, setTick] = createSignal(0); - const dispose = hydrate( - () => ( - - {tick()} - - ), - page.container - ); - await quiesce(); - expect(page.container.textContent).toBe("loading"); + // Revealed into the adopted region, it must mount all the same. Was red + // on `next` (the reveal applied nothing, so no sync ran over the revealed + // range); green under frames-rulings 2.3 — a reveal is an apply: the + // document face's reveal cascade syncs the adopting frame. + test("(b) direct-insert `children` occurrence revealed after adoption: mounted and live", async () => { + const fid = freshFid("c2b"); + const frag = "c2b"; + page = bootPage(pendingShell(fid, frag)); + page.declareFragment(frag); + const Comp = (globalThis as any)._$SC.r(fid); + const [tick, setTick] = createSignal(0); + const dispose = hydrate( + () => ( + + {tick()} + + ), + page.container + ); + await quiesce(); + expect(page.container.textContent).toBe("loading"); - page.revealFragment(frag, slotRange("children", liveChildrenHtml(fid))); - await quiesce(); - await quiesce(); - const b = page.container.querySelector("b")!; - expect(b).not.toBeNull(); - expect(page.container.textContent).toBe("0"); + page.revealFragment(frag, slotRange("children", liveChildrenHtml(fid))); + await quiesce(); + await quiesce(); + const b = page.container.querySelector("b")!; + expect(b).not.toBeNull(); + expect(page.container.textContent).toBe("0"); - setTick(1); - flush(); - // Observed on next: the revealed shows "0" after the bump (the - // client `children` JSX was never evaluated); no warning, no error. - // Expected: "1" — the occurrence mounted and its hole is live. Where - // it goes wrong: client.ts adoptBoundary's `fr.subscribe` callback is - // the only reaction to a reveal, and it does two things — claim nested - // `pl-*` placeholders and `drainRecords()`. `drainRecords` applies only - // NEW `sc:slot:`/`sc:region:` keys; a direct-insert occurrence has no - // record by design, so nothing reaches `host.apply`, no `#flush` runs, - // and frame-client.ts `#syncSlots` — the only place a marker pair is - // discovered and mounted — never walks the revealed content. The - // reveal itself (`$dfr` → `_$HY.fe`) carries no re-sync. - expect(page.container.textContent).toBe("1"); - expect(page.container.querySelector("b")).toBe(b); - expect(page.warnings).toEqual([]); - expect(page.errors).toEqual([]); - dispose(); - } - ); + setTick(1); + flush(); + // Observed on next: the revealed shows "0" after the bump (the + // client `children` JSX was never evaluated); no warning, no error. + // Expected: "1" — the occurrence mounted and its hole is live. Where + // it goes wrong: client.ts adoptBoundary's `fr.subscribe` callback is + // the only reaction to a reveal, and it does two things — claim nested + // `pl-*` placeholders and `drainRecords()`. `drainRecords` applies only + // NEW `sc:slot:`/`sc:region:` keys; a direct-insert occurrence has no + // record by design, so nothing reaches `host.apply`, no `#flush` runs, + // and frame-client.ts `#syncSlots` — the only place a marker pair is + // discovered and mounted — never walks the revealed content. The + // reveal itself (`$dfr` → `_$HY.fe`) carries no re-sync. + expect(page.container.textContent).toBe("1"); + expect(page.container.querySelector("b")).toBe(b); + expect(page.warnings).toEqual([]); + expect(page.errors).toEqual([]); + dispose(); + }); // Arm (c2): reveal BEFORE hydrate, post-done. Global hydration has already // completed in this worker (forced here with a throwaway pass, so the arm 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 2fc5635f2..4fdf3810e 100644 --- a/packages/web/test/consistency/c04-record-applies-once.spec.tsx +++ b/packages/web/test/consistency/c04-record-applies-once.spec.tsx @@ -204,47 +204,42 @@ describe("C4 — a record applies exactly once, in any drain order", () => { // and record are in the document (and the `_fr` settled) when the boundary // adopts, but its `$df` is deferred to the group's reveal. The adopt-time // drain applies the record while the range is still inside the template; - // the reveal then brings the range into the shown content. - test.fails( - "(d) drain-before-reveal: a record drained before its range is shown takes effect once the range is revealed", - async () => { - const fid = freshFid("c4d"); - const frag = "c4d-frag"; - page = bootPage(frameHtml(fid, `
    ${placeholderHtml(frag, "loading")}
`)); - page.slotRecord(fid, "item#0", { text: "one" }); - const reveal = parkFragment(page, frag, slotRange("item#0", fillHtml(fid, "item#0", "one"))); - const Comp = (globalThis as any)._$SC.r(fid); - const invocations: number[] = []; - const pushes: string[] = []; - const dispose = hydrate(() => , page.container); - await quiesce(); - // Pending at adopt: the fallback shows, the record is in the store. - expect(page.container.textContent).toBe("loading"); - expect(invocations.length).toBe(0); + // the reveal then brings the range into the shown content. Was red on + // `next`; green under frames-rulings 2.3/2.4 — a reveal is an apply (the + // reveal cascade syncs the adopting frame, which finds the range and the + // record it holds), and `appliedRecords` is a delivery dedupe, not an + // application: "applied" means shown. + test("(d) drain-before-reveal: a record drained before its range is shown takes effect once the range is revealed", async () => { + const fid = freshFid("c4d"); + const frag = "c4d-frag"; + page = bootPage(frameHtml(fid, `
    ${placeholderHtml(frag, "loading")}
`)); + page.slotRecord(fid, "item#0", { text: "one" }); + const reveal = parkFragment(page, frag, slotRange("item#0", fillHtml(fid, "item#0", "one"))); + const Comp = (globalThis as any)._$SC.r(fid); + const invocations: number[] = []; + const pushes: string[] = []; + const dispose = hydrate(() => , page.container); + await quiesce(); + // Pending at adopt: the fallback shows, the record is in the store. + expect(page.container.textContent).toBe("loading"); + expect(invocations.length).toBe(0); - // The group's reveal. - expect(reveal()).toBe(1); - await quiesce(); - await quiesce(); - expect(page.container.textContent).toBe("one"); - // Observed on next: the range is shown (text "one") but the record - // took effect ZERO times — invocations 0, no push; nothing logged. - // Expected: exactly one invocation with { text: "one" }. Where it goes - // wrong: client.ts adoptBoundary's adopt-time `drainRecords` applies - // `sc:slot::item#0` to the store (`host.apply` → `FrameImpl.apply` - // → `#flush` → `#syncSlots`), but the sync finds no `item#0` marker - // pair — the range is still inside `