From 51d005a1a929eba77d5d728eafaf3e42b3fbc960 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 14 Oct 2026 09:26:20 +0000
Subject: [PATCH 1/3] Server For keeps its rows across live-hole sweeps (#4056)
A markup hole that re-emits re-evaluated every row of a list on every
write. During a sweep, a row whose item is the same reference at the same
index now reuses the row rendered on the previous sweep. Plain SSR and a
server component's first render never read or fill the cache.
The chat example renders its finished messages through with rows
that keep their identity, so a streaming reply no longer re-renders the
whole history on every write.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01VywmD8RauUVvMzG8pYPUX8
---
.changeset/live-hole-for-keeps-rows.md | 5 +
examples/chat/src/routes/thread.tsx | 37 ++++-
packages/solid/src/server/signals.ts | 20 +++
.../server/live-hole-for-rows-4056.spec.tsx | 144 ++++++++++++++++++
4 files changed, 205 insertions(+), 1 deletion(-)
create mode 100644 .changeset/live-hole-for-keeps-rows.md
create mode 100644 packages/web/test/server/live-hole-for-rows-4056.spec.tsx
diff --git a/.changeset/live-hole-for-keeps-rows.md b/.changeset/live-hole-for-keeps-rows.md
new file mode 100644
index 0000000000..a4f74dc1d8
--- /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 is the same reference at the same index now reuses the row it rendered on the previous sweep, as the client's `For` does. Plain SSR and a server component's first render are unchanged. As on the client, a row is a function of its item: an item mutated in place keeps its old row.
diff --git a/examples/chat/src/routes/thread.tsx b/examples/chat/src/routes/thread.tsx
index 8f2ad4fff9..567a6c9cc8 100644
--- a/examples/chat/src/routes/thread.tsx
+++ b/examples/chat/src/routes/thread.tsx
@@ -85,6 +85,18 @@ const getThread = liveQuery(
const view = createMemo(() => db.watchThread(id, gone.signal));
const block = props.codeBlock();
+ // The finished messages as rows the server can keep: each write
+ // gives back the previous row object when nothing on it changed, so
+ // `` reuses the row it already rendered instead of running the
+ // markdown again (#4056). A row is what it shows, `last` included.
+ const keep = keeper();
+ const finished = createMemo(() => {
+ const messages = view().messages;
+ const lastId = messages.at(-1)?.id;
+ return messages
+ .filter(m => m.status !== "generating")
+ .map(m => keep(m.id, { message: m, last: m.id === lastId }));
+ });
return (
@@ -93,7 +105,7 @@ const getThread = liveQuery(
Answered by {model.modelName()}
- {messagesMarkup(view().messages, false, block)}
+ {row => messageMarkup(row.message, block, row.last)}
{messagesMarkup(view().messages, true, block)}
{/* Keyed by thread: going to another conversation is a new
@@ -130,6 +142,29 @@ function headMarkup(thread: Thread | undefined) {
);
}
+type Row = { message: Message; last: boolean };
+
+// Hands back the row it gave last time under `id` when every field the row
+// shows is unchanged, so the row keeps its identity across writes.
+function keeper() {
+ const rows = new Map();
+ return (id: string, next: Row): Row => {
+ const prev = rows.get(id);
+ const same =
+ prev &&
+ prev.last === next.last &&
+ (Object.keys(next.message) as (keyof Message)[]).every(
+ k =>
+ prev.message[k] === next.message[k] ||
+ (k === "usage" &&
+ JSON.stringify(prev.message.usage) === JSON.stringify(next.message.usage))
+ );
+ const row = same ? prev! : next;
+ rows.set(id, row);
+ return row;
+ };
+}
+
// The finished messages, or the one being written. Only the last message in
// the thread can be written again.
function messagesMarkup(messages: Message[], writing: boolean, block: { onCopy: CopyHandler }) {
diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts
index 34de1ae5bb..dba7e7e890 100644
--- a/packages/solid/src/server/signals.ts
+++ b/packages/solid/src/server/signals.ts
@@ -3156,10 +3156,25 @@ 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 is
+ // the same reference at the same index reuses the row it rendered on the
+ // previous sweep, as the client's `For` keeps its rows. 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 +3185,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 && prev.items[i] === items[i]) {
+ s.push(prev.rows[i]);
+ continue;
+ }
if (origId !== undefined) {
rowOwner.id = formatChildId(origId, i);
}
@@ -3200,6 +3219,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 }
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..de54ad9bc0
--- /dev/null
+++ b/packages/web/test/server/live-hole-for-rows-4056.spec.tsx
@@ -0,0 +1,144 @@
+/**
+ * @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 now reuses the row it rendered on the previous sweep, as the
+ * client's `For` keeps its rows. 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 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"]);
+});
From 9ccf52c7c5c2e090122e3c7e7e6fac7bea6a03d0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 14 Oct 2026 16:12:24 +0000
Subject: [PATCH 2/3] Reuse a swept row when its item is shallow-equal, drop
the chat keeper (#4056)
A live source usually hands back a fresh snapshot on every change, so a
reference check alone never hits. A row whose item at the same index is
shallow-equal to last sweep's now reuses its row, which removes the
identity-keeping helper from the chat example. Chat's Regenerate button
moves to CSS so a row depends only on its own message.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01VywmD8RauUVvMzG8pYPUX8
---
examples/chat/src/app.css | 5 ++
examples/chat/src/routes/thread.tsx | 57 +++++--------------
packages/solid/src/server/signals.ts | 21 +++++--
.../server/live-hole-for-rows-4056.spec.tsx | 48 +++++++++++++++-
4 files changed, 82 insertions(+), 49 deletions(-)
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 567a6c9cc8..6951a5f680 100644
--- a/examples/chat/src/routes/thread.tsx
+++ b/examples/chat/src/routes/thread.tsx
@@ -85,18 +85,6 @@ const getThread = liveQuery(
const view = createMemo(() => db.watchThread(id, gone.signal));
const block = props.codeBlock();
- // The finished messages as rows the server can keep: each write
- // gives back the previous row object when nothing on it changed, so
- // `` reuses the row it already rendered instead of running the
- // markdown again (#4056). A row is what it shows, `last` included.
- const keep = keeper();
- const finished = createMemo(() => {
- const messages = view().messages;
- const lastId = messages.at(-1)?.id;
- return messages
- .filter(m => m.status !== "generating")
- .map(m => keep(m.id, { message: m, last: m.id === lastId }));
- });
return (
@@ -105,7 +93,12 @@ const getThread = liveQuery(
Answered by {model.modelName()}
- {row => messageMarkup(row.message, block, row.last)}
+ {/* 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
@@ -142,36 +135,11 @@ function headMarkup(thread: Thread | undefined) {
);
}
-type Row = { message: Message; last: boolean };
-
-// Hands back the row it gave last time under `id` when every field the row
-// shows is unchanged, so the row keeps its identity across writes.
-function keeper() {
- const rows = new Map();
- return (id: string, next: Row): Row => {
- const prev = rows.get(id);
- const same =
- prev &&
- prev.last === next.last &&
- (Object.keys(next.message) as (keyof Message)[]).every(
- k =>
- prev.message[k] === next.message[k] ||
- (k === "usage" &&
- JSON.stringify(prev.message.usage) === JSON.stringify(next.message.usage))
- );
- const row = same ? prev! : next;
- rows.set(id, row);
- return row;
- };
-}
-
-// 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
@@ -181,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 (
@@ -193,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 && (
@@ -233,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 dba7e7e890..3f01bfa116 100644
--- a/packages/solid/src/server/signals.ts
+++ b/packages/solid/src/server/signals.ts
@@ -3159,9 +3159,10 @@ export function mapArray(
// 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 is
- // the same reference at the same index reuses the row it rendered on the
- // previous sweep, as the client's `For` keeps its rows. Sweeps only: 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
@@ -3185,7 +3186,7 @@ export function mapArray(
const origId = ownerId(rowOwner);
try {
for (let i = 0, len = items.length; i < len; i++) {
- if (prev && i < prev.items.length && prev.items[i] === items[i]) {
+ if (prev && i < prev.items.length && sameItem(prev.items[i], items[i])) {
s.push(prev.rows[i]);
continue;
}
@@ -3227,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
index de54ad9bc0..7b50a1006d 100644
--- a/packages/web/test/server/live-hole-for-rows-4056.spec.tsx
+++ b/packages/web/test/server/live-hole-for-rows-4056.spec.tsx
@@ -4,8 +4,9 @@
* 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 now reuses the row it rendered on the previous sweep, as the
- * client's `For` keeps its rows. The emitted markup is unchanged.
+ * 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";
@@ -101,6 +102,49 @@ test("a For row whose item is unchanged is not rendered again on a sweep", async
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[] = [];
From f517b15ecd61d800ae3a226935887ddf0b02ebb8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Wed, 14 Oct 2026 16:12:40 +0000
Subject: [PATCH 3/3] Changeset: describe the shallow-equal row reuse
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01VywmD8RauUVvMzG8pYPUX8
---
.changeset/live-hole-for-keeps-rows.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.changeset/live-hole-for-keeps-rows.md b/.changeset/live-hole-for-keeps-rows.md
index a4f74dc1d8..af897c8bf0 100644
--- a/.changeset/live-hole-for-keeps-rows.md
+++ b/.changeset/live-hole-for-keeps-rows.md
@@ -2,4 +2,4 @@
"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 is the same reference at the same index now reuses the row it rendered on the previous sweep, as the client's `For` does. Plain SSR and a server component's first render are unchanged. As on the client, a row is a function of its item: an item mutated in place keeps its old row.
+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.