Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/live-hole-for-keeps-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"solid-js": patch
---

Server `<For>` 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.
5 changes: 5 additions & 0 deletions examples/chat/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 14 additions & 8 deletions examples/chat/src/routes/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ const getThread = liveQuery(
<p class="muted">Answered by {model.modelName()}</p>
</header>
<ol class="messages">
{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). */}
<For each={view().messages.filter(m => m.status !== "generating")}>
{message => messageMarkup(message, block)}
</For>
{messagesMarkup(view().messages, true, block)}
</ol>
{/* Keyed by thread: going to another conversation is a new
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -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 (
<li class={`message assistant ${message.status}`} data-id={id}>
{reasoning && (
Expand Down Expand Up @@ -198,7 +204,7 @@ function messageMarkup(message: Message, block: { onCopy: CopyHandler }, last: b
</span>
)}
{again && (
<form action={regenerate.with(id)} method="post">
<form class="again" action={regenerate.with(id)} method="post">
<button type="submit">{again}</button>
</form>
)}
Expand Down
33 changes: 33 additions & 0 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3156,10 +3156,26 @@ export function mapArray<T, U>(
// 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;
Expand All @@ -3170,6 +3186,10 @@ export function mapArray<T, U>(
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);
}
Expand Down Expand Up @@ -3200,13 +3220,26 @@ export function mapArray<T, U>(
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 }
);
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<T>(
count: Accessor<number>,
mapFn: (i: number) => T,
Expand Down
188 changes: 188 additions & 0 deletions packages/web/test/server/live-hole-for-rows-4056.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* @jsxImportSource @solidjs/web
*
* solidjs/solid#4056 — a `<For>` 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<void>(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<T>() {
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<IteratorResult<T>> {
while (queue.length === 0) {
if (done) return { value: undefined as any, done: true };
await new Promise<void>(r => (notify = r));
}
return { value: queue.shift()!, done: false };
}
};
}
} as AsyncIterable<T>
};
}

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<Message[]>();
const rendered: string[] = [];
const Thread = () => {
const view = createMemo(() => ch.iterable);
return (
<Loading fallback={<p>wait</p>}>
<ol>
<For each={view()}>
{m => {
rendered.push(m.text);
return <li>{m.text}</li>;
}}
</For>
</ol>
</Loading>
);
};
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(
"<ol><!--lh:0--><li>A</li><!--lh:/0--></ol>"
);
expect(chunks.filter(c => c.type === "hole").map(c => c.html)).toEqual([
"<li>A</li><li>B</li>",
"<li>A</li><li>BB</li>"
]);
// 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<Message[]>();
const rendered: string[] = [];
const Thread = () => {
const view = createMemo(() => ch.iterable);
return (
<Loading fallback={<p>wait</p>}>
<ol>
<For each={view()}>
{m => {
rendered.push(m.text);
return <li>{m.text}</li>;
}}
</For>
</ol>
</Loading>
);
};
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([
"<li>A</li><li>B</li>",
"<li>A</li><li>BB</li>"
]);
expect(rendered).toEqual(["A", "B", "BB"]);
});

test("a row moved to another index renders again", async () => {
const ch = channel<Message[]>();
const rendered: string[] = [];
const Thread = () => {
const view = createMemo(() => ch.iterable);
return (
<Loading fallback={<p>wait</p>}>
<ol>
<For each={view()}>
{(m, i) => {
rendered.push(m.text);
return (
<li>
{i()}:{m.text}
</li>
);
}}
</For>
</ol>
</Loading>
);
};
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(
"<li><!--$-->0<!--/-->:<!--$-->B<!--/--></li><li><!--$-->1<!--/-->:<!--$-->A<!--/--></li>"
);
expect(rendered.slice(-2)).toEqual(["B", "A"]);
});
Loading