From ba88b34f1b6443b80dff98233654ac9b81e149a2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 2 Oct 2026 12:57:18 -0700 Subject: [PATCH 1/2] frames: refetched content lands at the transition's commit (#3759, ported onto L2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refetch or single-flight region for a call a boundary is showing is staged in the transport and the call resolves to a content token; the mount lands it in the two halves of the render effect that follows its address accessor. Under the hold model the two halves ride core's seams unchanged: the compute half is the delivering transaction's pass (its `preview` writes — the staged slot args — are held with it and commit in the frame whose landing dissolves the lane's guesses), the effect half is its `land` (the commit replays the staged chunks). Both signals pins (`compute-write-joins-transition`, `settle-folds-queued-writes`) and the optimistic-hold specs were green on untouched L2; the morph-in-transition specs were red (3/3) and are closed by the staging. Composes with e133516c8 (the switch gate re-arms in the pass): the rebind — display — moves to the effect half so a switch's content waits for the commit too (`frames-morph-in-transition` switch case), while the gate settles on the new address's first write through a frameless waiter registered on the host, so a second switch mid-flight still binds (`call-driven-lifecycle`). No core seam touched. Size caps not raised: frames 12,997 -> 13,770 B (cap 13.00 KB), page base 44,017 -> 44,765, page live 47,656 -> 48,447 — reported for the maintainer's exception. Signals 4898/0/2, web 1138/0 + server 1411/2 skipped + hydrate 275/0. Co-authored-by: Claude Co-authored-by: Cursor --- .changeset/frames-refetch-lands-at-commit.md | 9 + .../compute-write-joins-transition.test.ts | 109 +++++++ .../tests/settle-folds-queued-writes.test.ts | 86 ++++++ packages/web/frames/src/client.ts | 251 ++++++++++------ packages/web/frames/src/frame-client.ts | 109 ++++++- packages/web/frames/src/frame-transport.ts | 206 +++++++++++-- packages/web/test/frames-live.spec.tsx | 13 +- .../test/frames-morph-in-transition.spec.tsx | 243 ++++++++++++++++ .../web/test/frames-optimistic-hold.spec.tsx | 275 +++++++++++++++++- 9 files changed, 1158 insertions(+), 143 deletions(-) create mode 100644 .changeset/frames-refetch-lands-at-commit.md create mode 100644 packages/signals/tests/compute-write-joins-transition.test.ts create mode 100644 packages/signals/tests/settle-folds-queued-writes.test.ts create mode 100644 packages/web/test/frames-morph-in-transition.spec.tsx diff --git a/.changeset/frames-refetch-lands-at-commit.md b/.changeset/frames-refetch-lands-at-commit.md new file mode 100644 index 000000000..d7ad00e9e --- /dev/null +++ b/.changeset/frames-refetch-lands-at-commit.md @@ -0,0 +1,9 @@ +--- +"@solidjs/web": patch +--- + +Server-component content for a call a boundary is showing now lands with the transition that read it, instead of morphing in when the response arrives. A refetch, or a single-flight region, is staged: the call resolves to a binding naming the staged version, and the mount commits it when that binding reaches it. The slot args go first, under the transition, so a fill deriving optimistic intent over a server arg never reads the old arg. The markup lands at the commit. Single-flight regions show when the integration's cache takes the mutation's slice, so any cache that subscribes to flight data drives it, not just Solid Router. + +An address switch's new content lands at the same commit: the frame re-binds in the run of the effect that follows the address, while the switch gate re-arms in its pass and settles on the new address's first content through a frameless registration on the host — so a second switch mid-flight still binds to the live call, and the morph no longer lands beside siblings the transaction still holds. + +Behaviour change: a response for a showing call that no reader mounts is never shown. Previously it morphed every mount of the address on arrival. diff --git a/packages/signals/tests/compute-write-joins-transition.test.ts b/packages/signals/tests/compute-write-joins-transition.test.ts new file mode 100644 index 000000000..f622e6ea9 --- /dev/null +++ b/packages/signals/tests/compute-write-joins-transition.test.ts @@ -0,0 +1,109 @@ +// A write made from a computation's compute half while it runs under a held +// transition joins that transition: it is held with everything else the +// transition holds, and computations under the transition read it ahead of +// the settle that ends the action's optimistic overrides. +// +// Consumers rely on this: a server-component mount commits a refetched +// region in two phases. When its address accessor delivers the new content +// token, the mount's compute half pushes the region's slot args into the +// live fills — under the transition, so a fill deriving `intent ?? arg` +// reads the new arg while the intent is still live — and its effect half +// morphs the markup at the commit. +import { + action, + createMemo, + createOptimistic, + createRenderEffect, + createRoot, + createSignal, + flush +} from "../src/index.js"; + +afterEach(() => flush()); + +const tick = () => new Promise(r => setTimeout(r)); + +function setup() { + let release!: () => void; + let releaseOther!: () => void; + const trace: string[] = []; + const shown: string[] = []; + const [x, setX] = createSignal(0); + const [token, setToken] = createSignal("t0", { ownedWrite: true }); + const [arg, setArg] = createSignal(false, { ownedWrite: true }); + const [intent, setIntent] = createOptimistic(undefined); + createRoot(() => { + const source = createMemo(() => { + if (x() === 0) return "t0"; + const p = new Promise(r => (release = () => r("t1"))); + // The delivery: registered before the memo's own handler, so it runs + // just ahead of the landing (as `dynamic` delivers an address). + p.then(v => setToken(v)); + return p; + }); + // The mount: its compute half pushes the args a new token carries. + createRenderEffect( + () => { + const t = token(); + if (t === "t1") setArg(true); + return t; + }, + t => void shown.push(`token=${t}`) + ); + // The fill: derives the intent over the server's arg. + createMemo(() => trace.push(`${arg()}/${intent() ?? arg()}`)); + createRenderEffect(arg, v => void shown.push(`arg=${v}`)); + createRenderEffect(source, () => {}); + // A second read the same transition waits on (the `other` flag only). + const other = createMemo(() => + x() === 2 ? new Promise(r => (releaseOther = () => r("o1"))) : "o0" + ); + createRenderEffect(other, () => {}); + }); + flush(); + shown.length = 0; + const write = action(function* (n: number) { + setIntent(true); + setX(n); + }); + return { + trace, + shown, + write: (n = 1) => write(n), + release: () => release(), + releaseOther: () => releaseOther() + }; +} + +describe("a compute-half write under a held transition joins it", () => { + it("is held while the transition still waits, and commits with it", async () => { + const t = setup(); + t.write(2); + await tick(); + + t.release(); + await tick(); + // The token landed and the compute half wrote the arg; the transition + // still waits on `other`, so neither the token nor the arg is shown. + expect(t.shown).toEqual([]); + + t.releaseOther(); + await tick(); + expect(t.shown).toEqual(["token=t1", "arg=true"]); + }); + + it("the fill never reads the server's old arg once the intent was written", async () => { + const t = setup(); + expect(t.trace).toEqual(["false/false"]); + + t.write(); + await tick(); + expect(t.trace).toEqual(["false/false", "false/true"]); + + t.release(); + await tick(); + expect(t.shown).toEqual(["token=t1", "arg=true"]); + expect(t.trace.slice(2).filter(entry => entry.endsWith("/false"))).toEqual([]); + expect(t.trace.at(-1)).toBe("true/true"); + }); +}); diff --git a/packages/signals/tests/settle-folds-queued-writes.test.ts b/packages/signals/tests/settle-folds-queued-writes.test.ts new file mode 100644 index 000000000..3950ce932 --- /dev/null +++ b/packages/signals/tests/settle-folds-queued-writes.test.ts @@ -0,0 +1,86 @@ +// When an async node lands, core re-enters the transition waiting on it. +// Writes still queued at that point — made earlier in the same synchronous +// run, before the next flush — join that transition and commit with it. +// +// Consumers rely on this: `dynamic()` delivers a server component's new +// address to its mounted instance from the resolution callback, just before +// the landing, so the instance's rebind (and the morph it drives) waits for +// the transition's commit like everything else the transition holds. +// +// A write with nothing landing behind it is a plain write and applies now. +import { + action, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush +} from "../src/index.js"; + +afterEach(() => flush()); + +const tick = () => new Promise(r => setTimeout(r)); + +/** A transition held by two async reads, `a` and `b`; `s` is unrelated. */ +function setup(beforeLanding?: () => void) { + let releaseA!: () => void; + let releaseB!: () => void; + const log: string[] = []; + const [x, setX] = createSignal(0); + const [s, setS] = createSignal("s0"); + createRoot(() => { + const a = createMemo(() => { + if (x() === 0) return "a0"; + const p = new Promise(r => (releaseA = () => r("a1"))); + // Registered before the memo's own handler, so it runs first. + if (beforeLanding) p.then(beforeLanding); + return p; + }); + const b = createMemo(() => + x() === 0 ? "b0" : new Promise(r => (releaseB = () => r("b1"))) + ); + createRenderEffect(a, v => void log.push(`a=${v}`)); + createRenderEffect(b, v => void log.push(`b=${v}`)); + createRenderEffect(s, v => void log.push(`s=${v}`)); + }); + flush(); + log.length = 0; + action(function* () { + setX(1); + })(); + return { log, setS, releaseA: () => releaseA(), releaseB: () => releaseB() }; +} + +describe("a landing folds the writes queued ahead of it into its transition", () => { + it("a write queued just before a landing commits with the transition", async () => { + let setS!: (v: string) => void; + const t = setup(() => setS("s1")); + setS = t.setS; + await tick(); + + t.releaseA(); + await tick(); + // `a` landed and its transition still waits on `b`: so does the write. + expect(t.log).toEqual([]); + + t.releaseB(); + await tick(); + expect(t.log).toContain("s=s1"); + expect(t.log).toContain("a=a1"); + expect(t.log).toContain("b=b1"); + }); + + it("a write with no landing behind it applies at once", async () => { + const t = setup(); + await tick(); + + t.setS("s1"); + await tick(); + expect(t.log).toEqual(["s=s1"]); + + t.releaseA(); + t.releaseB(); + await tick(); + expect(t.log).toEqual(["s=s1", "b=b1", "a=a1"]); + }); +}); diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 7902540b1..6634f8994 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -36,7 +36,14 @@ import type { Element as SolidElement } from "solid-js"; // server-functions/client import below is. import { insert, assign } from "@solidjs/web"; import { createFrame, createFrameElement, createFrameHost, FRAME_ID_ATTR } from "./frame-client.js"; -import { COMPONENT_BINDING, createServerComponentHandler } from "./frame-transport.js"; +import { + COMPONENT_BINDING, + STAGED_DATA, + contentAddress, + createServerComponentHandler, + stagedContent, + type ServerComponentHandlerOptions +} from "./frame-transport.js"; // The container tier (DR-2 case 3): server projections cross the border as // TRACES (snapshot + patch batches) and materialize back into live local // projections. The materializer is solid's (it owns the patch protocol); @@ -134,20 +141,108 @@ function loadCodec() { })); } const tables = new Map(); -function ensureTable(root: string) { - let table = tables.get(root); - if (!table && codec) tables.set(root, (table = codec.createJSONDataTable())); +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) { - if (tables.has(id)) return ensureTable(id); - for (const root of tables.keys()) if (id.startsWith(root + ".")) return ensureTable(root); +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)) + }; +} +/** + * The render effect that follows a mount's address accessor. `dynamic` + * writes the accessor from the resolution that lands the call, so the + * write is held by the transaction that read the call and both halves of + * 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 + * 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. + * + * 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`). + * + * 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. + */ +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; + }; + 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 app-wide shared frame host (created lazily): one chunk router with * per-response codec data tables. @@ -272,7 +367,9 @@ function claimRender(prefix: string, existing: Node[], render: () => any) { * into the same instance" semantic compiled components already have. */ function liveSlotProps(initial: Record, ctx: any) { - const [args, setArgs] = createSignal(initial); + // `ownedWrite`: a staged response's args arrive from the mount's compute + // half (see followAddress), under the transition that delivered them. + const [args, setArgs] = createSignal(initial, { ownedWrite: true }); ctx.onUpdate((next: Record) => setArgs(() => next)); return slotArgsProxy(args); } @@ -896,7 +993,7 @@ function boundaryComponent(host: any, fnId: string) { // 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 ? binding() : fnId; + 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 @@ -909,10 +1006,17 @@ function boundaryComponent(host: any, fnId: string) { // 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 and rebind seeds run in the - // follow effect's write-legal half. + // 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. @@ -932,15 +1036,14 @@ function boundaryComponent(host: any, fnId: string) { // failed stream holds the fallback. onApply: () => { applied = true; - if (release) { - release(); - release = undefined; - } - setGate && setGate(undefined); + settle(); } }); - // `ownedWrite`: the re-arm below is written from a pass (the follow's - // compute), and a warm rebind's seed releases the gate from inside it. + // `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 } @@ -951,45 +1054,14 @@ function boundaryComponent(host: any, fnId: string) { // 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; an in-flight stream morphs in; - // slot occurrences whose ids persist keep their client state). - // `rebind` no-ops on the same address. - // - // An address SWITCH also 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) applies, the boundary - // is still showing the PREVIOUS call's content, and the source that - // drove the switch must keep reading pending or the UI tears ("count - // is 1" beside count-0's content). Async-holds-latest keeps the old - // content on screen while the re-armed gate pends; a server-rendered + // 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. Arm-then-rebind is - // self-correcting for warm stores: rebind's registration seeds - // synchronously, and the seed's apply releases the gate before any - // reader sees it. Only switches with a stream begun gate (same rule - // as the mount gate) — nothing else is coming to release one. - // - // Done in the PASS that sees the new address (the compute), not an - // effect's run: the re-arm and the rebind are plumbing, not display. - // Under the hold model a switch delivered while the previous switch's - // gate still pends lands the binding in the frame that gate holds, - // and an effect's run is stashed with that frame — behind the very - // gate the rebind would release (the superseded call never answers; - // the live one cannot be bound to). A second switch mid-flight is - // the shape (`call-driven-lifecycle`). - let bound: string | undefined; - createRenderEffect( - () => { - const address = binding(); - if (bound !== undefined && address !== bound && tables.has(address)) { - applied = false; - setGatePromise(arm()); - } - bound = address; - frame.rebind(address); - }, - () => {} - ); + // 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 @@ -1247,7 +1319,7 @@ function adoptBoundary( // binds the address's resident store, while `id` — the function id, the // document's wire name — stays the key records and region ids on the page // are written under. - const address = binding ? binding() : documentAddress(id); + const address = binding ? contentAddress(binding()) : documentAddress(id); // Occlusion records (case 3, document face): content a client wrapper // never rendered during SSR shipped ONCE as hydration data instead of // markup. Apply the records BEFORE binding the frame — the host buffers @@ -1383,6 +1455,16 @@ function adoptBoundary( // 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, { adopt: true, host, @@ -1390,15 +1472,7 @@ function adoptBoundary( slots: slotsFor(props), ownerScope: boundaryScope(owner), reveal: revealSeam(owner), - // Any apply for the currently bound address — a morph, a reveal, an - // error record — answers an armed switch gate (see below). - onApply: () => { - if (release) { - release(); - release = undefined; - } - setGate && setGate(undefined); - }, + 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 @@ -1427,44 +1501,27 @@ function adoptBoundary( claimScope: id } as {}) }); - // Follow the live address binding (see boundaryComponent): a kept - // resolution delivers the new call's address and the adopted frame - // re-binds its pull. + // Follow the live address binding (see boundaryComponent and + // followAddress): a kept resolution delivers the new call's address, or a + // 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). The binding resolved at response-header time, which is not an - // answer: until the new address's first content (or error) applies, the - // boundary still shows the t=0 call's content and the source that drove - // the switch must keep reading pending. 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. Arm-then-rebind is self-correcting for warm stores - // (rebind's registration seeds synchronously and the seed's apply - // releases in the effect's write-legal half); only switches with a - // stream begun gate — nothing else is coming to release one. + // 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. 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()); - // Re-arm and rebind in the pass that sees the new address (see the - // call-driven mount: an effect's run would be stashed behind the gate - // it releases when a switch lands mid-hold). - let bound: string | undefined; - createRenderEffect( - () => { - const address = binding(); - if (bound !== undefined && address !== bound && tables.has(address)) setGatePromise(arm()); - bound = address; - frame.rebind(address); - }, - () => {} - ); + followAddress(host, frame, binding, address, () => setGatePromise(arm()), settle); // The pending observer (no-op effect half: the pend IS the point). createRenderEffect( () => (gate(), undefined), @@ -1538,6 +1595,9 @@ export function installServerComponents(host: any = getFrameHost()) { // 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 @@ -1560,7 +1620,8 @@ 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 // by the transport, since hydration data seeds caches directly — are what diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index c3017cd95..d28248b1f 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -209,6 +209,15 @@ export interface Frame { * snapshot. */ have?(): Record | undefined; + /** + * Push a staged response's slot args into the live occurrences they + * address, ahead of the records' real apply (see `FrameHost.preview`). + * @internal + */ + preview?( + records: Record, + resolve?: (ref: { $ref: string }, frameId?: string) => unknown + ): void; /** Tear down: slot cleanups cascade, later chunks are ignored. Idempotent. */ dispose(): void; } @@ -228,6 +237,16 @@ export interface FrameHost { /** Remove one frame (or all frames of the id when `frame` is omitted). */ unregister(id: string, frame?: Frame): void; 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. + * @internal + */ + 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; serialize(value: unknown): { $ref: string }; @@ -754,6 +773,13 @@ export function createFrameHost(options = {}) { for (const frame of set) frame.apply({ version: chunk.version, r: records }); } }, + preview(chunk, resolve) { + 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); + }, get(id) { const set = frames.get(id); return set && set.values().next().value; @@ -977,6 +1003,63 @@ class FrameImpl { this.#flush(); } + /** + * The reactive half of a staged response (see FrameHost.preview): each + * slot record whose occurrence is mounted here with a live binding pushes + * 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). + * + * 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. + */ + preview(records, resolve?, inherited?) { + const adopted = new Set(); + if (this.#disposed) return adopted; + for (const key in records) { + const record = records[key]; + if (!record || record.kind !== "slot" || !key.startsWith("slot:")) continue; + if (inherited && key in this.#store) continue; + const occurrence = key.slice(5); + const update = this.#mountedSlots.has(occurrence) && this.#slotUpdaters.get(occurrence); + if (!update || this.#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); + this.#slotArgs.set(occurrence, record); + adopted.add(key); + if (!same) update(props); + } + 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 (!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) { + const regions = this.#slotRegions.get(occurrence); + for (const key in args) { + const entry = regions && regions.get(key); + if (isFrameRef(args[key]) && !(entry && entry.childId === args[key].$frame)) return true; + } + return false; + } + /** * Per-stream bookkeeping reset (the version-bump/rebind branch): reveal and * fallback state, the once-per-stream error notification, and the seg/error @@ -1523,22 +1606,28 @@ class FrameImpl { * 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) { - const { host, id } = this.#options; - if (host) - for (const key in args) { - if (isDataRef(args[key]) && host.resolve(args[key], id) === undefined) return true; - } + #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) { + #resolveArgs(slotKey, args, resolve?) { const host = this.#options.host; const regions = this.#regionsFor(slotKey); const props = {}; @@ -1550,7 +1639,7 @@ class FrameImpl { // 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 = host ? host.resolve(value, this.#options.id) : undefined; + const resolved = this.#resolveRef(value, resolve); let cache = this.#slotResolvedRefs.get(slotKey); if (!cache) this.#slotResolvedRefs.set(slotKey, (cache = {})); cache[key] = resolved; @@ -1611,7 +1700,7 @@ class FrameImpl { * non-JSON-comparable values fall back to "changed" (re-call) — the * conservative default. */ - #refArgsUnchanged(occurrence, record) { + #refArgsUnchanged(occurrence, record, resolve?) { const old = this.#slotArgs.get(occurrence); if (!record || record.kind !== "slot") return false; if (old && old.kind !== "slot") return false; @@ -1628,7 +1717,7 @@ class FrameImpl { if (isFrameRef(va) && isFrameRef(vb)) continue; if (isDataRef(va) && isDataRef(vb) && cache && key in cache) { const host = this.#options.host; - const next = host ? host.resolve(vb, this.#options.id) : undefined; + 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 diff --git a/packages/web/frames/src/frame-transport.ts b/packages/web/frames/src/frame-transport.ts index 4f6bcf717..9977db119 100644 --- a/packages/web/frames/src/frame-transport.ts +++ b/packages/web/frames/src/frame-transport.ts @@ -383,6 +383,50 @@ export const SERVER_COMPONENT_ADDRESS = /*#__PURE__*/ Symbol.for("solid.server-c */ export const COMPONENT_BINDING = /*#__PURE__*/ Symbol.for("solid.component-binding"); +// A content TOKEN names one staged response for an address: `address`, the +// 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. +const CONTENT_TOKEN = "\u0000"; + +/** + * The address a content token belongs to; a plain address passes through. + * @internal + */ +export function contentAddress(token: string): string; + +export function contentAddress(token) { + return token.split(CONTENT_TOKEN)[0]; +} + +/** + * The live handler's staged content (see `createServerComponentHandler`), + * 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. + * Installed by the handler (one active handler at a time, as for + * `resolveServerComponent` below). + * @internal + */ +export const stagedContent: { + preview(token: string): void; + 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): @@ -602,7 +646,13 @@ 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 }) { +export function createServerComponentHandler({ + host, + component, + onStream, + intercept, + [STAGED_DATA]: openData +}) { // Mount components, one per FUNCTION (the equals-gate identity). const byFn = new Map(); const componentFor = fnId => { @@ -630,9 +680,85 @@ export function createServerComponentHandler({ host, component, onStream, interc } 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). + 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) => { + // 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; + // Committed while its body is still arriving (a superseded reader + // settles on the newest token), the rest of the response writes + // 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); + }, + preview() { + if (host.preview) + for (const chunk of chunks) host.preview(chunk, data ? data.resolve : undefined); + }, + 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); + } + }; + staged.set(address, entry); + const comp = base[COMPONENT_BINDING].component; + const binding = props => comp(props, () => token); + binding[COMPONENT_BINDING] = { component: comp, address: token }; + latest.set(address, binding); + return entry; + }; + /** 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). */ + const named = (token, half) => { + const entry = staged.get(contentAddress(token)); + if (entry && entry.token === token) entry[half](); + }; + stagedContent.preview = token => named(token, "preview"); + stagedContent.commit = token => named(token, "commit"); /** Resolve a flight reference (see `ServerComponentPlugin`) to the call's * binding. Registered so repeat references stay identity-stable. */ - resolveServerComponent = (id, address) => bindingFor(address, id); + resolveServerComponent = (id, address) => settled(address, bindingFor(address, id)); // Version history per address, client-stamped: the client is the only // party that observes ordering across transports (a getter refetch, a // mutation's regions, a preload), so stale-guarding is per-address here. @@ -647,6 +773,9 @@ export function createServerComponentHandler({ host, component, onStream, interc const bump = address => { const version = (versions.get(address) || 0) + 1; versions.set(address, version); + // A newer response for the address outdates any staged one: committed + // late, the host's version guard would drop its writes anyway. + staged.delete(address); // Supersession is a death (§9.5, Client face 4): a newer version from // another response — a getter refetch, a preload, a mutation's region — // makes the open connection's later chunks inert under the stale-guard, @@ -751,28 +880,32 @@ export function createServerComponentHandler({ host, component, onStream, interc return binding; } const version = bump(address); - if (onStream) onStream(address, version, response); - const applied = applyFrameResponse(response, host, { as: address, version }).catch(err => - host.apply({ + // A refetch of a call a boundary is SHOWING is staged (see `stage`) + // and settles when its whole body is buffered, not at the header. The + // header is not an answer (#2977 said it for address switches; this + // is the same address): until the reader settles, the transition that + // drove the refetch — `isPending(source)`, a `refresh` inside an + // action's transaction holding an optimistic write over the old slot + // args (§9.2.2) — must keep reading pending, and the content must not + // land before it commits or the boundary tears against what that + // transition still holds. A cold mount or a switch to an address + // nothing shows writes through with header-time resolution: the mount + // 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 if (onStream) onStream(address, version, response); + const target = entry || host; + const applied = applyFrameResponse(response, target, { as: address, version }).catch(err => + target.apply({ type: "error", id: address, version, error: { message: String(err && err.message) } }) ); - // A refetch of a call a boundary is SHOWING settles when its response - // has applied, not at the header. The header is not an answer (#2977 - // said it for address switches; this is the same address): until the - // new content lands the boundary still shows the previous render, so - // a reader that drove the refetch — `isPending(source)`, a `refresh` - // inside an action's transaction holding an optimistic write over the - // old slot args (§9.2.2) — must keep reading pending or it tears. The - // hold is the whole body, as a single-flight mutation's already is. - // A cold mount or a switch to an address nothing shows keeps - // header-time resolution: the mount 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. - return host.get(address) ? applied.then(() => binding) : binding; + return entry ? applied.then(() => settled(address, binding)) : binding; }, /** @@ -836,14 +969,45 @@ export function createServerComponentHandler({ host, component, onStream, interc const payload = deserializeStream(new Response(source), flightCodec(getServerFunctionsCodec())); let carried = false; - await applyFrameResponse(response, host, { + // A region for a call a boundary is SHOWING is staged like a refetch + // (see `stage`): its chunks — nested regions' included, which ride under + // the root's wire name — buffer until a mount commits the token its + // envelope reference resolves, which happens when the integration's + // cache takes the slice this response delivers. A region nothing shows + // warms its store directly. + const regions = new Map(); + const regionOf = id => { + for (const [root, entry] of regions) + if (id === root || id.startsWith(root + ".")) return entry; + }; + const target = { + apply: chunk => { + const entry = regionOf(chunk.id); + if (entry) entry.apply(chunk); + else host.apply(chunk); + }, + prepareData: host.prepareData + }; + + await applyFrameResponse(response, target, { as, // Every frame in the response gets its own bump, and the integration // rotates that frame's response-scoped state (data tables) — a region // is as much a new stream into a boundary as a navigation is. version: frameId => { const version = bump(frameId); - if (onStream) onStream(frameId, version, response); + let entry = regionOf(frameId); + if (!entry && host.get(frameId)) { + entry = stage( + frameId, + frameId === as ? binding : byAddress.get(frameId), + version, + response + ); + if (entry) regions.set(frameId, entry); + } + if (entry) entry.stream(frameId, version); + else if (onStream) onStream(frameId, version, response); return version; }, onOutcome: text => { @@ -869,6 +1033,6 @@ export function createServerComponentHandler({ host, component, onStream, interc } // A mutation that answered with markup for its own boundary resolves to // the call's binding, like a getter would. - return rootId ? binding : envelope.value; + return rootId ? settled(address, binding) : envelope.value; } } diff --git a/packages/web/test/frames-live.spec.tsx b/packages/web/test/frames-live.spec.tsx index a6fccfdd5..f85dbfbea 100644 --- a/packages/web/test/frames-live.spec.tsx +++ b/packages/web/test/frames-live.spec.tsx @@ -14,7 +14,7 @@ // completes the iteration. Supersession by another response is a death; // an undeclared frame's death is an error; `onstatus` reports the wire. import { afterEach, describe, expect, test, vi } from "vitest"; -import { createRoot, createSignal, Loading } from "solid-js"; +import { createMemo, createRoot, createSignal, Loading } from "solid-js"; import { dynamic } from "../src/index.js"; import { installServerComponents } from "../frames/src/client.js"; import { createServerReference, live } from "../server-functions/src/client.js"; @@ -211,12 +211,16 @@ describe("frames consume live: supersession and undeclared death", () => { expect(m.div.querySelector("h1")!.textContent).toBe("live"); const h1 = m.div.querySelector("h1")!; - // Another caller reads the same (function, args): its response writes - // the address at a newer version. - await getRoomOnce(); + // Another caller reads the same (function, args) and mounts it: its + // response writes the address at a newer version — staged, since the + // address is showing, and committed when the second site mounts it. + const other = createRoot(() => createMemo(() => getRoomOnce() as any)); + const Other = dynamic(() => other()); + const second = mountUnderLoading(Other, {}); await pump(); expect(m.div.querySelector("h1")!.textContent).toBe("refetched"); expect(m.div.querySelector("h1")).toBe(h1); + expect(second.div.querySelector("h1")!.textContent).toBe("refetched"); // The live connection was cancelled by the host (the server sees the // disconnect), and the loop read a death. expect(liveHeld[0].state.cancelled).toBeTruthy(); @@ -229,6 +233,7 @@ describe("frames consume live: supersession and undeclared death", () => { expect(m.div.querySelector("h1")).toBe(h1); expect(status).toEqual(["connected", "reconnecting", "connected"]); + second.cleanup(); m.cleanup(); }); diff --git a/packages/web/test/frames-morph-in-transition.spec.tsx b/packages/web/test/frames-morph-in-transition.spec.tsx new file mode 100644 index 000000000..e8972d478 --- /dev/null +++ b/packages/web/test/frames-morph-in-transition.spec.tsx @@ -0,0 +1,243 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// A region's new markup is part of the transition that asked for it, as a +// navigation's is: when the same write also starts other async work, the +// markup must not land before that work does. Otherwise the page shows half +// a result — the region's answer beside stale siblings, intent marks and +// optimistic values that release only at the commit. +// +// Three ways a showing region changes inside an action: +// refetch — the write re-asks the same call (same address); +// switch — the write changes the call's arguments (a new address); +// single-flight — the mutation answers with the region and seeds the +// integration's cache with the call's reference. +import { afterEach, describe, expect, test, vi } from "vitest"; +import { action, createMemo, createRoot, createSignal, Loading } from "solid-js"; +import { dynamic } from "../src/index.js"; +import { installServerComponents } from "../frames/src/client.js"; +import { + SERVER_COMPONENT, + SERVER_COMPONENT_ADDRESS, + flightCodec +} from "../frames/src/frame-transport.js"; +import { createServerReference } from "../server-functions/src/client.js"; +import { + ChunkReader, + SINGLE_FLIGHT_HEADER, + createChunk, + serializeStream, + subscribeFlightData +} from "../server-functions/src/shared.js"; +import { makeHost, frameResponse, openFrameResponse, pump } from "./lifecycle-matrix/harness.js"; + +const LIST = "morph-tx/list"; +const region = (version: number, text: string, id = LIST) => [ + { type: "start", id, version }, + { type: "html", id, version, html: `

