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(); 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 },