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 => {
- ;
- 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 => {
+ ;
+ 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(/