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
    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 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[] = []; + 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"]); +});