From d6b67a59ef745c1a579d3caf12d4bfd4aeb8b4f6 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 10:54:21 -0700 Subject: [PATCH 1/5] =?UTF-8?q?frames:=20one=20sweep=20is=20one=20chunk=20?= =?UTF-8?q?=E2=80=94=20the=20`ops`=20member=20(C13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sink's `sweep()` collects the hole / attr re-emissions one pass produces and emits them as ONE `{ type: "ops", id, version, ops: [...] }` chunk on the stream face (one wire line) and one `sc:live` op of the same shape on the document face; a sweep that changes one binding emits that member alone, addressed, as before. Members ride unaddressed — the envelope addresses them. Client: `chunkToRecords` merges a unit's members into one record map, so `FrameImpl.apply` writes them in one write and `#flush` lands them in one hole pass; the hole pass now announces `frame:applied` ONCE per flush (it announced per hole, which let a listener read the DOM with one hole of a sweep moved and its sibling not). `applyFrames.drain` and `applyLiveOp` pass the unit through unchanged. The document op log (`liveOps`, last-value-wins per target) flattens a unit into its members. Pins: `c13-sweep-atomic` (a, b) flip to `test`, fed the unit as the sink now emits it, plus a (log) arm for the catch-up replay; `test/server/frame-sweep-ops.spec.tsx` pins the sink on both faces (a two-hole sweep → one unit; a one-hole sweep → the plain member). `frame-live-holes-projection` reads the unit's members. Wire: additive `FrameChunk` member — RFC addendum in documentation/server-components/frame-streams-rfc.md, with what an old consumer does with it (drops the write; under-updates, never tears). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/frames-c13-sweep-ops-chunk.md | 5 + .../server-components/frame-streams-rfc.md | 48 ++++ packages/web/frames/src/client.ts | 6 +- packages/web/frames/src/frame-client.ts | 35 ++- packages/web/frames/src/frame-sink.ts | 77 ++++-- .../consistency/c13-sweep-atomic.spec.tsx | 239 +++++++++++------- .../frame-live-holes-projection.spec.tsx | 11 +- .../web/test/server/frame-sweep-ops.spec.tsx | 175 +++++++++++++ 8 files changed, 474 insertions(+), 122 deletions(-) create mode 100644 .changeset/frames-c13-sweep-ops-chunk.md create mode 100644 packages/web/test/server/frame-sweep-ops.spec.tsx diff --git a/.changeset/frames-c13-sweep-ops-chunk.md b/.changeset/frames-c13-sweep-ops-chunk.md new file mode 100644 index 000000000..b1a08c0b4 --- /dev/null +++ b/.changeset/frames-c13-sweep-ops-chunk.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: one server sweep lands as one frame (C13). The sink collects the `hole` / `attr` re-emissions one sweep produces and emits them as one `{ type: "ops", ops: [...] }` chunk on the stream face (one wire line) and one `sc:live` op of the same shape on the document face; a sweep that changes one binding emits that member alone, as before. The client maps the unit to one record map and applies it as one write — one hole pass, one `frame:applied` (the hole pass now announces once per flush, not once per hole). The document op log flattens a unit into its members (last value per hole). Additive wire (`FrameChunk` gains the `ops` member; RFC addendum in `frame-streams-rfc.md`). diff --git a/documentation/server-components/frame-streams-rfc.md b/documentation/server-components/frame-streams-rfc.md index e64eaba83..c47359333 100644 --- a/documentation/server-components/frame-streams-rfc.md +++ b/documentation/server-components/frame-streams-rfc.md @@ -449,6 +449,54 @@ frame, and that slot/slot chunks follow the same rule. > currently only set flag keys — the readiness model re-derives everything from > the store each flush, so there is nothing to "replay." +### Addenda (2026-10-06 — the frames correctness pass, server half) + +Additive members; a producer may omit them and a consumer that predates them +reads the stream it always read (what an old consumer does with each is +stated). + +**`ops` — one sweep, one unit (C13, frames-rulings §"The server half").** + +```ts +| { + type: "ops"; + id: string; + version: number; + ops: ( + | { type: "hole"; key: string; html: string; digest?: string } + | { type: "attr"; key: string; attrs: string; removed?: string[]; digest?: string } + )[]; + } +``` + +The server's commit unit is the sweep: one pass over every open binding, +coalesced per microtask. Before this member the wire carried a sweep's +re-emissions as N independent `hole` / `attr` chunks with no edge between +them, and the consumer — whose unit of application is the chunk — landed +them one flush apart, so a listener (a `frame:applied` handler, a +`MutationObserver`) could observe one hole of a sweep moved while a sibling +of the same sweep still showed the previous value (contract R7). The `ops` +member is the sweep's edge on the wire: the producer collects the pass's +`hole` / `attr` emissions and ships them as ONE chunk (stream face — one +wire line) or ONE `sc:live` op of the same shape (document face — the op +carries no `id` / `version`, as no document op does). Members ride +unaddressed; the envelope addresses them. A pass that changed one binding +emits that member alone, exactly as before. The consumer maps the unit to +one record map (`chunkToRecords` merges the members') and applies it as one +write — one hole pass, one `frame:applied`. Nothing is buffered, nothing is +correlated, nothing times out: a connection that dies mid-sweep dies before +the unit was written, and the unit is never half-delivered. + +_Old consumer:_ `chunkToRecords` answers an unknown `type` with an empty +record map (its `default` arm), so the write lands nothing and the frame's +flush is a no-op — the sweep's values are **lost on that consumer** until a +later sweep that changes one binding at a time re-ships them (each as a +plain member), or a reconnect / refetch re-ships the root. The old consumer +does not crash and does not tear; it under-updates. The frames surface is an +experimental preview (RFC 11's status note): the member is taken as +additive on the producer and the consumer ships with it in the same +release. + ### Two identity schemes The format uses two deliberately distinct identity schemes: diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index df1755987..0f2866db6 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -1051,7 +1051,11 @@ function pumpLiveChannel() { reader.read().then((r: { done: boolean; value: any }) => { if (r.done) return; const op = r.value; - liveOps.set(`${op.type}:${op.fid || ""}:${op.key || ""}`, op); + // A sweep's `ops` unit is applied whole (one write per boundary) but + // logged by its members: the log is last-value-wins per target, and + // a member's target is the key, not the unit it rode in. + for (const m of op.type === "ops" ? op.ops : [op]) + liveOps.set(`${m.type}:${m.fid || ""}:${m.key || ""}`, m); for (const apply of liveAppliers) apply(op); return pump(); }); diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 1831cfb2f..c32949578 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -94,6 +94,22 @@ export type FrameChunk = preloads?: { href?: string; attrs: Record }[]; } | { type: "slot"; id: string; version: number; key: string; args: Record } + | { + /** + * One server sweep's re-emissions as one unit (RFC 11 addendum, C13): + * the `hole` / `attr` members a sweep produced, unaddressed (the + * envelope addresses them), applied as one write — one flush, one + * `frame:applied`. A sweep that changed one binding is emitted as + * that member alone. + */ + type: "ops"; + id: string; + version: number; + ops: ( + | { type: "hole"; key: string; html: string; digest?: string } + | { type: "attr"; key: string; attrs: string; removed?: string[]; digest?: string } + )[]; + } | { type: "complete"; id: string; version: number } | { type: "error"; id: string; version: number; key?: string; error: unknown }; @@ -639,6 +655,13 @@ export function chunkToRecords(chunk) { digest: chunk.digest } }; + case "ops": { + // One sweep's members as one write: the records merge into one map + // and the frame flushes once over all of them (C13). + const records = {}; + for (const op of chunk.ops) Object.assign(records, chunkToRecords(op)); + return records; + } case "complete": return { ":complete": true }; case "error": @@ -1233,7 +1256,12 @@ class FrameImpl { // mount's empty map replays the warm store). A hole error is terminal // server-side — the range latched at its last markup, and unlike a // rejected arg ref there is no client read to throw into, so it - // surfaces as a one-time diagnostic. + // surfaces as a one-time diagnostic. The pass is announced ONCE after + // every applicable record landed (C13: one write is one frame — a + // sweep's `ops` unit arrives as one write, and a listener on + // `frame:applied` must never read the DOM with one of its holes moved + // and a sibling still showing the previous value). + let morphed = false; for (const key in this.#store) { const record = this.#store[key]; if (!record || this.#appliedHoles.get(key) === record) continue; @@ -1245,16 +1273,17 @@ class FrameImpl { } else if (this.#applyHole(key.slice(5), record.value)) { this.#appliedHoles.set(key, record); this.#recordHave(key.slice(5), record); - this.#applied(version, "morph"); + morphed = true; } } else if (key.startsWith("attr:")) { if (this.#applyAttrs(key.slice(5), record.value, record.removed)) { this.#appliedHoles.set(key, record); this.#recordHave("lha:" + key.slice(5), record); - this.#applied(version, "morph"); + morphed = true; } } } + if (morphed) this.#applied(version, "morph"); // Root asset records reuse a store key, so consume them by identity. // Styles remain owned by the reveal gate. diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 5ba77b14a..f559dcbac 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -430,17 +430,38 @@ export function createFrameSink(emit, frame, have) { // sweep computes once; a memo pulled across commits recomputes — the // client contract applied to the server, without a subscriber graph). let epoch = 0; + // The sweep is one unit on the wire (C13, frames-rulings §"the server + // half": one sweep, one frame). The hole / attr re-emissions a pass + // produces are collected here and leave as ONE chunk — `{ type: "ops", + // ops: [...] }` when the pass changed more than one binding, the member + // itself when it changed one — so the client, whose unit of application + // is the chunk, lands the server's flush as one flush of its own. The + // chunk's edge is the delimiter: no sweep-end marker, no buffering on + // the client, nothing to time out if a connection dies mid-sweep. + let swept = null; const sweep = () => { epoch++; - for (const b of [...bindings.values()]) { - try { - b.sweep(); - } catch (_) { - // A sweep failure (a serializer already closed at the end-of-response - // latch) must not take the stream down: the binding's last emitted - // value stands. + const ops = (swept = []); + try { + for (const b of [...bindings.values()]) { + try { + b.sweep(); + } catch (_) { + // A sweep failure (a serializer already closed at the end-of-response + // latch) must not take the stream down: the binding's last emitted + // value stands. + } } + } finally { + swept = null; } + emitOps(ops); + }; + // Members ride unaddressed (the envelope addresses them); a lone member + // is addressed and emitted as the plain chunk it always was. + const emitOps = ops => { + if (ops.length === 1) emit(Object.assign({ type: ops[0].type, id, version }, ops[0])); + else if (ops.length) emit({ type: "ops", id, version, ops }); }; const scheduleSweep = () => { if (closed || sweepScheduled || !bindings.size) return; @@ -665,17 +686,19 @@ export function createFrameSink(emit, frame, have) { }, // A live-hole re-emission (Stage 3): the hole's re-resolved HTML, keyed // by its marker id — the consumer morphs the marked range in place. + // Produced by a sweep, so it joins the sweep's unit (see `sweep`). hole(key, html) { - emit({ type: "hole", id, version, key, html, digest: textDigest(html) }); + const op = { type: "hole", key, html, digest: textDigest(html) }; + swept ? swept.push(op) : emitOps([op]); }, // A live attr-hole re-emission: the addressed element's rebuilt // attribute text, plus the names that vanished since the last emission // (the server holds the previous text — the client never tracks name // history). attr(key, attrs, removed) { - const chunk = { type: "attr", id, version, key, attrs, digest: textDigest(attrs) }; - if (removed && removed.length) chunk.removed = removed; - emit(chunk); + const op = { type: "attr", key, attrs, digest: textDigest(attrs) }; + if (removed && removed.length) op.removed = removed; + swept ? swept.push(op) : emitOps([op]); }, // An attr hole's first-render text, keyed by its address — the digest // source for root/fragment `holes` maps and the resume compare. @@ -1722,16 +1745,31 @@ function armDocumentLiveHoles(ctx) { let epoch = 0; let sweepScheduled = false; let closed = false; + // The sweep is one op on the channel (C13 — see the stream sink's + // `sweep`): a pass that changed more than one hole / attr binding ships + // them as one `{ type: "ops", ops: [...] }` op, so an adopted boundary + // applies the server's flush as one flush. + let swept = null; const sweep = () => { epoch++; - for (const b of [...bindings.values()]) { - try { - b.sweep(); - } catch (_) { - // A sweep failure must not take the document down: the binding's - // last emitted value stands. + const ops = (swept = []); + try { + for (const b of [...bindings.values()]) { + try { + b.sweep(); + } catch (_) { + // A sweep failure must not take the document down: the binding's + // last emitted value stands. + } } + } finally { + swept = null; } + pushOps(ops); + }; + const pushOps = ops => { + if (ops.length === 1) push(ops[0]); + else if (ops.length) push({ type: "ops", ops }); }; const scheduleSweep = () => { if (closed || sweepScheduled || !bindings.size) return; @@ -1765,12 +1803,13 @@ function armDocumentLiveHoles(ctx) { // hashes) — the document channel's ops included, so a ledger seeded // from the document can follow what the channel later re-emits. hole(key, html) { - push({ type: "hole", key, html, digest: textDigest(html) }); + const op = { type: "hole", key, html, digest: textDigest(html) }; + swept ? swept.push(op) : pushOps([op]); }, attr(key, attrs, removed) { const op = { type: "attr", key, attrs, digest: textDigest(attrs) }; if (removed && removed.length) op.removed = removed; - push(op); + swept ? swept.push(op) : pushOps([op]); }, error(key, error) { push({ type: "error", key, error }); diff --git a/packages/web/test/consistency/c13-sweep-atomic.spec.tsx b/packages/web/test/consistency/c13-sweep-atomic.spec.tsx index 684249da2..b68287330 100644 --- a/packages/web/test/consistency/c13-sweep-atomic.spec.tsx +++ b/packages/web/test/consistency/c13-sweep-atomic.spec.tsx @@ -8,14 +8,17 @@ * visible together: no observable point shows one hole of the sweep updated * while a sibling hole of the same sweep still shows the previous value." * - * Mechanism meant to carry it: frames/src/frame-transport.ts - * `applyFrames.drain` (one `host.apply` per framed chunk, an `await` - * between), frames/src/client.ts `pumpLiveChannel` (the document channel is - * a ReadableStream read one op at a time), frames/src/frame-client.ts - * `FrameImpl.#flush` (the hole pass morphs every applicable hole record of - * the store) and `#applied` (a `frame:applied` event per hole). The wire - * carries no sweep delimiter: the server coalesces per BINDING ("at most - * one emission per binding per flush"), never per sweep. + * Mechanism that carries it (frames-rulings §"The server half", C13 — the + * sweep delimiter): the SINK's `sweep()` collects the pass's hole / attr + * re-emissions and ships them as ONE `{ type: "ops", ops: [...] }` chunk + * (stream face) / `sc:live` op (document face) — the chunk's edge is the + * unit; `chunkToRecords` merges the members into one record map and + * `FrameImpl.apply` flushes once over them (one hole pass, one + * `#applied("morph")`, one `frame:applied`). `applyFrames.drain` and + * `applyLiveOp` pass the unit through unchanged. The server arm — that the + * sink emits the member for a two-binding sweep — is pinned in + * test/server/frame-sweep-ops.spec.tsx; these arms feed the client the + * unit as the sink now emits it. * * Observation points: a `frame:applied` listener (the runtime's own * announcement of a landed morph) and a MutationObserver (a microtask @@ -65,98 +68,142 @@ afterEach(async () => { describe("C13 — one sweep, one frame", () => { // Document face: an adopted boundary; the server's sweep re-emits both - // holes as two `sc:live` ops written in one synchronous span. + // holes as ONE `sc:live` op — `{ type: "ops", ops: [hole, hole] }`, the + // shape frame-sink.ts's document sweep pushes for a two-binding pass. // - // Observed on `next`: applied === ["a1|b0", "a1|b1"] and frames === - // ["a0|b0", "a1|b0", "a1|b1"] — the first hole lands and is announced - // (and is visible at a microtask checkpoint) while the second still - // shows b0. Expected: no "a1|b0" anywhere. Where it goes wrong: the - // document channel is a ReadableStream of ops read one at a time - // (client.ts:pumpLiveChannel — `reader.read().then(op => applyLiveOp(op); - // pump())`), so each op is its own `host.apply` → `FrameImpl.apply` → - // `#flush`, whose hole pass morphs that one hole (`#applyHole`) and fires - // `#applied(version, "morph")` for it; the second op is a microtask later. - // Nothing on the wire says the two ops belong to one sweep (the server - // coalesces per binding, not per sweep), so the client has no unit larger - // than one op to make atomic. - test.fails( - "(a) document face: two `sc:live` ops of one sweep never show one hole updated without the other", - async () => { - const fid = freshFid("c13a"); - page = bootPage(frameHtml(fid, twoHoles("a0", "b0", 0))); - const Comp = (globalThis as any)._$SC.r(fid); - const applied: string[] = []; - page.container.addEventListener("frame:applied", () => applied.push(holes(page!.container))); - const dispose = hydrate(() => , page.container); - disposers.push(dispose); - await quiesce(); - expect(holes(page.container)).toBe("a0|b0"); - applied.length = 0; - const frames = watchFrames(page.container, () => holes(page!.container)); - // The sweep: both re-emissions in one synchronous span. - page.live.push({ type: "hole", key: "lh:0", html: "a1" }); - page.live.push({ type: "hole", key: "lh:1", html: "b1" }); - await quiesce(); - frames.sample(); - frames.stop(); - expect(holes(page.container)).toBe("a1|b1"); - expect(page.errors).toEqual([]); - expect(torn(applied)).toEqual([]); - expect(torn(frames.frames)).toEqual([]); - } - ); + // Was red on `next` (two separate ops): applied === ["a1|b0", "a1|b1"] + // and frames === ["a0|b0", "a1|b0", "a1|b1"] — each op was its own + // `host.apply` → `FrameImpl.apply` → `#flush`, a microtask apart, and + // the wire said nothing about the two belonging together. With the unit + // on the wire the pump hands one op to `applyLiveOp`, one write lands + // both records, and one flush morphs both holes. + test("(a) document face: one sweep's `ops` unit never shows one hole updated without the other", async () => { + const fid = freshFid("c13a"); + page = bootPage(frameHtml(fid, twoHoles("a0", "b0", 0))); + const Comp = (globalThis as any)._$SC.r(fid); + const applied: string[] = []; + page.container.addEventListener("frame:applied", () => applied.push(holes(page!.container))); + const dispose = hydrate(() => , page.container); + disposers.push(dispose); + await quiesce(); + expect(holes(page.container)).toBe("a0|b0"); + applied.length = 0; + const frames = watchFrames(page.container, () => holes(page!.container)); + // The sweep: both re-emissions as one unit. + page.live.push({ + type: "ops", + ops: [ + { type: "hole", key: "lh:0", html: "a1" }, + { type: "hole", key: "lh:1", html: "b1" } + ] + }); + await quiesce(); + frames.sample(); + frames.stop(); + expect(holes(page.container)).toBe("a1|b1"); + expect(page.errors).toEqual([]); + expect(torn(applied)).toEqual([]); + expect(torn(frames.frames)).toEqual([]); + // One flush: one announcement, one frame. + expect(applied).toEqual(["a1|b1"]); + expect(frames.frames).toEqual(["a0|b0", "a1|b1"]); + }); - // Stream face: a mounted call; the sweep's two `hole` chunks are enqueued - // back to back into one body (one network write). + // Stream face: a mounted call; the sweep arrives as ONE `ops` chunk (one + // wire line), the shape frame-sink.ts's stream sweep emits for a + // two-binding pass. // - // Observed on `next`: applied === ["a1|b0", "a1|b1"], frames === ["a0|b0", - // "a1|b0", "a1|b1"]. Expected: no torn pair. Where it goes wrong: - // frame-transport.ts:applyFrames.drain reads one framed chunk per - // `await reader.next()` and calls `host.apply(chunk)` per chunk — each - // `hole` chunk is a separate `FrameImpl.apply` → `#flush` → hole pass → - // `#applied("morph")`, with a microtask between the two; a MutationObserver - // fires in that gap. One body write is not one apply. - test.fails( - "(b) stream face: two hole chunks of one sweep never show one hole updated without the other", - async () => { - const id = freshFid("c13b"); - installServerComponents(makeHost().host); - const { held } = stubHeldFetch([id]); - const getRoom = createServerReference(id); - const Page = dynamic(() => getRoom() as any); - let div!: HTMLDivElement; - const dispose = createRoot(d => { -
- fallback}> - - -
; - document.body.appendChild(div); + // Was red on `next` (two `hole` chunks): applied === ["a1|b0", "a1|b1"], + // frames === ["a0|b0", "a1|b0", "a1|b1"] — applyFrames.drain did one + // `host.apply` per chunk with a microtask between. One chunk is one apply. + test("(b) stream face: one sweep's `ops` chunk never shows one hole updated without the other", async () => { + const id = freshFid("c13b"); + installServerComponents(makeHost().host); + const { held } = stubHeldFetch([id]); + const getRoom = createServerReference(id); + const Page = dynamic(() => getRoom() as any); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
+ fallback}> + + +
; + document.body.appendChild(div); + return d; + }); + disposers.push(dispose); + const applied: string[] = []; + div.addEventListener("frame:applied", () => applied.push(holes(div))); + await pump(); + held[0].send({ type: "start", id, version: 1 }); + held[0].send({ type: "html", id, version: 1, html: twoHoles("a0", "b0") }); + await pump(); + expect(holes(div)).toBe("a0|b0"); + applied.length = 0; + const frames = watchFrames(div, () => holes(div)); + // The sweep: both re-emissions as one unit. + held[0].send({ + type: "ops", + id, + version: 1, + ops: [ + { type: "hole", key: "lh:0", html: "a1" }, + { type: "hole", key: "lh:1", html: "b1" } + ] + }); + await pump(); + frames.sample(); + frames.stop(); + expect(holes(div)).toBe("a1|b1"); + expect(torn(applied)).toEqual([]); + expect(torn(frames.frames)).toEqual([]); + expect(applied).toEqual(["a1|b1"]); + expect(frames.frames).toEqual(["a0|b0", "a1|b1"]); + held[0].send({ type: "complete", id, version: 1 }); + held[0].close(); + }); + + // Catch-up: the document op log (`client.ts:liveOps`) is last-value-wins + // per TARGET, so a unit that arrived before a boundary adopted is logged + // by its members — a later single-hole op for one of them supersedes + // that member alone, and the late adopter replays the latest of each. + test("(log) an `ops` unit that arrived before a boundary adopted replays by its members, latest per hole", async () => { + const fidA = freshFid("c13d-a"); + const fidB = freshFid("c13d-b"); + // Two boundaries on the page: A adopts first (its adoption starts the + // channel pump, so the ops below are READ — into the log — before B + // exists); B adopts after and can only see them through the log. + page = bootPage(frameHtml(fidA, "