${text}

` }, + { type: "complete", id, version } +]; + +/** A flight reference as the server's transform serializes one. */ +function flightReference(id: string, address: string) { + const reference: any = () => undefined; + reference[SERVER_COMPONENT] = id; + reference[SERVER_COMPONENT_ADDRESS] = address; + return reference; +} + +/** A held single-flight response: the regions first, then the envelope. */ +function openFlightResponse() { + let controller!: ReadableStreamDefaultController; + const body = new ReadableStream({ start: c => void (controller = c) }); + return { + response: new Response(body, { + headers: { + "Content-Type": "application/x-frame-stream", + "X-Frame-Stream": "", + [SINGLE_FLIGHT_HEADER]: "true" + } + }), + send: (chunk: any) => controller.enqueue(createChunk(JSON.stringify(chunk))), + async outcome(envelope: unknown) { + const reader = new ChunkReader(serializeStream(envelope, flightCodec(undefined))); + for (let node = await reader.next(); !node.done; node = await reader.next()) + controller.enqueue(createChunk(JSON.stringify({ type: "outcome", payload: node.value }))); + }, + close: () => controller.close() + }; +} + +/** `other` is "a0" until `step` moves, then pending until `release()`. */ +function heldSibling(step: () => number) { + let release!: () => void; + const other = createRoot(() => + createMemo(() => { + const n = step(); + return n === 0 ? "a0" : new Promise(r => (release = () => r(`a${n}`))); + }) + ); + return { other, release: () => release() }; +} + +/** `{other()}` beside the region; `view()` reads both. */ +function mount(List: any, other: () => string) { + const container = document.createElement("div"); + document.body.appendChild(container); + const dispose = createRoot(d => { + container.appendChild( + ( +
+ {other()} + fallback}> + + +
+ ) as Node + ); + return d; + }); + return { + view: () => [ + container.querySelector("b")!.textContent, + container.querySelector("p")?.textContent + ], + cleanup() { + dispose(); + container.remove(); + } + }; +} + +const unsubscribes: (() => void)[] = []; +afterEach(() => { + vi.unstubAllGlobals(); + for (const unsubscribe of unsubscribes.splice(0)) unsubscribe(); +}); + +describe("a region's morph commits with its transition", () => { + test("refetch: the region's answer waits for the other work the write started", async () => { + const { host } = makeHost(); + installServerComponents(host); + const getList = createServerReference(LIST); + const refetch = openFrameResponse(LIST); + let fetches = 0; + vi.stubGlobal("fetch", async () => + ++fetches === 1 ? frameResponse(LIST, region(1, "v1")) : refetch.response + ); + + const [tick, setTick] = createSignal(0); + const list = createRoot(() => createMemo(() => (tick(), getList() as any))); + const sibling = heldSibling(tick); + const m = mount( + dynamic(() => list()), + sibling.other + ); + await pump(); + expect(m.view()).toEqual(["a0", "v1"]); + + action(function* () { + setTick(1); + })(); + await pump(3); + expect(fetches).toBe(2); + + for (const chunk of region(2, "v2")) refetch.send(chunk); + refetch.close(); + await pump(3); + expect(m.view()).toEqual(["a0", "v1"]); + + sibling.release(); + await pump(3); + expect(m.view()).toEqual(["a1", "v2"]); + m.cleanup(); + }); + + test("switch: the new call's content waits for the other work the write started", async () => { + const { host } = makeHost(); + installServerComponents(host); + const getList = createServerReference(LIST); + const second = openFrameResponse(LIST); + let fetches = 0; + vi.stubGlobal("fetch", async () => + ++fetches === 1 ? frameResponse(LIST, region(1, "page 0")) : second.response + ); + + const [page, setPage] = createSignal(0); + const list = createRoot(() => createMemo(() => getList(page()) as any)); + const sibling = heldSibling(page); + const m = mount( + dynamic(() => list()), + sibling.other + ); + await pump(); + expect(m.view()).toEqual(["a0", "page 0"]); + + action(function* () { + setPage(1); + })(); + await pump(3); + expect(fetches).toBe(2); + + for (const chunk of region(1, "page 1")) second.send(chunk); + second.close(); + await pump(3); + expect(m.view()).toEqual(["a0", "page 0"]); + + sibling.release(); + await pump(3); + expect(m.view()).toEqual(["a1", "page 1"]); + m.cleanup(); + }); + + // Single-flight has no reader of its own: the response seeds the + // integration's cache, which is the commit point for markup exactly as it + // is for data. So the region lands when — and only when — a JSON value + // seeded by the same response does. + test("single-flight: the mutation's region lands with the data the response seeds", async () => { + const { host } = makeHost(); + installServerComponents(host); + const getList = createServerReference(LIST); + const mutate = createServerReference("morph-tx/mutate"); + const flight = openFlightResponse(); + let fetches = 0; + vi.stubGlobal("fetch", async () => + ++fetches === 1 ? frameResponse(LIST, region(1, "v1")) : flight.response + ); + + // An integration's cache: the call's value and a JSON value beside it. + const [cache, setCache] = createSignal<{ list?: unknown; label: string }>({ label: "j0" }); + unsubscribes.push( + subscribeFlightData((slice: any) => { + setCache({ list: slice.list, label: slice.label }); + }) + ); + const list = createRoot(() => createMemo(() => (cache().list ?? getList()) as any)); + const m = mount( + dynamic(() => list()), + () => cache().label + ); + await pump(); + expect(m.view()).toEqual(["j0", "v1"]); + + const result = mutate(); + await pump(3); + expect(fetches).toBe(2); + + for (const chunk of region(1, "v2")) flight.send(chunk); + await pump(3); + // The region is in; the data that commits it is not. + expect(m.view()).toEqual(["j0", "v1"]); + + await flight.outcome({ + value: "ok", + data: { true: { list: flightReference(LIST, LIST), label: "j1" } } + }); + flight.close(); + await expect(result).resolves.toBe("ok"); + await pump(3); + expect(m.view()).toEqual(["j1", "v2"]); + m.cleanup(); + }); +}); diff --git a/packages/web/test/frames-optimistic-hold.spec.tsx b/packages/web/test/frames-optimistic-hold.spec.tsx index 1f2cbdb1c..9e6ec5958 100644 --- a/packages/web/test/frames-optimistic-hold.spec.tsx +++ b/packages/web/test/frames-optimistic-hold.spec.tsx @@ -9,8 +9,10 @@ // optimistic release and the new args. The fill here is a content slot; the // invariant is the same one attribute fills will rely on. // -// single-flight — the mutation's response carries the invalidated region; -// the handler applies it before the call resolves. +// single-flight — the mutation's response carries the invalidated region +// and seeds the integration's cache with the call's +// reference; the region lands with that seed, before the +// call resolves. // multi-flight — the mutation returns plain data; the action then // `refresh`es the source the boundary reads, and the // refetched region arrives on its own response. @@ -27,7 +29,12 @@ import { } from "solid-js"; import { dynamic } from "../src/index.js"; import { installServerComponents } from "../frames/src/client.js"; -import { flightCodec } from "../frames/src/frame-transport.js"; +import { FRAME_ID_ATTR } from "../frames/src/frame-client.js"; +import { + SERVER_COMPONENT, + SERVER_COMPONENT_ADDRESS, + flightCodec +} from "../frames/src/frame-transport.js"; import { createServerReference } from "../server-functions/src/client.js"; import { BODY_FORMAT_HEADER, @@ -35,9 +42,16 @@ import { ChunkReader, SINGLE_FLIGHT_HEADER, createChunk, - serializeStream + serializeStream, + subscribeFlightData } from "../server-functions/src/shared.js"; -import { makeHost, frameResponse, openFrameResponse, pump } from "./lifecycle-matrix/harness.js"; +import { + makeHost, + dataChunks, + frameResponse, + openFrameResponse, + pump +} from "./lifecycle-matrix/harness.js"; const TODOS = "hold/todos"; const listHtml = "
"; @@ -91,7 +105,13 @@ function plainResponse(value: unknown) { /** Mount ``; the fill traces every * re-derivation as `server/derived` and renders the derived value. */ -function mount(Comp: any, done: (p: any) => boolean, trace: string[]) { +function mount( + Comp: any, + done: (p: any) => boolean, + trace: string[], + server: (p: any) => boolean = p => p.completed, + extra: Record = {} +) { const container = document.createElement("div"); document.body.appendChild(container); let div!: HTMLDivElement; @@ -99,8 +119,9 @@ function mount(Comp: any, done: (p: any) => boolean, trace: string[]) {
fallback}> { - createMemo(() => trace.push(`${p.completed}/${done(p)}`)); + createMemo(() => trace.push(`${server(p)}/${done(p)}`)); return
  • {String(done(p))}
  • ; }} /> @@ -118,14 +139,42 @@ function mount(Comp: any, done: (p: any) => boolean, trace: string[]) { }; } -afterEach(() => vi.unstubAllGlobals()); +/** A flight reference as the server's transform serializes one. */ +function flightReference(id: string, address: string) { + const reference: any = () => undefined; + reference[SERVER_COMPONENT] = id; + reference[SERVER_COMPONENT_ADDRESS] = address; + return reference; +} + +/** + * The hold, as the fill derives it: once the intent was written (the trace's + * second entry), no derivation reads the old server value — the new args + * land under live intent, then the intent releases over agreeing truth. + * A derivation may repeat (core recomputes a reader of a value written in + * a compute half once more before the transition's pending value), so the + * invariant is what is asserted, not the count. + */ +function expectHeld(trace: string[]) { + expect(trace.slice(0, 2)).toEqual(["false/false", "false/true"]); + expect(trace.slice(2).filter(entry => entry.endsWith("/false"))).toEqual([]); + expect(trace.at(-1)).toBe("true/true"); +} + +const unsubscribes: (() => void)[] = []; +afterEach(() => { + vi.unstubAllGlobals(); + for (const unsubscribe of unsubscribes.splice(0)) unsubscribe(); +}); describe("optimism over server components holds until the authoritative args land", () => { - test("single-flight: the region applies before the mutation resolves; the derived value never flashes", async () => { + test("single-flight: the region lands with the cache seed, before the mutation resolves; the derived value never flashes", async () => { const { host } = makeHost(); installServerComponents(host); const getTodos = createServerReference("hold/todos"); const toggleTodo = createServerReference("hold/toggle-sf"); + const [cached, setCached] = createSignal(); + unsubscribes.push(subscribeFlightData((slice: any) => setCached(() => slice.todos))); const flight = openFlightResponse(); const urls: string[] = []; @@ -144,7 +193,7 @@ describe("optimism over server components holds until the authoritative args lan const [pending, setPending] = createRoot(() => createOptimistic>({})); const done = (p: any) => pending()[p.id] ?? p.completed; const trace: string[] = []; - const Todos = dynamic(() => getTodos() as any); + const Todos = dynamic(() => (cached() ?? getTodos()) as any); const m = mount(Todos, done, trace); await pump(); expect(m.text()).toBe("false"); @@ -166,7 +215,10 @@ describe("optimism over server components holds until the authoritative args lan flight.send(rowChunk(TODOS, 1, true)); flight.send({ type: "html", id: TODOS, version: 1, html: listHtml }); flight.send({ type: "complete", id: TODOS, version: 1 }); - await flight.outcome({ value: "ok", data: {} }); + await flight.outcome({ + value: "ok", + data: { true: { todos: flightReference(TODOS, TODOS) } } + }); flight.close(); await expect(result).resolves.toBe("ok"); await pump(3); @@ -236,8 +288,205 @@ describe("optimism over server components holds until the authoritative args lan await pump(3); expect(m.text()).toBe("true"); - // same shape as single-flight: args under live intent, then release - expect(trace).toEqual(["false/false", "false/true", "true/true", "true/true"]); + // same hold as single-flight: args under live intent, then release + expectHeld(trace); + expect(pending()).toEqual({}); + + m.cleanup(); + }); + + // Object args ride as `{$ref}`s into the response's data table, which the + // shared host rotates per response: the refetch's args resolve from ITS + // table while the previous response's is still the one on screen. + test("multi-flight, `{$ref}` args on the shared host: the refetched values reach the fill under live intent", async () => { + installServerComponents(); + const ID = "hold/todos-ref"; + const getTodos = createServerReference(ID); + const toggleTodo = createServerReference("hold/toggle-ref"); + const refRow = (version: number) => ({ + type: "slot", + id: ID, + version, + key: "row#0", + args: { todo: { $ref: "todo" } } + }); + const body = (version: number, completed: boolean) => [ + { type: "start", id: ID, version }, + refRow(version), + ...dataChunks(ID, version, { todo: { id: "1", completed } }), + { type: "html", id: ID, version, html: listHtml }, + { type: "complete", id: ID, version } + ]; + + const refetch = openFrameResponse(ID); + const urls: string[] = []; + vi.stubGlobal("fetch", async (input: any) => { + urls.push(typeof input === "string" ? input : input.url); + if (urls.length === 1) return frameResponse(ID, body(1, false)); + if (urls.length === 2) return plainResponse("ok"); + return refetch.response; + }); + + const [pending, setPending] = createRoot(() => createOptimistic>({})); + const done = (p: any) => pending()[p.todo.id] ?? p.todo.completed; + const trace: string[] = []; + const todos = createRoot(() => createMemo(() => getTodos() as any)); + const Todos = dynamic(() => todos()); + const m = mount(Todos, done, trace, p => p.todo.completed); + await pump(3); + expect(m.text()).toBe("false"); + expect(trace).toEqual(["false/false"]); + + const toggle = action(function* (id: string, completed: boolean) { + setPending(p => ({ ...p, [id]: completed })); + const r = yield toggleTodo(id, completed); + yield refresh(todos); + return r; + }); + const result = toggle("1", true); + await pump(3); + expect(urls).toHaveLength(3); + expect(m.text()).toBe("true"); + + for (const chunk of body(2, true)) refetch.send(chunk); + refetch.close(); + await expect(result).resolves.toBe("ok"); + await pump(3); + + expect(m.text()).toBe("true"); + expectHeld(trace); + expect(pending()).toEqual({}); + + m.cleanup(); + }); + + // The fill lives in a nested region (a `{$frame}` arg of the root's own + // occurrence), whose chunks ride the response under the region's wire id. + test("multi-flight, nested region: the refetched args reach a fill inside the region under live intent", async () => { + const { host } = makeHost(); + installServerComponents(host); + const ID = "hold/nested"; + const REGION = `${ID}.list#0.body`; + const getTodos = createServerReference(ID); + const toggleTodo = createServerReference("hold/toggle-nested"); + const body = (version: number, completed: boolean) => [ + { type: "start", id: ID, version }, + { type: "slot", id: ID, version, key: "list#0", args: { body: { $frame: REGION } } }, + { + type: "html", + id: ID, + version, + html: "
    " + }, + rowChunk(REGION, version, completed), + { type: "html", id: REGION, version, html: listHtml }, + { type: "complete", id: ID, version } + ]; + + const refetch = openFrameResponse(ID); + const urls: string[] = []; + vi.stubGlobal("fetch", async (input: any) => { + urls.push(typeof input === "string" ? input : input.url); + if (urls.length === 1) return frameResponse(ID, body(1, false)); + if (urls.length === 2) return plainResponse("ok"); + return refetch.response; + }); + + const [pending, setPending] = createRoot(() => createOptimistic>({})); + const done = (p: any) => pending()[p.id] ?? p.completed; + const trace: string[] = []; + const todos = createRoot(() => createMemo(() => getTodos() as any)); + const Todos = dynamic(() => todos()); + const m = mount(Todos, done, trace, undefined, { + list: (p: any) =>
    {p.body}
    + }); + await pump(3); + expect(m.text()).toBe("false"); + expect(trace).toEqual(["false/false"]); + + const toggle = action(function* (id: string, completed: boolean) { + setPending(p => ({ ...p, [id]: completed })); + const r = yield toggleTodo(id, completed); + yield refresh(todos); + return r; + }); + const result = toggle("1", true); + await pump(3); + expect(urls).toHaveLength(3); + expect(m.text()).toBe("true"); + + for (const chunk of body(2, true)) refetch.send(chunk); + refetch.close(); + await expect(result).resolves.toBe("ok"); + await pump(3); + + expect(m.text()).toBe("true"); + expectHeld(trace); + expect(pending()).toEqual({}); + + m.cleanup(); + }); + + // A refetch that renames the region (a new `{$frame}` wire name) is + // structural: the region's chunks ride the new name, so the live region + // must follow the rename when the response lands. + test("multi-flight, renamed nested region: the region follows the rename when the response lands", async () => { + const { host } = makeHost(); + installServerComponents(host); + const ID = "hold/renamed"; + const getTodos = createServerReference(ID); + const toggleTodo = createServerReference("hold/toggle-renamed"); + const body = (version: number, completed: boolean, region: string) => [ + { type: "start", id: ID, version }, + { type: "slot", id: ID, version, key: "list#0", args: { body: { $frame: region } } }, + { + type: "html", + id: ID, + version, + html: "
    " + }, + rowChunk(region, version, completed), + { type: "html", id: region, version, html: listHtml }, + { type: "complete", id: ID, version } + ]; + + const refetch = openFrameResponse(ID); + const urls: string[] = []; + vi.stubGlobal("fetch", async (input: any) => { + urls.push(typeof input === "string" ? input : input.url); + if (urls.length === 1) return frameResponse(ID, body(1, false, `${ID}.list#0.body`)); + if (urls.length === 2) return plainResponse("ok"); + return refetch.response; + }); + + const [pending, setPending] = createRoot(() => createOptimistic>({})); + const done = (p: any) => pending()[p.id] ?? p.completed; + const trace: string[] = []; + const todos = createRoot(() => createMemo(() => getTodos() as any)); + const Todos = dynamic(() => todos()); + const m = mount(Todos, done, trace, undefined, { + list: (p: any) =>
    {p.body}
    + }); + await pump(3); + expect(m.text()).toBe("false"); + + const toggle = action(function* (id: string, completed: boolean) { + setPending(p => ({ ...p, [id]: completed })); + const r = yield toggleTodo(id, completed); + yield refresh(todos); + return r; + }); + const result = toggle("1", true); + await pump(3); + expect(m.text()).toBe("true"); + + for (const chunk of body(2, true, `${ID}.list#0.body~2`)) refetch.send(chunk); + refetch.close(); + await expect(result).resolves.toBe("ok"); + await pump(3); + + expect(m.text()).toBe("true"); + expect(document.querySelector(`[${FRAME_ID_ATTR}="${ID}.list#0.body~2"]`)).not.toBeNull(); expect(pending()).toEqual({}); m.cleanup(); From 4f7f4155a19f4415387e2351498e0fb6091bb458 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 4 Oct 2026 03:37:45 -0700 Subject: [PATCH 2/2] =?UTF-8?q?size:=20Size-Exceptions=20for=20#3759=20on?= =?UTF-8?q?=20L2=20=E2=80=94=20frames=2013.00=20->=2013.78=20KB,=20page=20?= =?UTF-8?q?base=2044.05=20->=2044.78,=20page=20live=2047.67=20->=2048.45?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refetched content lands at the transition's commit (the staging, the content token, the two halves of the mount's follow effect, the staged data tables, and on L2 the switch's rebind in the effect half with the frameless host waiter). Measured against `next` @ bde429992 (after #3790/#3791): frames 12,997 -> 13,770 B (+773; +2,262 B minified, frames client +2,255), page base 44,048 -> 44,762 B (+714; +2,265 B minified), page live 47,670 -> 48,436 B (+766; +2,265 B minified). Caps set at head + 10 B. Ledger notes in scenarios.js re-applied from the PR's 1b347c72f against these numbers, after #3743's page-base note. Size-Exception: frames: eager client consumer 12,997 -> 13,770 B (cap 13.00 -> 13.78 KB); page: base server components 44,048 -> 44,762 B (cap 44.05 -> 44.78 KB); page: live server components 47,670 -> 48,436 B (cap 47.67 -> 48.45 KB) — #3759's staging so refetched content lands at the transition's commit; accepted by the maintainer (2026-10-04). Co-authored-by: Claude Co-authored-by: Cursor --- scripts/size/floor-caps.json | 4 ++-- scripts/size/scenarios.js | 46 +++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 9fe0f5ba7..17b9886af 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -2,8 +2,8 @@ "signals: core floor (createSignal/Memo/Effect/Root/flush)": "7.33 KB", "app: render + one signal (the simple-app floor)": "9.83 KB", "app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "17.66 KB", - "page: base server components (hydrating + dynamic + frames + sf reference)": "44.05 KB", - "page: live server components (base + live/GET + action + isPending/latest)": "47.67 KB", + "page: base server components (hydrating + dynamic + frames + sf reference)": "44.78 KB", + "page: live server components (base + live/GET + action + isPending/latest)": "48.45 KB", "server: floor (getRequestEvent + isServer)": "1.34 KB", "server: renderToString (the server-render floor)": "20.42 KB" } diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index c9aae16b5..12a34abd1 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -3035,7 +3035,34 @@ module.exports = [ // the live call (`call-driven-lifecycle`). The gate signal takes // `ownedWrite`; the bytes are the two `bound` locals and the option. // Accepted by the maintainer. The cap is frozen again at 13.00 KB. - limit: "13.00 KB", + // Size-Exception (#3759, 2026-10-04): 13.00 -> + // 13.78 KB, measured at 13,770 B against `next` @ bde429992's 12,997 + // (+773 B; 770 B over the cap; +2,262 B minified, 41,048 -> 43,310: + // frames client +2,255, sf client slice +7). A refetch or single-flight + // region for a call a mount is showing is staged instead of written, so + // it lands in the commit of the transaction that read it: the handler's + // staged entries (chunks, deferred `onStream`, a content token per + // version, single-flight regions routed by root), the staged data tables + // (`stageTables`), and the two halves of the mount's follow effect + // (`followAddress`) — `FrameImpl.preview` pushing the staged slot args + // into live fills from the compute half (held with the transaction, so + // optimistic intent never reads the old args: the lane's guesses + // dissolve at the landing, and an effect-run write would land a flush + // behind), `stagedContent.commit` replaying the rest from the effect + // half. On L2 the switch's rebind moves to the effect half too (the + // switch is display, one reveal — ruling 2026-10-04) and a frameless + // waiter registered on the host settles the gate on the new address's + // first write, so a second switch mid-flight still binds. First measured + // pre-L2 at 13,726 B against `next` @ 9338c00c5's 12,977 (+749; a 12.98 + // -> 13.73 KB raise); the L2 port adds the waiter and shares the follow + // effect (-21 B minified against the PR; brotli layout +24 B). Dropping + // either half was weighed and rejected: without the preview the + // one-flush optimistic gap returns (verified on L2: `false/false` in all + // three multi-flight specs), without staged tables the shown content + // reads the new response's refs before the commit. Accepted by the + // maintainer (2026-10-04). The cap is frozen again at 13.78 KB (head + + // 10 B). + limit: "13.78 KB", alias: framesAlias, external: framesExternal }, @@ -3168,6 +3195,8 @@ module.exports = [ // Twelve equivalent encodings measured; this is the only one over by // page base alone. Accepted by the maintainer. The cap is frozen again // at 46.25 KB. + // Lowered (#3774, 2026-10-04): 46.25 -> 44.03 KB (floor-caps.json; the + // hold model, measured by CI at 22c3d3e14). // Size-Exception (#3743, 2026-10-04): 44.03 -> 44.05 KB, measured at // 44,031 B against `next` @ 1a3f87fd1's 44,029 (+2 B; 1 B over the cap; // -97 B minified). +2 B br / -97 B min — #3743 fold presence diff; @@ -3177,6 +3206,14 @@ module.exports = [ // minified, the live page at -5 B. Cap set at measured + 10 B rounded // up to 0.01 KB. Accepted by the maintainer (2026-10-04). The cap is // frozen again at 44.05 KB. + // Size-Exception (#3759, 2026-10-04): 44.05 -> 44.78 KB, measured at + // 44,762 B against `next` @ bde429992's 44,048 (+714 B; 712 B over the + // cap; +2,265 B minified, frames client +2,055). The frames client's + // staging and two-phase landing (the frames note); the frames client + // imports nothing new, so the remaining ~210 B minified across signals, + // solid, web and the sf client is attribution drift. Accepted by the + // maintainer (2026-10-04). The cap is frozen again at 44.78 KB (head + + // 10 B). limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], alias: pageAlias }, @@ -3262,6 +3299,13 @@ module.exports = [ // at 50,346 B against #3713 @ 73640f5cd's 50,150 (a 50.15 -> 50.35 KB // raise); the base moved under the PR (the fake-`Promise` cap above). // Size vetted by the maintainer. The cap is frozen again at 50.45 KB. + // Lowered (#3774, 2026-10-04): 50.45 -> 47.67 KB (floor-caps.json; the + // hold model, measured by CI at 22c3d3e14). + // Size-Exception (#3759, 2026-10-04): 47.67 -> 48.45 KB, measured at + // 48,436 B against `next` @ bde429992's 47,670 (+766 B; 766 B over the + // cap; +2,265 B minified, frames client +2,044). The same staging bytes + // as the base page. Accepted by the maintainer (2026-10-04). The cap is + // frozen again at 48.45 KB (head + 10 B). limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], alias: pageAlias },