diff --git a/.changeset/live-hole-for-keeps-rows.md b/.changeset/live-hole-for-keeps-rows.md
new file mode 100644
index 0000000000..af897c8bf0
--- /dev/null
+++ b/.changeset/live-hole-for-keeps-rows.md
@@ -0,0 +1,5 @@
+---
+"solid-js": patch
+---
+
+Server `` keeps its rows across live-hole sweeps (#4056). A markup hole that re-emits (a live server component, a standing source) re-evaluated every row of a list on every write. During a sweep, a row whose item at the same index is unchanged (the same reference, or a shallow-equal object, as a live source's fresh snapshot usually is) now reuses the row it rendered on the previous sweep. Plain SSR and a server component's first render are unchanged. A row is a function of its item: an item mutated in place keeps its old row, and a row that reads outside data keeps the value it rendered with.
diff --git a/examples/chat/src/app.css b/examples/chat/src/app.css
index ba46a022d6..039e3e508f 100644
--- a/examples/chat/src/app.css
+++ b/examples/chat/src/app.css
@@ -169,6 +169,11 @@ body {
cursor: pointer;
}
+/* Only the last message in the thread can be written again. */
+.messages > li:not(:last-child) form.again {
+ display: none;
+}
+
/* The router marks a form while its submission is in flight. */
form[aria-busy] button {
opacity: 0.5;
diff --git a/examples/chat/src/routes/thread.tsx b/examples/chat/src/routes/thread.tsx
index 8f2ad4fff9..6951a5f680 100644
--- a/examples/chat/src/routes/thread.tsx
+++ b/examples/chat/src/routes/thread.tsx
@@ -93,7 +93,12 @@ const getThread = liveQuery(
Answered by {model.modelName()}
- {messagesMarkup(view().messages, false, block)}
+ {/* A row the server has already rendered is kept while its
+ message is unchanged, so a streaming reply doesn't render
+ the whole history again on every write (#4056). */}
+ m.status !== "generating")}>
+ {message => messageMarkup(message, block)}
+
{messagesMarkup(view().messages, true, block)}
{/* Keyed by thread: going to another conversation is a new
@@ -130,13 +135,11 @@ function headMarkup(thread: Thread | undefined) {
);
}
-// The finished messages, or the one being written. Only the last message in
-// the thread can be written again.
+// The finished messages, or the one being written.
function messagesMarkup(messages: Message[], writing: boolean, block: { onCopy: CopyHandler }) {
- const lastId = messages.at(-1)?.id;
return messages
.filter(m => (m.status === "generating") === writing)
- .map(message => messageMarkup(message, block, message.id === lastId));
+ .map(message => messageMarkup(message, block));
}
// One message as markup. A plain function, not a component: the server
@@ -146,7 +149,7 @@ function messagesMarkup(messages: Message[], writing: boolean, block: { onCopy:
// Every condition below tests a local, not a property. The compiler wraps a
// condition it cannot see through (`message.usage && …`) in a memo, and a
// read the server re-runs cannot create one.
-function messageMarkup(message: Message, block: { onCopy: CopyHandler }, last: boolean) {
+function messageMarkup(message: Message, block: { onCopy: CopyHandler }) {
const { id, text, reasoning, error, usage } = message;
if (message.role === "user") {
return (
@@ -158,7 +161,10 @@ function messageMarkup(message: Message, block: { onCopy: CopyHandler }, last: b
const generating = message.status === "generating";
const stopped = message.status === "stopped";
const failed = message.status === "failed";
- const again = last && !generating && (failed ? "Retry" : "Regenerate");
+ // Every finished reply carries its Retry/Regenerate form and the
+ // stylesheet shows it only on the last message, so a row depends on
+ // nothing but its own message.
+ const again = !generating && (failed ? "Retry" : "Regenerate");
return (
{reasoning && (
@@ -198,7 +204,7 @@ function messageMarkup(message: Message, block: { onCopy: CopyHandler }, last: b
)}
{again && (
-
)}
diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts
index 34de1ae5bb..3f01bfa116 100644
--- a/packages/solid/src/server/signals.ts
+++ b/packages/solid/src/server/signals.ts
@@ -3156,10 +3156,26 @@ export function mapArray(
// per evaluation (#2976). Evaluating it in the row id space would both
// misplace that allocation and shift the row base by one.
const rowOwner = createOwner() as unknown as SSROwner;
+ // Rows kept across live-hole sweeps (#4056). A markup hole that re-emits
+ // (a live response, a standing source) re-evaluates its whole value on
+ // every commit, and without this every row re-ran `mapFn` each time — a
+ // list's render cost paid per write. During a sweep a row whose item at
+ // the same index is unchanged — the same reference, or a shallow-equal
+ // object, since a live source usually hands back a fresh snapshot — reuses
+ // the row it rendered on the previous sweep. Sweeps only: a
+ // sweep's evaluation is mint-suppressed and owner-free (the engine's
+ // impurity gate closes any hole that creates owners), so a row from one
+ // sweep carries no markers or bindings that another could duplicate. The
+ // first render and its retries never read or fill the cache, so their
+ // ids and minting are exactly as before.
+ let kept: { items: readonly T[]; rows: U[] } | null = null;
const read = createMemo(
() => {
const items = list();
const s: U[] = [];
+ const live = (sharedConfig.context as any)?.liveHoles;
+ const sweeping = !!(live && live.sweeping);
+ const prev = sweeping ? kept : null;
// Reset per pass so retries re-synthesize row/fallback ids from 0,
// like the client's fresh row owners on its first successful pass.
rowOwner._childCount = 0;
@@ -3170,6 +3186,10 @@ export function mapArray(
const origId = ownerId(rowOwner);
try {
for (let i = 0, len = items.length; i < len; i++) {
+ if (prev && i < prev.items.length && sameItem(prev.items[i], items[i])) {
+ s.push(prev.rows[i]);
+ continue;
+ }
if (origId !== undefined) {
rowOwner.id = formatChildId(origId, i);
}
@@ -3200,6 +3220,7 @@ export function mapArray(
const fo = runWithOwner(rowOwner as unknown as Owner, () => createOwner());
s.push(runWithOwner(fo, () => options.fallback!()) as U);
}
+ kept = sweeping && items && items.length ? { items: items.slice(), rows: s } : null;
return s;
},
{ sync: true }
@@ -3207,6 +3228,18 @@ export function mapArray(
return read;
}
+// A sweep's row reuse test (see mapArray): the same value, or two plain
+// objects/arrays whose own entries are the same values.
+function sameItem(a: unknown, b: unknown): boolean {
+ if (a === b) return true;
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
+ const ka = Object.keys(a);
+ if (ka.length !== Object.keys(b).length) return false;
+ for (const k of ka) if ((a as any)[k] !== (b as any)[k] || !(k in b)) return false;
+ return true;
+}
+
export function repeat(
count: Accessor,
mapFn: (i: number) => T,
diff --git a/packages/web/test/server/live-hole-for-rows-4056.spec.tsx b/packages/web/test/server/live-hole-for-rows-4056.spec.tsx
new file mode 100644
index 0000000000..7b50a1006d
--- /dev/null
+++ b/packages/web/test/server/live-hole-for-rows-4056.spec.tsx
@@ -0,0 +1,188 @@
+/**
+ * @jsxImportSource @solidjs/web
+ *
+ * solidjs/solid#4056 — a `` inside a markup hole that re-emits ran
+ * `mapFn` for every row on every sweep, so a list's render cost was paid per
+ * write. During sweeps a row whose item is the same reference at the same
+ * index — the same reference, or a fresh shallow-equal object, as a live
+ * source's snapshot usually is — now reuses the row it rendered on the
+ * previous sweep. The emitted markup is unchanged.
+ */
+import { expect, test } from "vitest";
+import { createMemo, For } from "solid-js";
+import { Loading } from "@solidjs/web";
+import { renderServerComponent } from "../../frames/src/frame-sink.js";
+
+const tick = (ms = 5) => new Promise(r => setTimeout(r, ms));
+
+function consume(stream: any) {
+ const chunks: any[] = [];
+ const done = new Promise(res =>
+ stream.pipe({ write: (c: any) => chunks.push(c), end: res })
+ );
+ return { chunks, done };
+}
+
+/** A push-driven async iterable (the chat model's channel shape). */
+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
+ };
+}
+
+type Message = { id: string; text: string };
+
+test("a For row whose item is unchanged is not rendered again on a sweep", async () => {
+ const ch = channel();
+ const rendered: string[] = [];
+ const Thread = () => {
+ const view = createMemo(() => ch.iterable);
+ return (
+ wait
}>
+
+
+ {m => {
+ rendered.push(m.text);
+ return - {m.text}
;
+ }}
+
+
+
+ );
+ };
+ const { chunks, done } = consume(renderServerComponent(Thread, { frame: { id: "f" } }));
+ await tick();
+ const a = { id: "a", text: "A" };
+ const b = { id: "b", text: "B" };
+ ch.push([a]);
+ await tick(20);
+ ch.push([a, b]);
+ await tick(20);
+ ch.push([a, { ...b, text: "BB" }]);
+ await tick(20);
+ ch.end();
+ await done;
+
+ expect(chunks.find(c => c.type === "fragment").html).toBe(
+ "- A
"
+ );
+ expect(chunks.filter(c => c.type === "hole").map(c => c.html)).toEqual([
+ "AB",
+ "ABB"
+ ]);
+ // Each row renders once per distinct item; before #4056 this was
+ // A,A,A,B,A,BB,A,BB.
+ expect(rendered).toEqual(["A", "B", "BB"]);
+});
+
+test("a fresh snapshot whose items are shallow-equal reuses its rows", async () => {
+ const ch = channel();
+ const rendered: string[] = [];
+ const Thread = () => {
+ const view = createMemo(() => ch.iterable);
+ return (
+ wait}>
+
+
+ {m => {
+ rendered.push(m.text);
+ return - {m.text}
;
+ }}
+
+
+
+ );
+ };
+ const { chunks, done } = consume(renderServerComponent(Thread, { frame: { id: "f" } }));
+ await tick();
+ // Every push is a new array of new objects, as a re-run query hands back.
+ ch.push([{ id: "a", text: "A" }]);
+ await tick(20);
+ ch.push([
+ { id: "a", text: "A" },
+ { id: "b", text: "B" }
+ ]);
+ await tick(20);
+ ch.push([
+ { id: "a", text: "A" },
+ { id: "b", text: "BB" }
+ ]);
+ await tick(20);
+ ch.end();
+ await done;
+
+ expect(chunks.filter(c => c.type === "hole").map(c => c.html)).toEqual([
+ "AB",
+ "ABB"
+ ]);
+ expect(rendered).toEqual(["A", "B", "BB"]);
+});
+
+test("a row moved to another index renders again", async () => {
+ const ch = channel();
+ const rendered: string[] = [];
+ const Thread = () => {
+ const view = createMemo(() => ch.iterable);
+ return (
+ wait}>
+
+
+ {(m, i) => {
+ rendered.push(m.text);
+ return (
+ -
+ {i()}:{m.text}
+
+ );
+ }}
+
+
+
+ );
+ };
+ const { chunks, done } = consume(renderServerComponent(Thread, { frame: { id: "f" } }));
+ await tick();
+ const a = { id: "a", text: "A" };
+ const b = { id: "b", text: "B" };
+ ch.push([a, b]);
+ await tick(20);
+ ch.push([a, b, { id: "c", text: "C" }]);
+ await tick(20);
+ ch.push([b, a]);
+ await tick(20);
+ ch.end();
+ await done;
+
+ const holes = chunks.filter(c => c.type === "hole").map(c => c.html);
+ expect(holes.at(-1)).toBe(
+ "0:B1:A"
+ );
+ expect(rendered.slice(-2)).toEqual(["B", "A"]);
+});