x

")); + const other = document.createElement("div"); + other.innerHTML = frameHtml(fidB, twoHoles("a0", "b0", 20)); + document.body.appendChild(other); + page.hy.fe("__shell", other); + const CompA = (globalThis as any)._$SC.r(fidA); + const CompB = (globalThis as any)._$SC.r(fidB); + disposers.push(hydrate(() => , page.container)); + await quiesce(); + // A unit, then one member moved again — before B adopts. + page.live.push({ + type: "ops", + ops: [ + { type: "hole", key: "lh:20", html: "a1" }, + { type: "hole", key: "lh:21", html: "b1" } + ] + }); + page.live.push({ type: "hole", key: "lh:21", html: "b2" }); + await quiesce(); + expect(holes(other)).toBe("a0|b0"); + disposers.push( + createRoot(d => { + ; return d; - }); - disposers.push(dispose); - const applied: string[] = []; - div.addEventListener("frame:applied", () => applied.push(holes(div))); - await pump(); - held[0].send({ type: "start", id, version: 1 }); - held[0].send({ type: "html", id, version: 1, html: twoHoles("a0", "b0") }); - await pump(); - expect(holes(div)).toBe("a0|b0"); - applied.length = 0; - const frames = watchFrames(div, () => holes(div)); - // The sweep: both re-emissions in one burst. - held[0].send({ type: "hole", id, version: 1, key: "lh:0", html: "a1" }); - held[0].send({ type: "hole", id, version: 1, key: "lh:1", html: "b1" }); - await pump(); - frames.sample(); - frames.stop(); - expect(holes(div)).toBe("a1|b1"); - expect(torn(applied)).toEqual([]); - expect(torn(frames.frames)).toEqual([]); - held[0].send({ type: "complete", id, version: 1 }); - held[0].close(); - } - ); + }) + ); + await quiesce(); + expect(holes(other)).toBe("a1|b2"); + expect(page.errors).toEqual([]); + }); // Control: a sweep that touches ONE hole is trivially atomic — the single // `frame:applied` and the single frame both show the new value, and the diff --git a/packages/web/test/server/frame-live-holes-projection.spec.tsx b/packages/web/test/server/frame-live-holes-projection.spec.tsx index 254370c28..e25c771a2 100644 --- a/packages/web/test/server/frame-live-holes-projection.spec.tsx +++ b/packages/web/test/server/frame-live-holes-projection.spec.tsx @@ -96,7 +96,11 @@ function collectDocument(code: () => any): Promise { }); } -const holeHtml = (chunks: any[]) => chunks.filter(c => c.type === "hole").map(h => h.html); +// A sweep that changes two holes ships them as one `ops` unit (C13); read +// the members as the re-emissions they are. +const holeChunks = (chunks: any[]) => + chunks.flatMap(c => (c.type === "ops" ? c.ops : [c])).filter(c => c.type === "hole"); +const holeHtml = (chunks: any[]) => holeChunks(chunks).map(h => h.html); describe("projections pump in frame scope (B5)", () => { it("createProjection over a value-yielding iterable: per-yield hole re-emits, completes when the source ends — as the memo does", async () => { @@ -155,7 +159,7 @@ describe("projections pump in frame scope (B5)", () => { ch.push("a"); await until(c => c.type === "fragment"); ch.push("a b"); - await until(c => c.type === "hole" && c.html === "2"); + await until(c => holeChunks([c]).some(h => h.html === "2")); ch.end(); await done; @@ -163,8 +167,9 @@ describe("projections pump in frame scope (B5)", () => { expect(fragment.html).toContain("a"); expect(fragment.html).toMatch(/1/); // Both holes read the store: the text hole and the counter re-emitted - // once each for the second yield. + // once each for the second yield — in ONE unit, the sweep's (C13). expect(holeHtml(chunks).sort()).toEqual(["2", "a b"]); + expect(chunks.filter(c => c.type === "ops")).toHaveLength(1); expect(chunks[chunks.length - 1].type).toBe("complete"); }, 8000); diff --git a/packages/web/test/server/frame-sweep-ops.spec.tsx b/packages/web/test/server/frame-sweep-ops.spec.tsx new file mode 100644 index 000000000..0fbef1ee2 --- /dev/null +++ b/packages/web/test/server/frame-sweep-ops.spec.tsx @@ -0,0 +1,175 @@ +/** + * @jsxImportSource @solidjs/web + * + * C13 — one sweep, one frame: the SERVER half (frames-rulings §"The server + * half", the sweep delimiter). The sink's `sweep()` walks every binding in + * one span; the hole / attr re-emissions one pass produces leave as ONE + * unit — `{ type: "ops", ops: [...] }` on the stream face (one chunk, one + * wire line) and one `sc:live` op of the same shape on the document face — + * so the client, whose unit of application is the chunk, lands the + * server's flush as one flush. A pass that changes one binding emits that + * member alone, as before (the client pins' control arm). + * + * Shape: two content holes reading one async-iterable memo. Each yield is + * one commit → one sweep → both bindings change in the same pass. + */ +import { describe, expect, it } from "vitest"; +import vm from "node:vm"; +import { createMemo } from "solid-js"; +import { Loading, renderToStream } from "@solidjs/web"; +import { + frameTransformDirectResult, + renderServerComponent, + ServerComponentPlugin +} from "../../frames/src/frame-sink.js"; + +const tick = (ms = 5) => new Promise(r => setTimeout(r, ms)); + +function consume(stream: any) { + const chunks: any[] = []; + const waiters: { test: (c: any) => boolean; resolve: () => void }[] = []; + const done = new Promise(res => + stream.pipe({ + write: (c: any) => { + chunks.push(c); + for (let i = waiters.length - 1; i >= 0; i--) { + if (waiters[i].test(c)) waiters.splice(i, 1)[0].resolve(); + } + }, + end: res + }) + ); + const until = (test: (c: any) => boolean) => { + if (chunks.some(test)) return Promise.resolve(); + return new Promise(resolve => waiters.push({ test, resolve })); + }; + return { chunks, until, done }; +} + +/** A push-driven async iterable. */ +function channel() { + const queue: T[] = []; + let notify: (() => void) | null = null; + let done = false; + const wake = () => { + notify?.(); + notify = null; + }; + return { + push(v: T) { + queue.push(v); + wake(); + }, + end() { + done = true; + wake(); + }, + iterable: { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + while (queue.length === 0) { + if (done) return { value: undefined as any, done: true }; + await new Promise(r => (notify = r)); + } + return { value: queue.shift()!, done: false }; + } + }; + } + } as AsyncIterable + }; +} + +/** Two holes over one source: `

