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
  1. {m.text}
  2. ; + }} +
    +
+
+ ); + }; + 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( + "
  1. A
" + ); + expect(chunks.filter(c => c.type === "hole").map(c => c.html)).toEqual([ + "
  • A
  • B
  • ", + "
  • A
  • BB
  • " + ]); + // 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 ( +
    1. + {i()}:{m.text} +
    2. + ); + }} +
      +
    +
    + ); + }; + 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:B
  • 1: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
    1. {m.text}
    2. ; + }} +
      +
    +
    + ); + }; + 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([ + "
  • A
  • B
  • ", + "
  • A
  • BB
  • " + ]); + 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.