From 7c36f2bee0a984d836fc3236b8c8a15291324770 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 20:14:59 +0000 Subject: [PATCH] fix(server): re-render live-hole For rows that read async sources A sweep reused a row whenever its item looked unchanged, so a row that also read an async memo or projection (a selected id) kept stale markup, and an item edited in place compared equal to itself. Count reads of async sources (async memos, async projections, memos derived from them) and only reuse rows that made none; compare items against a deep copy taken when the row rendered. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VywmD8RauUVvMzG8pYPUX8 --- .changeset/live-for-async-reads.md | 5 + packages/solid/src/server/signals.ts | 90 ++++++++-- .../server/live-hole-for-rows-4056.spec.tsx | 160 +++++++++++++++++- 3 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 .changeset/live-for-async-reads.md diff --git a/.changeset/live-for-async-reads.md b/.changeset/live-for-async-reads.md new file mode 100644 index 0000000000..b0ea206f9c --- /dev/null +++ b/.changeset/live-for-async-reads.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +Server `For` in a live hole: a row that read an async memo or projection besides its item, or whose item was edited in place, now renders again on each sweep instead of reusing stale markup. diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 3f01bfa116..70f9c8bc0c 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -685,6 +685,9 @@ interface ServerComputation { // hook (`ctx.commitEpoch`) only exists where a binding ledger is live. sync?: boolean; epoch?: number; + // The value can change between live-hole sweeps: the result is async, or + // a compute read something that is (see asyncReads). + moving?: boolean; // Runs when the owner disposes the compute (see `armDispose`), for work // that cannot wait for its next pull to notice `disposed`: a pumped // iterator parked on a `next()` that will not settle until the world @@ -1178,6 +1181,13 @@ function warnServerWrite(category: "signal" | "store" | "optimistic"): void { }); } +// Reads of values that can change between two live-hole sweeps, counted so +// a sweep can tell a `For` row that read one (see mapArray). Only async +// sources move on the server — a plain signal or store has no writer once +// the render runs — so this counts reads of async memos and async +// projections, and of any memo whose last compute read one of those. +let asyncReads = 0; + export function createSignal(): Signal; export function createSignal(value: Exclude, options?: SignalOptions): Signal; // Commit #0 (loadingValue) removes the uninitialized window: never undefined @@ -1324,10 +1334,12 @@ export function createMemo( resetOwnerForRerun(owner); const prev = sharedConfig.context; sharedConfig.context = ctx; + const reads = asyncReads; try { return runWithOwner(owner, () => runWithObserver(comp, () => comp.compute(comp.value))); } finally { sharedConfig.context = prev; + if (asyncReads !== reads) comp.moving = true; } }; @@ -1346,8 +1358,10 @@ export function createMemo( typeof result === "object" && (typeof (result as any)[Symbol.asyncIterator] === "function" || typeof (result as any).then === "function") - ) + ) { + comp.moving = true; armDispose(); + } processResult( comp, result, @@ -1412,6 +1426,7 @@ export function createMemo( // as epoch comparison, no subscriber graph needed. update(); } + if (comp.moving) asyncReads++; if (comp.errored) { // A client hole read outside a Loading discovery pass must fail loudly // rather than wedge the stream (see clientHoleRead). Identity check on @@ -1472,6 +1487,8 @@ function createSyncMemo( let cached = false; // The commit epoch `value`/`error` was computed at (frame renders only). let epoch: number | undefined; + // See ServerComputation.moving. + let moving = false; function pull(): T | undefined { // Inlined `runWithOwner` — avoids the per-pull `() => compute(value)` @@ -1487,6 +1504,7 @@ function createSyncMemo( // created by the eventual successful pull drift ahead of the client's // (#2801, generalized to all retry paths in #2900). resetOwnerForRerun(owner); + const reads = asyncReads; try { value = compute(value) as T; error = undefined; @@ -1504,6 +1522,7 @@ function createSyncMemo( throw err; } finally { currentOwner = prev; + if (asyncReads !== reads) moving = true; } } @@ -1516,6 +1535,7 @@ function createSyncMemo( } return (() => { + if (moving) asyncReads++; if (cached && (!ctx?.commitEpoch || ctx.commitEpoch() === epoch)) { if (errored) throw error; return value; @@ -2473,7 +2493,7 @@ function createPendingProxy( let status: 0 | 1 | 2 = 0; let error: any; let readTarget: T = state; - const gate = () => { + const open = () => { if (status > 1) { // A derive that landed on a client hole (settleServerAsync's FINAL // reclassification, #3659) errors with the tagged NotReady: the same @@ -2487,6 +2507,11 @@ function createPendingProxy( if (source === CLIENT_HOLE) clientHoleRead(); throw new NotReadyError(source); }; + // Every read counts as an async read (see asyncReads), ready or not. + const gate = () => { + asyncReads++; + open(); + }; const proxy = new Proxy(state, { get(obj, key, receiver) { gate(); @@ -3160,15 +3185,21 @@ export function mapArray( // (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 + // the same index is unchanged reuses the row it rendered on the previous + // sweep. Unchanged is deep equality with a copy of the item taken when the + // row rendered — a live source usually hands back a fresh snapshot, and a + // server store may edit the same object in place. A row reused must also + // have read no async source while rendering (an async memo or projection, + // or a memo derived from one: see asyncReads), since only those change + // between sweeps; such a row renders again every sweep. This is the + // client's rule at row grain: a row is kept while its item is, and what + // else it read re-runs it. 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; + let kept: { items: unknown[]; rows: U[]; pure: boolean[] } | null = null; const read = createMemo( () => { const items = list(); @@ -3176,6 +3207,8 @@ export function mapArray( const live = (sharedConfig.context as any)?.liveHoles; const sweeping = !!(live && live.sweeping); const prev = sweeping ? kept : null; + const snaps: unknown[] = []; + const pure: boolean[] = []; // 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; @@ -3186,10 +3219,18 @@ 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])) { + if ( + prev && + i < prev.items.length && + prev.pure[i] && + sameItem(prev.items[i], items[i]) + ) { s.push(prev.rows[i]); + snaps.push(prev.items[i]); + pure.push(true); continue; } + const reads = asyncReads; if (origId !== undefined) { rowOwner.id = formatChildId(origId, i); } @@ -3210,6 +3251,10 @@ export function mapArray( ? (mapFn as (v: T, i: Accessor) => U)(items[i], () => i) : (mapFn as (v: T) => U)(items[i]) ); + if (sweeping) { + snaps.push(copyItem(items[i], 0)); + pure.push(asyncReads === reads); + } } } finally { rowOwner.id = origId; @@ -3220,7 +3265,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; + kept = sweeping && items && items.length ? { items: snaps, rows: s, pure } : null; return s; }, { sync: true } @@ -3228,15 +3273,30 @@ 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; +// A sweep's row reuse test (see mapArray): `a` is the copy `copyItem` took +// when the row rendered, `b` the item now; deep equality over plain objects +// and arrays. Past the depth limit (or a cycle) the copy holds NOT_COPIED, +// which equals nothing, so the row renders again. +const NOT_COPIED = {}; +const COPY_DEPTH = 8; +function copyItem(v: any, depth: number): unknown { + if (!v || typeof v !== "object") return v; + if (depth > COPY_DEPTH) return NOT_COPIED; + if (Array.isArray(v)) return v.map(x => copyItem(x, depth + 1)); + const proto = Object.getPrototypeOf(v); + // Class instances (a Date, a Map) are not copied: the row renders again. + if (proto !== Object.prototype && proto !== null) return NOT_COPIED; + const out: Record = {}; + for (const k in v) out[k] = copyItem(v[k], depth + 1); + return out; +} +function sameItem(a: any, b: any): boolean { + if (a === b) return a !== NOT_COPIED; if (!a || !b || typeof a !== "object" || typeof b !== "object") return false; - if (Array.isArray(a) !== Array.isArray(b)) return false; + if (a === NOT_COPIED || 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; + for (const k of ka) if (!(k in b) || !sameItem(a[k], b[k])) return false; return 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 index 7b50a1006d..79e3b4cf39 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 @@ -9,7 +9,7 @@ * previous sweep. The emitted markup is unchanged. */ import { expect, test } from "vitest"; -import { createMemo, For } from "solid-js"; +import { createMemo, createStore, For } from "solid-js"; import { Loading } from "@solidjs/web"; import { renderServerComponent } from "../../frames/src/frame-sink.js"; @@ -186,3 +186,161 @@ test("a row moved to another index renders again", async () => { ); expect(rendered.slice(-2)).toEqual(["B", "A"]); }); + +test("a row that read a memo besides its item renders again on every sweep", async () => { + type View = { items: Message[]; sel: string }; + const ch = channel(); + const rendered: string[] = []; + const Thread = () => { + const view = createMemo(() => ch.iterable); + return ( + wait

}> +
    + + {m => { + rendered.push(m.text); + return
  1. {view().sel === m.id ? "*" : 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({ items: [a, b], sel: "a" }); + await tick(20); + ch.push({ items: [a, b], sel: "b" }); + await tick(20); + ch.push({ items: [a, b], sel: "x" }); + await tick(20); + ch.end(); + await done; + + // The selection moves with every push though no item changed. + expect(chunks.filter(c => c.type === "hole").map(c => c.html)).toEqual([ + "
  • A
  • *
  • ", + "
  • A
  • B
  • " + ]); + expect(rendered.length).toBeGreaterThan(2); +}); + +test("an item edited in place is not the item the row rendered", 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(); + // One object, edited between pushes — as a store updated in place hands back. + const a = { id: "a", text: "A" }; + const list = [a]; + ch.push(list); + await tick(20); + list.push({ id: "b", text: "B" }); + ch.push(list); + await tick(20); + a.text = "AA"; + ch.push(list); + await tick(20); + ch.end(); + await done; + + expect(chunks.filter(c => c.type === "hole").map(c => c.html)).toEqual([ + "
  • A
  • B
  • ", + "
  • AA
  • B
  • " + ]); + expect(rendered).toEqual(["A", "B", "AA"]); +}); + +test("a row whose memos read only its item is still reused", async () => { + const ch = channel(); + const rendered: string[] = []; + const Thread = () => { + const view = createMemo(() => ch.iterable); + return ( + wait

    }> +
      + + {m => { + rendered.push(m.text); + const loud = createMemo(() => m.text.toUpperCase()); + return
    1. {m.text.length > 1 ? {loud()} : {m.text}}
    2. ; + }} +
      +
    +
    + ); + }; + const { chunks, done } = consume(renderServerComponent(Thread, { frame: { id: "f" } })); + await tick(); + ch.push([{ id: "a", text: "a" }]); + 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
  • BB
  • " + ]); + expect(rendered).toEqual(["a", "bb"]); +}); + +test("a row that read an async store besides its item renders again on every sweep", async () => { + const items = channel(); + const sel = channel(); + const Thread = () => { + const view = createMemo(() => items.iterable); + const [state] = createStore<{ sel?: string }>(async function* (draft) { + for await (const s of sel.iterable) { + draft.sel = s; + yield; + } + }, {}); + return ( + wait

    }> +
      + {m =>
    1. {state.sel === m.id ? "*" : 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" }; + sel.push("a"); + items.push([a, b]); + await tick(20); + sel.push("b"); + await tick(20); + items.end(); + sel.end(); + await done; + + expect( + chunks + .filter(c => c.type === "hole") + .map(c => c.html) + .at(-1) + ).toBe("
  • A
  • *
  • "); +});