{a}

{b}

`, both move per yield. */ +function twoHoleComponent(source: () => AsyncIterable<[string, string]>) { + return () => { + const pair = createMemo(source); + return ( + typing

}> +

{pair()[0]}

+

{pair()[1]}

+
+ ); + }; +} + +describe("C13 server half — the sink emits one sweep as one unit", () => { + it("stream face: a sweep that changes two holes emits ONE `ops` chunk; a sweep that changes one emits the plain `hole`", async () => { + const ch = channel<[string, string]>(); + const { chunks, until, done } = consume( + renderServerComponent( + twoHoleComponent(() => ch.iterable), + { frame: { id: "f" } } + ) + ); + await tick(); + ch.push(["a0", "b0"]); + await until(c => c.type === "fragment"); + // Both move: one unit. + ch.push(["a1", "b1"]); + await until(c => c.type === "ops"); + // One moves (the second value repeats): the plain member. + ch.push(["a2", "b1"]); + await until(c => c.type === "hole"); + ch.end(); + await done; + + const ops = chunks.filter(c => c.type === "ops"); + expect(ops).toHaveLength(1); + expect(ops[0].id).toBe("f"); + expect(ops[0].version).toBe(1); + // Members are unaddressed (the envelope addresses them) and carry + // their digests like any re-emission. + expect(ops[0].ops.map((m: any) => [m.type, m.html, "id" in m])).toEqual([ + ["hole", "a1", false], + ["hole", "b1", false] + ]); + for (const m of ops[0].ops) expect(typeof m.digest).toBe("string"); + const holes = chunks.filter(c => c.type === "hole"); + expect(holes.map(h => [h.id, h.version, h.html])).toEqual([["f", 1, "a2"]]); + expect(chunks[chunks.length - 1].type).toBe("complete"); + }, 8000); + + it("document face: a sweep that changes two holes pushes ONE `ops` op on `sc:live`", async () => { + const ServerComp = twoHoleComponent(async function* () { + yield ["a0", "b0"] as [string, string]; + await tick(); + yield ["a1", "b1"] as [string, string]; + await tick(); + yield ["a2", "b1"] as [string, string]; + }); + const Inline = frameTransformDirectResult(ServerComp, { id: "sweep-ops/doc" }) as any; + const html = await new Promise(resolve => { + const out: string[] = []; + renderToStream(() => Inline({}), { plugins: [ServerComponentPlugin] } as any).pipe({ + write: (c: string) => out.push(c), + end: () => resolve(out.join("")) + }); + }); + const sandbox: any = { + document: { getElementById: () => null, addEventListener() {} }, + _$HY: { r: {}, fe() {} }, + ReadableStream, + Promise, + Symbol + }; + sandbox.self = sandbox; + vm.createContext(sandbox); + for (const [, src] of html.matchAll(/]*>([\s\S]*?)<\/script>/g)) { + vm.runInContext(src, sandbox); + } + const reader = sandbox._$HY.r["sc:live"].getReader(); + const ops: any[] = []; + for (;;) { + const r = await reader.read(); + if (r.done) break; + ops.push(r.value); + } + expect(ops.map(op => op.type)).toEqual(["ops", "hole"]); + expect(ops[0].ops.map((m: any) => [m.type, m.html])).toEqual([ + ["hole", "a1"], + ["hole", "b1"] + ]); + expect(ops[1].html).toBe("a2"); + }); +}); From 822a2f7d727c49c47fed3767d56571324be00af2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 11:06:13 -0700 Subject: [PATCH 2/5] =?UTF-8?q?frames:=20the=20plain=20response's=20stream?= =?UTF-8?q?ing=20bound=20=E2=80=94=20`complete.bound`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plain (non-`live`) server component whose content reads a standing source kept its response open until the source settled — never, for a generator that never returns — with none of `live`'s reconnect semantics. `frameStream` now ends such a response at a bound, detectably: - `bound: "yields"` after `maxYields` emitting sweeps past the first flush (default 64; a sweep that emits nothing is not a yield — the sink's new `onYield` hook counts them); - `bound: "time"` `maxDurationMs` after the first flush (default 30 000), or when the request's `signal` aborts after the first flush (a platform deadline the client can tell from a death); before the first flush, or for a `live` response, the abort stays the death it was. The sink's end-latch runs as for any completion, `complete` carries the bound, the body closes, and the render is torn down quietly — the abort reason carries `quiet: true`, which `renderToStream` reads as "the response chose to end" (no SSR_STREAM_ABANDONED). A body's own cancel (the reader left) stays a death: never dressed as a bound. A `live` response is never bounded. Client: `:bound` is stored beside `:complete`; the frame lands as on any `complete`; in dev the host warns once per cut-off, naming `live()`. Public surface: `FrameStreamOptions.maxYields` / `maxDurationMs` (new); `createFrameSink(emit, frame, have, hooks?)` (fourth parameter, `onYield`) and the sink's `end(bound?)`; `FrameChunk`'s `complete` gains `bound?`. RFC addendum in frame-streams-rfc.md. `frameTransformResult` takes the defaults (it is a policy function with no options object). Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/frames-plain-response-bound.md | 5 + .../server-components/frame-streams-rfc.md | 39 ++ packages/web/frames/src/frame-client.ts | 32 +- packages/web/frames/src/frame-sink.ts | 180 ++++++-- packages/web/src/server.ts | 8 +- packages/web/test/frames-plain-bound.spec.tsx | 121 ++++++ .../test/server/frame-plain-bound.spec.tsx | 411 ++++++++++++++++++ .../web/test/server/frame-teardown.spec.tsx | 28 +- 8 files changed, 791 insertions(+), 33 deletions(-) create mode 100644 .changeset/frames-plain-response-bound.md create mode 100644 packages/web/test/frames-plain-bound.spec.tsx create mode 100644 packages/web/test/server/frame-plain-bound.spec.tsx diff --git a/.changeset/frames-plain-response-bound.md b/.changeset/frames-plain-response-bound.md new file mode 100644 index 000000000..94ad56934 --- /dev/null +++ b/.changeset/frames-plain-response-bound.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +frames: a plain (non-`live`) server component response ends at a streaming bound. A response whose content reads a standing source — a generator memo, a projection — used to stay open until the source settled, which for a source that never returns was never, with none of `live`'s reconnect semantics. The producer now ends it after `maxYields` emitting sweeps past the first flush (default 64) or `maxDurationMs` after the first flush (default 30 000 ms) — and when the request's `signal` aborts after the first flush — emitting `{ type: "complete", bound: "yields" | "time" }` before the body closes, and tears the render down quietly (sources returned, no abandonment finding). Both are new options on `FrameStreamOptions` (`renderServerComponent`, `renderToFrameStream`, `serverComponentResponse`); a `live` response is never bounded. The client stores `:bound` beside `:complete` and, in dev, warns once per cut-off naming `live()` as the declared way past the bound. `createFrameSink` gains an optional fourth `hooks` argument (`onYield`) and its `end(bound?)` takes the bound. Additive wire; RFC addendum in `frame-streams-rfc.md`. diff --git a/documentation/server-components/frame-streams-rfc.md b/documentation/server-components/frame-streams-rfc.md index c47359333..a512a3960 100644 --- a/documentation/server-components/frame-streams-rfc.md +++ b/documentation/server-components/frame-streams-rfc.md @@ -497,6 +497,45 @@ experimental preview (RFC 11's status note): the member is taken as additive on the producer and the consumer ships with it in the same release. +**`complete.bound` — the plain response's streaming bound (savings pass §6 +decision 4, ruled 2026-10-06).** + +```ts +| { type: "complete"; id: string; version: number; bound?: "yields" | "time" } +``` + +A plain (non-`live`) server component whose content reads a standing source +— a generator memo, a projection over an async iterable — keeps its +response open and ships each later commit as holes, with no declaration of +liveness anywhere; its only end was "the source settles", which for a +source that never returns is never. The producer now ends such a response +at a bound and says so: `bound: "yields"` after `maxYields` emitting sweeps +past the first flush (default 64; a sweep that emits nothing — the source +repeating a value — is not a yield), `bound: "time"` `maxDurationMs` after +the first flush (default 30 000) **or when the request's `signal` aborts +after the first flush** (a platform deadline is a time bound the client can +tell from a death). The sink's end-of-response latch runs as for any +completion (the last sweep's values ship before the `complete`), the body +closes, and the render is torn down quietly (sources returned, holds +released; no abandonment finding — the response chose to end). Both +defaults are options on `FrameStreamOptions` (`maxYields`, +`maxDurationMs`); `0` / `Infinity` disable one. A `live` response is never +bounded: liveness IS the declaration that there is no bound, and `live()` +is the documented way past it. A `complete` with no `bound` means what it +always meant. A body that ends without any `complete` stays what it is: the +open frame's `:error` (undeclared death). + +_Consumer:_ `chunkToRecords` stores `:bound` beside `:complete`; the frame +lands as on any `complete` (the covering boundary releases, `landing` +resolves), and a consumer that cares can tell a cut-off from a settled +value by the key. In dev the host warns once per response, naming `live()`. +Not surfaced as an error: the content shown is the server's last value, +which is what the frame says it is. + +_Old consumer:_ reads `complete` as before (the extra field is ignored by +its `chunkToRecords`); it sees a completed frame and never learns it was a +cut-off. Degrades to today's behaviour minus the (new) distinction. + ### Two identity schemes The format uses two deliberately distinct identity schemes: diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index c32949578..7c0ba5ddc 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -110,7 +110,20 @@ export type FrameChunk = | { type: "attr"; key: string; attrs: string; removed?: string[]; digest?: string } )[]; } - | { type: "complete"; id: string; version: number } + | { + type: "complete"; + id: string; + version: number; + /** + * Present when the producer ended a plain (non-`live`) response at + * its streaming bound rather than at its sources' settling (RFC 11 + * addendum): `"yields"` — the later-yield count; `"time"` — the + * wall-clock bound after the first flush, or the request's abort + * after it. The content shown is a cut-off, not a settled value; + * `live()` is the declared way past the bound. + */ + bound?: "yields" | "time"; + } | { type: "error"; id: string; version: number; key?: string; error: unknown }; /** @@ -663,7 +676,10 @@ export function chunkToRecords(chunk) { return records; } case "complete": - return { ":complete": true }; + // `:bound` beside `:complete` when the producer cut the response at + // its streaming bound: a consumer can tell a cut-off from a settled + // value (the frame landed either way). + return chunk.bound ? { ":complete": true, ":bound": chunk.bound } : { ":complete": true }; case "error": // Keyed errors scope to what the key names: a hole key (`lh:N`) is a // failed live-hole sweep — terminal for the hole, whose range latched @@ -850,6 +866,18 @@ export function createFrameHost(options = {}) { const records = chunkToRecords(chunk); const store = storeFor(chunk.id); if (!write(store, chunk.version, records)) return; + // The producer cut a plain response at its streaming bound: what the + // address shows is a cut-off, not a settled value. `live()` is the + // declared way past the bound; say so once per response, in dev. + if ("_SOLID_DEV_" && chunk.type === "complete" && chunk.bound) { + console.warn( + `Server component "${chunk.id}" kept streaming past the server's ${chunk.bound} ` + + `bound and was cut off (complete.bound: "${chunk.bound}"); its content is the last ` + + `value the server sent, not a settled one. A source meant to keep streaming is ` + + `declared with live(): wrap the server function (live(fn)) so the client holds a ` + + `standing connection instead.` + ); + } let r = records; // The address as a source: `start` opens a flight; the write that // lands it makes the version the one SHOWN and answers whoever awaited diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index f559dcbac..fc793e7ed 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -81,6 +81,28 @@ export interface FrameStreamOptions { * event stream. The chunk protocol is unchanged; only the framing is. */ live?: boolean; + /** + * The plain response's streaming bound, in later yields (frames savings + * pass §6 decision 4). A plain (non-`live`) server component whose + * content reads a standing source — a generator, a projection — keeps + * its response open and ships each later commit as holes; with no + * declaration of liveness anywhere, that response ends here: after this + * many emitting sweeps past the first flush the producer emits + * `{ type: "complete", bound: "yields" }` and closes. `live()` is the + * declared way past the bound; a `live` response is never bounded. + * Default 64. `0` / `Infinity` disables the count bound. + */ + maxYields?: number; + /** + * The plain response's streaming bound, in wall-clock milliseconds after + * the first flush (the same decision): `{ type: "complete", bound: + * "time" }` then the body closes. The request's `signal` aborting after + * the first flush ends a plain response the same way — a platform's + * deadline is a time bound the client can tell from a death. Default + * 30 000. `0` / `Infinity` disables the timer (the `signal` still ends + * it). + */ + maxDurationMs?: number; /** * A RESUME (RFC 11 §9.5): the have-list the reconnecting client sent — * the digests it holds for this address, keyed as the chunks carry them @@ -354,12 +376,22 @@ function withHoles(chunk, holes) { * have-list (see `FrameStreamOptions.resume`): present, the sink emits * conditionally against it. * + * `hooks.onYield` is called after every sweep that emitted something — the + * visible effect of one commit (a source yielding, a promise settling): the + * producer's bound on a plain response counts these (see `frameStream`). + * * @param {(chunk: object) => void} emit * @param {{ id: string, version: number }} frame * @param {Record} [have] + * @param {{ onYield?: () => void }} [hooks] */ -export function createFrameSink(emit, frame, have) { +export function createFrameSink(write, frame, have, hooks) { const { id, version } = frame; + // Every emission passes here, so a sweep knows whether it produced one. + const emit = chunk => { + if (swept) swept.emitted = true; + write(chunk); + }; // Conditional emission (Stage 8 B4, RFC 11 §9.5 Server face 2). `have` // is the client's ledger for this address; `conditional` arms once the // shell decides the client's structure stands (skeleton digests equal) @@ -441,7 +473,7 @@ export function createFrameSink(emit, frame, have) { let swept = null; const sweep = () => { epoch++; - const ops = (swept = []); + const pass = (swept = { ops: [], emitted: false }); try { for (const b of [...bindings.values()]) { try { @@ -452,10 +484,15 @@ export function createFrameSink(emit, frame, have) { // value stands. } } + emitOps(pass.ops); + // A pass that emitted is one visible commit — one "yield" to the + // plain-response bound (`frameStream`). After the pass's unit left, + // so a cut taken here follows it on the wire; still inside the pass, + // so `end` knows nothing is owed to the latch. + if (pass.emitted && hooks && hooks.onYield) hooks.onYield(); } finally { swept = null; } - emitOps(ops); }; // Members ride unaddressed (the envelope addresses them); a lone member // is addressed and emitted as the plain chunk it always was. @@ -660,14 +697,24 @@ export function createFrameSink(emit, frame, have) { emit({ type: "assets", id, version, key: "", preloads: [wirePreload(value)] }); } }, - end() { + /** + * End the response: `complete`, with `bound` when the producer cut a + * plain response at its streaming bound (`"yields"` | `"time"`; see + * `frameStream`) rather than its sources settling. Idempotent — a cut + * and the render's own end may both reach here. + */ + end(bound) { + if (closed) return; // The end-of-response latch: one final synchronous sweep so a commit // that landed in the last flush still ships before `complete` (the // scheduled microtask would lose that race). Completion latches every - // binding's last value as final. - if (bindings.size) sweep(); + // binding's last value as final. A cut taken from inside a sweep's + // yield hook has just swept; nothing is owed. + if (bindings.size && !swept) sweep(); closed = true; - emit({ type: "complete", id, version }); + const chunk = { type: "complete", id, version }; + if (bound) chunk.bound = bound; + emit(chunk); }, error(errorId, error) { emit({ type: "error", id, version, key: errorId, error }); @@ -689,7 +736,7 @@ export function createFrameSink(emit, frame, have) { // Produced by a sweep, so it joins the sweep's unit (see `sweep`). hole(key, html) { const op = { type: "hole", key, html, digest: textDigest(html) }; - swept ? swept.push(op) : emitOps([op]); + swept ? swept.ops.push(op) : emitOps([op]); }, // A live attr-hole re-emission: the addressed element's rebuilt // attribute text, plus the names that vanished since the last emission @@ -698,7 +745,7 @@ export function createFrameSink(emit, frame, have) { attr(key, attrs, removed) { const op = { type: "attr", key, attrs, digest: textDigest(attrs) }; if (removed && removed.length) op.removed = removed; - swept ? swept.push(op) : emitOps([op]); + swept ? swept.ops.push(op) : emitOps([op]); }, // An attr hole's first-render text, keyed by its address — the digest // source for root/fragment `holes` maps and the resume compare. @@ -847,23 +894,101 @@ export function renderServerComponent(component, options = {}) { // emission, `complete` + end on the stream settling. `makeCode` builds the // render thunk with access to the sink/frame (the slot-props proxy needs // both); no document text is ever written. +// The plain response's defaults (frames savings pass §6 decision 4, ruled +// 2026-10-06): 64 later yields, or 30 s after the first flush. +const DEFAULT_MAX_YIELDS = 64; +const DEFAULT_MAX_DURATION_MS = 30_000; +// The abort reason a response's own `cancel` tears its render down with: +// the reader left, so the stream must not dress the end as a bound. +const DISCONNECTED = Symbol("solid.frames.disconnected"); + function frameStream(makeCode, options) { const { id = "", version = 1 } = options.frame || {}; const frame = { id, version }; + // The plain-response streaming bound. A plain server component reading + // a standing source would otherwise hold its response open for as long + // as a `live` one does, with none of `live`'s reconnect semantics; the + // bound ends it, detectably: `complete` carries `bound`, so the client + // can tell a cut-off from a settled value. A `live` response is never + // bounded — liveness is the declaration that there is no bound. + const bounded = !options.live; + const maxYields = bounded ? (options.maxYields ?? DEFAULT_MAX_YIELDS) : 0; + const maxDurationMs = bounded ? (options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS) : 0; function stream(w) { // Observe tier: the server half of the `"frame"` record // (`OBSERVE.records`, see `FrameProducedEvent`) — start → complete, with // the chunk census. Nothing is read, not even the clock, without a // listener. const observation = observeFrame(frame); - const emit = observation - ? chunk => { - observation.chunk(chunk); - w.write(chunk); - } - : chunk => w.write(chunk); - const sink = createFrameSink(emit, frame, options.resume && options.resume.have); + // The render's own teardown, chained from the caller's signal: a cut at + // the bound tears the render down (its sources returned, its holds + // released — nothing produces for a response that has ended) without + // touching the caller's signal. + const render = new AbortController(); + const upstream = options.signal; + let ended = false; + let flushed = false; + let yields = 0; + let timer; + function finish() { + if (ended) return; + ended = true; + if (timer) clearTimeout(timer); + if (upstream) upstream.removeEventListener("abort", onSignal); + w.end && w.end(); + } + // End a plain response at a bound: the sink's end (the latch sweep, + // then `complete` with the bound), the body's end, then the render's + // teardown — quiet, so the renderer records no abandonment for a + // response that chose to end. + function cut(bound) { + if (ended) return; + sink.end(bound); + observation && observation.settle("complete"); + finish(); + render.abort({ quiet: true, bound }); + } + // The caller's signal (the request's, through `serverComponentResponse`) + // ends the response. After a plain response's first flush that end is + // its time bound — a platform deadline, a proxy's idle cut — and the + // client is told so (`complete.bound: "time"`); before the first flush, + // for a `live` response, or when the reader itself is gone (the body's + // cancel, flagged on the reason — nobody to tell), the body simply ends + // — the death the consumer already knows (an open frame's `:error`, a + // live loop's reconnect). Either way the render is torn down after the + // body's end, so the `complete` leaves before it. + function onSignal() { + const gone = upstream.reason && upstream.reason[DISCONNECTED]; + if (bounded && flushed && !gone) return cut("time"); + finish(); + render.abort(upstream.reason); + } + const emit = chunk => { + if (observation) observation.chunk(chunk); + w.write(chunk); + // First flush: the root's html. From here the bound counts. + if (bounded && !flushed && chunk.type === "html" && chunk.id === id) { + flushed = true; + if (maxDurationMs > 0 && maxDurationMs !== Infinity) + timer = setTimeout(() => cut("time"), maxDurationMs); + } + }; + const sink = createFrameSink( + emit, + frame, + options.resume && options.resume.have, + bounded + ? { + onYield() { + if (flushed && maxYields > 0 && ++yields >= maxYields) cut("yields"); + } + } + : undefined + ); w.write({ type: "start", id, version }); + // A caller already gone has nobody to render for. + if (upstream && upstream.aborted) return finish(); + if (upstream) upstream.addEventListener("abort", onSignal, { once: true }); const code = makeCode(sink, frame); try { // Frames default to the keyed JSON codec for data records (eval-free @@ -875,6 +1000,7 @@ function frameStream(makeCode, options) { renderToStream(() => serverOwned(code), { serializer: createJSONSerializer, ...options, + signal: render.signal, sink }).pipe({ // Every document emission is intercepted by the frame sink, so no @@ -882,9 +1008,10 @@ function frameStream(makeCode, options) { // signal. write() {}, end() { + if (ended) return; sink.end(); observation && observation.settle("complete"); - w.end && w.end(); + finish(); } }); } catch (err) { @@ -897,7 +1024,7 @@ function frameStream(makeCode, options) { sink.error("", wire instanceof Error ? wire.message : String(wire)); sink.end(); observation && observation.settle("error", err); - w.end && w.end(); + finish(); } } return { @@ -2465,12 +2592,11 @@ export function serverComponentResponse(component, options = {}, init = {}) { controller.close(); } catch (_) {} }; - // A torn-down render never ends its sink (nobody is listening), so the - // body closes here when the abort came from the request rather than - // from this body's own cancel — including a request gone before the - // body was ever read. + // A request gone before the body was ever read: nothing to render + // for. Once piping, the stream ends the body on the abort itself (and + // after a plain response's first flush, with `complete.bound: "time"` + // ahead of the close — see `frameStream`). if (teardown.signal.aborted) return end(); - teardown.signal.addEventListener("abort", end, { once: true }); // Chaos ends the body as a dying connection would: the render is torn // down first (its sources returned, as on a real disconnect), then // the body errors with the frame still open — a death to the reader. @@ -2496,7 +2622,9 @@ export function serverComponentResponse(component, options = {}, init = {}) { closed = true; disarm(); if (stopLive) stopLive(); - teardown.abort(); + // The reader is gone: a death, never a bound (there is nobody to + // tell) — the render abandons as on any disconnect. + teardown.abort({ [DISCONNECTED]: true }); } }); return new Response(body, { status: init.status || 200, headers }); @@ -2803,7 +2931,9 @@ export function frameFlightResponse({ primary, regions = [], outcome, codec, sig cancel() { closed = true; disarm(); - teardown.abort(); + // The reader is gone: a death for the frame in progress, never a + // bound (see serverComponentResponse's cancel). + teardown.abort({ [DISCONNECTED]: true }); } }); return new Response(body, { status: init.status || 200, headers }); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index b2b9ea334..b8277b060 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -2959,7 +2959,13 @@ export function renderToStream(code, options = {}) { // registry, the sink and the serializer, all declared above — and disarmed // by the render's final dispose, which every ending runs through. const signal = options.signal; - const onAbort = signal ? () => abandon("signal") : undefined; + // A reason carrying `quiet: true` is a teardown the response chose — a + // frame stream ending a plain response at its streaming bound (see + // frame-sink's `frameStream`) — not a client that left: no abandonment + // finding for it. + const onAbort = signal + ? () => abandon("signal", !!(signal.reason && signal.reason.quiet === true)) + : undefined; let html = root( d => { dispose = () => { diff --git a/packages/web/test/frames-plain-bound.spec.tsx b/packages/web/test/frames-plain-bound.spec.tsx new file mode 100644 index 000000000..aab380a7d --- /dev/null +++ b/packages/web/test/frames-plain-bound.spec.tsx @@ -0,0 +1,121 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// The plain-response streaming bound, client half (frames savings pass §6 +// decision 4). A `complete` carrying `bound` is a landing like any other +// `complete` — the frame settles, the covering boundary releases — and the +// store keeps `:bound` beside `:complete`, so a consumer can tell a cut-off +// from a settled value. In dev the cut-off is named once per response, with +// `live()` as the declared way past the bound. +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createRoot, Loading } from "solid-js"; +import { dynamic } from "../src/index.js"; +import { installServerComponents } from "../frames/src/client.js"; +import { createServerReference } from "../server-functions/src/client.js"; +import { frameAddress } from "../server-functions/src/shared.js"; +import { makeHost, frameResponse, pump } from "./lifecycle-matrix/harness.js"; + +const html = (text: string) => `

${text}

`; + +function mountUnderLoading(Comp: any) { + const container = document.createElement("div"); + document.body.appendChild(container); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
+ shell-fallback}> + + +
; + container.appendChild(div); + return d; + }); + return { + div, + cleanup() { + dispose(); + container.remove(); + } + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("plain-response bound — the client", () => { + test("`complete.bound` lands the frame, stores `:bound` beside `:complete`, and dev names live() once", async () => { + const { host } = makeHost(); + installServerComponents(host); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", async () => + frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "html", id: "srv", version: 1, html: html("v3") }, + { type: "complete", id: "srv", version: 1, bound: "yields" } + ]) + ); + const getFeed = createServerReference("frames-bound/yields"); + const Page = dynamic(() => getFeed() as any); + const m = mountUnderLoading(Page); + await pump(); + + // Landed: the content shows, the covering boundary released. + expect(m.div.querySelector("p")!.textContent).toBe("v3"); + expect(m.div.textContent).not.toContain("shell-fallback"); + const frame: any = host.get(frameAddress("frames-bound/yields")); + expect(frame.store[":complete"]).toBe(true); + expect(frame.store[":bound"]).toBe("yields"); + expect(frame.error).toBeUndefined(); + // Named once, with the way past it. + const named = warn.mock.calls.filter(c => String(c[0]).includes("bound")); + expect(named).toHaveLength(1); + expect(String(named[0][0])).toContain('complete.bound: "yields"'); + expect(String(named[0][0])).toContain("live("); + m.cleanup(); + }); + + test("a `complete` without `bound` stores no `:bound` and warns nothing", async () => { + const { host } = makeHost(); + installServerComponents(host); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", async () => + frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "html", id: "srv", version: 1, html: html("settled") }, + { type: "complete", id: "srv", version: 1 } + ]) + ); + const getFeed = createServerReference("frames-bound/settled"); + const Page = dynamic(() => getFeed() as any); + const m = mountUnderLoading(Page); + await pump(); + const frame: any = host.get(frameAddress("frames-bound/settled")); + expect(frame.store[":complete"]).toBe(true); + expect(":bound" in frame.store).toBe(false); + expect(warn.mock.calls.filter(c => String(c[0]).includes("bound"))).toHaveLength(0); + m.cleanup(); + }); + + test("a time bound is stored as such", async () => { + const { host } = makeHost(); + installServerComponents(host); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubGlobal("fetch", async () => + frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "html", id: "srv", version: 1, html: html("t") }, + { type: "complete", id: "srv", version: 1, bound: "time" } + ]) + ); + const getFeed = createServerReference("frames-bound/time"); + const Page = dynamic(() => getFeed() as any); + const m = mountUnderLoading(Page); + await pump(); + const frame: any = host.get(frameAddress("frames-bound/time")); + expect(frame.store[":bound"]).toBe("time"); + m.cleanup(); + }); +}); diff --git a/packages/web/test/server/frame-plain-bound.spec.tsx b/packages/web/test/server/frame-plain-bound.spec.tsx new file mode 100644 index 000000000..9737e2dcb --- /dev/null +++ b/packages/web/test/server/frame-plain-bound.spec.tsx @@ -0,0 +1,411 @@ +/** + * @jsxImportSource @solidjs/web + * + * The plain-response streaming bound (frames savings pass §6 decision 4, + * ruled 2026-10-06; frames-rulings §"The server half" (ii)). A plain + * (non-`live`) server component whose content reads a standing source + * keeps its response open and ships each later commit as holes — with no + * declaration of liveness anywhere. The producer ends such a response at a + * bound, detectably: `complete` carries `bound: "yields" | "time"`, then + * the body closes, and the render is torn down (its sources returned). A + * `live` response is never bounded — liveness IS the declaration that + * there is no bound. The request's `signal` aborting after the first flush + * ends a plain response as the time bound does. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMemo } from "solid-js"; +import { Loading } from "@solidjs/web"; +import { renderServerComponent, serverComponentResponse } from "../../frames/src/frame-sink.js"; +import { ChunkReader } from "../../server-functions/src/shared.js"; + +const tick = (ms = 5) => new Promise(r => setTimeout(r, ms)); + +function consume(stream: any) { + const chunks: any[] = []; + const waiters: { test: (c: any) => boolean; resolve: () => void }[] = []; + const done = new Promise(res => + stream.pipe({ + write: (c: any) => { + chunks.push(c); + for (let i = waiters.length - 1; i >= 0; i--) { + if (waiters[i].test(c)) waiters.splice(i, 1)[0].resolve(); + } + }, + end: res + }) + ); + const until = (test: (c: any) => boolean) => { + if (chunks.some(test)) return Promise.resolve(); + return new Promise(resolve => waiters.push({ test, resolve })); + }; + return { chunks, until, done }; +} + +/** A push-driven async iterable that records whether it was returned. */ +function channel() { + const queue: T[] = []; + let notify: (() => void) | null = null; + let done = false; + const state = { closed: false, pulls: 0 }; + const wake = () => { + notify?.(); + notify = null; + }; + return { + state, + push(v: T) { + queue.push(v); + wake(); + }, + end() { + done = true; + wake(); + }, + iterable: { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + state.pulls++; + while (queue.length === 0) { + if (done) return { value: undefined as any, done: true }; + await new Promise(r => (notify = r)); + } + return { value: queue.shift()!, done: false }; + }, + return() { + state.closed = true; + done = true; + wake(); + return Promise.resolve({ value: undefined as any, done: true as const }); + } + }; + } + } as AsyncIterable + }; +} + +/** One live hole over the channel, under a boundary (the first yield settles it). */ +function holeComponent(source: () => AsyncIterable) { + return () => { + const text = createMemo(source); + return ( + typing

}> +

{text()}

+
+ ); + }; +} + +const complete = (chunks: any[]) => chunks.find(c => c.type === "complete"); +const yields = (chunks: any[]) => + chunks.filter(c => c.type === "hole" || c.type === "ops" || c.type === "attr").length; + +async function readBody(response: Response) { + const reader = new ChunkReader(response.body!); + const chunks: any[] = []; + for (let r = await reader.next(); !r.done; r = await reader.next()) { + chunks.push(JSON.parse(r.value as string)); + } + return chunks; +} + +describe("the plain-response streaming bound", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('yields: a plain response over a source that keeps yielding ends at `maxYields` later yields with `complete.bound: "yields"`, and the source is returned', async () => { + const ch = channel(); + const { chunks, until, done } = consume( + renderServerComponent( + holeComponent(() => ch.iterable), + { + frame: { id: "b" }, + maxYields: 3 + } + ) + ); + await tick(); + ch.push("v0"); + await until(c => c.type === "fragment"); + // Later yields — more than the bound allows; each is one commit, one + // emitting sweep, one hole. + for (let i = 1; i <= 10; i++) { + ch.push(`v${i}`); + await tick(1); + } + await done; + + const end = complete(chunks); + expect(end).toBeDefined(); + expect(end.bound).toBe("yields"); + expect(chunks[chunks.length - 1]).toBe(end); + // Exactly the bound's worth of later yields shipped; the first yield + // rode the fragment (not a later yield). + expect(yields(chunks)).toBe(3); + expect(chunks.filter(c => c.type === "hole").map(h => h.html)).toEqual(["v1", "v2", "v3"]); + // The render was torn down at the cut: the source was returned, not + // left pumping for a response that ended. + expect(ch.state.closed).toBe(true); + }, 8000); + + it("yields: the default is 64 later yields", async () => { + const ch = channel(); + const { chunks, until, done } = consume( + renderServerComponent( + holeComponent(() => ch.iterable), + { frame: { id: "b64" } } + ) + ); + await tick(); + ch.push("v0"); + await until(c => c.type === "fragment"); + for (let i = 1; i <= 70; i++) { + ch.push(`v${i}`); + await tick(0); + } + await done; + expect(complete(chunks).bound).toBe("yields"); + expect(yields(chunks)).toBe(64); + }, 8000); + + it("yields: a sweep that emits nothing is not a yield (the source repeating a value does not count)", async () => { + const ch = channel(); + const { chunks, until, done } = consume( + renderServerComponent( + holeComponent(() => ch.iterable), + { + frame: { id: "bq" }, + maxYields: 2 + } + ) + ); + await tick(); + ch.push("v0"); + await until(c => c.type === "fragment"); + // Three repeats of the first value: three commits, no emission. + ch.push("v0"); + await tick(1); + ch.push("v0"); + await tick(1); + ch.push("v0"); + await tick(1); + ch.push("v1"); + await until(c => c.type === "hole" && c.html === "v1"); + ch.push("v2"); + await done; + expect(complete(chunks).bound).toBe("yields"); + expect(chunks.filter(c => c.type === "hole").map(h => h.html)).toEqual(["v1", "v2"]); + }, 8000); + + it('time: a plain response ends `maxDurationMs` after its first flush with `complete.bound: "time"` (fake timers)', async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const ch = channel(); + const { chunks, until, done } = consume( + renderServerComponent( + holeComponent(() => ch.iterable), + { + frame: { id: "bt" }, + maxDurationMs: 1000 + } + ) + ); + // The shell flushes with the fallback (the first flush); the source + // never yields. Nothing ends the response but the timer. + await until(c => c.type === "html"); + await vi.advanceTimersByTimeAsync(999); + expect(complete(chunks)).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(complete(chunks).bound).toBe("time"); + expect(chunks[chunks.length - 1].type).toBe("complete"); + expect(ch.state.closed).toBe(true); + }); + + it("time: the default is 30 s after the first flush", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const ch = channel(); + const { chunks, until, done } = consume( + renderServerComponent( + holeComponent(() => ch.iterable), + { frame: { id: "bt30" } } + ) + ); + await until(c => c.type === "html"); + await vi.advanceTimersByTimeAsync(29_999); + expect(complete(chunks)).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(complete(chunks).bound).toBe("time"); + }); + + it("time: the timer arms at the first flush, not at the request", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + // An un-boundaried async read: the shell itself waits on the first + // value, so the clock must not start until it arrives. + const ch = channel(); + const Comp = () => { + const text = createMemo(() => ch.iterable); + return

{text()}

; + }; + const { chunks, until, done } = consume( + renderServerComponent(Comp, { frame: { id: "bta" }, maxDurationMs: 1000 }) + ); + await vi.advanceTimersByTimeAsync(5000); + expect(chunks.some(c => c.type === "html")).toBe(false); + expect(complete(chunks)).toBeUndefined(); + ch.push("v0"); + await until(c => c.type === "html"); + await vi.advanceTimersByTimeAsync(1000); + await done; + expect(complete(chunks).bound).toBe("time"); + }); + + it("a `live` response is not bounded: neither count nor clock ends it; it completes when its source does, with no `bound`", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const ch = channel(); + const { chunks, until, done } = consume( + renderServerComponent( + holeComponent(() => ch.iterable), + { + frame: { id: "bl" }, + live: true, + maxYields: 2, + maxDurationMs: 1000 + } + ) + ); + await vi.advanceTimersByTimeAsync(1); + ch.push("v0"); + await until(c => c.type === "fragment"); + for (let i = 1; i <= 5; i++) { + ch.push(`v${i}`); + await until(c => c.type === "hole" && c.html === `v${i}`); + } + await vi.advanceTimersByTimeAsync(60_000); + expect(complete(chunks)).toBeUndefined(); + ch.end(); + await done; + const end = complete(chunks); + expect(end).toBeDefined(); + expect("bound" in end).toBe(false); + expect(chunks.filter(c => c.type === "hole")).toHaveLength(5); + }); + + it("`complete` without a bound is unchanged for a response whose sources settle", async () => { + const ch = channel(); + const { chunks, until, done } = consume( + renderServerComponent( + holeComponent(() => ch.iterable), + { + frame: { id: "bs" }, + maxYields: 10 + } + ) + ); + await tick(); + ch.push("v0"); + await until(c => c.type === "fragment"); + ch.push("v1"); + await until(c => c.type === "hole"); + ch.end(); + await done; + expect(complete(chunks)).toEqual({ type: "complete", id: "bs", version: 1 }); + }); + + describe("the request's signal", () => { + it('aborting after the first flush ends a plain response as the time bound does: `complete.bound: "time"`, then the body closes', async () => { + const ch = channel(); + const controller = new AbortController(); + const response = serverComponentResponse( + holeComponent(() => ch.iterable), + { + frame: { id: "sig" }, + signal: controller.signal + } + ); + const reader = new ChunkReader(response.body!); + const chunks: any[] = []; + const read = async () => { + for (let r = await reader.next(); !r.done; r = await reader.next()) { + chunks.push(JSON.parse(r.value as string)); + } + }; + const ended = read(); + await tick(); + ch.push("v0"); + await tick(10); + expect(chunks.some(c => c.type === "fragment")).toBe(true); + controller.abort(); + await ended; + const end = complete(chunks); + expect(end).toBeDefined(); + expect(end.bound).toBe("time"); + expect(chunks[chunks.length - 1]).toBe(end); + expect(ch.state.closed).toBe(true); + }); + + it("aborting before the first flush ends the body without `complete` (the death the client already knows)", async () => { + const ch = channel(); + const controller = new AbortController(); + const Comp = () => { + const text = createMemo(() => ch.iterable); + return

{text()}

; + }; + const response = serverComponentResponse(Comp, { + frame: { id: "sig0" }, + signal: controller.signal + }); + const reading = readBody(response); + await tick(); + controller.abort(); + const chunks = await reading; + expect(chunks.map(c => c.type)).toEqual(["start"]); + expect(ch.state.closed).toBe(true); + }); + + it("aborting a `live` response ends the body without `complete`", async () => { + const ch = channel(); + const controller = new AbortController(); + const response = serverComponentResponse( + holeComponent(() => ch.iterable), + { + frame: { id: "sigl" }, + signal: controller.signal, + live: true + } + ); + const text = response.body!.pipeThrough(new TextDecoderStream()); + const reader = text.getReader(); + let out = ""; + const reading = (async () => { + for (let r = await reader.read(); !r.done; r = await reader.read()) out += r.value; + })(); + await tick(); + ch.push("v0"); + await tick(10); + expect(out).toContain('"fragment"'); + controller.abort(); + await reading; + expect(out).not.toContain('"complete"'); + }); + + it("the body's own cancel (the reader left) is a death, never a bound", async () => { + const ch = channel(); + const response = serverComponentResponse( + holeComponent(() => ch.iterable), + { + frame: { id: "cancel" } + } + ); + const reader = response.body!.getReader(); + await reader.read(); + await tick(); + ch.push("v0"); + await tick(10); + await reader.cancel(); + await tick(5); + expect(ch.state.closed).toBe(true); + }); + }); +}); diff --git a/packages/web/test/server/frame-teardown.spec.tsx b/packages/web/test/server/frame-teardown.spec.tsx index 3ae77d43a..31c96ea60 100644 --- a/packages/web/test/server/frame-teardown.spec.tsx +++ b/packages/web/test/server/frame-teardown.spec.tsx @@ -112,9 +112,14 @@ describe("frame teardown on disconnect (Stage 8 B1)", () => { controller.abort(); await tick(); expect(state.returned).toBe(true); - // Nobody ends a torn-down render's sink; the response closes itself. - const { done } = await reader.read(); - expect(done).toBe(true); + // The abort came after the first flush, so a PLAIN response ends as at + // its time bound — `complete.bound: "time"` (the client can tell a + // deadline from a death) — and then the body closes. + const rest = await drain(reader); + expect(rest).toContain('"type":"complete"'); + expect(rest).toContain('"bound":"time"'); + // No abandonment finding: the response ended at a bound, nobody left. + expect(capture.events.filter(e => e.code === "SSR_STREAM_ABANDONED")).toHaveLength(0); }); it("a request already aborted renders for nobody: torn down at once", async () => { @@ -172,7 +177,20 @@ describe("frame teardown on disconnect (Stage 8 B1)", () => { expect(first.state.returned).toBe(true); // The second frame never started: its source was never pulled. expect(second.state.pulls).toBe(0); - const { done } = await reader.read(); - expect(done).toBe(true); + // The frame in progress had flushed: it ends at its time bound; the + // body closes after it with no outcome. + const rest = await drain(reader); + expect(rest).toContain('"bound":"time"'); + expect(rest).not.toContain('"outcome"'); }); }); + +/** Read a body to its end; the text after the point the caller stopped at. */ +async function drain(reader: ReadableStreamDefaultReader) { + const decoder = new TextDecoder(); + let text = ""; + for (let r = await reader.read(); !r.done; r = await reader.read()) { + text += decoder.decode(r.value, { stream: true }); + } + return text; +} From c624f7def2d949828c3d6573354a809328286d0d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 6 Oct 2026 11:20:28 -0700 Subject: [PATCH 3/5] frames: a server 's post-flush failure renders its outcome (C12 c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server `` inside a server component that fails after the first flush has no client twin to render over its position; the fragment used to carry a blank (`" "`) and the position emptied silently (R6). The document face now renders what the SERVER rendered for the outcome, as the stream face's `meta.error` path already revealed (frames-rulings 3.3, A0 corollary 4 inward): - the nearest SERVER ``'s fallback for the error, at the ``'s position — asked through the boundary error handler's new `outcome` mode (`createErrorBoundary` answers with its fallback as finished markup when it is inside the component's scope; a `` between passes the question up; an `` outside the component — the app's twin at t = 0 — answers nothing). Rule: "a post-flush error inside a server component's shows the nearest 's fallback at the boundary's position". Ids are the component's own hydration-free scope's; head / asset registrations drop as before; - with no server `` the error ESCAPES the component — the frame as one async value errored, the outward face: the stream face's unkeyed `error` chunk (`:error`); the document face's `sc:live` `{ type: "error", fid, error }` op, which only the owning adopted boundary applies (`applyLiveOp`'s `fid` gate now covers unkeyed error ops) — and the position keeps the boundary's own fallback, never a blank. `_fr` still rejects and the keyed error chunk still rides. Outside a server component nothing changes: the blank the client twin renders fresh over. Pins: C12 (c2) flips to `test` (the page carries the Errored's fallback as the server now writes it); (c3) added for the escape arm; `test/server/frame-fragment-error-outcome.spec.tsx` pins the sink on both faces, the nested-Loading pass-up, and the unchanged non-component case. The maintainer's refetch question (a) is pinned `test.fails` in `test/frames-errored-reset-refetch.spec.tsx`: it fails at its first step — the client `` never catches a frame's `:error` on this branch (the landing resolves on it) — and the two-part fix is described in the pin and in frames-rulings 3.3. Internal surface: `HydrationContext.registerFragment`'s resolver takes a third `escaped?: { frame?: string }`; `HydrationContext.frameId` (`@internal`, set by `frameTransformDirectResult`); the module-internal `ErrorContext` handler takes `(err, outcome?: true)`. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .../frames-c12-fragment-error-outcome.md | 6 + packages/solid/src/server/hydration.ts | 82 ++++-- packages/solid/src/server/shared.ts | 16 +- packages/solid/src/server/signals.ts | 56 +++- packages/web/frames/src/client.ts | 6 +- packages/web/frames/src/frame-sink.ts | 10 + packages/web/src/server.ts | 22 +- .../consistency/c12-boundary-parity.spec.tsx | 134 ++++++--- .../frames-errored-reset-refetch.spec.tsx | 122 ++++++++ .../frame-fragment-error-outcome.spec.tsx | 266 ++++++++++++++++++ 10 files changed, 645 insertions(+), 75 deletions(-) create mode 100644 .changeset/frames-c12-fragment-error-outcome.md create mode 100644 packages/web/test/frames-errored-reset-refetch.spec.tsx create mode 100644 packages/web/test/server/frame-fragment-error-outcome.spec.tsx diff --git a/.changeset/frames-c12-fragment-error-outcome.md b/.changeset/frames-c12-fragment-error-outcome.md new file mode 100644 index 000000000..2d33fcaad --- /dev/null +++ b/.changeset/frames-c12-fragment-error-outcome.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +A server `` inside a server component that fails after the first flush renders the server's outcome into its fragment instead of a blank (C12 (c), frames-rulings 3.3): the nearest server ``'s fallback for the error, at the ``'s position (asked through the boundary error handler's new `outcome` mode; a `` between passes the question up); with no server `` the error escapes the component — the frame's own `:error` on the stream face (an unkeyed `error` chunk), a frame-addressed `{ type: "error", fid, error }` op on the document face's `sc:live` channel (only the owning adopted boundary applies it) — and the position keeps the boundary's own fallback. `_fr` still rejects and the keyed error chunk still rides (the diagnostics). Outside a server component nothing changes (the blank the client twin renders fresh over). `HydrationContext.registerFragment`'s resolver gains a third argument (`escaped?: { frame?: string }`) and the context an internal `frameId`. diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index 11bf03277..08062f345 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -13,7 +13,8 @@ import { reportServerError, throwerOf, ownerId, - onCleanup + onCleanup, + inServerComponentScope } from "./signals.js"; import { OBSERVE } from "@solidjs/signals"; import { sharedConfig, NoHydrateContext, callerRenderContext } from "./shared.js"; @@ -115,7 +116,7 @@ function ssrLoadingBoundary( const flattenId = id + (hasOn ? "02" : "01"); (o as any).id = contentId; - let done: ((value?: string, error?: any) => boolean) | undefined; + let done: ReturnType | undefined; let handledRenderError: any; let retryPromise: Promise | undefined; @@ -309,12 +310,59 @@ function ssrLoadingBoundary( if (modules) ctx.serialize(id + "_assets", { ...modules }); } + // What the fragment carries for a failure once it is registered (its + // channel owns the routing — see runLoadingPhase). Outside a server + // component: nothing — the blank the client twin renders fresh over, as + // always (`hydratedCreateLoadingBoundary`'s rejected arm). Inside one + // (frames-rulings 3.3, A0 corollary 4 inward — the position shows what the + // SERVER rendered for the outcome, never a blank, never a client-invented + // state; there is no twin): the nearest server ``'s fallback for + // the error, rendered at this boundary's position (asked through the + // handler chain's `outcome` mode; a `` between passes the + // question up); with none, the error ESCAPES the component — the frame as + // one async value errors (the outward face; the renderer's sink carries + // it) and the position keeps the boundary's own markup, its fallback. + // Hydration ids inside the rendered fallback are the component's own + // hydration-free scope's; its head and asset registrations drop with the + // error, as the error path drops them today. + function errorOutcome(err: any): { value?: string; escaped?: { frame?: string } } | undefined { + if (!inServerComponentScope(o as any)) return undefined; + const rendered = parentHandler ? parentHandler(err, true) : undefined; + if (typeof rendered === "string") return { value: rendered }; + return { value: plainFallback(), escaped: { frame: ctx.frameId } }; + } + // Settle the fragment with the failure. The server error hook hears of it + // first (the `_fr` rejection and a transport sink's error chunk read the + // verdict it decides) — as `handling: "client"` when the client is where + // it goes, unless a server `` just reported it as its own + // (`"fallback"`, once per error); `report` is false where the caller + // defers that to the parent handler (the pre-flush path). + // Whether the last failure's outcome was rendered by a server + // (its own finding names it; the "client re-renders" one would be wrong). + let outcomeRendered = false; + function failFragment(err: any, report: boolean): boolean { + const outcome = errorOutcome(err); + outcomeRendered = !!outcome && outcome.value !== undefined && !outcome.escaped; + if (report && !outcomeRendered) { + reportServerError( + err, + { kind: "render", handling: "client", boundary: id }, + o, + ctx.errorPolicy + ); + } + return done!(outcome && outcome.value, err, outcome && outcome.escaped); + } + function runLoadingPhase(render: () => T): T { handledRenderError = undefined; return runWithBoundaryErrorContext( o, render, - (err: any, parentHandler) => { + (err: any, handler, outcome) => { + // The outcome question (see errorOutcome) is a ``'s to pass + // up: it owns no fallback for an error. + if (outcome) return handler ? handler(err, true) : undefined; handledRenderError = err; if (done) { // Once the fragment is registered, its channel owns error routing: @@ -326,18 +374,11 @@ function ssrLoadingBoundary( // so its only lasting effect is serializing the error at the // Errored id, which makes the hydrating client render the error // fallback expecting server DOM that was never emitted, derailing - // hydration before the fragment channel can engage. - reportRouted(err, "client"); - // The server error hook hears of it here, before the channel - // carries it (the `_fr` rejection, a transport sink's error chunk - // read the verdict the hook decides). - reportServerError( - err, - { kind: "render", handling: "client", boundary: id }, - o, - ctx.errorPolicy - ); - streamedOnError = done(undefined, err); + // hydration before the fragment channel can engage. (Inside a + // server component the nearest server `` IS asked — for + // its rendered outcome, not to route; see errorOutcome.) + streamedOnError = failFragment(err, true); + if (!outcomeRendered) reportRouted(err, "client"); throw err; } // Synchronous discovery (no fragment yet): the enclosing Errored's @@ -379,15 +420,8 @@ function ssrLoadingBoundary( // when it delivers) and the failure is met next by the parent handler // — an rendering its fallback — or fails the request below. const streamed = ctx.flushed !== undefined && ctx.flushed(); - if (streamed) - reportServerError( - err, - { kind: "render", handling: "client", boundary: id }, - o, - ctx.errorPolicy - ); - if (done(undefined, err)) { - reportRouted(err, "client"); + if (failFragment(err, streamed)) { + if (!outcomeRendered) reportRouted(err, "client"); record("error", true, err); return; } diff --git a/packages/solid/src/server/shared.ts b/packages/solid/src/server/shared.ts index f99de3997..02aeceb4e 100644 --- a/packages/solid/src/server/shared.ts +++ b/packages/solid/src/server/shared.ts @@ -36,10 +36,24 @@ export type HydrationContext = { escape(value: any): string; replace: (id: string, replacement: () => any) => void; block: (p: Promise) => void; + /** + * Register a deferred fragment; the resolver settles it: `v` its markup, + * `err` the failure it settled with, `escaped` that the failure escaped a + * server component (no server `` rendered an outcome for it) — + * the renderer surfaces it as the frame's own error, the outward face; + * `escaped.frame` names the component's frame where the channel needs it + * (the document face's `sc:live` op). + */ registerFragment: ( v: string, options?: { revealGroup?: string } - ) => (v?: string, err?: any) => boolean; + ) => (v?: string, err?: any, escaped?: { frame?: string }) => boolean; + /** + * @internal The frame id of the server component this context renders + * inside, on the document face (set by the frames server runtime on the + * component's render context; inherited by every clone below it). + */ + frameId?: string; revealFragments?: (groupOrKeys: string | string[]) => void; revealFallbacks?: (groupOrKeys: string | string[]) => void; /** Register a client-side asset discovered during SSR (e.g. from lazy()). */ diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index fd27c51f7..7ef273700 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -3211,7 +3211,20 @@ export function repeat( // === Boundary primitives === -const ErrorContext: Context<((err: any) => void) | null> = { +/** + * The error handler a boundary installs for its subtree. Called with the + * error alone it routes it (an `` renders its fallback and throws; + * a `` channels it). Called with `outcome: true` it is asked for + * the SERVER's rendered outcome for a post-flush failure inside a server + * component (frames-rulings 3.3 — the position shows what the server + * rendered, never a blank): the nearest server `` answers with + * its fallback as markup; a `` passes the question up; a handler + * that belongs to no server `` answers `undefined` — the error + * escapes the component. + */ +export type BoundaryErrorHandler = (err: any, outcome?: true) => string | undefined | void; + +const ErrorContext: Context = { id: Symbol("ErrorContext"), defaultValue: null }; @@ -3258,7 +3271,11 @@ export const RevealGroupContext: Context = { export function runWithBoundaryErrorContext( owner: Owner, render: () => T, - onError: (err: any, parentHandler: ((err: any) => void) | null) => void, + onError: ( + err: any, + parentHandler: BoundaryErrorHandler | null, + outcome?: true + ) => string | undefined | void, context?: NonNullable, boundaryId?: string ): T { @@ -3284,7 +3301,7 @@ export function runWithBoundaryErrorContext( try { return runWithOwner(owner, () => { const parentHandler = getContext(ErrorContext); - setContext(ErrorContext, err => onError(err, parentHandler)); + setContext(ErrorContext, (err, outcome) => onError(err, parentHandler, outcome)); return render(); }) as T; } finally { @@ -3677,6 +3694,36 @@ export function createErrorBoundary( serializeError(wire); return renderFallback(wire); }; + // The server's rendered OUTCOME for a post-flush failure inside a server + // component (frames-rulings 3.3, A0 corollary 4 inward; asked through the + // handler's `outcome` mode by the `` whose fragment failed): this + // boundary's fallback for the error, as finished markup, rendered at the + // asking boundary's position — this boundary's own subtree is already in + // the shell, so its fallback replacing the placeholder is the one layout + // the fragment can express. Only a SERVER `` answers — one inside + // the component's scope, whose ids the client never claims (the scope is + // hydration-free) and whose record nothing adopts; an `` outside + // the component (the app's, at t = 0 — a client twin) answers nothing, and + // the error escapes the component as the frame's own error (the outward + // face). A fallback still resolving (an async hole in it) has no finished + // markup to answer with and escapes the same way. + const renderOutcome = (err: any): string | undefined => { + if (!ctx || !inServerComponentScope(owner as unknown as SSROwner)) return undefined; + // Rendered from a resume loop, where the render context the compiled + // template reads has long moved past this boundary's: restore it. + const prevCtx = sharedConfig.context; + sharedConfig.context = ctx; + try { + const resolved: any = ctx.resolve(ctx.escape(handleError(err))); + if (!resolved || (resolved.h && resolved.h.length)) return undefined; + const t = resolved.t; + return Array.isArray(t) ? t[0] : t; + } catch { + return undefined; + } finally { + sharedConfig.context = prevCtx; + } + }; // `$lhSkip`: boundary machinery owns this position (see ssrLoadingBoundary) // — a live binding over the boundary's output would re-run resolve(), // which re-creates owners and re-enters retry plumbing per sweep. @@ -3689,8 +3736,9 @@ export function createErrorBoundary( if (ctx && !pending) disposeOwner(owner, false); try { result = ctx - ? runWithBoundaryErrorContext(owner, resolve, err => { + ? runWithBoundaryErrorContext(owner, resolve, (err, _parent, outcome) => { if (err instanceof NotReadyError) throw err; + if (outcome) return renderOutcome(err); handled = true; result = handleError(err); throw err; diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts index 0f2866db6..6ff466781 100644 --- a/packages/web/frames/src/client.ts +++ b/packages/web/frames/src/client.ts @@ -1391,9 +1391,11 @@ function adoptBoundary( // store-keyed — two boundaries can share an occurrence name — so they // carry the producing frame's id and only the owning boundary applies // (the stray `fid` field rides into the apply; records are built from - // key/args, so it is ignored). + // key/args, so it is ignored). So does a frame-addressed ERROR op — a + // failure that escaped the server component (its `:error`, the outward + // face; frames-rulings 3.3); hole-keyed errors stay geometry-routed. const applyLiveOp = (op: any) => { - if (op.type === "slot" && op.fid !== id) return; + if (op.fid !== undefined && op.fid !== id) return; host.apply({ ...op, id: address, version: 0 }); }; liveAppliers.add(applyLiveOp); diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index fc793e7ed..77a20843f 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -1972,6 +1972,13 @@ function armDocumentLiveHoles(ctx) { }, commit: scheduleSweep }; + // A failure that escaped a server component on the document face + // (frames-rulings 3.3; web's fragment resolver): the frame as one async + // value errored — an unkeyed error op addressed to its frame, which only + // the owning adopted boundary applies (`:error`). + live.error = (fid, error) => { + push({ type: "error", fid, error }); + }; live.end = () => { if (closed) return; if (bindings.size) sweep(); @@ -2024,6 +2031,9 @@ export function frameTransformDirectResult(value, { id, args }) { // outside the component barrier — neither marks nor warns. const ctx = Object.create(page); ctx.claims = CLAIMS_DOCUMENT; + // The frame this scope renders: a failure escaping a boundary inside + // it is addressed to this frame on the live channel (`live.error`). + ctx.frameId = id; sharedConfig.context = ctx; try { const slotProps = createDocumentSlotProps(props, id); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index b8277b060..c4fcf56b3 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -2850,7 +2850,7 @@ export function renderToStream(code, options = {}) { (stubBatch ||= new Map()).set(key + "_fr", p); else serializer.write(key + "_fr", p); } - return (value, error) => { + return (value, error, escaped) => { if (registry.has(key)) { const item = registry.get(key); registry.delete(key); @@ -2860,6 +2860,19 @@ export function renderToStream(code, options = {}) { // `_fr` rejection, a transport sink's error chunk — gets what // the wire policy allows (#3468). if (error) abandonSubtree(key, error); + // A failure that ESCAPED a server component (frames-rulings 3.3: + // no server rendered an outcome for it; `value` is the + // boundary's own markup) is the frame's — one async value errored, + // the outward face: the frame sink's unkeyed error chunk + // (`:error`), or the document face's `sc:live` error op addressed + // to the component's frame. The fragment still settles below (its + // position never blanks; `_fr` still rejects — the diagnostic). + if (error && escaped) { + const wire = ssrSanitizeError(error, null); + const message = wire instanceof Error ? wire.message : String(wire); + if (sink.error) sink.error("", message); + else if (context.live && context.live.error) context.live.error(escaped.frame, message); + } // A settled nested fragment parked its markup here to be spliced // into this fragment's content. On the error path there is no @@ -2907,7 +2920,12 @@ export function renderToStream(code, options = {}) { // (its protocol rejects `_fr` via item.resolve below), but // transport sinks with no resume protocol need the signal. // Post-flush: the boundary told the hook before settling, so - // the verdict the chunk carries is the decided one. + // the verdict the chunk carries is the decided one. On the + // error path `value` is what the boundary rendered for the + // outcome — a server 's fallback or the boundary's own + // markup inside a server component (frames-rulings 3.3) — and + // nothing outside one, where the client twin renders fresh over + // the blank. sink.fragment(key, resolveSSRSelectValues(value !== undefined ? value : " "), { styles, revealGroup, diff --git a/packages/web/test/consistency/c12-boundary-parity.spec.tsx b/packages/web/test/consistency/c12-boundary-parity.spec.tsx index 8109fa62a..63467978a 100644 --- a/packages/web/test/consistency/c12-boundary-parity.spec.tsx +++ b/packages/web/test/consistency/c12-boundary-parity.spec.tsx @@ -177,47 +177,97 @@ describe("C12 — boundary parity at claim", () => { dispose(); }); - test.fails( - "(c2) rejected after adopt: the position shows the server's rendered outcome, never a blank (server half)", - async () => { - const fid = freshFid("c12c"); - const frag = "c12c-frag"; - page = bootPage(shell(fid, frag)); - const fetches = countFetches(); - const fr = page.declareFragment(frag); - const Comp = (globalThis as any)._$SC.r(fid); - const frames = watchFrames(page.container); - const dispose = hydrate( - () =>
  • {p.text}
  • } />, - page.container - ); - await quiesce(); - expect(frames.frames).toEqual(["loading"]); + // The server half (frames-rulings §"The server half" (iii), built): the + // document face's error path renders the boundary's error outcome into the + // fragment template — the nearest SERVER ``'s fallback at the + // ``'s position (test/server/frame-fragment-error-outcome.spec.tsx + // pins the sink on both faces) — and `_fr` rejects as the diagnostic. The + // page below carries that output: the template is the Errored's fallback, + // not the `" "` the server used to write. The client shows it and invents + // nothing (c1). + test("(c2) rejected after adopt: the position shows the server's rendered outcome — the server 's fallback — never a blank", async () => { + const fid = freshFid("c12c"); + const frag = "c12c-frag"; + page = bootPage(shell(fid, frag)); + const fetches = countFetches(); + const fr = page.declareFragment(frag); + const Comp = (globalThis as any)._$SC.r(fid); + const frames = watchFrames(page.container); + const dispose = hydrate( + () =>
  • {p.text}
  • } />, + page.container + ); + await quiesce(); + expect(frames.frames).toEqual(["loading"]); - // The rejected fragment's chunk: blank template + `$df`, then the - // `_fr` rejection. - const swapped = page.revealFragment(frag, " ", false); - fr.reject(new Error("boom")); - await quiesce(); - await quiesce(); - frames.sample(); - expect(swapped).toBe(1); - expect(fr.promise.s).toBe(2); - expect(page.hy.fr.pending()).toBe(false); - expect(fetches).toEqual([]); - // Observed: the swap lands the blank template the server wrote — the - // frame's text goes "loading" → " " (the fallback is gone, the - // position is empty). Expected: the server's rendered outcome for the - // failure at the position — the nearest server ``'s - // fallback; with none, the error escapes the server component and - // the whole response is the frame's `:error`. The gap is the server - // half's: `server.ts`'s error path hands `sink.fragment` a `" "` - // template (the client twin, when there is one, renders over it; a - // server component's boundary has none). The client correctly - // invents nothing here (see c1). - expect(page.container.textContent.trim()).not.toBe(""); - frames.stop(); - dispose(); - } - ); + // The rejected fragment's chunk as the server now writes it: the + // Errored's fallback as the template + `$df`, then the `_fr` rejection. + const swapped = page.revealFragment(frag, 'failed: boom', false); + fr.reject(new Error("boom")); + await quiesce(); + await quiesce(); + frames.sample(); + expect(swapped).toBe(1); + expect(fr.promise.s).toBe(2); + expect(page.hy.fr.pending()).toBe(false); + expect(fetches).toEqual([]); + // The server's outcome at the position, in one visible transition. + expect(frames.frames).toEqual(["loading", "failed: boom"]); + expect(page.container.querySelector("em.fail")).not.toBeNull(); + expect(page.container.querySelector("i")).toBeNull(); + // Reported once in dev (c1); no client error state. + expect(page.errors.length).toBe(1); + expect(page.errors[0]).toContain(`fragment "${frag}"`); + expect(page.container.querySelector("li")).toBeNull(); + frames.stop(); + dispose(); + }); + + // The escape arm: no server `` encloses the boundary. The server + // keeps the boundary's own markup at the position (its fallback — never a + // blank) and the error escapes the component: the frame as one async + // value errored, carried on the document face as an `sc:live` error op + // addressed to the frame (`fid`), which only the owning boundary applies + // — the frame's `:error` (the outward face; what the client does with it + // beyond recording it is the client's — today `frame.error`). + test("(c3) rejected after adopt, no server : the position keeps the fallback and the frame records the escaped error", async () => { + const fid = freshFid("c12c3"); + const other = freshFid("c12c3-other"); + const frag = "c12c3-frag"; + page = bootPage(shell(fid, frag) + frameHtml(other, "

    other

    ")); + const fetches = countFetches(); + const fr = page.declareFragment(frag); + const Comp = (globalThis as any)._$SC.r(fid); + const Other = (globalThis as any)._$SC.r(other); + const frames = watchFrames(page.container); + const dispose = hydrate( + () => ( + <> +
  • {p.text}
  • } /> + + + ), + page.container + ); + await quiesce(); + expect(frames.frames).toEqual(["loadingother"]); + + const swapped = page.revealFragment(frag, "loading", false); + fr.reject(new Error("boom")); + page.live.push({ type: "error", fid, error: "boom" }); + await quiesce(); + await quiesce(); + frames.sample(); + expect(swapped).toBe(1); + expect(fr.promise.s).toBe(2); + expect(fetches).toEqual([]); + // The position never blanked: the fallback stands. + expect(frames.frames).toEqual(["loadingother"]); + // The escaped error is the frame's — this frame's, not its neighbour's. + expect((page.host.get(fid) as any).error).toBe("boom"); + expect((page.host.get(other) as any).error).toBeUndefined(); + expect(page.errors.length).toBe(1); + frames.stop(); + dispose(); + }); }); diff --git a/packages/web/test/frames-errored-reset-refetch.spec.tsx b/packages/web/test/frames-errored-reset-refetch.spec.tsx new file mode 100644 index 000000000..91adb5904 --- /dev/null +++ b/packages/web/test/frames-errored-reset-refetch.spec.tsx @@ -0,0 +1,122 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +// Can the client refetch a server component whose response errored? (The +// maintainer's question, 2026-10-06; frames-rulings 3.3.) The non-SC rule: +// `reset` re-creates an ``'s children, so an async node under it +// re-asks by construction. For a frame the content node is the mount's +// `landing(address)` — the frame as one async value outward (A0, corollary +// 4) — cached per address in the host. The rule under A0: an errored +// landing is not a landing for a fresh consumer — a re-read after `reset` +// starts a new flight (a version bump) for the same address. +// +// Pinned RED (2026-10-06, A6). It fails at its first step: the client +// never catches. What it would take, in order: +// +// 1. The outward face — the frame's `:error` must REJECT the landing: +// `createFrameHost.apply` settles the address's landing on an `:error` +// write by rejecting it (today it resolves: "the root, the stream's +// error, or its completion" all resolve, default #1), and `client.ts`'s +// `landing()` memo then throws into the enclosing as any async +// node does. The `call-driven/error-record` pins ("the boundary mounts +// EMPTY, not stuck on fallback") assert today's reading and would +// re-pin to the ruling's (an un-boundaried frame error surfaces). +// 2. The re-ask — `reset` re-creates the children, but the CALL lives in +// `dynamic`'s hoisted factory memo (computed once; the re-created +// instance reads the same binding), so no request is made by +// construction; and `host.landing(address)` reads the errored store as +// warm (`shown` set) — a fresh consumer sees the error synchronously. +// The rule needs the host to answer an errored `shown` with a NEW +// flight for a fresh consumer: a per-address re-invoke the handler +// records at `handle` (it has `ctx.id` / `ctx.args` there; the address +// alone is a one-way hash) and the landing calls, bumping the version. +// That is `frame-transport.ts` (the handler), `frame-client.ts` (the +// host's `landing`), `client.ts` (`landing`) and the server-functions +// client's handler context — not the one-line `landing` rule, so it is +// left described, not built, in A6. +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createRoot, Errored, Loading } from "solid-js"; +import { dynamic } from "../src/index.js"; +import { installServerComponents } from "../frames/src/client.js"; +import { createServerReference } from "../server-functions/src/client.js"; +import { frameAddress } from "../server-functions/src/shared.js"; +import { makeHost, frameResponse, pump } from "./lifecycle-matrix/harness.js"; + +const html = (text: string) => `

    ${text}

    `; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("refetch after a client caught the frame's error", () => { + test.fails( + "(a) the frame errors → the client catches → reset() → a new request, the new content shows", + async () => { + const { host } = makeHost(); + installServerComponents(host); + let call = 0; + vi.stubGlobal("fetch", async () => { + call++; + return call === 1 + ? frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "error", id: "srv", version: 1, error: { message: "boom" } } + ]) + : frameResponse("srv", [ + { type: "start", id: "srv", version: 1 }, + { type: "html", id: "srv", version: 1, html: html("recovered") }, + { type: "complete", id: "srv", version: 1 } + ]); + }); + const getStory = createServerReference("frames-reset/story"); + const Page = dynamic(() => getStory() as any); + let resetFn: (() => void) | undefined; + const container = document.createElement("div"); + document.body.appendChild(container); + let div!: HTMLDivElement; + const dispose = createRoot(d => { +
    + { + resetFn = reset; + return failed: {(err() as any)?.message}; + }} + > + shell-fallback}> + + + +
    ; + container.appendChild(div); + return d; + }); + await pump(); + + // STEP 1 — the frame's `:error` is the enclosing client 's + // to catch (the frame as one errored async value, 3.3's outward + // face). Observed on this branch: it is NOT — `landing()` resolves + // on the error write (frames-rulings default #1: the landing is "the + // root, the stream's error, or its completion"), the covering + // releases over an EMPTY , `frame.error` holds + // the record and nothing throws it outward (call-driven-lifecycle's + // "error/before-html" pins exactly this: "the boundary mounts empty"). + const frame: any = host.get(frameAddress("frames-reset/story")); + expect(frame.error).toEqual({ message: "boom" }); + expect(call).toBe(1); + expect(div.querySelector(".err")).not.toBeNull(); + expect(div.querySelector(".err")!.textContent).toBe("failed: boom"); + + // STEP 2 — `reset` re-creates the children: the frame's node re-asks. + resetFn!(); + await pump(); + expect(call).toBe(2); + expect(div.querySelector("p")!.textContent).toBe("recovered"); + expect(div.querySelector(".err")).toBeNull(); + + dispose(); + container.remove(); + } + ); +}); diff --git a/packages/web/test/server/frame-fragment-error-outcome.spec.tsx b/packages/web/test/server/frame-fragment-error-outcome.spec.tsx new file mode 100644 index 000000000..93161003a --- /dev/null +++ b/packages/web/test/server/frame-fragment-error-outcome.spec.tsx @@ -0,0 +1,266 @@ +/** + * @jsxImportSource @solidjs/web + * + * C12 (c), the server half (frames-rulings 3.3; A0 corollary 4 inward): a + * server `` inside a SERVER COMPONENT that rejects after the first + * flush has no client twin to render over its position, so the fragment + * carries what the SERVER rendered for the outcome — never a blank: + * + * - the nearest server ``'s fallback for the error, rendered at + * the ``'s position (the Errored's own subtree is already in + * the shell; its fallback replacing the placeholder is the one layout + * the fragment can express) — the rule: "a post-flush error inside a + * server component's shows the nearest 's fallback + * at the boundary's position"; + * - with no server ``, the error ESCAPES the component: the frame + * as one async value errors (the stream face's unkeyed `error` chunk — + * `:error`; the document face's frame-addressed `sc:live` error op) and + * the position keeps the boundary's own markup, its fallback. + * + * `_fr` still rejects (the client's dev diagnostic, c1), the keyed error + * chunk still rides. Outside a server component nothing changes: the blank + * the client twin renders fresh over. + */ +import { describe, expect, it } from "vitest"; +import vm from "node:vm"; +import { createMemo } from "solid-js"; +import { Errored, Loading, renderToStream } from "@solidjs/web"; +import { + frameTransformDirectResult, + renderServerComponent, + ServerComponentPlugin +} from "../../frames/src/frame-sink.js"; + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); + +/** An async memo that rejects after the shell flushed. */ +function lateReject(message: string) { + return createMemo(async () => { + await delay(10); + throw new Error(message); + }); +} + +const collectStream = (stream: any) => + new Promise(resolve => { + const chunks: any[] = []; + stream.pipe({ write: (c: any) => chunks.push(c), end: () => resolve(chunks) }); + }); + +const collectDocument = (code: () => any) => + new Promise(resolve => { + const out: string[] = []; + renderToStream(code, { plugins: [ServerComponentPlugin], onError() {} } as any).pipe({ + write: (c: string) => out.push(c), + end: () => resolve(out.join("")) + }); + }); + +/** Every `` of a document, by key. */ +function templates(html: string) { + const out: Record = {}; + for (const m of html.matchAll(/