From 62de2f5ca85770ee4febb4e717e680ae45bad9ec Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 15:30:57 -0700 Subject: [PATCH 01/20] =?UTF-8?q?feat(web,solid):=20unified=20For=20driver?= =?UTF-8?q?=20spike=20=E2=80=94=20one=20structure=20owns=20rows=20and=20pl?= =?UTF-8?q?acement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The $for seam: keyed For returns a callable carrying { each, row, keyed }; an armed insert offers it to the driver, which keeps an intrusive row chain + incremental key map per list and updates via prefix/suffix/LIS in a two-phase render effect (compute diffs + builds detached rows; effect is the only writer of chain and live DOM — holds can never half-apply, H1). Engaged-path parity pinned by for.unified.spec (permutation matrix, fragments, multi-slot, demotes) and a classic H1 probe twin; web 683 and solid 580 green. Co-authored-by: Cursor --- .changeset/unified-for-driver-spike.md | 6 + packages/solid/src/client/flow.ts | 10 + packages/web/src/client.ts | 33 ++ packages/web/src/for-driver.ts | 341 ++++++++++++++++++ packages/web/src/index.ts | 1 + .../test/for.unified.classic.probe.spec.tsx | 59 +++ packages/web/test/for.unified.spec.tsx | 276 ++++++++++++++ 7 files changed, 726 insertions(+) create mode 100644 .changeset/unified-for-driver-spike.md create mode 100644 packages/web/src/for-driver.ts create mode 100644 packages/web/test/for.unified.classic.probe.spec.tsx create mode 100644 packages/web/test/for.unified.spec.tsx diff --git a/.changeset/unified-for-driver-spike.md b/.changeset/unified-for-driver-spike.md new file mode 100644 index 000000000..9071421e6 --- /dev/null +++ b/.changeset/unified-for-driver-spike.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Unified For driver (spike): keyed `` returns a callable carrying a `$for` descriptor; an armed web renderer (`enableUnifiedFor()`) owns rows and DOM placement in one persistent structure — intrusive row chain + incremental key map, prefix/suffix/LIS update pass in an ordinary two-phase render effect — bypassing both mapArray and reconcileArrays for engaged lists. Declines (hydration, keyed fns, duplicate keys, dynamic top-level rows, non-array subjects) land on the classic path; late demotion re-enters classic under the original owner. Off unless armed. diff --git a/packages/solid/src/client/flow.ts b/packages/solid/src/client/flow.ts index 4494fca13..4edcef3a1 100644 --- a/packages/solid/src/client/flow.ts +++ b/packages/solid/src/client/flow.ts @@ -105,6 +105,16 @@ export function For(props: { // mapArray at all. if (sharedConfig.hydrating) mapped = create(); const list = () => (mapped ?? (mapped = create()))(); + // Unified-For seam (DESIGN-UNIFIED-FOR §4): the returned value IS a data + // structure — a callable carrying the list descriptor. A renderer that + // understands `$for` may own rows and placement in one persistent + // structure (no mapArray, no value diff); everything else (children(), + // universal renderers, introspection) calls it and gets classic mapArray + // rows. Eligibility mirrors the classic contract the driver can honor: + // reference identity or key-fn rows (`keyed !== false`), no fallback, and + // no index parameter (row arity < 2). + if (props.keyed !== false && !("fallback" in props) && props.children.length < 2) + (list as any).$for = { each: () => props.each, row: props.children, keyed: props.keyed }; return list as unknown as SolidElement; } diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index e346c051d..d5a6f1322 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -19,6 +19,15 @@ import { } from "solid-js"; import { effect, memo } from "./render.js"; +// Unified-For driver registration (pay-for-use: `insert` rides every bundle, +// the driver only rides apps that arm it — see for-driver.ts). +let listDriver: + | ((parent: Node, listFn: any, marker: Node | undefined, lateClassic: () => void) => boolean) + | undefined; +export function setListDriver(driver: typeof listDriver): void { + listDriver = driver; +} + import { JSX } from "../jsx/jsx.js"; import type { RequestEventLocals } from "./server.js"; @@ -903,6 +912,30 @@ export function insert(parent, accessor, marker, initial, options) { const host = options && options.host; if (multi && !initial) initial = []; if (hydrationRt !== null) initial = hydrationRt.claimInitial(parent, multi, initial); + // Unified-For seam (DESIGN-UNIFIED-FOR §4): a list value carrying the + // `$for` descriptor is offered to the registered keyed-list driver first. + // `false` declines to classic (the descriptor is also a callable — calling + // it IS the classic mapArray path). The lateClassic thunk serves ENGAGED + // lists that later leave the driver's contract: it re-enters this insert + // under the ORIGINAL owner with a bare accessor (no `$for` marker). + if (listDriver !== undefined && typeof accessor === "function" && accessor.$for !== undefined) { + const listAccessor = accessor; + const owner = getOwner(); + if ( + listDriver(parent, accessor, marker ?? undefined, () => + runWithOwner(owner, () => + insert( + parent, + () => listAccessor(), + marker, + marker !== undefined ? [] : undefined, + options + ) + ) + ) + ) + return; + } if (typeof accessor !== "function") { accessor = normalize(accessor, initial, multi, true); if (typeof accessor !== "function") { diff --git a/packages/web/src/for-driver.ts b/packages/web/src/for-driver.ts new file mode 100644 index 000000000..6a79957c5 --- /dev/null +++ b/packages/web/src/for-driver.ts @@ -0,0 +1,341 @@ +/** + * Unified For driver (SPIKE — DESIGN-UNIFIED-FOR.md). + * + * One persistent structure owns both the row bookkeeping AND the DOM + * placement for a keyed : an intrusive doubly-linked chain of rows plus + * an incrementally-maintained key→row Map, per engaged list. The update is + * pull-based — an ordinary two-phase render effect reads `each()`, diffs + * against its own committed chain (prefix walk, suffix walk, middle + * partition + LIS), and commits placement — no delivery seam, no message + * channel, no second diff: mapArray and reconcileArrays are both bypassed + * for engaged lists. + * + * PHASE DISCIPLINE (the H1 bet): the COMPUTE half reads, diffs, and may + * create fresh rows as DETACHED DOM (same legality as template cloning in + * classic computes), but never touches the live document or the committed + * chain. The EFFECT half is the only writer of both. Under a held + * transition the effect doesn't run until reveal, so the slot can never + * half-apply speculative state; a re-compute before the effect (transition + * retry, rapid writes) discards the superseded plan's fresh rows and diffs + * again from committed state — the "retry against uncorrupted state" + * contract mapArray's strong-abort ordering exists to provide, here free by + * construction. + * + * SPIKE SCOPE — declines (pre-engage) or late-classic demotes (post-engage) + * rather than implements: hydration claiming (H2), `keyed` functions + * (accessor-row contract, H4), duplicate keys, rows whose top level is a + * FUNCTION (dynamic top-level content), empty-rendering rows, and non-array + * subjects. Every decline lands on the classic mapArray path. + */ +import { flatten, onCleanup, sharedConfig, createRoot, untrack } from "solid-js"; +import { effect } from "./render.js"; +import { $$SLOT } from "./constants.js"; +import { setListDriver } from "./client.js"; + +interface Row { + /** Row key — the item reference itself (identity mode only in the spike). */ + k: any; + /** Root disposer for the row's owned scope. */ + d: () => void; + /** Single-root fast form (the common compiled shape)... */ + n: Node | null; + /** ...or the fragment form (multi-root rows); exactly one of n/ns is set. */ + ns: Node[] | null; + p: Row | null; + x: Row | null; + /** True once the effect phase has placed the row into live DOM. */ + live: boolean; +} + +interface Plan { + /** Final row order for the CHANGED middle window only. */ + order: Row[]; + /** Rows needing placement this commit (fresh or moved). */ + place: Set; + /** Committed rows leaving the list — detach + dispose at commit. */ + removes: Row[]; + /** Chain splice boundaries: last untouched prefix row / first untouched + * suffix row (null = list edge). */ + before: Row | null; + after: Row | null; + /** List length after this plan applies. */ + len: number; +} + +interface Slot { + head: Row | null; + tail: Row | null; + size: number; + map: Map; + parent: Node; + end: Node | null; + pending: Plan | null; + dead: boolean; +} + +const firstNode = (r: Row): Node => (r.n !== null ? r.n : r.ns![0]); + +/** Insert (fresh) or move (live) a row's nodes before `anchor`. */ +function placeRow(slot: Slot, r: Row, anchor: Node | null): void { + const tag = slot.end; + if (r.n !== null) { + slot.parent.insertBefore(r.n, anchor); + if (tag && !r.live) (r.n as any)[$$SLOT] = tag; + } else { + const ns = r.ns!; + for (let i = 0; i < ns.length; i++) { + slot.parent.insertBefore(ns[i], anchor); + if (tag && !r.live) (ns[i] as any)[$$SLOT] = tag; + } + } + if (!r.live) { + r.live = true; + slot.map.set(r.k, r); + } +} + +function removeRow(r: Row): void { + if (r.live) { + if (r.n !== null) (r.n as ChildNode).remove(); + else for (const n of r.ns!) (n as ChildNode).remove(); + } + r.d(); +} + +/** Build a row: owned root, body called untracked (component semantics), + * result flattened WITHOUT unwrap so a top-level function is detectable + * (declined). Detached DOM only — placement is the commit's job. Returns + * null when the row shape is outside the spike contract. */ +function buildRow(rowFn: (item: any) => any, item: any): Row | null { + let out: Row | null = null; + const dispose = createRoot(d => { + const v = flatten( + untrack(() => rowFn(item)), + { skipNonRendered: true, doNotUnwrap: true } + ); + if (typeof v === "function") return d; + if (Array.isArray(v)) { + if (v.length === 0) return d; + const ns: Node[] = new Array(v.length); + for (let i = 0; i < v.length; i++) { + const c = v[i]; + ns[i] = (c as any)?.nodeType ? (c as Node) : document.createTextNode(String(c)); + } + out = { k: item, d, n: null, ns, p: null, x: null, live: false }; + } else { + const n: Node = (v as any)?.nodeType ? (v as Node) : document.createTextNode(String(v ?? "")); + out = { k: item, d, n, ns: null, p: null, x: null, live: false }; + } + return d; + }); + if (out === null) dispose(); + return out; +} + +/** Longest increasing subsequence over old-middle indices (-1 = fresh row). + * Returns the set of `order` positions that KEEP their DOM position. */ +function stablePositions(oldPos: number[]): Set { + const tails: number[] = []; + const tailIdx: number[] = []; + const prev: number[] = new Array(oldPos.length).fill(-1); + for (let i = 0; i < oldPos.length; i++) { + const v = oldPos[i]; + if (v === -1) continue; + let lo = 0, + hi = tails.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (tails[mid] < v) lo = mid + 1; + else hi = mid; + } + tails[lo] = v; + prev[i] = lo > 0 ? tailIdx[lo - 1] : -1; + tailIdx[lo] = i; + } + const keep = new Set(); + let at = tails.length > 0 ? tailIdx[tails.length - 1] : -1; + while (at !== -1) { + keep.add(at); + at = prev[at]; + } + return keep; +} + +const IDENTICAL = 0 as const; +const DEMOTE = 1 as const; +type ComputeOut = Plan | typeof IDENTICAL | typeof DEMOTE; + +function driveKeyedFor( + parent: Node, + listFn: any, + marker: Node | undefined, + lateClassic: () => void +): boolean { + const meta = listFn.$for; + // H4 pin: keyed-fn rows receive accessors in the classic contract — the + // driver binds raw items, so engaging would hand user code the wrong shape. + if (typeof meta.keyed === "function") return false; + // Hydration claiming is post-spike (design §6 H2): decline to classic. + if (sharedConfig.hydrating) return false; + + const slot: Slot = { + head: null, + tail: null, + size: 0, + map: new Map(), + parent, + end: marker ?? null, + pending: null, + dead: false + }; + + const dropPending = (): void => { + if (slot.pending !== null) { + for (const r of slot.pending.place) if (!r.live) r.d(); + slot.pending = null; + } + }; + + const demote = (): void => { + // Late-classic (contract carried from the patch-driver era): tear the + // slot down whole, then re-enter classic insert under the ORIGINAL owner. + __unifiedForStats.demoted++; + slot.dead = true; + dropPending(); + for (let r = slot.head; r !== null; r = r.x) removeRow(r); + slot.head = slot.tail = null; + slot.size = 0; + slot.map.clear(); + lateClassic(); + }; + + __unifiedForStats.engaged++; + onCleanup(() => { + slot.dead = true; + dropPending(); + for (let r = slot.head; r !== null; r = r.x) r.d(); + }); + + effect( + (): ComputeOut => { + if (slot.dead) return IDENTICAL; + // Read FIRST (phase separation, design H5): a NotReady here leaves the + // slot untouched and rides the boundary like any compute throw. + const items = meta.each(); + if (items != null && items !== false && !Array.isArray(items)) return DEMOTE; + const arr: readonly any[] = items == null || items === false ? [] : items; + // A superseded plan's fresh rows were never placed — discard, then + // diff again from COMMITTED state (retry against uncorrupted state). + dropPending(); + const len = arr.length; + // ── Prefix walk. + let cursor = slot.head; + let i = 0; + while (cursor !== null && i < len && cursor.k === arr[i]) { + cursor = cursor.x; + i++; + } + if (i === len && cursor === null) return IDENTICAL; + const before = cursor === null ? slot.tail : cursor.p; // last prefix row + // ── Suffix walk. + let tailCursor = slot.tail; + let end = len - 1; + let oldRemain = slot.size - i; + while (tailCursor !== null && oldRemain > 0 && end >= i && tailCursor.k === arr[end]) { + tailCursor = tailCursor.p; + end--; + oldRemain--; + } + const after = oldRemain === 0 ? cursor : tailCursor!.x; // first suffix row + // ── Old middle rows, keyed for reuse. + const oldMid: Row[] = new Array(oldRemain); + { + let r = cursor; + for (let c = 0; c < oldRemain; c++) { + oldMid[c] = r!; + r = r!.x; + } + } + const oldIndexOf = new Map(); + for (let j = 0; j < oldRemain; j++) { + if (oldIndexOf.has(oldMid[j].k)) return DEMOTE; // duplicate keys + oldIndexOf.set(oldMid[j].k, j); + } + // ── New middle: reuse by key, build the rest (detached). + const width = end - i + 1; + const order: Row[] = new Array(width); + const oldPos: number[] = new Array(width); + const reused = new Set(); + for (let j = 0; j < width; j++) { + const item = arr[i + j]; + const at = oldIndexOf.get(item); + if (at !== undefined) { + const row = oldMid[at]; + if (reused.has(row)) return DEMOTE; // duplicate incoming key + reused.add(row); + order[j] = row; + oldPos[j] = at; + } else if (slot.map.has(item)) { + // Same identity alive outside the middle window = duplicate key + // across the prefix/suffix boundary. Classic owns duplicates. + return DEMOTE; + } else { + const fresh = buildRow(meta.row, item); + if (fresh === null) return DEMOTE; // dynamic/empty row shape + order[j] = fresh; + oldPos[j] = -1; + } + } + const keep = stablePositions(oldPos); + const place = new Set(); + for (let j = 0; j < width; j++) if (!keep.has(j)) place.add(order[j]); + const removes: Row[] = []; + for (let j = 0; j < oldRemain; j++) if (!reused.has(oldMid[j])) removes.push(oldMid[j]); + return (slot.pending = { order, place, removes, before, after, len }); + }, + out => { + if (out === IDENTICAL) return; + if (out === DEMOTE) return demote(); + const plan = out as Plan; + if (plan !== slot.pending) return; // superseded mid-flight + slot.pending = null; + const { order, place, removes, before, after } = plan; + // 1. Removes: detach + dispose + unmap. + for (let j = 0; j < removes.length; j++) { + removeRow(removes[j]); + slot.map.delete(removes[j].k); + } + // 2. Place fresh/moved rows back-to-front so anchors are always final. + let anchor: Node | null = after !== null ? firstNode(after) : slot.end; + for (let j = order.length - 1; j >= 0; j--) { + const r = order[j]; + if (place.has(r)) placeRow(slot, r, anchor); + anchor = firstNode(r); + } + // 3. Splice the chain: [before] → order… → [after]. + let prev = before; + for (let j = 0; j < order.length; j++) { + const r = order[j]; + r.p = prev; + if (prev !== null) prev.x = r; + else slot.head = r; + prev = r; + } + if (prev !== null) prev.x = after; + else slot.head = after; + if (after !== null) after.p = prev; + else slot.tail = prev; + slot.size = plan.len; + } + ); + return true; +} + +/** Arm the unified For driver (spike registration — pay-for-use: `insert` + * is in every bundle; the driver rides only apps that call this). */ +export function enableUnifiedFor(): void { + setListDriver(driveKeyedFor); +} + +/** Spike test probes: engagement / late-classic-demotion counters. */ +export const __unifiedForStats = { engaged: 0, demoted: 0 }; diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index e6257d2d5..a2c31ab01 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -31,6 +31,7 @@ import { import type { JSX } from "../jsx/jsx.js"; export * from "./client.js"; +export { enableUnifiedFor, __unifiedForStats } from "./for-driver.js"; // Pay-for-use: retained only when compiled patch-mode output imports export * from "./server-mock.js"; export * from "./response.js"; diff --git a/packages/web/test/for.unified.classic.probe.spec.tsx b/packages/web/test/for.unified.classic.probe.spec.tsx new file mode 100644 index 000000000..d93e32384 --- /dev/null +++ b/packages/web/test/for.unified.classic.probe.spec.tsx @@ -0,0 +1,59 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * CLASSIC BASELINE for the unified-For H1 scenario — driver NOT armed. + * Pins what mapArray does so the driver suite asserts parity, not fiction. + */ +import { describe, expect, test } from "vitest"; +import { createRoot, createOptimisticStore, flush, For } from "solid-js"; +import { insert } from "@solidjs/web"; + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +describe("classic H1 baseline — holds and optimism", () => { + test("held async update never half-applies; in-flight push holds with the flight", async () => { + const container = document.createElement("div"); + let resolveTruth!: () => void; + const gate = new Promise(r => (resolveTruth = r)); + + let push!: () => void; + createRoot(() => { + const [s, ss] = createOptimisticStore<{ id: string }[]>( + async function* (draft) { + yield [{ id: "a" }, { id: "b" }]; + await gate; + yield [{ id: "c" }, { id: "d" }, { id: "e" }]; + }, + [{ id: "a" }, { id: "b" }] + ); + push = () => + ss(draft => { + draft.push({ id: "opt" }); + }); + insert( + container, + () => ({(item: any) => {item.id}}) as any + ); + }); + flush(); + await sleep(10); + expect(container.innerHTML).toBe("ab"); + + // Optimistic structural write DURING the store's own truth flight: the + // bare write rides the FLIGHT'S transaction (#3146 declared ownership) + // and holds with it — no flash, no half-applied frame. (Classic mapArray + // behaves identically — pinned by the classic probe twin of this suite.) + push(); + flush(); + await sleep(10); + expect(container.innerHTML).toBe("ab"); + + // Truth lands: committed topology replaces both the old rows and the + // optimistic row at the reveal — no intermediate half-applied frame. + resolveTruth(); + await sleep(20); + flush(); + expect(container.innerHTML).toBe("cde"); + }); +}); diff --git a/packages/web/test/for.unified.spec.tsx b/packages/web/test/for.unified.spec.tsx new file mode 100644 index 000000000..bd15ac8b4 --- /dev/null +++ b/packages/web/test/for.unified.spec.tsx @@ -0,0 +1,276 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Unified-For driver SPIKE suite (DESIGN-UNIFIED-FOR.md). + * + * Three jobs: + * 1. Semantics parity on the engaged path — the classic for.spec matrix + * (permutations, inserts, removes, clear/refill) must hold verbatim. + * 2. Contract edges — fragment rows, multi-slot mode, duplicate-key and + * non-array DEMOTION to classic (correct rendering after demote). + * 3. H1 — holds/transitions: an optimistic store's held update must not + * half-apply the slot (old DOM until reveal, optimistic writes visible + * in flight, revert restores committed). + */ +import { beforeEach, describe, expect, test } from "vitest"; +import { createRoot, createSignal, createOptimisticStore, flush, For } from "solid-js"; +// IMPORTANT: the packaged specifier, NOT ../src — compiled JSX resolves +// `@solidjs/web` to dist (browser+development), and arming the driver on a +// second from-source instance would leave the compiled inserts classic. +import { insert, enableUnifiedFor, __unifiedForStats } from "@solidjs/web"; + +enableUnifiedFor(); + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +describe("unified For: engaged semantics parity", () => { + let div!: HTMLDivElement, disposer: () => void; + const n1 = "a", + n2 = "b", + n3 = "c", + n4 = "d"; + const [list, setList] = createSignal([n1, n2, n3, n4]); + const Component = () => ( +
+ {item => item} +
+ ); + + function apply(array: string[]) { + setList(array); + flush(); + expect(div.innerHTML).toBe(array.join("")); + setList([n1, n2, n3, n4]); + flush(); + expect(div.innerHTML).toBe("abcd"); + } + + test("creates and ENGAGES the driver", () => { + const before = __unifiedForStats.engaged; + createRoot(dispose => { + disposer = dispose; + ; + }); + flush(); + expect(div.innerHTML).toBe("abcd"); + expect(__unifiedForStats.engaged).toBe(before + 1); + }); + + test("1 missing", () => { + apply([n2, n3, n4]); + apply([n1, n3, n4]); + apply([n1, n2, n4]); + apply([n1, n2, n3]); + }); + + test("2 missing", () => { + apply([n3, n4]); + apply([n2, n4]); + apply([n2, n3]); + apply([n1, n4]); + apply([n1, n3]); + apply([n1, n2]); + }); + + test("3 missing", () => { + apply([n1]); + apply([n2]); + apply([n3]); + apply([n4]); + }); + + test("all missing + refill", () => { + apply([]); + }); + + test("swaps", () => { + apply([n2, n1, n3, n4]); + apply([n3, n2, n1, n4]); + apply([n4, n2, n3, n1]); + apply([n1, n3, n2, n4]); + apply([n1, n4, n3, n2]); + }); + + test("rotations and reverse", () => { + apply([n2, n3, n4, n1]); + apply([n4, n1, n2, n3]); + apply([n4, n3, n2, n1]); + apply([n3, n1, n4, n2]); + }); + + test("inserts", () => { + apply([n1, "e", n2, n3, n4]); + apply(["e", n1, n2, n3, n4]); + apply([n1, n2, n3, n4, "e"]); + apply(["e", n1, "f", n3, n4]); + }); + + test("dispose is inert: rows stop reacting, no crash", () => { + disposer(); + flush(); + const html = div.innerHTML; + setList(["z"]); + flush(); + expect(div.innerHTML).toBe(html); // dead slot never mutates again + setList([n1, n2, n3, n4]); + flush(); + }); +}); + +describe("unified For: element rows and moves preserve identity", () => { + test("row DOM nodes survive reorders", () => { + const a = { id: "a" }, + b = { id: "b" }, + c = { id: "c" }; + const [list, setList] = createSignal([a, b, c]); + let div!: HTMLDivElement; + createRoot(() => { +
+ {(item: any) => {item.id}} +
; + }); + flush(); + expect(div.innerHTML).toBe("abc"); + const [sa, sb, sc] = Array.from(div.children); + setList([c, a, b]); + flush(); + expect(div.innerHTML).toBe("cab"); + // Same elements, moved — never rebuilt. + expect(Array.from(div.children)).toEqual([sc, sa, sb]); + }); + + test("fragment rows (multi-root) move as a unit", () => { + const a = { id: "a" }, + b = { id: "b" }; + const [list, setList] = createSignal([a, b]); + let div!: HTMLDivElement; + createRoot(() => { +
+ + {(item: any) => ( + <> + {item.id} + ! + + )} + +
; + }); + flush(); + expect(div.innerHTML).toBe("a!b!"); + setList([b, a]); + flush(); + expect(div.innerHTML).toBe("b!a!"); + }); + + test("multi-slot mode: list bounded by siblings", () => { + const [list, setList] = createSignal(["x", "y"]); + let div!: HTMLDivElement; + createRoot(() => { +
+
H
+ {item => {item}} +
F
+
; + }); + flush(); + expect(div.innerHTML).toBe("
H
xy
F
"); + setList(["y", "x", "z"]); + flush(); + expect(div.innerHTML).toBe( + "
H
yxz
F
" + ); + setList([]); + flush(); + expect(div.innerHTML).toBe("
H
F
"); + }); +}); + +describe("unified For: demotion to classic", () => { + beforeEach(() => { + __unifiedForStats.demoted = 0; + }); + + test("duplicate keys demote and still render correctly", () => { + const [list, setList] = createSignal(["a", "b"]); + let div!: HTMLDivElement; + createRoot(() => { +
+ {item => {item}} +
; + }); + flush(); + expect(div.innerHTML).toBe("ab"); + setList(["a", "a", "b"]); // duplicate identity → driver demotes + flush(); + expect(__unifiedForStats.demoted).toBe(1); + expect(div.innerHTML).toBe("aab"); + // Classic owns it from here on — still fully live. + setList(["b", "a"]); + flush(); + expect(div.innerHTML).toBe("ba"); + }); + + test("non-array subject demotes to classic single-value insert", () => { + const [list, setList] = createSignal(["a"]); + let div!: HTMLDivElement; + createRoot(() => { +
+ {(item: any) => {item}} +
; + }); + flush(); + expect(div.innerHTML).toBe("a"); + setList("not-an-array" as any); + flush(); + expect(__unifiedForStats.demoted).toBe(1); + }); +}); + +describe("unified For: H1 — holds and optimism", () => { + test("held async update never half-applies; in-flight push holds with the flight", async () => { + const container = document.createElement("div"); + let resolveTruth!: () => void; + const gate = new Promise(r => (resolveTruth = r)); + + let push!: () => void; + createRoot(() => { + const [s, ss] = createOptimisticStore<{ id: string }[]>( + async function* (draft) { + yield [{ id: "a" }, { id: "b" }]; + await gate; + yield [{ id: "c" }, { id: "d" }, { id: "e" }]; + }, + [{ id: "a" }, { id: "b" }] + ); + push = () => + ss(draft => { + draft.push({ id: "opt" }); + }); + insert( + container, + () => ({(item: any) => {item.id}}) as any + ); + }); + flush(); + await sleep(10); + expect(container.innerHTML).toBe("ab"); + + // Optimistic structural write DURING the store's own truth flight: the + // bare write rides the FLIGHT'S transaction (#3146 declared ownership) + // and holds with it — no flash, no half-applied frame. (Classic mapArray + // behaves identically — pinned by the classic probe twin of this suite.) + push(); + flush(); + await sleep(10); + expect(container.innerHTML).toBe("ab"); + + // Truth lands: committed topology replaces both the old rows and the + // optimistic row at the reveal — no intermediate half-applied frame. + resolveTruth(); + await sleep(20); + flush(); + expect(container.innerHTML).toBe("cde"); + }); +}); From 1c843683acc7098485e7a381212835b0e6de8b18 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 15:40:04 -0700 Subject: [PATCH 02/20] =?UTF-8?q?perf(web):=20unified=20For=20batch=20clea?= =?UTF-8?q?r=20=E2=80=94=20whole-parent=20N=E2=86=920=20rides=20one=20text?= =?UTF-8?q?Content=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- packages/web/src/for-driver.ts | 17 ++++++++++++++++- packages/web/test/for.unified.spec.tsx | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/web/src/for-driver.ts b/packages/web/src/for-driver.ts index 6a79957c5..7daa0898f 100644 --- a/packages/web/src/for-driver.ts +++ b/packages/web/src/for-driver.ts @@ -300,6 +300,21 @@ function driveKeyedFor( if (plan !== slot.pending) return; // superseded mid-flight slot.pending = null; const { order, place, removes, before, after } = plan; + // Batch clear (design §5.2): N→0 on a whole-parent slot is one + // `textContent = ''` instead of N removeChild calls — the rows' nodes + // are already detached wholesale, so dispose skips per-node removal. + if (plan.len === 0 && slot.end === null && before === null && after === null) { + __unifiedForStats.batchCleared++; + (slot.parent as Element).textContent = ""; + for (let j = 0; j < removes.length; j++) { + removes[j].live = false; + removes[j].d(); + } + slot.map.clear(); + slot.head = slot.tail = null; + slot.size = 0; + return; + } // 1. Removes: detach + dispose + unmap. for (let j = 0; j < removes.length; j++) { removeRow(removes[j]); @@ -338,4 +353,4 @@ export function enableUnifiedFor(): void { } /** Spike test probes: engagement / late-classic-demotion counters. */ -export const __unifiedForStats = { engaged: 0, demoted: 0 }; +export const __unifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; diff --git a/packages/web/test/for.unified.spec.tsx b/packages/web/test/for.unified.spec.tsx index bd15ac8b4..2adcb8b62 100644 --- a/packages/web/test/for.unified.spec.tsx +++ b/packages/web/test/for.unified.spec.tsx @@ -274,3 +274,23 @@ describe("unified For: H1 — holds and optimism", () => { expect(container.innerHTML).toBe("cde"); }); }); + +describe("unified For: batch clear engagement", () => { + test("whole-parent N→0 rides textContent, not per-row removes", () => { + const items = Array.from({ length: 100 }, (_, i) => ({ id: i })); + const [list, setList] = createSignal(items); + let div!: HTMLDivElement; + createRoot(() => { +
+ {(item: any) => {item.id}} +
; + }); + flush(); + expect(div.childNodes.length).toBe(100); + const before = __unifiedForStats.batchCleared; + setList([]); + flush(); + expect(div.innerHTML).toBe(""); + expect(__unifiedForStats.batchCleared).toBe(before + 1); + }); +}); From f2230643a20d878fac868ae31ebe4af35ee45e9d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 17:30:44 -0700 Subject: [PATCH 03/20] =?UTF-8?q?perf(web):=20unified=20For=20row=20diet?= =?UTF-8?q?=20=E2=80=94=20mapArray's=20owner=20shape,=20flatten=20fast=20p?= =?UTF-8?q?ath,=20zero-Set=20passes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-row createOwner + runWithOwner (untracked+owned) replaces the createRoot closure protocol; compiled single-root rows skip flatten via a nodeType fast path; per-pass Sets become row flags (mv) + a generation stamp; LIS scratch is module-reused; the slot owner chains rows under the insert context (auto-teardown, batch clear = one dispose(false)). Tear fix the diet exposed: the middle window must be read TRACKED before entering the owner wrapper — untracked store reads resolve committed backing mid-flush while length already reports the pending write (row built for undefined). Selection/structural probe suite pins it. jfb: main suite at parity; reorder matrix flips the creation ops from 0.6-0.9x to 1.5-2.3x faster (prepend100 2.33x, append100 1.66x). Co-authored-by: Cursor --- packages/web/src/for-driver.ts | 265 +++++++++++------- .../test/for.unified.selection.probe.spec.tsx | 82 ++++++ 2 files changed, 248 insertions(+), 99 deletions(-) create mode 100644 packages/web/test/for.unified.selection.probe.spec.tsx diff --git a/packages/web/src/for-driver.ts b/packages/web/src/for-driver.ts index 7daa0898f..39370825e 100644 --- a/packages/web/src/for-driver.ts +++ b/packages/web/src/for-driver.ts @@ -10,16 +10,23 @@ * channel, no second diff: mapArray and reconcileArrays are both bypassed * for engaged lists. * - * PHASE DISCIPLINE (the H1 bet): the COMPUTE half reads, diffs, and may - * create fresh rows as DETACHED DOM (same legality as template cloning in - * classic computes), but never touches the live document or the committed - * chain. The EFFECT half is the only writer of both. Under a held - * transition the effect doesn't run until reveal, so the slot can never - * half-apply speculative state; a re-compute before the effect (transition - * retry, rapid writes) discards the superseded plan's fresh rows and diffs - * again from committed state — the "retry against uncorrupted state" - * contract mapArray's strong-abort ordering exists to provide, here free by - * construction. + * PHASE DISCIPLINE (the H1 bet, validated by the spike suites): the COMPUTE + * half reads, diffs, and may create fresh rows as DETACHED DOM (same + * legality as template cloning in classic computes), but never touches the + * live document or the committed chain. The EFFECT half is the only writer + * of both. Under a held transition the effect doesn't run until reveal, so + * the slot can never half-apply speculative state; a re-compute before the + * effect discards the superseded plan's fresh rows and diffs again from + * committed state. + * + * ROW OWNERSHIP (mapArray's own diet, copied): the slot carries ONE owner + * created at engage time under the insert context — rows inherit context + * through it, survive compute reruns because they never chain to the + * per-run scope, and the whole slot tears down automatically with the + * component (no manual cleanup walk). Per row: `createOwner()` + + * `runWithOwner` (untracked + owned — no createRoot closure protocol), and + * a `nodeType` fast path that skips flatten entirely for the compiled + * single-root shape. Bulk teardown (clear) is `owner.dispose(false)`. * * SPIKE SCOPE — declines (pre-engage) or late-classic demotes (post-engage) * rather than implements: hydration claiming (H2), `keyed` functions @@ -27,16 +34,18 @@ * FUNCTION (dynamic top-level content), empty-rendering rows, and non-array * subjects. Every decline lands on the classic mapArray path. */ -import { flatten, onCleanup, sharedConfig, createRoot, untrack } from "solid-js"; +import { createOwner, runWithOwner, flatten, onCleanup, sharedConfig } from "solid-js"; import { effect } from "./render.js"; import { $$SLOT } from "./constants.js"; import { setListDriver } from "./client.js"; +type RowOwner = { dispose(self?: boolean): void }; + interface Row { /** Row key — the item reference itself (identity mode only in the spike). */ k: any; - /** Root disposer for the row's owned scope. */ - d: () => void; + /** Row owner (context carrier + disposer). */ + o: RowOwner; /** Single-root fast form (the common compiled shape)... */ n: Node | null; /** ...or the fragment form (multi-root rows); exactly one of n/ns is set. */ @@ -45,13 +54,16 @@ interface Row { x: Row | null; /** True once the effect phase has placed the row into live DOM. */ live: boolean; + /** Needs placement this commit (fresh or displaced) — set by compute, + * cleared by the commit. Replaces a per-pass Set. */ + mv: boolean; + /** Reuse stamp for the current pass (duplicate detection without Sets). */ + g: number; } interface Plan { /** Final row order for the CHANGED middle window only. */ order: Row[]; - /** Rows needing placement this commit (fresh or moved). */ - place: Set; /** Committed rows leaving the list — detach + dispose at commit. */ removes: Row[]; /** Chain splice boundaries: last untouched prefix row / first untouched @@ -60,6 +72,8 @@ interface Plan { after: Row | null; /** List length after this plan applies. */ len: number; + /** Count of freshly built rows in `order` (dispose-on-supersede set). */ + fresh: number; } interface Slot { @@ -69,10 +83,14 @@ interface Slot { map: Map; parent: Node; end: Node | null; + owner: RowOwner; pending: Plan | null; dead: boolean; } +/** Pass generation counter (Row.g stamps). */ +let gen = 0; + const firstNode = (r: Row): Node => (r.n !== null ? r.n : r.ns![0]); /** Insert (fresh) or move (live) a row's nodes before `anchor`. */ @@ -99,66 +117,79 @@ function removeRow(r: Row): void { if (r.n !== null) (r.n as ChildNode).remove(); else for (const n of r.ns!) (n as ChildNode).remove(); } - r.d(); + r.o.dispose(); } -/** Build a row: owned root, body called untracked (component semantics), - * result flattened WITHOUT unwrap so a top-level function is detectable - * (declined). Detached DOM only — placement is the commit's job. Returns - * null when the row shape is outside the spike contract. */ +const FLATTEN_OPTS = { skipNonRendered: true, doNotUnwrap: true } as const; + +/** Build a row under its own owner (untracked + owned via runWithOwner — + * mapArray's per-row shape). Fast path: compiled single-root rows return an + * element directly and skip flatten. Detached DOM only — placement is the + * commit's job. Returns null when the row shape is outside the spike + * contract. MUST run inside `runWithOwner(slot.owner, ...)` so the row + * owner chains to the slot (context + auto-teardown). */ function buildRow(rowFn: (item: any) => any, item: any): Row | null { - let out: Row | null = null; - const dispose = createRoot(d => { - const v = flatten( - untrack(() => rowFn(item)), - { skipNonRendered: true, doNotUnwrap: true } - ); - if (typeof v === "function") return d; - if (Array.isArray(v)) { - if (v.length === 0) return d; - const ns: Node[] = new Array(v.length); - for (let i = 0; i < v.length; i++) { - const c = v[i]; - ns[i] = (c as any)?.nodeType ? (c as Node) : document.createTextNode(String(c)); - } - out = { k: item, d, n: null, ns, p: null, x: null, live: false }; - } else { - const n: Node = (v as any)?.nodeType ? (v as Node) : document.createTextNode(String(v ?? "")); - out = { k: item, d, n, ns: null, p: null, x: null, live: false }; + const o = createOwner() as unknown as RowOwner; + let v = runWithOwner(o as any, () => rowFn(item)); + if (v != null && (v as any).nodeType !== undefined) + return { k: item, o, n: v as Node, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; + const t = typeof v; + if (t === "string" || t === "number") { + const n = document.createTextNode(String(v)); + return { k: item, o, n, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; + } + // Slow path: fragments / nested arrays / signals — flatten (still owned). + v = runWithOwner(o as any, () => flatten(v, FLATTEN_OPTS)); + if (Array.isArray(v) && v.length > 0) { + const ns: Node[] = new Array(v.length); + for (let i = 0; i < v.length; i++) { + const c = v[i]; + if (typeof c === "function") return (o.dispose(), null); + ns[i] = (c as any)?.nodeType ? (c as Node) : document.createTextNode(String(c)); } - return d; - }); - if (out === null) dispose(); - return out; + return { k: item, o, n: null, ns, p: null, x: null, live: false, mv: true, g: 0 }; + } + if (v != null && (v as any).nodeType !== undefined) + return { k: item, o, n: v as Node, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; + o.dispose(); + return null; // function / empty / unrenderable top level → classic } -/** Longest increasing subsequence over old-middle indices (-1 = fresh row). - * Returns the set of `order` positions that KEEP their DOM position. */ -function stablePositions(oldPos: number[]): Set { - const tails: number[] = []; - const tailIdx: number[] = []; - const prev: number[] = new Array(oldPos.length).fill(-1); - for (let i = 0; i < oldPos.length; i++) { +// LIS scratch (module-level, reused — stablePositions runs NO user code, so +// reentrancy is impossible mid-call). +let lisTails: number[] = []; +let lisTailIdx: number[] = []; +let lisPrev: number[] = []; + +/** Mark rows that KEEP their DOM position (longest increasing subsequence of + * old-middle indices); everything else gets `mv = true`. `oldPos[j]` is -1 + * for fresh rows (already stamped mv by buildRow). */ +function markMoves(order: Row[], oldPos: number[]): void { + const len = oldPos.length; + if (lisPrev.length < len) lisPrev = new Array(len); + let tlen = 0; + for (let i = 0; i < len; i++) { const v = oldPos[i]; if (v === -1) continue; let lo = 0, - hi = tails.length; + hi = tlen; while (lo < hi) { const mid = (lo + hi) >> 1; - if (tails[mid] < v) lo = mid + 1; + if (lisTails[mid] < v) lo = mid + 1; else hi = mid; } - tails[lo] = v; - prev[i] = lo > 0 ? tailIdx[lo - 1] : -1; - tailIdx[lo] = i; + lisTails[lo] = v; + lisPrev[i] = lo > 0 ? lisTailIdx[lo - 1] : -1; + lisTailIdx[lo] = i; + if (lo === tlen) tlen++; } - const keep = new Set(); - let at = tails.length > 0 ? tailIdx[tails.length - 1] : -1; + // Everything moves unless proven stable. + for (let i = 0; i < len; i++) if (oldPos[i] !== -1) order[i].mv = true; + let at = tlen > 0 ? lisTailIdx[tlen - 1] : -1; while (at !== -1) { - keep.add(at); - at = prev[at]; + order[at].mv = false; + at = lisPrev[at]; } - return keep; } const IDENTICAL = 0 as const; @@ -185,13 +216,19 @@ function driveKeyedFor( map: new Map(), parent, end: marker ?? null, + // Slot owner under the INSERT context: rows inherit context through it, + // survive compute reruns, and tear down automatically with the + // component — cleanup needs no row walk. + owner: createOwner() as unknown as RowOwner, pending: null, dead: false }; + __unifiedForStats.engaged++; const dropPending = (): void => { if (slot.pending !== null) { - for (const r of slot.pending.place) if (!r.live) r.d(); + const { order } = slot.pending; + for (let j = 0; j < order.length; j++) if (!order[j].live) order[j].o.dispose(); slot.pending = null; } }; @@ -202,18 +239,21 @@ function driveKeyedFor( __unifiedForStats.demoted++; slot.dead = true; dropPending(); - for (let r = slot.head; r !== null; r = r.x) removeRow(r); + for (let r = slot.head; r !== null; r = r.x) { + if (r.n !== null) (r.n as ChildNode).remove(); + else if (r.ns !== null) for (const n of r.ns) (n as ChildNode).remove(); + } + slot.owner.dispose(false); // bulk: every row owner is a child slot.head = slot.tail = null; slot.size = 0; slot.map.clear(); lateClassic(); }; - __unifiedForStats.engaged++; + // The insert owner disposes slot.owner (and with it every row) through the + // owner tree — cleanup only has to silence the slot. onCleanup(() => { slot.dead = true; - dropPending(); - for (let r = slot.head; r !== null; r = r.x) r.d(); }); effect( @@ -247,7 +287,8 @@ function driveKeyedFor( oldRemain--; } const after = oldRemain === 0 ? cursor : tailCursor!.x; // first suffix row - // ── Old middle rows, keyed for reuse. + const passGen = ++gen; + // ── Old middle rows, keyed for reuse (map probe stamps duplicates). const oldMid: Row[] = new Array(oldRemain); { let r = cursor; @@ -261,37 +302,64 @@ function driveKeyedFor( if (oldIndexOf.has(oldMid[j].k)) return DEMOTE; // duplicate keys oldIndexOf.set(oldMid[j].k, j); } - // ── New middle: reuse by key, build the rest (detached). + // ── New middle: reuse by key; build the rest (detached, owned by the + // slot owner — untracked via runWithOwner inside buildRow). const width = end - i + 1; + // Read the window TRACKED, before entering the owner wrapper: inside + // runWithOwner reads are untracked, and an untracked store read + // resolves the COMMITTED backing while this flush's setter writes are + // still pending — `length` (a written node) says N+1 while the unread + // index N falls back to committed undefined. mapArray solves the same + // tear with `_owner._parentComputed` routing; the driver hoists the + // reads instead. + const midItems: any[] = new Array(width); + for (let j = 0; j < width; j++) midItems[j] = arr[i + j]; const order: Row[] = new Array(width); const oldPos: number[] = new Array(width); - const reused = new Set(); - for (let j = 0; j < width; j++) { - const item = arr[i + j]; - const at = oldIndexOf.get(item); - if (at !== undefined) { - const row = oldMid[at]; - if (reused.has(row)) return DEMOTE; // duplicate incoming key - reused.add(row); - order[j] = row; - oldPos[j] = at; - } else if (slot.map.has(item)) { - // Same identity alive outside the middle window = duplicate key - // across the prefix/suffix boundary. Classic owns duplicates. - return DEMOTE; - } else { - const fresh = buildRow(meta.row, item); - if (fresh === null) return DEMOTE; // dynamic/empty row shape - order[j] = fresh; - oldPos[j] = -1; + let fresh = 0; + let demoteFlag = false; + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < width; j++) { + const item = midItems[j]; + const at = oldIndexOf.get(item); + if (at !== undefined) { + const row = oldMid[at]; + if (row.g === passGen) { + demoteFlag = true; // duplicate incoming key + return; + } + row.g = passGen; + order[j] = row; + oldPos[j] = at; + } else if (slot.map.has(item)) { + // Same identity alive outside the middle window = duplicate key + // across the prefix/suffix boundary. Classic owns duplicates. + demoteFlag = true; + return; + } else { + const built = buildRow(meta.row, item); + if (built === null) { + demoteFlag = true; // dynamic/empty row shape + return; + } + fresh++; + order[j] = built; + oldPos[j] = -1; + } + } + }); + if (demoteFlag) { + // Partial build: dispose what this pass created before demoting. + for (let j = 0; j < width; j++) { + const r = order[j]; + if (r !== undefined && !r.live) r.o.dispose(); } + return DEMOTE; } - const keep = stablePositions(oldPos); - const place = new Set(); - for (let j = 0; j < width; j++) if (!keep.has(j)) place.add(order[j]); + markMoves(order, oldPos); const removes: Row[] = []; - for (let j = 0; j < oldRemain; j++) if (!reused.has(oldMid[j])) removes.push(oldMid[j]); - return (slot.pending = { order, place, removes, before, after, len }); + for (let j = 0; j < oldRemain; j++) if (oldMid[j].g !== passGen) removes.push(oldMid[j]); + return (slot.pending = { order, removes, before, after, len, fresh }); }, out => { if (out === IDENTICAL) return; @@ -299,17 +367,13 @@ function driveKeyedFor( const plan = out as Plan; if (plan !== slot.pending) return; // superseded mid-flight slot.pending = null; - const { order, place, removes, before, after } = plan; + const { order, removes, before, after } = plan; // Batch clear (design §5.2): N→0 on a whole-parent slot is one - // `textContent = ''` instead of N removeChild calls — the rows' nodes - // are already detached wholesale, so dispose skips per-node removal. + // `textContent = ''` + one bulk owner dispose — no per-row work. if (plan.len === 0 && slot.end === null && before === null && after === null) { __unifiedForStats.batchCleared++; (slot.parent as Element).textContent = ""; - for (let j = 0; j < removes.length; j++) { - removes[j].live = false; - removes[j].d(); - } + slot.owner.dispose(false); slot.map.clear(); slot.head = slot.tail = null; slot.size = 0; @@ -324,7 +388,10 @@ function driveKeyedFor( let anchor: Node | null = after !== null ? firstNode(after) : slot.end; for (let j = order.length - 1; j >= 0; j--) { const r = order[j]; - if (place.has(r)) placeRow(slot, r, anchor); + if (r.mv) { + placeRow(slot, r, anchor); + r.mv = false; + } anchor = firstNode(r); } // 3. Splice the chain: [before] → order… → [after]. @@ -352,5 +419,5 @@ export function enableUnifiedFor(): void { setListDriver(driveKeyedFor); } -/** Spike test probes: engagement / late-classic-demotion counters. */ +/** Spike test probes: engagement / demotion / batch-clear counters. */ export const __unifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; diff --git a/packages/web/test/for.unified.selection.probe.spec.tsx b/packages/web/test/for.unified.selection.probe.spec.tsx new file mode 100644 index 000000000..3cdf3a1bc --- /dev/null +++ b/packages/web/test/for.unified.selection.probe.spec.tsx @@ -0,0 +1,82 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * Repro probe: jfb KEYED selection shape under the unified driver. + */ +import { describe, expect, test } from "vitest"; +import { createRoot, createStore, flush, For } from "solid-js"; +import { enableUnifiedFor } from "@solidjs/web"; + +enableUnifiedFor(); + +describe("unified For: selection map binding", () => { + test("row class updates from a sibling store branch", () => { + let div!: HTMLDivElement; + const [state, setState] = createStore({ + rows: [{ id: 1 }, { id: 2 }, { id: 3 }], + selection: {} + }); + createRoot(() => { +
+ + {(row: any) => {row.id}} + +
; + }); + flush(); + expect(div.innerHTML).toBe( + '123' + ); + setState((s: any) => { + s.selection[2] = true; + }); + flush(); + expect(div.querySelectorAll(".danger").length).toBe(1); + expect(div.children[1].className).toBe("danger"); + // move selection + setState((s: any) => { + delete s.selection[2]; + s.selection[3] = true; + }); + flush(); + expect(div.children[1].className).toBe(""); + expect(div.children[2].className).toBe("danger"); + }); +}); + +describe("unified For: bindings survive structural passes", () => { + test("selection still works after replace + append", () => { + let div!: HTMLDivElement; + const [state, setState] = createStore({ + rows: [{ id: 1 }, { id: 2 }], + selection: {} + }); + createRoot(() => { +
+ + {(row: any) => {row.id}} + +
; + }); + flush(); + // structural pass 1: full replace + setState((s: any) => { + s.rows = [{ id: 10 }, { id: 11 }, { id: 12 }]; + }); + flush(); + expect(div.textContent).toBe("101112"); + // structural pass 2: append + setState((s: any) => { + s.rows.push({ id: 13 }); + }); + flush(); + expect(div.textContent).toBe("10111213"); + // NOW select — bindings on survivors must still be live. + setState((s: any) => { + s.selection[11] = true; + }); + flush(); + expect(div.querySelectorAll(".danger").length).toBe(1); + expect(div.children[1].className).toBe("danger"); + }); +}); From 3184a3cce53bf5e9ef5bb44cc556dce16020cfdc Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 19:04:30 -0700 Subject: [PATCH 04/20] =?UTF-8?q?perf(web):=20unified=20For=20full-replace?= =?UTF-8?q?=20fast=20path=20=E2=80=94=20no-survivor=20windows=20bulk-detac?= =?UTF-8?q?h=20like=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- packages/web/src/for-driver.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/web/src/for-driver.ts b/packages/web/src/for-driver.ts index 39370825e..69c06811c 100644 --- a/packages/web/src/for-driver.ts +++ b/packages/web/src/for-driver.ts @@ -379,10 +379,29 @@ function driveKeyedFor( slot.size = 0; return; } - // 1. Removes: detach + dispose + unmap. - for (let j = 0; j < removes.length; j++) { - removeRow(removes[j]); - slot.map.delete(removes[j].k); + // Full replace (no survivors, whole-parent): bulk-detach the old rows + // with one textContent write, dispose them without per-node removes, + // and let the placement walk below append the fresh window. Covers the + // jfb `replace` / `runlots`-over-rows shapes. + if ( + slot.end === null && + before === null && + after === null && + removes.length === slot.size && + removes.length > 0 + ) { + (slot.parent as Element).textContent = ""; + for (let j = 0; j < removes.length; j++) { + removes[j].live = false; + removes[j].o.dispose(); + } + slot.map.clear(); + } else { + // 1. Removes: detach + dispose + unmap. + for (let j = 0; j < removes.length; j++) { + removeRow(removes[j]); + slot.map.delete(removes[j].k); + } } // 2. Place fresh/moved rows back-to-front so anchors are always final. let anchor: Node | null = after !== null ? firstNode(after) : slot.end; From b52a0afb5bce09c76794243d4e37b2e6644ab5f3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 21:08:52 -0700 Subject: [PATCH 05/20] feat(web): ownerless-rows measurement flag + the create-floor finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner tax measured at ~5% of 10k creation; removing it reaches classic parity, not victory — all list architectures share the same floor (clone + one grouped effect + store target). Create wins are core-signals work. Co-authored-by: Cursor --- packages/web/src/for-driver.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/web/src/for-driver.ts b/packages/web/src/for-driver.ts index 69c06811c..a961603f7 100644 --- a/packages/web/src/for-driver.ts +++ b/packages/web/src/for-driver.ts @@ -122,6 +122,9 @@ function removeRow(r: Row): void { const FLATTEN_OPTS = { skipNonRendered: true, doNotUnwrap: true } as const; +/** Shared no-op owner for the ownerless measurement mode. */ +const NO_OWNER: RowOwner = { dispose() {} }; + /** Build a row under its own owner (untracked + owned via runWithOwner — * mapArray's per-row shape). Fast path: compiled single-root rows return an * element directly and skip flatten. Detached DOM only — placement is the @@ -129,8 +132,10 @@ const FLATTEN_OPTS = { skipNonRendered: true, doNotUnwrap: true } as const; * contract. MUST run inside `runWithOwner(slot.owner, ...)` so the row * owner chains to the slot (context + auto-teardown). */ function buildRow(rowFn: (item: any) => any, item: any): Row | null { - const o = createOwner() as unknown as RowOwner; - let v = runWithOwner(o as any, () => rowFn(item)); + // Measurement flag: ambient (slot) ownership, no per-row owner. Removed + // rows leak their effect until slot teardown — bench-only semantics. + const o: RowOwner = __ownerlessRows ? NO_OWNER : (createOwner() as unknown as RowOwner); + let v = __ownerlessRows ? rowFn(item) : runWithOwner(o as any, () => rowFn(item)); if (v != null && (v as any).nodeType !== undefined) return { k: item, o, n: v as Node, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; const t = typeof v; @@ -139,7 +144,9 @@ function buildRow(rowFn: (item: any) => any, item: any): Row | null { return { k: item, o, n, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; } // Slow path: fragments / nested arrays / signals — flatten (still owned). - v = runWithOwner(o as any, () => flatten(v, FLATTEN_OPTS)); + v = __ownerlessRows + ? flatten(v, FLATTEN_OPTS) + : runWithOwner(o as any, () => flatten(v, FLATTEN_OPTS)); if (Array.isArray(v) && v.length > 0) { const ns: Node[] = new Array(v.length); for (let i = 0; i < v.length; i++) { @@ -432,9 +439,18 @@ function driveKeyedFor( return true; } +/** MEASUREMENT-ONLY spike flag (never product): skip per-row owners + * entirely — rows run with the SLOT owner ambient, so row effects chain to + * the slot and individual row disposal is a no-op (removed rows leak their + * effect until slot teardown). Quantifies the per-row ownership tax + * (createOwner + runWithOwner + dispose) that a compiler-proven + * single-effect row contract would eliminate soundly. */ +export let __ownerlessRows = false; + /** Arm the unified For driver (spike registration — pay-for-use: `insert` * is in every bundle; the driver rides only apps that call this). */ -export function enableUnifiedFor(): void { +export function enableUnifiedFor(options?: { unsafeOwnerlessRows?: boolean }): void { + __ownerlessRows = options?.unsafeOwnerlessRows === true; setListDriver(driveKeyedFor); } From 69b8b25902ea80f47bb1abb7dd0b5524fdc422f6 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 3 Sep 2026 19:01:21 -0700 Subject: [PATCH 06/20] =?UTF-8?q?perf(web):=20unified=20For=20lazy=20struc?= =?UTF-8?q?ture=20=E2=80=94=20flat=20first=20fills,=20materialize=20on=20f?= =?UTF-8?q?irst=20partial=20structural=20op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flat mode: fills build owners+DOM into parallel arrays (no Rows/chain/map); aligned passes return IDENTICAL on an array walk; clears and no-survivor replaces swap the flat window wholesale; a PARTIAL structural op materializes the chain once (phase-safe: pure bookkeeping over committed state), amortized into the op the chain's 1.5-3.6x wins then repay. Kills the mount-regression blocker: armed jfb-signal run 2.1 / runlots 18.1 = classic parity (was +40% eager), swap 0.5 retained, battery geomean 0.638 clean, all semantic gates green, web 686 green. Co-authored-by: Cursor --- .changeset/unified-for-lazy-structure.md | 5 + packages/web/src/for-driver.ts | 248 +++++++++++++++++++++-- 2 files changed, 239 insertions(+), 14 deletions(-) create mode 100644 .changeset/unified-for-lazy-structure.md diff --git a/.changeset/unified-for-lazy-structure.md b/.changeset/unified-for-lazy-structure.md new file mode 100644 index 000000000..0a2d0e76b --- /dev/null +++ b/.changeset/unified-for-lazy-structure.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Unified For slot: lazy structure — first fills carry no Row objects, chain, or key map (parallel arrays, mapArray's mount economics); the structure materializes once, on the first partial structural op. Aligned lists, clears, and no-survivor replaces stay flat forever. Removes the slot's creation regression: armed jfb-signal run/runlots at classic parity with the structural wins retained (geomean 0.638 vs baseline, gates green). diff --git a/packages/web/src/for-driver.ts b/packages/web/src/for-driver.ts index a961603f7..8e6b9e14a 100644 --- a/packages/web/src/for-driver.ts +++ b/packages/web/src/for-driver.ts @@ -76,6 +76,27 @@ interface Plan { fresh: number; } +/** FLAT MODE (lazy structure — the mount-regression fix): first fills carry + * NO Row objects, no chain, no key map — just parallel arrays (mapArray's + * own mount economics). The structure MATERIALIZES once, lazily, on the + * first PARTIAL structural op (the moment the chain/LIS wins start paying); + * aligned ticks, clears, and no-survivor full replaces stay flat forever. */ +interface Flat { + /** Committed item snapshot (identity keys). */ + items: any[]; + owners: RowOwner[]; + nodes: (Node | Node[])[]; +} + +interface FlatPlan { + ff: 1; + mode: "fill" | "replace" | "clear"; + items: any[]; + owners: RowOwner[]; + nodes: (Node | Node[])[]; + len: number; +} + interface Slot { head: Row | null; tail: Row | null; @@ -84,7 +105,8 @@ interface Slot { parent: Node; end: Node | null; owner: RowOwner; - pending: Plan | null; + flat: Flat | null; + pending: Plan | FlatPlan | null; dead: boolean; } @@ -131,18 +153,17 @@ const NO_OWNER: RowOwner = { dispose() {} }; * commit's job. Returns null when the row shape is outside the spike * contract. MUST run inside `runWithOwner(slot.owner, ...)` so the row * owner chains to the slot (context + auto-teardown). */ -function buildRow(rowFn: (item: any) => any, item: any): Row | null { +/** Row-body build core: owner + detached DOM, no bookkeeping. Returns + * [owner, node|nodes] or null (shape outside contract). Shared by the flat + * fill (arrays only) and structural buildRow (wraps into a Row). */ +function buildParts(rowFn: (item: any) => any, item: any): [RowOwner, Node | Node[]] | null { // Measurement flag: ambient (slot) ownership, no per-row owner. Removed // rows leak their effect until slot teardown — bench-only semantics. const o: RowOwner = __ownerlessRows ? NO_OWNER : (createOwner() as unknown as RowOwner); let v = __ownerlessRows ? rowFn(item) : runWithOwner(o as any, () => rowFn(item)); - if (v != null && (v as any).nodeType !== undefined) - return { k: item, o, n: v as Node, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; + if (v != null && (v as any).nodeType !== undefined) return [o, v as Node]; const t = typeof v; - if (t === "string" || t === "number") { - const n = document.createTextNode(String(v)); - return { k: item, o, n, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; - } + if (t === "string" || t === "number") return [o, document.createTextNode(String(v))]; // Slow path: fragments / nested arrays / signals — flatten (still owned). v = __ownerlessRows ? flatten(v, FLATTEN_OPTS) @@ -154,14 +175,72 @@ function buildRow(rowFn: (item: any) => any, item: any): Row | null { if (typeof c === "function") return (o.dispose(), null); ns[i] = (c as any)?.nodeType ? (c as Node) : document.createTextNode(String(c)); } - return { k: item, o, n: null, ns, p: null, x: null, live: false, mv: true, g: 0 }; + return [o, ns]; } - if (v != null && (v as any).nodeType !== undefined) - return { k: item, o, n: v as Node, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; + if (v != null && (v as any).nodeType !== undefined) return [o, v as Node]; o.dispose(); return null; // function / empty / unrenderable top level → classic } +function buildRow(rowFn: (item: any) => any, item: any): Row | null { + const parts = buildParts(rowFn, item); + if (parts === null) return null; + const nd = parts[1]; + return Array.isArray(nd) + ? { k: item, o: parts[0], n: null, ns: nd, p: null, x: null, live: false, mv: true, g: 0 } + : { k: item, o: parts[0], n: nd, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; +} + +/** Lossless representation change: committed flat arrays → chain + map. + * Runs in COMPUTE (phase-safe: it derives bookkeeping from COMMITTED state, + * touches no DOM, and stays valid if the pass aborts). Returns false on + * duplicate identity keys (classic owns duplicates → demote). */ +function materialize(slot: Slot): boolean { + const f = slot.flat!; + const n = f.items.length; + let prev: Row | null = null; + for (let i = 0; i < n; i++) { + const nd = f.nodes[i]; + const r: Row = Array.isArray(nd) + ? { + k: f.items[i], + o: f.owners[i], + n: null, + ns: nd, + p: prev, + x: null, + live: true, + mv: false, + g: 0 + } + : { + k: f.items[i], + o: f.owners[i], + n: nd, + ns: null, + p: prev, + x: null, + live: true, + mv: false, + g: 0 + }; + if (slot.map.has(r.k)) { + // Roll back the partial chain bookkeeping; demote handles teardown. + slot.map.clear(); + slot.head = slot.tail = null; + return false; + } + slot.map.set(r.k, r); + if (prev !== null) prev.x = r; + else slot.head = r; + prev = r; + } + slot.tail = prev; + slot.size = n; + slot.flat = null; + return true; +} + // LIS scratch (module-level, reused — stablePositions runs NO user code, so // reentrancy is impossible mid-call). let lisTails: number[] = []; @@ -201,7 +280,7 @@ function markMoves(order: Row[], oldPos: number[]): void { const IDENTICAL = 0 as const; const DEMOTE = 1 as const; -type ComputeOut = Plan | typeof IDENTICAL | typeof DEMOTE; +type ComputeOut = Plan | FlatPlan | typeof IDENTICAL | typeof DEMOTE; function driveKeyedFor( parent: Node, @@ -227,6 +306,7 @@ function driveKeyedFor( // survive compute reruns, and tear down automatically with the // component — cleanup needs no row walk. owner: createOwner() as unknown as RowOwner, + flat: null, pending: null, dead: false }; @@ -234,18 +314,41 @@ function driveKeyedFor( const dropPending = (): void => { if (slot.pending !== null) { - const { order } = slot.pending; - for (let j = 0; j < order.length; j++) if (!order[j].live) order[j].o.dispose(); + if ((slot.pending as FlatPlan).ff === 1) { + // A superseded flat plan placed nothing — dispose all its owners. + const { owners } = slot.pending as FlatPlan; + for (let j = 0; j < owners.length; j++) owners[j].dispose(); + } else { + const { order } = slot.pending as Plan; + for (let j = 0; j < order.length; j++) if (!order[j].live) order[j].o.dispose(); + } slot.pending = null; } }; + const removeFlatDom = (): void => { + const f = slot.flat!; + if (slot.end === null) (slot.parent as Element).textContent = ""; + else + for (let i = 0; i < f.nodes.length; i++) { + const nd = f.nodes[i]; + if (Array.isArray(nd)) for (const n of nd) (n as ChildNode).remove(); + else (nd as ChildNode).remove(); + } + }; + const demote = (): void => { // Late-classic (contract carried from the patch-driver era): tear the // slot down whole, then re-enter classic insert under the ORIGINAL owner. __unifiedForStats.demoted++; slot.dead = true; dropPending(); + if (slot.flat !== null) { + removeFlatDom(); + const f = slot.flat; + for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); + slot.flat = null; + } for (let r = slot.head; r !== null; r = r.x) { if (r.n !== null) (r.n as ChildNode).remove(); else if (r.ns !== null) for (const n of r.ns) (n as ChildNode).remove(); @@ -257,6 +360,30 @@ function driveKeyedFor( lateClassic(); }; + /** Build the flat arrays for `arr` (tracked snapshot already taken by the + * caller). Returns null when a row shape is outside the contract. */ + const buildFlat = (itemsSnap: any[]): FlatPlan | null => { + const len = itemsSnap.length; + const owners: RowOwner[] = new Array(len); + const nodes: (Node | Node[])[] = new Array(len); + let failed = false; + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < len; j++) { + const parts = buildParts(meta.row, itemsSnap[j]); + if (parts === null) { + // Dispose what this pass created before demoting. + for (let d = 0; d < j; d++) owners[d].dispose(); + failed = true; + return; + } + owners[j] = parts[0]; + nodes[j] = parts[1]; + } + }); + if (failed) return null; + return { ff: 1, mode: "fill", items: itemsSnap, owners, nodes, len }; + }; + // The insert owner disposes slot.owner (and with it every row) through the // owner tree — cleanup only has to silence the slot. onCleanup(() => { @@ -275,6 +402,63 @@ function driveKeyedFor( // diff again from COMMITTED state (retry against uncorrupted state). dropPending(); const len = arr.length; + // ── FLAT MODE (lazy structure): aligned lists stay flat (zero work); + // clears and no-survivor replaces stay flat (bulk swap); only a + // PARTIAL structural op materializes the chain — once, amortized into + // the op the chain's wins then repay. + if (slot.flat !== null) { + const fi = slot.flat.items; + if (len === fi.length) { + let aligned = true; + for (let j = 0; j < len; j++) + if (arr[j] !== fi[j]) { + aligned = false; + break; + } + if (aligned) return IDENTICAL; + } + if (len === 0) + return (slot.pending = { + ff: 1, + mode: "clear", + items: [], + owners: [], + nodes: [], + len: 0 + }); + // Survivor probe (once, on the rare structural event): no shared + // identities = full replace — swap flat wholesale, never build rows. + let survivor = false; + { + const old = new Set(fi); + for (let j = 0; j < len; j++) + if (old.has(arr[j])) { + survivor = true; + break; + } + } + if (!survivor) { + const snap: any[] = new Array(len); + for (let j = 0; j < len; j++) snap[j] = arr[j]; + const plan = buildFlat(snap); + if (plan === null) return DEMOTE; + plan.mode = "replace"; + return (slot.pending = plan); + } + // Partial structure: materialize the chain from committed flat state + // (phase-safe: pure bookkeeping over committed rows, no DOM) and + // fall through to the structural walk. + if (!materialize(slot)) return DEMOTE; // duplicate identity keys + } + // ── FLAT FILL: an empty slot fills with arrays only (mapArray's mount + // economics — no Rows, no chain, no map). + if (slot.head === null && slot.size === 0) { + if (len === 0) return IDENTICAL; + const snap: any[] = new Array(len); + for (let j = 0; j < len; j++) snap[j] = arr[j]; + const plan = buildFlat(snap); + return plan === null ? DEMOTE : (slot.pending = plan); + } // ── Prefix walk. let cursor = slot.head; let i = 0; @@ -371,6 +555,42 @@ function driveKeyedFor( out => { if (out === IDENTICAL) return; if (out === DEMOTE) return demote(); + if ((out as FlatPlan).ff === 1) { + const fp = out as FlatPlan; + if (fp !== slot.pending) return; // superseded mid-flight + slot.pending = null; + if (fp.mode === "clear") { + removeFlatDom(); + const f = slot.flat!; + for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); + slot.flat = null; + slot.size = 0; + __unifiedForStats.batchCleared++; + return; + } + if (fp.mode === "replace") { + removeFlatDom(); + const f = slot.flat!; + for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); + } + // fill / replace: append the new window before the end anchor. + const tag = slot.end; + for (let i = 0; i < fp.nodes.length; i++) { + const nd = fp.nodes[i]; + if (Array.isArray(nd)) + for (const n of nd) { + slot.parent.insertBefore(n, slot.end); + if (tag) (n as any)[$$SLOT] = tag; + } + else { + slot.parent.insertBefore(nd, slot.end); + if (tag) (nd as any)[$$SLOT] = tag; + } + } + slot.flat = { items: fp.items, owners: fp.owners, nodes: fp.nodes }; + slot.size = fp.len; + return; + } const plan = out as Plan; if (plan !== slot.pending) return; // superseded mid-flight slot.pending = null; From 73ce20d7b08455e91ef6299a081f35a165223d0b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 4 Sep 2026 12:13:25 -0700 Subject: [PATCH 07/20] =?UTF-8?q?refactor(web):=20unified=20For=20slot=20b?= =?UTF-8?q?ehind=20renderer=20ops=20=E2=80=94=20platform=20handed=20in=20b?= =?UTF-8?q?y=20the=20engaging=20insert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One SlotOps singleton per renderer (web: domOps), threaded through Slot and the row builders. Interleaved A/B on frozen dists: mount 13.6/13.6, tick 5.4/5.4, tick_partial 1.3/1.3 — the indirection is free (monomorphic sites). Groundwork for the module-graph landing: the slot rides For's own import, insert supplies the platform, no registration API, no compiler emission. Co-authored-by: Cursor --- .changeset/unified-for-renderer-ops.md | 5 ++ packages/web/src/for-driver.ts | 113 ++++++++++++++++++------- 2 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 .changeset/unified-for-renderer-ops.md diff --git a/.changeset/unified-for-renderer-ops.md b/.changeset/unified-for-renderer-ops.md new file mode 100644 index 000000000..1d0180af9 --- /dev/null +++ b/.changeset/unified-for-renderer-ops.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Unified For slot is renderer-agnostic: all platform touches ride a SlotOps interface (insert/remove/createText/isNode/clear/tag) handed to the slot by the engaging insert as one module-level singleton — monomorphic call sites, verified perf-neutral (interleaved A/B dead even on mount/tick/tick_partial). This is what lets the slot travel with For's module graph and gives universal renderers a direct adoption path. diff --git a/packages/web/src/for-driver.ts b/packages/web/src/for-driver.ts index 8e6b9e14a..908f41bf4 100644 --- a/packages/web/src/for-driver.ts +++ b/packages/web/src/for-driver.ts @@ -97,6 +97,45 @@ interface FlatPlan { len: number; } +/** RENDERER OPS — the slot's entire platform surface. The slot never touches + * DOM directly: the engaging insert() hands it ONE module-level singleton + * (web: `domOps` below), so every call site stays monomorphic and V8 inlines + * the indirection. This is what lets the slot ride For's own module graph + * (pay-for-use via tree-shaking, no compiler emission, no registration API) + * and what gives universal renderers a direct adoption path: pass your ops. */ +export interface SlotOps { + insert(parent: Node, node: Node, anchor: Node | null): void; + remove(node: Node): void; + createText(text: string): Node; + isNode(v: unknown): boolean; + /** Whole-parent bulk clear (batch-clear / full-replace fast paths). */ + clear(parent: Node): void; + /** Ownership marker for multi-slot parents (web: `$$SLOT`). */ + tag(node: Node, marker: Node): void; +} + +/** Web's ops singleton — the one instance every web slot shares. */ +const domOps: SlotOps = { + insert(parent, node, anchor) { + parent.insertBefore(node, anchor); + }, + remove(node) { + (node as ChildNode).remove(); + }, + createText(text) { + return document.createTextNode(text); + }, + isNode(v) { + return v != null && (v as any).nodeType !== undefined; + }, + clear(parent) { + (parent as Element).textContent = ""; + }, + tag(node, marker) { + (node as any)[$$SLOT] = marker; + } +}; + interface Slot { head: Row | null; tail: Row | null; @@ -108,6 +147,7 @@ interface Slot { flat: Flat | null; pending: Plan | FlatPlan | null; dead: boolean; + ops: SlotOps; } /** Pass generation counter (Row.g stamps). */ @@ -118,14 +158,15 @@ const firstNode = (r: Row): Node => (r.n !== null ? r.n : r.ns![0]); /** Insert (fresh) or move (live) a row's nodes before `anchor`. */ function placeRow(slot: Slot, r: Row, anchor: Node | null): void { const tag = slot.end; + const ops = slot.ops; if (r.n !== null) { - slot.parent.insertBefore(r.n, anchor); - if (tag && !r.live) (r.n as any)[$$SLOT] = tag; + ops.insert(slot.parent, r.n, anchor); + if (tag && !r.live) ops.tag(r.n, tag); } else { const ns = r.ns!; for (let i = 0; i < ns.length; i++) { - slot.parent.insertBefore(ns[i], anchor); - if (tag && !r.live) (ns[i] as any)[$$SLOT] = tag; + ops.insert(slot.parent, ns[i], anchor); + if (tag && !r.live) ops.tag(ns[i], tag); } } if (!r.live) { @@ -134,10 +175,10 @@ function placeRow(slot: Slot, r: Row, anchor: Node | null): void { } } -function removeRow(r: Row): void { +function removeRow(r: Row, ops: SlotOps): void { if (r.live) { - if (r.n !== null) (r.n as ChildNode).remove(); - else for (const n of r.ns!) (n as ChildNode).remove(); + if (r.n !== null) ops.remove(r.n); + else for (const n of r.ns!) ops.remove(n); } r.o.dispose(); } @@ -156,14 +197,18 @@ const NO_OWNER: RowOwner = { dispose() {} }; /** Row-body build core: owner + detached DOM, no bookkeeping. Returns * [owner, node|nodes] or null (shape outside contract). Shared by the flat * fill (arrays only) and structural buildRow (wraps into a Row). */ -function buildParts(rowFn: (item: any) => any, item: any): [RowOwner, Node | Node[]] | null { +function buildParts( + rowFn: (item: any) => any, + item: any, + ops: SlotOps +): [RowOwner, Node | Node[]] | null { // Measurement flag: ambient (slot) ownership, no per-row owner. Removed // rows leak their effect until slot teardown — bench-only semantics. const o: RowOwner = __ownerlessRows ? NO_OWNER : (createOwner() as unknown as RowOwner); let v = __ownerlessRows ? rowFn(item) : runWithOwner(o as any, () => rowFn(item)); - if (v != null && (v as any).nodeType !== undefined) return [o, v as Node]; + if (ops.isNode(v)) return [o, v as Node]; const t = typeof v; - if (t === "string" || t === "number") return [o, document.createTextNode(String(v))]; + if (t === "string" || t === "number") return [o, ops.createText(String(v))]; // Slow path: fragments / nested arrays / signals — flatten (still owned). v = __ownerlessRows ? flatten(v, FLATTEN_OPTS) @@ -173,17 +218,17 @@ function buildParts(rowFn: (item: any) => any, item: any): [RowOwner, Node | Nod for (let i = 0; i < v.length; i++) { const c = v[i]; if (typeof c === "function") return (o.dispose(), null); - ns[i] = (c as any)?.nodeType ? (c as Node) : document.createTextNode(String(c)); + ns[i] = ops.isNode(c) ? (c as Node) : ops.createText(String(c)); } return [o, ns]; } - if (v != null && (v as any).nodeType !== undefined) return [o, v as Node]; + if (ops.isNode(v)) return [o, v as Node]; o.dispose(); return null; // function / empty / unrenderable top level → classic } -function buildRow(rowFn: (item: any) => any, item: any): Row | null { - const parts = buildParts(rowFn, item); +function buildRow(rowFn: (item: any) => any, item: any, ops: SlotOps): Row | null { + const parts = buildParts(rowFn, item, ops); if (parts === null) return null; const nd = parts[1]; return Array.isArray(nd) @@ -286,7 +331,8 @@ function driveKeyedFor( parent: Node, listFn: any, marker: Node | undefined, - lateClassic: () => void + lateClassic: () => void, + ops: SlotOps ): boolean { const meta = listFn.$for; // H4 pin: keyed-fn rows receive accessors in the classic contract — the @@ -308,7 +354,8 @@ function driveKeyedFor( owner: createOwner() as unknown as RowOwner, flat: null, pending: null, - dead: false + dead: false, + ops }; __unifiedForStats.engaged++; @@ -328,12 +375,12 @@ function driveKeyedFor( const removeFlatDom = (): void => { const f = slot.flat!; - if (slot.end === null) (slot.parent as Element).textContent = ""; + if (slot.end === null) ops.clear(slot.parent); else for (let i = 0; i < f.nodes.length; i++) { const nd = f.nodes[i]; - if (Array.isArray(nd)) for (const n of nd) (n as ChildNode).remove(); - else (nd as ChildNode).remove(); + if (Array.isArray(nd)) for (const n of nd) ops.remove(n); + else ops.remove(nd); } }; @@ -350,8 +397,8 @@ function driveKeyedFor( slot.flat = null; } for (let r = slot.head; r !== null; r = r.x) { - if (r.n !== null) (r.n as ChildNode).remove(); - else if (r.ns !== null) for (const n of r.ns) (n as ChildNode).remove(); + if (r.n !== null) ops.remove(r.n); + else if (r.ns !== null) for (const n of r.ns) ops.remove(n); } slot.owner.dispose(false); // bulk: every row owner is a child slot.head = slot.tail = null; @@ -369,7 +416,7 @@ function driveKeyedFor( let failed = false; runWithOwner(slot.owner as any, () => { for (let j = 0; j < len; j++) { - const parts = buildParts(meta.row, itemsSnap[j]); + const parts = buildParts(meta.row, itemsSnap[j], ops); if (parts === null) { // Dispose what this pass created before demoting. for (let d = 0; d < j; d++) owners[d].dispose(); @@ -528,7 +575,7 @@ function driveKeyedFor( demoteFlag = true; return; } else { - const built = buildRow(meta.row, item); + const built = buildRow(meta.row, item, ops); if (built === null) { demoteFlag = true; // dynamic/empty row shape return; @@ -579,12 +626,12 @@ function driveKeyedFor( const nd = fp.nodes[i]; if (Array.isArray(nd)) for (const n of nd) { - slot.parent.insertBefore(n, slot.end); - if (tag) (n as any)[$$SLOT] = tag; + ops.insert(slot.parent, n, slot.end); + if (tag) ops.tag(n, tag); } else { - slot.parent.insertBefore(nd, slot.end); - if (tag) (nd as any)[$$SLOT] = tag; + ops.insert(slot.parent, nd, slot.end); + if (tag) ops.tag(nd, tag); } } slot.flat = { items: fp.items, owners: fp.owners, nodes: fp.nodes }; @@ -599,7 +646,7 @@ function driveKeyedFor( // `textContent = ''` + one bulk owner dispose — no per-row work. if (plan.len === 0 && slot.end === null && before === null && after === null) { __unifiedForStats.batchCleared++; - (slot.parent as Element).textContent = ""; + ops.clear(slot.parent); slot.owner.dispose(false); slot.map.clear(); slot.head = slot.tail = null; @@ -617,7 +664,7 @@ function driveKeyedFor( removes.length === slot.size && removes.length > 0 ) { - (slot.parent as Element).textContent = ""; + ops.clear(slot.parent); for (let j = 0; j < removes.length; j++) { removes[j].live = false; removes[j].o.dispose(); @@ -626,7 +673,7 @@ function driveKeyedFor( } else { // 1. Removes: detach + dispose + unmap. for (let j = 0; j < removes.length; j++) { - removeRow(removes[j]); + removeRow(removes[j], ops); slot.map.delete(removes[j].k); } } @@ -671,7 +718,11 @@ export let __ownerlessRows = false; * is in every bundle; the driver rides only apps that call this). */ export function enableUnifiedFor(options?: { unsafeOwnerlessRows?: boolean }): void { __ownerlessRows = options?.unsafeOwnerlessRows === true; - setListDriver(driveKeyedFor); + // Web hands the slot ITS platform: the domOps singleton. (Interim wiring — + // the module-graph landing passes ops at insert's engagement site instead.) + setListDriver((parent, listFn, marker, lateClassic) => + driveKeyedFor(parent, listFn, marker, lateClassic, domOps) + ); } /** Spike test probes: engagement / demotion / batch-clear counters. */ From 4d7b4ced03361411a1115a0f4c17b49a0e69c903 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 4 Sep 2026 13:25:18 -0700 Subject: [PATCH 08/20] =?UTF-8?q?feat(solid,web):=20unified=20For=20defaul?= =?UTF-8?q?t-on=20=E2=80=94=20the=20slot=20rides=20For's=20module=20graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slot moves to solid-js client (packages/solid/src/client/for-slot.ts) and travels on $for.impl; web's insert engages it with its domOps singleton. Registration API (enableUnifiedFor/setListDriver) deleted; measurement-only ownerless-rows flag dropped. Every keyed in the web corpus now runs the slot: web 696 / solid 585 / signals 1469 / universal 43 / element 10 / html 192 green. Size: signals+frames flat, floor +153 B (seam + ops), For scenarios +2.1-2.2 KB (the deliberate default-on bill), budgets ratcheted. Co-authored-by: Cursor --- .changeset/unified-for-module-graph.md | 6 ++ packages/solid/src/client/flow.ts | 10 +- .../src/client/for-slot.ts} | 100 +++++++----------- packages/solid/src/index.ts | 5 + packages/solid/src/server/index.ts | 4 + packages/web/src/client.ts | 71 ++++++++----- packages/web/src/index.ts | 1 - .../test/for.unified.selection.probe.spec.tsx | 4 +- packages/web/test/for.unified.spec.tsx | 18 ++-- scripts/size/.size-limit.js | 32 +++++- 10 files changed, 148 insertions(+), 103 deletions(-) create mode 100644 .changeset/unified-for-module-graph.md rename packages/{web/src/for-driver.ts => solid/src/client/for-slot.ts} (89%) diff --git a/.changeset/unified-for-module-graph.md b/.changeset/unified-for-module-graph.md new file mode 100644 index 000000000..4f3001391 --- /dev/null +++ b/.changeset/unified-for-module-graph.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Unified For ships default-on through For's own module graph: the slot algorithm (chain + LIS structural updates, flat-mode mounts) lives in solid-js client and travels on the `$for.impl` descriptor For stamps; web's insert engages it by handing over its SlotOps singleton. Zero user API, zero compiler involvement, exact pay-for-use — apps without For tree-shake the slot entirely (~2.1 KB in For-bearing bundles, +153 B engagement seam on the web floor). Renderers that ignore `$for` keep classic mapArray; universal adoption is passing its own ops. enableUnifiedFor and the registration seam are deleted. diff --git a/packages/solid/src/client/flow.ts b/packages/solid/src/client/flow.ts index 4edcef3a1..626b81eb4 100644 --- a/packages/solid/src/client/flow.ts +++ b/packages/solid/src/client/flow.ts @@ -9,6 +9,7 @@ import { runWithOwner } from "@solidjs/signals"; import { createErrorBoundary, createLoadingBoundary, sharedConfig } from "./hydration.js"; +import { unifiedForSlot } from "./for-slot.js"; import type { Accessor, RevealOrder } from "@solidjs/signals"; export type { RevealOrder }; import type { Element as SolidElement } from "../types.js"; @@ -114,7 +115,14 @@ export function For(props: { // reference identity or key-fn rows (`keyed !== false`), no fallback, and // no index parameter (row arity < 2). if (props.keyed !== false && !("fallback" in props) && props.children.length < 2) - (list as any).$for = { each: () => props.each, row: props.children, keyed: props.keyed }; + (list as any).$for = { + each: () => props.each, + row: props.children, + keyed: props.keyed, + // The slot rides For's OWN module graph: apps without For tree-shake + // it; a renderer's insert() engages it by passing its SlotOps. + impl: unifiedForSlot + }; return list as unknown as SolidElement; } diff --git a/packages/web/src/for-driver.ts b/packages/solid/src/client/for-slot.ts similarity index 89% rename from packages/web/src/for-driver.ts rename to packages/solid/src/client/for-slot.ts index 908f41bf4..b9e507e74 100644 --- a/packages/web/src/for-driver.ts +++ b/packages/solid/src/client/for-slot.ts @@ -1,7 +1,7 @@ /** - * Unified For driver (SPIKE — DESIGN-UNIFIED-FOR.md). + * Unified For SLOT (DESIGN-UNIFIED-FOR.md). * - * One persistent structure owns both the row bookkeeping AND the DOM + * One persistent structure owns both the row bookkeeping AND the node * placement for a keyed : an intrusive doubly-linked chain of rows plus * an incrementally-maintained key→row Map, per engaged list. The update is * pull-based — an ordinary two-phase render effect reads `each()`, diffs @@ -10,6 +10,13 @@ * channel, no second diff: mapArray and reconcileArrays are both bypassed * for engaged lists. * + * DELIVERY (module-graph, no registration): this module rides For's OWN + * import graph — For stamps `$for.impl` with `unifiedForSlot`, and a + * renderer's insert() engages it by calling the impl with ITS `SlotOps` + * singleton (web: `domOps`). Apps without For tree-shake the slot entirely; + * renderers that ignore `$for` call the accessor and get classic mapArray. + * The slot itself is platform-free — every node touch rides the ops. + * * PHASE DISCIPLINE (the H1 bet, validated by the spike suites): the COMPUTE * half reads, diffs, and may create fresh rows as DETACHED DOM (same * legality as template cloning in classic computes), but never touches the @@ -34,10 +41,20 @@ * FUNCTION (dynamic top-level content), empty-rendering rows, and non-array * subjects. Every decline lands on the classic mapArray path. */ -import { createOwner, runWithOwner, flatten, onCleanup, sharedConfig } from "solid-js"; -import { effect } from "./render.js"; -import { $$SLOT } from "./constants.js"; -import { setListDriver } from "./client.js"; +import { + createOwner, + createRenderEffect, + flatten, + onCleanup, + runWithOwner +} from "@solidjs/signals"; +import { sharedConfig } from "./hydration.js"; + +// The two-phase render effect in web's `effect()` shape: transparent + sync. +const transparentOptions = { transparent: true, sync: true } as const; +function effect(fn: (prev?: T) => T, effectFn: (value: T, prev?: T) => void): void { + createRenderEffect(fn, effectFn, transparentOptions); +} type RowOwner = { dispose(self?: boolean): void }; @@ -114,28 +131,6 @@ export interface SlotOps { tag(node: Node, marker: Node): void; } -/** Web's ops singleton — the one instance every web slot shares. */ -const domOps: SlotOps = { - insert(parent, node, anchor) { - parent.insertBefore(node, anchor); - }, - remove(node) { - (node as ChildNode).remove(); - }, - createText(text) { - return document.createTextNode(text); - }, - isNode(v) { - return v != null && (v as any).nodeType !== undefined; - }, - clear(parent) { - (parent as Element).textContent = ""; - }, - tag(node, marker) { - (node as any)[$$SLOT] = marker; - } -}; - interface Slot { head: Row | null; tail: Row | null; @@ -185,16 +180,13 @@ function removeRow(r: Row, ops: SlotOps): void { const FLATTEN_OPTS = { skipNonRendered: true, doNotUnwrap: true } as const; -/** Shared no-op owner for the ownerless measurement mode. */ -const NO_OWNER: RowOwner = { dispose() {} }; - /** Build a row under its own owner (untracked + owned via runWithOwner — * mapArray's per-row shape). Fast path: compiled single-root rows return an - * element directly and skip flatten. Detached DOM only — placement is the - * commit's job. Returns null when the row shape is outside the spike + * element directly and skip flatten. Detached nodes only — placement is the + * commit's job. Returns null when the row shape is outside the slot * contract. MUST run inside `runWithOwner(slot.owner, ...)` so the row * owner chains to the slot (context + auto-teardown). */ -/** Row-body build core: owner + detached DOM, no bookkeeping. Returns +/** Row-body build core: owner + detached nodes, no bookkeeping. Returns * [owner, node|nodes] or null (shape outside contract). Shared by the flat * fill (arrays only) and structural buildRow (wraps into a Row). */ function buildParts( @@ -202,17 +194,13 @@ function buildParts( item: any, ops: SlotOps ): [RowOwner, Node | Node[]] | null { - // Measurement flag: ambient (slot) ownership, no per-row owner. Removed - // rows leak their effect until slot teardown — bench-only semantics. - const o: RowOwner = __ownerlessRows ? NO_OWNER : (createOwner() as unknown as RowOwner); - let v = __ownerlessRows ? rowFn(item) : runWithOwner(o as any, () => rowFn(item)); + const o: RowOwner = createOwner() as unknown as RowOwner; + let v = runWithOwner(o as any, () => rowFn(item)); if (ops.isNode(v)) return [o, v as Node]; const t = typeof v; if (t === "string" || t === "number") return [o, ops.createText(String(v))]; // Slow path: fragments / nested arrays / signals — flatten (still owned). - v = __ownerlessRows - ? flatten(v, FLATTEN_OPTS) - : runWithOwner(o as any, () => flatten(v, FLATTEN_OPTS)); + v = runWithOwner(o as any, () => flatten(v, FLATTEN_OPTS)); if (Array.isArray(v) && v.length > 0) { const ns: Node[] = new Array(v.length); for (let i = 0; i < v.length; i++) { @@ -327,7 +315,10 @@ const IDENTICAL = 0 as const; const DEMOTE = 1 as const; type ComputeOut = Plan | FlatPlan | typeof IDENTICAL | typeof DEMOTE; -function driveKeyedFor( +/** THE unified For slot — For stamps this as `$for.impl`; a renderer's + * insert() engages it with its SlotOps. Returns false to decline (classic + * path); lateClassic re-enters classic insert after a post-engage demote. */ +export function unifiedForSlot( parent: Node, listFn: any, marker: Node | undefined, @@ -336,9 +327,9 @@ function driveKeyedFor( ): boolean { const meta = listFn.$for; // H4 pin: keyed-fn rows receive accessors in the classic contract — the - // driver binds raw items, so engaging would hand user code the wrong shape. + // slot binds raw items, so engaging would hand user code the wrong shape. if (typeof meta.keyed === "function") return false; - // Hydration claiming is post-spike (design §6 H2): decline to classic. + // Hydration claiming is future work (design §6 H2): decline to classic. if (sharedConfig.hydrating) return false; const slot: Slot = { @@ -706,24 +697,5 @@ function driveKeyedFor( return true; } -/** MEASUREMENT-ONLY spike flag (never product): skip per-row owners - * entirely — rows run with the SLOT owner ambient, so row effects chain to - * the slot and individual row disposal is a no-op (removed rows leak their - * effect until slot teardown). Quantifies the per-row ownership tax - * (createOwner + runWithOwner + dispose) that a compiler-proven - * single-effect row contract would eliminate soundly. */ -export let __ownerlessRows = false; - -/** Arm the unified For driver (spike registration — pay-for-use: `insert` - * is in every bundle; the driver rides only apps that call this). */ -export function enableUnifiedFor(options?: { unsafeOwnerlessRows?: boolean }): void { - __ownerlessRows = options?.unsafeOwnerlessRows === true; - // Web hands the slot ITS platform: the domOps singleton. (Interim wiring — - // the module-graph landing passes ops at insert's engagement site instead.) - setListDriver((parent, listFn, marker, lateClassic) => - driveKeyedFor(parent, listFn, marker, lateClassic, domOps) - ); -} - -/** Spike test probes: engagement / demotion / batch-clear counters. */ +/** Test probes: engagement / demotion / batch-clear counters. */ export const __unifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 326b45a0f..292986ed7 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -94,6 +94,11 @@ export type { export * from "./client/component.js"; export * from "./client/flow.js"; +// Unified For slot: type surface for renderer integrators (web's insert +// passes its SlotOps) + the engagement/demotion test probe. The impl itself +// travels on `$for.impl` — not a user API. +export type { SlotOps } from "./client/for-slot.js"; +export { __unifiedForStats } from "./client/for-slot.js"; export type { ArrayElement, Element } from "./types.js"; export { sharedConfig, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 0bec743d2..c12cc909f 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -104,6 +104,10 @@ export * from "./component.js"; // Flow controls export * from "./flow.js"; +// Unified For slot surface, server parity: the slot is client-only (server +// For renders arrays directly), but isomorphic imports must resolve. +export type { SlotOps } from "../client/for-slot.js"; +export const __unifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; export type { ArrayElement, Element } from "../types.js"; // SSR coordination diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index d5a6f1322..d93766630 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -19,14 +19,31 @@ import { } from "solid-js"; import { effect, memo } from "./render.js"; -// Unified-For driver registration (pay-for-use: `insert` rides every bundle, -// the driver only rides apps that arm it — see for-driver.ts). -let listDriver: - | ((parent: Node, listFn: any, marker: Node | undefined, lateClassic: () => void) => boolean) - | undefined; -export function setListDriver(driver: typeof listDriver): void { - listDriver = driver; -} +// Unified-For engagement ops: the slot algorithm rides For's own module +// graph (solid-js client, `$for.impl`); web hands it THIS platform — one +// module-level singleton, so every op call site in the slot stays +// monomorphic. Apps without For tree-shake the slot; renderers that never +// check `$for` call the accessor and get classic mapArray. +const domOps = { + insert(parent: Node, node: Node, anchor: Node | null): void { + parent.insertBefore(node, anchor); + }, + remove(node: Node): void { + (node as ChildNode).remove(); + }, + createText(text: string): Node { + return document.createTextNode(text); + }, + isNode(v: unknown): boolean { + return v != null && (v as any).nodeType !== undefined; + }, + clear(parent: Node): void { + (parent as Element).textContent = ""; + }, + tag(node: Node, marker: Node): void { + (node as any)[$$SLOT] = marker; + } +}; import { JSX } from "../jsx/jsx.js"; @@ -913,25 +930,31 @@ export function insert(parent, accessor, marker, initial, options) { if (multi && !initial) initial = []; if (hydrationRt !== null) initial = hydrationRt.claimInitial(parent, multi, initial); // Unified-For seam (DESIGN-UNIFIED-FOR §4): a list value carrying the - // `$for` descriptor is offered to the registered keyed-list driver first. - // `false` declines to classic (the descriptor is also a callable — calling - // it IS the classic mapArray path). The lateClassic thunk serves ENGAGED - // lists that later leave the driver's contract: it re-enters this insert - // under the ORIGINAL owner with a bare accessor (no `$for` marker). - if (listDriver !== undefined && typeof accessor === "function" && accessor.$for !== undefined) { + // `$for` descriptor brings the slot impl WITH it (For's module graph); + // insert engages it by handing over web's domOps. `false` declines to + // classic (the descriptor is also a callable — calling it IS the classic + // mapArray path). The lateClassic thunk serves ENGAGED lists that later + // leave the slot's contract: it re-enters this insert under the ORIGINAL + // owner with a bare accessor (no `$for` marker). + if (typeof accessor === "function" && accessor.$for !== undefined) { const listAccessor = accessor; const owner = getOwner(); if ( - listDriver(parent, accessor, marker ?? undefined, () => - runWithOwner(owner, () => - insert( - parent, - () => listAccessor(), - marker, - marker !== undefined ? [] : undefined, - options - ) - ) + accessor.$for.impl( + parent, + accessor, + marker ?? undefined, + () => + runWithOwner(owner, () => + insert( + parent, + () => listAccessor(), + marker, + marker !== undefined ? [] : undefined, + options + ) + ), + domOps ) ) return; diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index a2c31ab01..e6257d2d5 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -31,7 +31,6 @@ import { import type { JSX } from "../jsx/jsx.js"; export * from "./client.js"; -export { enableUnifiedFor, __unifiedForStats } from "./for-driver.js"; // Pay-for-use: retained only when compiled patch-mode output imports export * from "./server-mock.js"; export * from "./response.js"; diff --git a/packages/web/test/for.unified.selection.probe.spec.tsx b/packages/web/test/for.unified.selection.probe.spec.tsx index 3cdf3a1bc..fb4bbca51 100644 --- a/packages/web/test/for.unified.selection.probe.spec.tsx +++ b/packages/web/test/for.unified.selection.probe.spec.tsx @@ -5,9 +5,7 @@ */ import { describe, expect, test } from "vitest"; import { createRoot, createStore, flush, For } from "solid-js"; -import { enableUnifiedFor } from "@solidjs/web"; - -enableUnifiedFor(); +// No arming: the slot rides For's module graph and engages by default. describe("unified For: selection map binding", () => { test("row class updates from a sibling store branch", () => { diff --git a/packages/web/test/for.unified.spec.tsx b/packages/web/test/for.unified.spec.tsx index 2adcb8b62..8a69499d3 100644 --- a/packages/web/test/for.unified.spec.tsx +++ b/packages/web/test/for.unified.spec.tsx @@ -14,13 +14,19 @@ * in flight, revert restores committed). */ import { beforeEach, describe, expect, test } from "vitest"; -import { createRoot, createSignal, createOptimisticStore, flush, For } from "solid-js"; +import { + createRoot, + createSignal, + createOptimisticStore, + flush, + For, + __unifiedForStats +} from "solid-js"; // IMPORTANT: the packaged specifier, NOT ../src — compiled JSX resolves -// `@solidjs/web` to dist (browser+development), and arming the driver on a -// second from-source instance would leave the compiled inserts classic. -import { insert, enableUnifiedFor, __unifiedForStats } from "@solidjs/web"; - -enableUnifiedFor(); +// `solid-js`/`@solidjs/web` to dist (browser+development); the stats probe +// must come from the SAME solid-js instance the compiled For runs on. No +// arming: the slot rides For's module graph and engages by default. +import { insert } from "@solidjs/web"; const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 0c66b3c20..2637d8763 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -390,7 +390,14 @@ module.exports = [ // reaches (asciiLowerCase, qualifierValue), which shifts esbuild's // identifier allocation over the same-length output — brotli layout // drift, 31 B. Ratcheted to the next 0.01 kB per this file's rule. - limit: "10.74 KB", + + // + // Unified For slot, default-on (2026-09-04): 10.73 -> 10.89 KB, measured + // at 10.884. The floor has NO For — this is the ENGAGEMENT SEAM only: + // insert's `$for.impl` call site plus the domOps singleton (the platform + // web hands the slot). The slot algorithm itself rides For's module + // graph in solid-js and tree-shakes out of For-less apps like this one. + limit: "10.89 KB", modifyEsbuildConfig }, { @@ -455,7 +462,16 @@ module.exports = [ // Patch-channel removal (2026-09-02): 17.72 -> 17.61 KB, measured at // 17.58. The channel is deleted from next — regions own value delivery, // the unified-For design owns structure — reclaiming the insert $ll seam and core emission bytes. - limit: "17.61 KB", + // + // Unified For slot, default-on (2026-09-04): 17.61 -> 19.76 KB, measured + // at 19.75. THE deliberate bill: this scenario renders , so it + // retains the slot (~2.1 KB) through For's own module graph — every + // keyed For gets chain+LIS structural updates and flat-mode mounts with + // zero API and zero compiler involvement (jfb-signal structural geomean + // 0.63, uibench 0.73, creates at parity — see DESIGN-UNIFIED-FOR.md). + // Hydration claiming declines to classic at runtime today; the bytes + // still ride for post-hydration mounts. + limit: "19.76 KB", modifyEsbuildConfig }, { @@ -552,7 +568,11 @@ module.exports = [ // rc.6 P1 store sweep (#3282/#3283/#3284): 26.37 -> 26.43 KB, measured // at 26.42 macOS / 26424 B Linux CI (the usual +4-7 B Linux delta) — // see the createStore note; this scenario retains all of it. - limit: "26.43 KB", + // + // Unified For slot, default-on (2026-09-04): 26.43 -> 28.64 KB — the + // slot bytes through For's module graph (see the hydrating no-stores + // note). + limit: "28.64 KB", modifyEsbuildConfig }, { @@ -598,7 +618,11 @@ module.exports = [ // the canonicalization split and hasWidthDescriptor to client.ts — // those bytes land here, and this scenario was not ratcheted with the // hydrating ones. Ratchet on next so the branch is green again. - limit: "13.01 KB", + + // Unified For slot, default-on (2026-09-04): 12.97 -> 15.20 KB, measured + // at 15.19 — the slot bytes through For's module graph (see the + // hydrating no-stores note). + limit: "15.20 KB", modifyEsbuildConfig }, { From 5cb96d2e01c4f7e5df0d7d6fbd23b32442928538 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 4 Sep 2026 21:46:15 -0700 Subject: [PATCH 09/20] =?UTF-8?q?fix(solid,web):=20unified=20For=20P0=20sw?= =?UTF-8?q?eep=20=E2=80=94=20ownership-safe=20bulk=20clears,=20empty-row?= =?UTF-8?q?=20placeholders,=20throw-safe=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External audit fixes: - P0: marker tri-state preserved through the seam (undefined = whole parent, null = trailing MULTI child) and every bulk-clear path gated on classic's ownsAllChildren ruling — preceding siblings and streamed foreign nodes survive clear/replace/batch-clear/demote (regression suite covers all four paths plus foreign-node survival). - Empty-rendering rows (null/boolean/empty) hold position with a placeholder text node instead of demoting — sibling DOM state (typed inputs) survives. - Row fns that throw mid-pass dispose the rows built so far (they chain to the persistent slot owner) before the error rides the boundary. - __unifiedForStats increments are IS_DEV-gated (frozen in prod). - Four spike-history changesets consolidated into one describing the shipped behavior. Co-authored-by: Cursor --- .changeset/unified-for-driver-spike.md | 6 - .changeset/unified-for-lazy-structure.md | 5 - .changeset/unified-for-module-graph.md | 6 - .changeset/unified-for-renderer-ops.md | 5 - .changeset/unified-for-slot.md | 8 + packages/solid/src/client/for-slot.ts | 169 ++++++++---- packages/web/src/client.ts | 7 +- .../web/test/for.unified.siblings.spec.tsx | 256 ++++++++++++++++++ scripts/size/.size-limit.js | 12 +- 9 files changed, 392 insertions(+), 82 deletions(-) delete mode 100644 .changeset/unified-for-driver-spike.md delete mode 100644 .changeset/unified-for-lazy-structure.md delete mode 100644 .changeset/unified-for-module-graph.md delete mode 100644 .changeset/unified-for-renderer-ops.md create mode 100644 .changeset/unified-for-slot.md create mode 100644 packages/web/test/for.unified.siblings.spec.tsx diff --git a/.changeset/unified-for-driver-spike.md b/.changeset/unified-for-driver-spike.md deleted file mode 100644 index 9071421e6..000000000 --- a/.changeset/unified-for-driver-spike.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"solid-js": patch -"@solidjs/web": patch ---- - -Unified For driver (spike): keyed `` returns a callable carrying a `$for` descriptor; an armed web renderer (`enableUnifiedFor()`) owns rows and DOM placement in one persistent structure — intrusive row chain + incremental key map, prefix/suffix/LIS update pass in an ordinary two-phase render effect — bypassing both mapArray and reconcileArrays for engaged lists. Declines (hydration, keyed fns, duplicate keys, dynamic top-level rows, non-array subjects) land on the classic path; late demotion re-enters classic under the original owner. Off unless armed. diff --git a/.changeset/unified-for-lazy-structure.md b/.changeset/unified-for-lazy-structure.md deleted file mode 100644 index 0a2d0e76b..000000000 --- a/.changeset/unified-for-lazy-structure.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@solidjs/web": patch ---- - -Unified For slot: lazy structure — first fills carry no Row objects, chain, or key map (parallel arrays, mapArray's mount economics); the structure materializes once, on the first partial structural op. Aligned lists, clears, and no-survivor replaces stay flat forever. Removes the slot's creation regression: armed jfb-signal run/runlots at classic parity with the structural wins retained (geomean 0.638 vs baseline, gates green). diff --git a/.changeset/unified-for-module-graph.md b/.changeset/unified-for-module-graph.md deleted file mode 100644 index 4f3001391..000000000 --- a/.changeset/unified-for-module-graph.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"solid-js": patch -"@solidjs/web": patch ---- - -Unified For ships default-on through For's own module graph: the slot algorithm (chain + LIS structural updates, flat-mode mounts) lives in solid-js client and travels on the `$for.impl` descriptor For stamps; web's insert engages it by handing over its SlotOps singleton. Zero user API, zero compiler involvement, exact pay-for-use — apps without For tree-shake the slot entirely (~2.1 KB in For-bearing bundles, +153 B engagement seam on the web floor). Renderers that ignore `$for` keep classic mapArray; universal adoption is passing its own ops. enableUnifiedFor and the registration seam are deleted. diff --git a/.changeset/unified-for-renderer-ops.md b/.changeset/unified-for-renderer-ops.md deleted file mode 100644 index 1d0180af9..000000000 --- a/.changeset/unified-for-renderer-ops.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@solidjs/web": patch ---- - -Unified For slot is renderer-agnostic: all platform touches ride a SlotOps interface (insert/remove/createText/isNode/clear/tag) handed to the slot by the engaging insert as one module-level singleton — monomorphic call sites, verified perf-neutral (interleaved A/B dead even on mount/tick/tick_partial). This is what lets the slot travel with For's module graph and gives universal renderers a direct adoption path. diff --git a/.changeset/unified-for-slot.md b/.changeset/unified-for-slot.md new file mode 100644 index 000000000..510465278 --- /dev/null +++ b/.changeset/unified-for-slot.md @@ -0,0 +1,8 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Unified For: keyed `` is driven by one persistent slot that owns both row bookkeeping and DOM placement — an intrusive row chain + incremental key map updated by a prefix/suffix/LIS pass inside an ordinary two-phase render effect — replacing the mapArray + reconcileArrays double pass. Structural operations (swap, reorder, insert, remove) run 1.2–7x faster across jfb and uibench; creation and clear stay at parity via flat-mode first fills (parallel arrays, structure materializes lazily on the first partial structural op). + +Default-on with zero new API and zero compiler involvement: the slot rides For's own module graph (`$for.impl`), web's insert engages it with its renderer-ops singleton, and apps without For tree-shake it entirely (~2.1 KB in For-bearing bundles). Bulk-clear fast paths honor classic's ownership rules — `null` markers (trailing child with preceding siblings) and streamed foreign nodes are never wiped. Empty-rendering rows hold their position with a placeholder instead of demoting. Declines to classic mapArray: hydration claiming (post-hydration mounts engage), key functions, duplicate keys, dynamic top-level rows, `fallback`, non-array subjects; post-engage contract exits demote to classic under the original owner. diff --git a/packages/solid/src/client/for-slot.ts b/packages/solid/src/client/for-slot.ts index b9e507e74..c11f8a001 100644 --- a/packages/solid/src/client/for-slot.ts +++ b/packages/solid/src/client/for-slot.ts @@ -49,6 +49,7 @@ import { runWithOwner } from "@solidjs/signals"; import { sharedConfig } from "./hydration.js"; +import { IS_DEV } from "./core.js"; // The two-phase render effect in web's `effect()` shape: transparent + sync. const transparentOptions = { transparent: true, sync: true } as const; @@ -137,7 +138,12 @@ interface Slot { size: number; map: Map; parent: Node; + /** Placement anchor: the end marker Node, or null (append at parent end). */ end: Node | null; + /** True ONLY for whole-parent inserts (marker === undefined). A `null` + * marker is classic MULTI mode — trailing child with preceding siblings — + * and must NEVER take a `textContent = ""` bulk path (P0). */ + whole: boolean; owner: RowOwner; flat: Flat | null; pending: Plan | FlatPlan | null; @@ -145,6 +151,29 @@ interface Slot { ops: SlotOps; } +/** Whole-parent bulk ops (`ops.clear`) are safe only when our window IS the + * parent's entire child list — classic's ownsAllChildren ruling: streaming + * appends foreign nodes (late-flushed s) that must survive a clear. */ +function ownsParent(slot: Slot): boolean { + if (!slot.whole) return false; + let first: Node | null = null; + let last: Node | null = null; + const f = slot.flat; + if (f !== null) { + const n = f.nodes.length; + if (n === 0) return false; + const n0 = f.nodes[0]; + first = Array.isArray(n0) ? n0[0] : n0; + const nl = f.nodes[n - 1]; + last = Array.isArray(nl) ? nl[nl.length - 1] : nl; + } else if (slot.head !== null) { + first = firstNode(slot.head); + const t = slot.tail!; + last = t.n !== null ? t.n : t.ns![t.ns!.length - 1]; + } else return false; + return slot.parent.firstChild === first && slot.parent.lastChild === last; +} + /** Pass generation counter (Row.g stamps). */ let gen = 0; @@ -211,8 +240,15 @@ function buildParts( return [o, ns]; } if (ops.isNode(v)) return [o, v as Node]; + // Empty-rendering rows (null/undefined/false/true/"" or an empty flatten) + // hold their position with an empty text node — classic's own multi-mode + // trick. Demoting here would tear down SIBLING rows' DOM state (focused + // inputs, scroll) to rebuild through classic; a placeholder is strictly + // better and keeps the row addressable for reorders. + if (v == null || typeof v === "boolean" || v === "" || (Array.isArray(v) && v.length === 0)) + return [o, ops.createText("")]; o.dispose(); - return null; // function / empty / unrenderable top level → classic + return null; // function top level (dynamic content) / unrenderable → classic } function buildRow(rowFn: (item: any) => any, item: any, ops: SlotOps): Row | null { @@ -321,7 +357,7 @@ type ComputeOut = Plan | FlatPlan | typeof IDENTICAL | typeof DEMOTE; export function unifiedForSlot( parent: Node, listFn: any, - marker: Node | undefined, + marker: Node | null | undefined, lateClassic: () => void, ops: SlotOps ): boolean { @@ -339,6 +375,7 @@ export function unifiedForSlot( map: new Map(), parent, end: marker ?? null, + whole: marker === undefined, // Slot owner under the INSERT context: rows inherit context through it, // survive compute reruns, and tear down automatically with the // component — cleanup needs no row walk. @@ -348,7 +385,7 @@ export function unifiedForSlot( dead: false, ops }; - __unifiedForStats.engaged++; + if (IS_DEV) __unifiedForStats.engaged++; const dropPending = (): void => { if (slot.pending !== null) { @@ -366,7 +403,7 @@ export function unifiedForSlot( const removeFlatDom = (): void => { const f = slot.flat!; - if (slot.end === null) ops.clear(slot.parent); + if (ownsParent(slot)) ops.clear(slot.parent); else for (let i = 0; i < f.nodes.length; i++) { const nd = f.nodes[i]; @@ -378,7 +415,7 @@ export function unifiedForSlot( const demote = (): void => { // Late-classic (contract carried from the patch-driver era): tear the // slot down whole, then re-enter classic insert under the ORIGINAL owner. - __unifiedForStats.demoted++; + if (IS_DEV) __unifiedForStats.demoted++; slot.dead = true; dropPending(); if (slot.flat !== null) { @@ -405,19 +442,27 @@ export function unifiedForSlot( const owners: RowOwner[] = new Array(len); const nodes: (Node | Node[])[] = new Array(len); let failed = false; - runWithOwner(slot.owner as any, () => { - for (let j = 0; j < len; j++) { - const parts = buildParts(meta.row, itemsSnap[j], ops); - if (parts === null) { - // Dispose what this pass created before demoting. - for (let d = 0; d < j; d++) owners[d].dispose(); - failed = true; - return; + try { + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < len; j++) { + const parts = buildParts(meta.row, itemsSnap[j], ops); + if (parts === null) { + // Dispose what this pass created before demoting. + for (let d = 0; d < j; d++) owners[d].dispose(); + failed = true; + return; + } + owners[j] = parts[0]; + nodes[j] = parts[1]; } - owners[j] = parts[0]; - nodes[j] = parts[1]; - } - }); + }); + } catch (e) { + // A row fn threw mid-pass: rows built so far chain to the PERSISTENT + // slot owner (by design — they survive compute reruns), so they'd leak + // until slot death. Dispose, then let the throw ride the boundary. + for (let d = 0; d < len; d++) owners[d]?.dispose(); + throw e; + } if (failed) return null; return { ff: 1, mode: "fill", items: itemsSnap, owners, nodes, len }; }; @@ -547,36 +592,48 @@ export function unifiedForSlot( const oldPos: number[] = new Array(width); let fresh = 0; let demoteFlag = false; - runWithOwner(slot.owner as any, () => { - for (let j = 0; j < width; j++) { - const item = midItems[j]; - const at = oldIndexOf.get(item); - if (at !== undefined) { - const row = oldMid[at]; - if (row.g === passGen) { - demoteFlag = true; // duplicate incoming key + try { + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < width; j++) { + const item = midItems[j]; + const at = oldIndexOf.get(item); + if (at !== undefined) { + const row = oldMid[at]; + if (row.g === passGen) { + demoteFlag = true; // duplicate incoming key + return; + } + row.g = passGen; + order[j] = row; + oldPos[j] = at; + } else if (slot.map.has(item)) { + // Same identity alive outside the middle window = duplicate key + // across the prefix/suffix boundary. Classic owns duplicates. + demoteFlag = true; return; + } else { + const built = buildRow(meta.row, item, ops); + if (built === null) { + demoteFlag = true; // dynamic row shape (function top level) + return; + } + fresh++; + order[j] = built; + oldPos[j] = -1; } - row.g = passGen; - order[j] = row; - oldPos[j] = at; - } else if (slot.map.has(item)) { - // Same identity alive outside the middle window = duplicate key - // across the prefix/suffix boundary. Classic owns duplicates. - demoteFlag = true; - return; - } else { - const built = buildRow(meta.row, item, ops); - if (built === null) { - demoteFlag = true; // dynamic/empty row shape - return; - } - fresh++; - order[j] = built; - oldPos[j] = -1; } + }); + } catch (e) { + // A row fn threw mid-pass: fresh rows chain to the PERSISTENT slot + // owner and would leak until slot death — dispose before the throw + // rides the boundary. (Reused rows stay live; their g-stamps are + // reset by the next pass's fresh passGen.) + for (let j = 0; j < width; j++) { + const r = order[j]; + if (r !== undefined && !r.live) r.o.dispose(); } - }); + throw e; + } if (demoteFlag) { // Partial build: dispose what this pass created before demoting. for (let j = 0; j < width; j++) { @@ -603,7 +660,7 @@ export function unifiedForSlot( for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); slot.flat = null; slot.size = 0; - __unifiedForStats.batchCleared++; + if (IS_DEV) __unifiedForStats.batchCleared++; return; } if (fp.mode === "replace") { @@ -633,10 +690,12 @@ export function unifiedForSlot( if (plan !== slot.pending) return; // superseded mid-flight slot.pending = null; const { order, removes, before, after } = plan; - // Batch clear (design §5.2): N→0 on a whole-parent slot is one + // Batch clear (design §5.2): N→0 on an OWNED whole-parent slot is one // `textContent = ''` + one bulk owner dispose — no per-row work. - if (plan.len === 0 && slot.end === null && before === null && after === null) { - __unifiedForStats.batchCleared++; + // ownsParent guards both the null-marker MULTI case (preceding + // siblings, P0) and foreign nodes streaming appended to our parent. + if (plan.len === 0 && before === null && after === null && ownsParent(slot)) { + if (IS_DEV) __unifiedForStats.batchCleared++; ops.clear(slot.parent); slot.owner.dispose(false); slot.map.clear(); @@ -644,16 +703,16 @@ export function unifiedForSlot( slot.size = 0; return; } - // Full replace (no survivors, whole-parent): bulk-detach the old rows - // with one textContent write, dispose them without per-node removes, - // and let the placement walk below append the fresh window. Covers the - // jfb `replace` / `runlots`-over-rows shapes. + // Full replace (no survivors, owned whole parent): bulk-detach the old + // rows with one textContent write, dispose them without per-node + // removes, and let the placement walk below append the fresh window. + // Covers the jfb `replace` / `runlots`-over-rows shapes. if ( - slot.end === null && before === null && after === null && removes.length === slot.size && - removes.length > 0 + removes.length > 0 && + ownsParent(slot) ) { ops.clear(slot.parent); for (let j = 0; j < removes.length; j++) { @@ -697,5 +756,7 @@ export function unifiedForSlot( return true; } -/** Test probes: engagement / demotion / batch-clear counters. */ +/** DEV-ONLY test probes: engagement / demotion / batch-clear counters. + * Increments are IS_DEV-gated — frozen at zero in prod bundles (the export + * itself is a few bytes; the double-underscore marks it non-API). */ export const __unifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index d93766630..e43c615f1 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -940,10 +940,15 @@ export function insert(parent, accessor, marker, initial, options) { const listAccessor = accessor; const owner = getOwner(); if ( + // Marker passes through UNTOUCHED: `undefined` = whole-parent insert, + // `null` = trailing child with preceding siblings (classic MULTI mode + // — the compiler emits it for `

{list}

`), Node = bounded + // hole. The slot's bulk-clear paths key off this distinction (P0: + // collapsing null→undefined wiped preceding siblings). accessor.$for.impl( parent, accessor, - marker ?? undefined, + marker, () => runWithOwner(owner, () => insert( diff --git a/packages/web/test/for.unified.siblings.spec.tsx b/packages/web/test/for.unified.siblings.spec.tsx new file mode 100644 index 000000000..bfe12e053 --- /dev/null +++ b/packages/web/test/for.unified.siblings.spec.tsx @@ -0,0 +1,256 @@ +/** + * @vitest-environment jsdom + * + * P0 regression suite (external audit, 2026-09-04): the slot's bulk-clear + * paths must never wipe nodes it doesn't own. + * + * 1. `marker = null` (the compiler's TRAILING-child shape — + * `

`) is classic MULTI mode, NOT whole-parent + * ownership: clear / no-survivor replace / chain batch-clear must remove + * only slot rows. + * 2. Even true whole-parent slots honor classic's ownsAllChildren ruling: + * foreign nodes appended to the parent (streaming's late s) survive + * bulk ops. + * 3. Empty-rendering rows (null) hold position with a placeholder text node + * instead of demoting — sibling rows keep their DOM state (typed inputs). + */ +import { beforeEach, describe, expect, test } from "vitest"; +// The packaged specifier, NOT ../src — compiled JSX resolves solid-js to +// dist; probes must share that instance. +import { createSignal, flush, For, __unifiedForStats } from "solid-js"; +import { render } from "@solidjs/web"; + +describe("unified For: preceding siblings survive bulk paths (P0)", () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); + }); + + test("clear (flat path) removes only slot rows", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+

Title

+ {item => {item}} +
+ ), + container + ); + expect(container.innerHTML).toBe("

Title

ab
"); + setList([]); + flush(); + expect(container.innerHTML).toBe("

Title

"); + }); + + test("no-survivor replace (flat path) removes only slot rows, appends in place", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+

Title

+ {item => {item}} +
+ ), + container + ); + setList(["x", "y"]); + flush(); + expect(container.innerHTML).toBe("

Title

xy
"); + }); + + test("clear after materialization (chain path) removes only slot rows", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + dispose = render( + () => ( +
+

Title

+ {item => {item}} +
+ ), + container + ); + // Partial structural op materializes the chain out of flat mode. + setList(["b", "a", "c"]); + flush(); + expect(container.innerHTML).toBe( + "

Title

bac
" + ); + setList([]); + flush(); + expect(container.innerHTML).toBe("

Title

"); + }); + + test("no-survivor replace after materialization removes only slot rows", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+

Title

+ {item => {item}} +
+ ), + container + ); + setList(["b", "a"]); + flush(); + setList(["x", "y"]); + flush(); + expect(container.innerHTML).toBe("

Title

xy
"); + }); + + test("demote (flat) with preceding sibling: classic rebuild keeps the sibling", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+

Title

+ + {(item: any) => (typeof item === "function" ? item : {item})} + +
+ ), + container + ); + // A row whose top level is a FUNCTION demotes to classic. + const before = __unifiedForStats.demoted; + setList(["a", () => dyn]); + flush(); + expect(__unifiedForStats.demoted).toBe(before + 1); + expect(container.querySelector("h1")).not.toBeNull(); + expect(container.querySelector("h1")!.textContent).toBe("Title"); + expect(container.querySelectorAll("span").length).toBe(1); + expect(container.querySelector("b")!.textContent).toBe("dyn"); + }); +}); + +describe("unified For: whole-parent ownership guard (foreign nodes survive)", () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); + }); + + // For as the SOLE child of a compiled element — the true whole-parent + // shape (`insert(_el$, comp)`, marker undefined). `render(() => )` + // wraps the accessor and runs classic; the compiled shape is the one that + // engages with whole-parent ownership. + test("streamed foreign node survives a flat clear", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+ {item => {item}} +
+ ), + container + ); + const section = container.querySelector("section")!; + // Streaming appends a foreign node (late-flushed ) to our parent. + const link = document.createElement("link"); + section.appendChild(link); + setList([]); + flush(); + expect(section.contains(link)).toBe(true); + expect(section.querySelectorAll("span").length).toBe(0); + }); + + test("streamed foreign node survives a chain batch clear", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + dispose = render( + () => ( +
+ {item => {item}} +
+ ), + container + ); + const section = container.querySelector("section")!; + setList(["b", "a", "c"]); // materialize the chain + flush(); + const link = document.createElement("link"); + section.appendChild(link); + setList([]); + flush(); + expect(section.contains(link)).toBe(true); + expect(section.querySelectorAll("span").length).toBe(0); + }); + + test("owned whole-parent clear still takes the bulk path", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + dispose = render( + () => ( +
+ {item => {item}} +
+ ), + container + ); + const section = container.querySelector("section")!; + setList(["c", "b", "a"]); // materialize + flush(); + const before = __unifiedForStats.batchCleared; + setList([]); + flush(); + expect(__unifiedForStats.batchCleared).toBe(before + 1); + expect(section.innerHTML).toBe(""); + }); +}); + +describe("unified For: empty-rendering rows hold position (no demote)", () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); + }); + + test("a null row arriving late does NOT demote — sibling input state survives", () => { + type R = { id: string; hidden?: boolean }; + const a: R = { id: "a" }; + const b: R = { id: "b" }; + const [list, setList] = createSignal([a, b]); + dispose = render( + () => {(it: R) => (it.hidden ? null : )}, + container + ); + const inputA = container.querySelector("input")!; + inputA.value = "typed"; + const before = __unifiedForStats.demoted; + setList([a, b, { id: "c", hidden: true }]); + flush(); + expect(__unifiedForStats.demoted).toBe(before); + expect(container.querySelector("input")).toBe(inputA); // same node + expect(inputA.value).toBe("typed"); // state intact + expect(container.querySelectorAll("input").length).toBe(2); + }); + + test("null rows participate in reorders and removals", () => { + type R = { id: string; hidden?: boolean }; + const a: R = { id: "a" }; + const gap: R = { id: "gap", hidden: true }; + const b: R = { id: "b" }; + const [list, setList] = createSignal([a, gap, b]); + dispose = render( + () => {(it: R) => (it.hidden ? null : {it.id})}, + container + ); + expect(container.querySelectorAll("span").length).toBe(2); + setList([b, gap, a]); + flush(); + const spans = [...container.querySelectorAll("span")].map(s => s.textContent); + expect(spans).toEqual(["b", "a"]); + setList([a, b]); + flush(); + expect([...container.querySelectorAll("span")].map(s => s.textContent)).toEqual(["a", "b"]); + }); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 2637d8763..9137fb516 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -397,7 +397,7 @@ module.exports = [ // insert's `$for.impl` call site plus the domOps singleton (the platform // web hands the slot). The slot algorithm itself rides For's module // graph in solid-js and tree-shakes out of For-less apps like this one. - limit: "10.89 KB", + limit: "10.90 KB", modifyEsbuildConfig }, { @@ -470,8 +470,10 @@ module.exports = [ // zero API and zero compiler involvement (jfb-signal structural geomean // 0.63, uibench 0.73, creates at parity — see DESIGN-UNIFIED-FOR.md). // Hydration claiming declines to classic at runtime today; the bytes - // still ride for post-hydration mounts. - limit: "19.76 KB", + // still ride for post-hydration mounts. P0 audit sweep (ownsParent + // guards on every bulk clear, empty-row placeholders, throw-safe + // builds) adds ~120 B here; siblings/foreign-node safety is the cost. + limit: "19.88 KB", modifyEsbuildConfig }, { @@ -572,7 +574,7 @@ module.exports = [ // Unified For slot, default-on (2026-09-04): 26.43 -> 28.64 KB — the // slot bytes through For's module graph (see the hydrating no-stores // note). - limit: "28.64 KB", + limit: "28.75 KB", modifyEsbuildConfig }, { @@ -622,7 +624,7 @@ module.exports = [ // Unified For slot, default-on (2026-09-04): 12.97 -> 15.20 KB, measured // at 15.19 — the slot bytes through For's module graph (see the // hydrating no-stores note). - limit: "15.20 KB", + limit: "15.29 KB", modifyEsbuildConfig }, { From 671bb9895b1fa8cf48ed0f24d886fc04f8c71a42 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 4 Sep 2026 21:51:47 -0700 Subject: [PATCH 10/20] =?UTF-8?q?test(web):=20reconcile=20parity=20matrix?= =?UTF-8?q?=20=E2=80=94=20slot=20vs=20live=20classic=20oracle=20across=20s?= =?UTF-8?q?hapes=20and=20anchors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic for.spec transition families plus jfb-style moves (31 transitions), run through BOTH implementations: the slot (arity-1 keyed rows) and forced classic (arity-2 rows decline the $for stamp — same semantics through keyed mapArray + reconcileArrays, a live oracle). Each mode covers three row shapes (text / element / static fragment) in three container anchors (whole parent / trailing null marker / bounded element marker), with engagement and zero-demotion asserted for the slot. A differential section renders both modes off one signal through a cumulative no-reset sequence and asserts DOM equality after every step. Co-authored-by: Cursor --- .../for.unified.reconcile-parity.spec.tsx | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 packages/web/test/for.unified.reconcile-parity.spec.tsx diff --git a/packages/web/test/for.unified.reconcile-parity.spec.tsx b/packages/web/test/for.unified.reconcile-parity.spec.tsx new file mode 100644 index 000000000..251cec60b --- /dev/null +++ b/packages/web/test/for.unified.reconcile-parity.spec.tsx @@ -0,0 +1,273 @@ +/** + * @vitest-environment jsdom + * + * RECONCILE PARITY MATRIX — the classic for.spec transition table (and then + * some), driven through BOTH implementations: + * + * slot — arity-1 keyed rows (default-on unified For) + * classic — arity-2 rows (`(item, _i) =>`): the index param declines the + * `$for` stamp pre-engage, so the SAME semantics run through + * keyed mapArray + reconcileArrays. Identical expected output — + * a live oracle, not a snapshot. + * + * Each mode runs the full matrix in three container shapes, because the P0 + * audit proved anchoring is where list bugs hide: + * whole — For is the sole child (marker undefined; bulk-clear paths) + * trailing— preceding sibling, For last (marker null; classic MULTI) + * bounded — siblings on both sides (marker = element node) + * + * And in three row shapes (text / element / static fragment — fragments + * exercise the multi-node ns rows). + * + * The differential section renders slot and classic off ONE signal and + * asserts DOM equality after every step of a cumulative no-reset sequence — + * state-to-state transitions, not just canonical-to-X. + */ +import { beforeEach, describe, expect, test } from "vitest"; +import { createSignal, flush, For, __unifiedForStats } from "solid-js"; +import { render } from "@solidjs/web"; + +type Shape = { + name: string; + row: (item: string) => any; + rowIdx: (item: string, i: any) => any; + html: (k: string) => string; +}; + +const SHAPES: Shape[] = [ + { + name: "text", + row: (item: string) => item, + rowIdx: (item: string, _i: any) => item, + html: k => k + }, + { + name: "element", + row: (item: string) => {item}, + rowIdx: (item: string, _i: any) => {item}, + html: k => `${k}` + }, + { + name: "fragment", + row: (item: string) => ( + <> + {item} + ! + + ), + rowIdx: (item: string, _i: any) => ( + <> + {item} + ! + + ), + html: k => `${k}!` + } +]; + +const CANON = ["a", "b", "c", "d", "e"]; + +// Canonical-to-X transition table: the for.spec families plus jfb-style +// moves, boundary inserts/removes, and compound displacements. +const TRANSITIONS: [string, string[]][] = [ + ["identity", ["a", "b", "c", "d", "e"]], + ["1 missing head", ["b", "c", "d", "e"]], + ["1 missing mid", ["a", "b", "d", "e"]], + ["1 missing tail", ["a", "b", "c", "d"]], + ["2 missing ends", ["b", "c", "d"]], + ["2 missing mid", ["a", "c", "e"]], + ["3 missing", ["a", "e"]], + ["single survivor head", ["a"]], + ["single survivor mid", ["c"]], + ["single survivor tail", ["e"]], + ["all missing", []], + ["swap adjacent", ["b", "a", "c", "d", "e"]], + ["swap ends", ["e", "b", "c", "d", "a"]], + ["swap inner", ["a", "d", "c", "b", "e"]], + ["rotate forward", ["b", "c", "d", "e", "a"]], + ["rotate backward", ["e", "a", "b", "c", "d"]], + ["reversal", ["e", "d", "c", "b", "a"]], + ["full replace", ["f", "g", "h", "i", "j"]], + ["partial replace overlap", ["a", "x", "c", "y", "e"]], + ["prepend", ["x", "a", "b", "c", "d", "e"]], + ["append", ["a", "b", "c", "d", "e", "x"]], + ["insert middle", ["a", "b", "x", "c", "d", "e"]], + ["insert both ends", ["x", "a", "b", "c", "d", "e", "y"]], + ["remove+insert mixed", ["x", "b", "d", "y"]], + ["move first to last", ["b", "c", "d", "e", "a"]], + ["move last to first", ["e", "a", "b", "c", "d"]], + ["displace 3 forward", ["b", "c", "d", "a", "e"]], + ["shuffle fixed", ["c", "a", "e", "b", "d"]], + ["grow from subset", ["a", "b", "c", "d", "e", "f", "g"]], + ["interleave new", ["a", "x", "b", "y", "c", "z"]] +]; + +type Container = { + name: string; + mount: (list: () => string[], row: any) => [HTMLElement, () => void]; + wrap: (rows: string) => string; +}; + +function makeContainers(useIdx: boolean, shape: Shape): Container[] { + const rowFn: any = useIdx ? shape.rowIdx : shape.row; + return [ + { + name: "whole", + mount: (list, _row) => { + const host = document.createElement("div"); + const dispose = render( + () => ( +
+ {rowFn} +
+ ), + host + ); + return [host.querySelector("section")!, dispose]; + }, + wrap: rows => rows + }, + { + name: "trailing (null marker)", + mount: (list, _row) => { + const host = document.createElement("div"); + const dispose = render( + () => ( +
+ pre + {rowFn} +
+ ), + host + ); + return [host.querySelector("section")!, dispose]; + }, + wrap: rows => `pre${rows}` + }, + { + name: "bounded (element marker)", + mount: (list, _row) => { + const host = document.createElement("div"); + const dispose = render( + () => ( +
+ pre + {rowFn} + post +
+ ), + host + ); + return [host.querySelector("section")!, dispose]; + }, + wrap: rows => `pre${rows}post` + } + ]; +} + +for (const mode of ["slot", "classic"] as const) { + const useIdx = mode === "classic"; + for (const shape of SHAPES) { + describe(`reconcile parity [${mode}] [${shape.name} rows]`, () => { + for (const container of makeContainers(useIdx, shape)) { + test(`${container.name}: full transition matrix`, () => { + const [list, setList] = createSignal(CANON); + const engagedBefore = __unifiedForStats.engaged; + const demotedBefore = __unifiedForStats.demoted; + const [el, dispose] = container.mount(list, null); + try { + // Mode sanity: slot engages exactly once, classic never. + if (mode === "slot") { + expect(__unifiedForStats.engaged).toBe(engagedBefore + 1); + } else { + expect(__unifiedForStats.engaged).toBe(engagedBefore); + } + const expected = (arr: string[]) => container.wrap(arr.map(shape.html).join("")); + expect(el.innerHTML).toBe(expected(CANON)); + for (const [label, target] of TRANSITIONS) { + setList(target); + flush(); + expect(el.innerHTML, `${label} (forward)`).toBe(expected(target)); + setList(CANON); + flush(); + expect(el.innerHTML, `${label} (reset)`).toBe(expected(CANON)); + } + // The whole matrix must run WITHOUT falling back to classic. + if (mode === "slot") { + expect(__unifiedForStats.demoted).toBe(demotedBefore); + } + } finally { + dispose(); + } + }); + } + }); + } +} + +describe("reconcile parity: differential (slot vs classic, one signal, no resets)", () => { + // Cumulative state-to-state sequence — every step diffs against the + // PREVIOUS state, so this covers transitions the canonical matrix cannot. + const SEQUENCE: string[][] = [ + ["a", "b", "c", "d", "e"], + ["e", "d", "c", "b", "a"], // reversal + ["e", "c", "a"], // remove evens (of reversed) + ["x", "e", "c", "a", "y"], // grow both ends + ["y", "x", "e", "c", "a"], // rotate + ["a", "c", "e", "x", "y"], // reversal again + [], // clear + ["m", "n"], // refill small + ["n", "m"], // swap pair + ["n", "q", "m"], // insert middle + ["q"], // collapse to middle survivor + ["q", "r", "s", "t", "u", "v", "w"], // grow long + ["w", "q", "s", "u", "t", "r", "v"], // shuffle + ["v", "w"], // heavy shrink, tail survivors + ["f", "g", "h"], // full replace + ["h", "g", "f"], // reverse the replacement + ["a", "b", "c", "d", "e"] // back to canon + ]; + + for (const shape of SHAPES) { + test(`${shape.name} rows: DOM identical after every step`, () => { + const [list, setList] = createSignal(SEQUENCE[0]); + const slotHost = document.createElement("div"); + const classicHost = document.createElement("div"); + const rowSlot: any = shape.row; + const rowClassic: any = shape.rowIdx; + const disposeSlot = render( + () => ( +
+ pre + {rowSlot} + post +
+ ), + slotHost + ); + const disposeClassic = render( + () => ( +
+ pre + {rowClassic} + post +
+ ), + classicHost + ); + try { + expect(slotHost.innerHTML).toBe(classicHost.innerHTML); + for (let i = 1; i < SEQUENCE.length; i++) { + setList(SEQUENCE[i]); + flush(); + expect(slotHost.innerHTML, `step ${i}: ${SEQUENCE[i].join(",") || "(empty)"}`).toBe( + classicHost.innerHTML + ); + } + } finally { + disposeSlot(); + disposeClassic(); + } + }); + } +}); From a7ed39dda2afed54cee6d83685542ade8cbc53ab Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 00:23:07 -0700 Subject: [PATCH 11/20] =?UTF-8?q?feat(solid,web,signals):=20unified=20For?= =?UTF-8?q?=20hydration=20=E2=80=94=20slot=20claims=20server=20rows=20with?= =?UTF-8?q?=20id=20parity,=20reversible=20mid-fill=20demote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whole-parent keyed lists now ENGAGE during hydration instead of running classic for life: - Id parity: For peeks the id classic's mapArray owner would spend (sharedConfig.peekNextContextId, installed by enableHydration) and the slot creates its row parent with that explicit id — rows mint identical hydration keys. Proven against real server artifacts (for-then-siblings). - mapArray gains an @internal lazy option: For sets it under hydration so the eager classic pass no longer claims rows first; the owner (id slot) is still created eagerly (#3161 preserved). Classic readers claim on first read with identical ids. - Claims are RECORDED during the hydrating fill (registry delete shadowed); a demote mid-fill hands them back so classic's re-run claims the same nodes — never a stranded claim. - Fill commit is a claim pass: zero DOM writes unless mismatch (leftover server rows removed, key-missed fresh rows inserted in order). - All hydration behavior lives in for-slot-hydration.ts, installed by enableHydration(): CSR bundles shake it (CSR 15.68 -> 15.48 KB; floor 10.89 -> 10.85 after dropping For's direct id-formatter import). Tests: 8 server->client hydration scenarios via the real harness (basic reorder with server-node identity, text rows, mismatch both directions with exact warning counts, demote mid-fill with zero warnings, empty, trailing hole staying classic, nested engagement). web 728 / server 749 / hydrate 173 / solid 585 / signals 1490 / universal 43. Co-authored-by: Cursor --- packages/signals/src/map.ts | 18 +- packages/solid/src/client/flow.ts | 22 +- .../solid/src/client/for-slot-hydration.ts | 104 +++++++ packages/solid/src/client/for-slot.ts | 92 ++++++- packages/solid/src/client/hydration.ts | 13 + packages/web/src/client.ts | 8 +- .../reactive-ref-lone-spread-id-parity.json | 2 +- .../__artifacts__/slot-hydrate-basic.json | 5 + .../slot-hydrate-demote-mid-fill.json | 5 + .../__artifacts__/slot-hydrate-empty.json | 5 + .../slot-hydrate-mismatch-fewer.json | 5 + .../slot-hydrate-mismatch-more.json | 5 + .../__artifacts__/slot-hydrate-nested.json | 5 + .../__artifacts__/slot-hydrate-text-rows.json | 5 + .../slot-hydrate-trailing-classic.json | 5 + .../web/test/harness/for-slot-scenarios.tsx | 260 ++++++++++++++++++ packages/web/test/hydration/for-slot.spec.tsx | 120 ++++++++ .../test/server/hydration-harness.spec.tsx | 26 ++ scripts/size/.size-limit.js | 21 +- 19 files changed, 704 insertions(+), 22 deletions(-) create mode 100644 packages/solid/src/client/for-slot-hydration.ts create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-basic.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-demote-mid-fill.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-empty.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-fewer.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-more.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-nested.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-text-rows.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-trailing-classic.json create mode 100644 packages/web/test/harness/for-slot-scenarios.tsx create mode 100644 packages/web/test/hydration/for-slot.spec.tsx diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index 255489fb0..eedd612c9 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -67,7 +67,17 @@ export function mapArray( | ((value: Item, index: Accessor) => MappedItem) | ((value: Accessor, index: number) => MappedItem) | ((value: Accessor, index: Accessor) => MappedItem), - options?: { keyed?: boolean | ((item: Item) => any); fallback?: Accessor; name?: string } + options?: { + keyed?: boolean | ((item: Item) => any); + fallback?: Accessor; + name?: string; + /** @internal solid-js For, hydration only: create the internal owner NOW — + * it spends the list's hydration id slot at For's source position — but + * defer the first mapping pass to the first read. Lets a renderer that + * engages the unified For slot claim the server rows itself; the classic + * fallback still claims them on first read, minting identical ids. */ + lazy?: boolean; + } ): Accessor { const keyFn = typeof options?.keyed === "function" ? options.keyed : undefined; const indexes = map.length > 1; @@ -96,7 +106,10 @@ export function mapArray( _byIndex: options?.keyed === false, _fallback: options?.fallback }; - const node = computed(updateKeyedMap.bind(data as MapData)); + const node = computed( + updateKeyedMap.bind(data as MapData), + options?.lazy ? LAZY_OPTIONS : undefined + ); // Untracked reads inside the internal owner resolve via _parentComputed; routing // them through node lets store-proxy lookups see pending writes (not stale _value). data._owner._parentComputed = node; @@ -105,6 +118,7 @@ export function mapArray( } const pureOptions = { ownedWrite: true }; +const LAZY_OPTIONS = { lazy: true } as const; // Exception safety (#2903): a map callback can throw NotReadyError mid-pass // (async read), and the computed re-runs the whole pass after settle. Every // pass therefore STAGES its work — new rows are created into temp arrays and diff --git a/packages/solid/src/client/flow.ts b/packages/solid/src/client/flow.ts index 626b81eb4..1af851b6b 100644 --- a/packages/solid/src/client/flow.ts +++ b/packages/solid/src/client/flow.ts @@ -104,7 +104,23 @@ export function For(props: { // the list (the siblings hydrated detached: dead buttons). Outside // hydration the laziness stands: an unread list never builds its // mapArray at all. - if (sharedConfig.hydrating) mapped = create(); + // Unified-For id parity: the slot's rows must mint the SAME hydration ids + // classic's would. Classic rows hang under mapArray's internal owner, which + // is the next child of For's owner at creation — peek that id BEFORE the + // eager create() consumes it, and hand it to the slot (`$for.hid`) so its + // row parent can be created with the identical explicit id. + let hid: string | undefined; + if (sharedConfig.hydrating) { + // Installed by enableHydration() (CSR bundles never carry the id peek). + hid = sharedConfig.peekNextContextId?.(); + // Lazy pass: the map's owner still spends the id slot HERE (the #3161 + // fix), but the first mapping pass waits for the first read — so when + // the renderer engages the slot, the slot's rows claim the server nodes + // instead of an eager classic pass claiming them first. Classic readers + // (universal, declines) still claim on first read with identical ids. + (options as any).lazy = true; + mapped = create(); + } const list = () => (mapped ?? (mapped = create()))(); // Unified-For seam (DESIGN-UNIFIED-FOR §4): the returned value IS a data // structure — a callable carrying the list descriptor. A renderer that @@ -121,7 +137,9 @@ export function For(props: { keyed: props.keyed, // The slot rides For's OWN module graph: apps without For tree-shake // it; a renderer's insert() engages it by passing its SlotOps. - impl: unifiedForSlot + impl: unifiedForSlot, + // Hydration only: the id classic's row parent would carry. + hid }; return list as unknown as SolidElement; } diff --git a/packages/solid/src/client/for-slot-hydration.ts b/packages/solid/src/client/for-slot-hydration.ts new file mode 100644 index 000000000..ca438b64b --- /dev/null +++ b/packages/solid/src/client/for-slot-hydration.ts @@ -0,0 +1,104 @@ +/** + * Unified For — HYDRATION hooks (H2 v1). Installed by enableHydration(); CSR + * bundles never import this module, so the slot's null-guarded hook calls + * fold away (#2883's pay-for-hydration discipline). + * + * Contract: engage only whole-parent lists carrying an id-parity handle + * (`$for.hid`) and a region snapshot. Row templates then CLAIM server nodes + * exactly as classic's would — the slot's row parent takes the SAME id + * classic's mapArray owner spends, so rows mint identical hydration keys. + * Claims are RECORDED so a demote mid-fill hands them back: classic's re-run + * mints the same ids and claims the same nodes (never a stranded claim). + * The fill commit mutates only on MISMATCH (leftover server rows removed, + * key-missed fresh rows inserted); the normal case is zero DOM writes. + * Anchored holes (null/element markers) stay classic under hydration. + */ +import { sharedConfig } from "./hydration.js"; +import { installSlotHydration, type FlatPlan, type Slot } from "./for-slot.js"; + +const hooks = { + engage( + meta: any, + marker: Node | null | undefined, + region: Node[] | undefined + ): { id: string } | null | false { + if (!sharedConfig.hydrating) return false; + if (marker !== undefined || meta.hid === undefined || region === undefined) return null; + return { id: meta.hid }; + }, + + record(slot: Slot, fn: () => T): T { + // Shadow the registry's `delete` for the duration of the build so every + // key the row templates consume is logged (with its node). + const reg = sharedConfig.registry as Map | undefined; + if (!reg) return fn(); + const log: [string, Element][] = (slot.hydLog ??= []); + const proto = Map.prototype.delete; + (reg as any).delete = function (this: Map, key: string): boolean { + const node = this.get(key); + if (node !== undefined) log.push([key, node]); + return proto.call(this, key); + }; + try { + return fn(); + } finally { + delete (reg as any).delete; // back to the prototype method + } + }, + + restore(slot: Slot): void { + // Nothing was placed (compute never writes DOM); only registry keys were + // consumed. Hand them all back, and un-complete the nodes. + slot.hyd = false; + const reg = sharedConfig.registry as Map | undefined; + const log = slot.hydLog; + if (reg && log !== null) { + for (let i = 0; i < log.length; i++) { + reg.set(log[i][0], log[i][1]); + (sharedConfig as any).completed?.delete(log[i][1]); + } + } + slot.hydLog = null; + }, + + commitFill(slot: Slot, fp: FlatPlan): void { + // Past this point the slot owns the rows for good: drop the claim log. + slot.hyd = false; + slot.hydLog = null; + const ops = slot.ops; + const ours = new Set(); + for (let i = 0; i < fp.nodes.length; i++) { + const nd = fp.nodes[i]; + if (Array.isArray(nd)) for (const n of nd) ours.add(n); + else ours.add(nd); + } + // Leftovers: server rows the client no longer has, separator comments. + const region = slot.region!; + for (let i = 0; i < region.length; i++) + if (!ours.has(region[i]) && ops.contains(slot.parent, region[i])) ops.remove(region[i]); + // Fresh rows (template key-missed → detached; the runtime already + // warned) are inserted at their position, back to front so anchors are + // always attached. Whole-parent: the list ends at the parent's end. + let anchor: Node | null = null; + for (let i = fp.nodes.length - 1; i >= 0; i--) { + const nd = fp.nodes[i]; + if (Array.isArray(nd)) { + for (let k = nd.length - 1; k >= 0; k--) { + if (!ops.contains(slot.parent, nd[k])) ops.insert(slot.parent, nd[k], anchor); + anchor = nd[k]; + } + } else { + if (!ops.contains(slot.parent, nd)) ops.insert(slot.parent, nd, anchor); + anchor = nd; + } + } + slot.region = undefined; + slot.flat = { items: fp.items, owners: fp.owners, nodes: fp.nodes }; + slot.size = fp.len; + } +}; + +/** Called by enableHydration(). */ +export function installForSlotHydration(): void { + installSlotHydration(hooks); +} diff --git a/packages/solid/src/client/for-slot.ts b/packages/solid/src/client/for-slot.ts index c11f8a001..817207c5d 100644 --- a/packages/solid/src/client/for-slot.ts +++ b/packages/solid/src/client/for-slot.ts @@ -48,9 +48,31 @@ import { onCleanup, runWithOwner } from "@solidjs/signals"; -import { sharedConfig } from "./hydration.js"; import { IS_DEV } from "./core.js"; +/** HYDRATION HOOKS — installed by enableHydration() (for-slot-hydration.ts), + * null in CSR bundles so every hydration path here folds away (#2883's + * discipline: pay for hydration only when you hydrate). */ +export interface SlotHydration { + /** Engage-time decision: `false` = not hydrating (normal engage); `null` = + * decline to classic; `{ id }` = hydrating engage with the parity owner id. */ + engage( + meta: any, + marker: Node | null | undefined, + region: Node[] | undefined + ): { id: string } | null | false; + /** Run a build pass recording the registry keys its templates consume. */ + record(slot: Slot, fn: () => T): T; + /** Demote mid-fill: hand recorded claims back for classic's re-run. */ + restore(slot: Slot): void; + /** Hydrating fill commit: reconcile claimed rows against the region. */ + commitFill(slot: Slot, fp: FlatPlan): void; +} +let slotHydration: SlotHydration | null = null; +export function installSlotHydration(h: SlotHydration): void { + slotHydration = h; +} + // The two-phase render effect in web's `effect()` shape: transparent + sync. const transparentOptions = { transparent: true, sync: true } as const; function effect(fn: (prev?: T) => T, effectFn: (value: T, prev?: T) => void): void { @@ -59,7 +81,7 @@ function effect(fn: (prev?: T) => T, effectFn: (value: T, prev?: T) => void): type RowOwner = { dispose(self?: boolean): void }; -interface Row { +export interface Row { /** Row key — the item reference itself (identity mode only in the spike). */ k: any; /** Row owner (context carrier + disposer). */ @@ -99,14 +121,14 @@ interface Plan { * own mount economics). The structure MATERIALIZES once, lazily, on the * first PARTIAL structural op (the moment the chain/LIS wins start paying); * aligned ticks, clears, and no-survivor full replaces stay flat forever. */ -interface Flat { +export interface Flat { /** Committed item snapshot (identity keys). */ items: any[]; owners: RowOwner[]; nodes: (Node | Node[])[]; } -interface FlatPlan { +export interface FlatPlan { ff: 1; mode: "fill" | "replace" | "clear"; items: any[]; @@ -130,9 +152,11 @@ export interface SlotOps { clear(parent: Node): void; /** Ownership marker for multi-slot parents (web: `$$SLOT`). */ tag(node: Node, marker: Node): void; + /** True when `node` is a direct child of `parent` (hydration fix-up). */ + contains(parent: Node, node: Node): boolean; } -interface Slot { +export interface Slot { head: Row | null; tail: Row | null; size: number; @@ -149,6 +173,15 @@ interface Slot { pending: Plan | FlatPlan | null; dead: boolean; ops: SlotOps; + /** HYDRATING FILL in progress (engaged during hydration; cleared by the + * first commit). While set: row templates CLAIM server nodes, registry + * deletions are recorded so a demote can hand them back, and the commit + * reconciles claimed rows against the region instead of placing. */ + hyd: boolean; + /** Registry entries consumed during the hydrating fill (key, node). */ + hydLog: [string, Element][] | null; + /** Hydration: the claimed region snapshot (whole-parent childNodes). */ + region: Node[] | undefined; } /** Whole-parent bulk ops (`ops.clear`) are safe only when our window IS the @@ -359,14 +392,26 @@ export function unifiedForSlot( listFn: any, marker: Node | null | undefined, lateClassic: () => void, - ops: SlotOps + ops: SlotOps, + region?: Node[] ): boolean { const meta = listFn.$for; // H4 pin: keyed-fn rows receive accessors in the classic contract — the // slot binds raw items, so engaging would hand user code the wrong shape. if (typeof meta.keyed === "function") return false; - // Hydration claiming is future work (design §6 H2): decline to classic. - if (sharedConfig.hydrating) return false; + // HYDRATION (H2): decided by the installed hooks (null in CSR bundles — + // the branch folds away). A hydrating engage hands back the parity owner + // id so the slot's rows mint the same hydration keys classic's would. + let ownerOpts: { id: string } | undefined; + let hyd = false; + if (slotHydration !== null) { + const h = slotHydration.engage(meta, marker, region); + if (h === null) return false; + if (h !== false) { + ownerOpts = h; + hyd = true; + } + } const slot: Slot = { head: null, @@ -378,12 +423,16 @@ export function unifiedForSlot( whole: marker === undefined, // Slot owner under the INSERT context: rows inherit context through it, // survive compute reruns, and tear down automatically with the - // component — cleanup needs no row walk. - owner: createOwner() as unknown as RowOwner, + // component — cleanup needs no row walk. Under hydration it takes the + // explicit parity id (no consumption of the insert owner's counter). + owner: createOwner(ownerOpts) as unknown as RowOwner, flat: null, pending: null, dead: false, - ops + ops, + hyd, + hydLog: null, + region }; if (IS_DEV) __unifiedForStats.engaged++; @@ -418,6 +467,9 @@ export function unifiedForSlot( if (IS_DEV) __unifiedForStats.demoted++; slot.dead = true; dropPending(); + // Demote DURING a hydrating fill: hand recorded claims back so classic's + // re-run (same parity ids) claims the same server nodes. + if (slot.hyd) slotHydration!.restore(slot); if (slot.flat !== null) { removeFlatDom(); const f = slot.flat; @@ -442,7 +494,7 @@ export function unifiedForSlot( const owners: RowOwner[] = new Array(len); const nodes: (Node | Node[])[] = new Array(len); let failed = false; - try { + const build = (): void => { runWithOwner(slot.owner as any, () => { for (let j = 0; j < len; j++) { const parts = buildParts(meta.row, itemsSnap[j], ops); @@ -456,6 +508,12 @@ export function unifiedForSlot( nodes[j] = parts[1]; } }); + }; + try { + // Hydrating fill: row templates claim server nodes; record the claims + // so a demote can hand them back to classic's re-run. + if (slot.hyd) slotHydration!.record(slot, build); + else build(); } catch (e) { // A row fn threw mid-pass: rows built so far chain to the PERSISTENT // slot owner (by design — they survive compute reruns), so they'd leak @@ -536,7 +594,12 @@ export function unifiedForSlot( // ── FLAT FILL: an empty slot fills with arrays only (mapArray's mount // economics — no Rows, no chain, no map). if (slot.head === null && slot.size === 0) { - if (len === 0) return IDENTICAL; + if (len === 0) { + if (!slot.hyd) return IDENTICAL; + // Empty hydrating fill still commits: clears the hydration state + // and removes any server rows the client no longer has. + return (slot.pending = { ff: 1, mode: "fill", items: [], owners: [], nodes: [], len: 0 }); + } const snap: any[] = new Array(len); for (let j = 0; j < len; j++) snap[j] = arr[j]; const plan = buildFlat(snap); @@ -668,6 +731,9 @@ export function unifiedForSlot( const f = slot.flat!; for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); } + // Hydrating fill: a claim pass, not a placement pass — the hooks + // reconcile claimed rows against the region (mismatch only). + if (slot.hyd) return slotHydration!.commitFill(slot, fp); // fill / replace: append the new window before the end anchor. const tag = slot.end; for (let i = 0; i < fp.nodes.length; i++) { diff --git a/packages/solid/src/client/hydration.ts b/packages/solid/src/client/hydration.ts index 6d238d4ef..9350a5c96 100644 --- a/packages/solid/src/client/hydration.ts +++ b/packages/solid/src/client/hydration.ts @@ -44,6 +44,7 @@ import { } from "@solidjs/signals"; import type { Element as SolidElement } from "../types.js"; import { IS_DEV } from "./core.js"; +import { installForSlotHydration } from "./for-slot-hydration.js"; type HydrationSsrFields = { /** @@ -148,6 +149,9 @@ type SharedConfig = { // Assigned by enableHydration(); callers only reach it behind a // `sharedConfig.hydrating` check, which can never be true before that. getNextContextId?: () => string; + /** Peek the NEXT context id without consuming it (unified For's id-parity + * handle). Assigned by enableHydration(), same gating as getNextContextId. */ + peekNextContextId?: () => string | undefined; /** * Whether a hydration pass is still claiming server-rendered DOM — true * from hydrate()'s synchronous walk until every streamed boundary has @@ -197,6 +201,11 @@ function hydrationGetNextContextId(): string { if (getContext(NoHydrateContext)) return undefined as unknown as string; return getNextChildId(o); } +function hydrationPeekNextContextId(): string | undefined { + const o = getOwner(); + if (!o || o.id == null || getContext(NoHydrateContext)) return undefined; + return peekNextChildId(o); +} // === Hydration phase API === @@ -1273,6 +1282,10 @@ export function enableHydration() { _createLoadingBoundary = hydratedCreateLoadingBoundary; _lazyHydrationLookup = lazyHydrationLookup; sharedConfig.getNextContextId = hydrationGetNextContextId; + sharedConfig.peekNextContextId = hydrationPeekNextContextId; + // Unified For: the slot's hydration hooks (claim recording, reversible + // demote, mismatch fix-up) install here so CSR bundles shake them. + installForSlotHydration(); // Installed here rather than in the sharedConfig literal so CSR bundles // shake the hydration-phase bookkeeping these close over. Consumers treat // absence as "not hydrating": the refresh runtime optional-chains diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index e43c615f1..1919bcca2 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -42,6 +42,9 @@ const domOps = { }, tag(node: Node, marker: Node): void { (node as any)[$$SLOT] = marker; + }, + contains(parent: Node, node: Node): boolean { + return node.parentNode === parent; } }; @@ -959,7 +962,10 @@ export function insert(parent, accessor, marker, initial, options) { options ) ), - domOps + domOps, + // Hydration: the claimed region snapshot (claimInitial ran above) — + // the slot's fill reconciles claimed rows against it (whole-parent). + hydrationRt !== null && Array.isArray(initial) ? initial : undefined ) ) return; diff --git a/packages/web/test/harness/__artifacts__/reactive-ref-lone-spread-id-parity.json b/packages/web/test/harness/__artifacts__/reactive-ref-lone-spread-id-parity.json index 72add67c0..2528f2881 100644 --- a/packages/web/test/harness/__artifacts__/reactive-ref-lone-spread-id-parity.json +++ b/packages/web/test/harness/__artifacts__/reactive-ref-lone-spread-id-parity.json @@ -2,4 +2,4 @@ "name": "reactive-ref-lone-spread-id-parity", "shell": "
spread
", "rest": "" -} +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-basic.json b/packages/web/test/harness/__artifacts__/slot-hydrate-basic.json new file mode 100644 index 000000000..af23e82ec --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-basic.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-basic", + "shell": "
  • a
  • b
  • c
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-demote-mid-fill.json b/packages/web/test/harness/__artifacts__/slot-hydrate-demote-mid-fill.json new file mode 100644 index 000000000..e8f7a76d3 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-demote-mid-fill.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-demote-mid-fill", + "shell": "
  • a
  • b
  • c
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-empty.json b/packages/web/test/harness/__artifacts__/slot-hydrate-empty.json new file mode 100644 index 000000000..54e25d9bf --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-empty.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-empty", + "shell": "
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-fewer.json b/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-fewer.json new file mode 100644 index 000000000..7ac2b1846 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-fewer.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-mismatch-fewer", + "shell": "
    • a
    • b
    • c
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-more.json b/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-more.json new file mode 100644 index 000000000..7b7ed67db --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-more.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-mismatch-more", + "shell": "
    • a
    • b
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-nested.json b/packages/web/test/harness/__artifacts__/slot-hydrate-nested.json new file mode 100644 index 000000000..a749d343d --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-nested.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-nested", + "shell": "
      • 12
      • 3
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-text-rows.json b/packages/web/test/harness/__artifacts__/slot-hydrate-text-rows.json new file mode 100644 index 000000000..499a918fb --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-text-rows.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-text-rows", + "shell": "
      abc
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-trailing-classic.json b/packages/web/test/harness/__artifacts__/slot-hydrate-trailing-classic.json new file mode 100644 index 000000000..000ddc79a --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-trailing-classic.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-trailing-classic", + "shell": "
    • head
    • a
    • b
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/for-slot-scenarios.tsx b/packages/web/test/harness/for-slot-scenarios.tsx new file mode 100644 index 000000000..398f40134 --- /dev/null +++ b/packages/web/test/harness/for-slot-scenarios.tsx @@ -0,0 +1,260 @@ +/** + * @jsxImportSource @solidjs/web + * + * Unified For — HYDRATION scenarios (H2 v1). Rendered by the server harness + * (test/server/hydration-harness.spec.tsx → __artifacts__) and hydrated by + * test/hydration/for-slot.spec.tsx, which asserts slot-specific invariants + * on top of the generic parity ones: + * + * - whole-parent keyed lists ENGAGE during hydration (engaged counter) + * - rows are the SERVER nodes (identity), no key-miss warnings + * - the first post-hydration STRUCTURAL update runs through the slot + * - server/client MISMATCH reconciles at the fill commit (both directions) + * - a demote DURING the hydrating fill hands claims back — classic's + * re-run claims the same nodes (the "never strand a claim" invariant) + * - anchored holes (null/element markers) stay classic under hydration + * + * Mismatch scenarios diverge on `isServer` so one source renders both sides. + */ +import { createSignal, For, Show } from "solid-js"; +import { isServer } from "@solidjs/web"; + +export type ForSlotScenario = { + name: string; + App: () => any; + /** container.textContent after hydration settles */ + expectedText: string; + /** server-visible text when it legitimately differs (mismatch cases) */ + serverText?: string; + /** how many slots must ENGAGE during hydrate() (0 = classic expected) */ + engaged: number; + /** how many slots must DEMOTE during hydrate() */ + demoted: number; + /** expected console.warn calls during hydrate (key misses on real mismatch) */ + warnings: number; + /** selector for row nodes that must be the SERVER nodes after hydration */ + identitySelector?: string; + /** post-hydration update + expectations */ + update?: () => void; + expectedTextAfterUpdate?: string; + /** after update: these server nodes (by initial text) must survive as the + * same node objects (moved, not recreated) */ + survivorsAfterUpdate?: string[]; +}; + +// --------------------------------------------------------------------------- +// 1. Basic whole-parent list; post-hydration REORDER (structural, slot path) +let setBasic!: (v: string[]) => void; +function SlotBasic() { + const [items, set] = createSignal(["a", "b", "c"]); + setBasic = set; + return ( +
      + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 2. Text rows (no template keys) — fresh text replaces server text at the +// fill commit; post-hydration append. +let setText!: (v: string[]) => void; +function SlotTextRows() { + const [items, set] = createSignal(["a", "b", "c"]); + setText = set; + return ( +
      + {item => item} +
    + ); +} + +// --------------------------------------------------------------------------- +// 3. Mismatch: server has MORE rows than the client — leftover removed. +function SlotFewer() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( +
      + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 4. Mismatch: client has MORE rows than the server — fresh row inserted +// (one key-miss warning is the expected, honest signal). +function SlotMore() { + const [items] = createSignal(isServer ? ["a", "b"] : ["a", "b", "c"]); + return ( +
      + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 5. Demote DURING the hydrating fill: row "b" renders a (function +// top level) after row "a" already CLAIMED. The slot must hand a's claim +// back so classic's re-run claims the same server node — no warnings, +// no phantom rows, and the classic path then owns the list. +let setDemote!: (v: string[]) => void; +function SlotDemoteMidFill() { + const [items, set] = createSignal(["a", "b", "c"]); + setDemote = set; + return ( +
      + + {item => + item === "b" ? ( + +
    • {item}
    • +
      + ) : ( +
    • {item}
    • + ) + } +
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 6. Empty list on both sides; post-hydration first row. +let setEmpty!: (v: string[]) => void; +function SlotEmpty() { + const [items, set] = createSignal([]); + setEmpty = set; + return ( +
      + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 7. Trailing hole (preceding sibling → null marker): stays CLASSIC under +// hydration in v1; must hydrate cleanly and update. +let setTrailing!: (v: string[]) => void; +function SlotTrailingClassic() { + const [items, set] = createSignal(["a", "b"]); + setTrailing = set; + return ( +
      +
    • head
    • + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 8. Nested whole-parent lists: both engage; nested ids mint in parity. +// Stable group objects: the outer reorder must MOVE rows (identity keys), +// not rebuild them — otherwise the survivor check would be vacuous. +const GX = { g: "x", items: ["1", "2"] }; +const GY = { g: "y", items: ["3"] }; +let setNested!: (v: { g: string; items: string[] }[]) => void; +function SlotNested() { + const [groups, set] = createSignal([GX, GY]); + setNested = set; + return ( +
      + + {group => ( +
    • +
        + {item => {item}} +
      +
    • + )} +
      +
    + ); +} + +export const forSlotScenarios: ForSlotScenario[] = [ + { + name: "slot-hydrate-basic", + App: SlotBasic, + expectedText: "abc", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li", + update: () => setBasic(["c", "a", "b"]), + expectedTextAfterUpdate: "cab", + survivorsAfterUpdate: ["a", "b", "c"] + }, + { + name: "slot-hydrate-text-rows", + App: SlotTextRows, + expectedText: "abc", + engaged: 1, + demoted: 0, + warnings: 0, + update: () => setText(["a", "b", "c", "d"]), + expectedTextAfterUpdate: "abcd" + }, + { + name: "slot-hydrate-mismatch-fewer", + App: SlotFewer, + expectedText: "ab", + serverText: "abc", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li" + }, + { + name: "slot-hydrate-mismatch-more", + App: SlotMore, + expectedText: "abc", + serverText: "ab", + engaged: 1, + demoted: 0, + warnings: 1 + }, + { + name: "slot-hydrate-demote-mid-fill", + App: SlotDemoteMidFill, + expectedText: "abc", + engaged: 1, + demoted: 1, + warnings: 0, + identitySelector: "li", + update: () => setDemote(["a", "b", "c", "d"]), + expectedTextAfterUpdate: "abcd" + }, + { + name: "slot-hydrate-empty", + App: SlotEmpty, + expectedText: "", + engaged: 1, + demoted: 0, + warnings: 0, + update: () => setEmpty(["a"]), + expectedTextAfterUpdate: "a" + }, + { + name: "slot-hydrate-trailing-classic", + App: SlotTrailingClassic, + expectedText: "headab", + engaged: 0, + demoted: 0, + warnings: 0, + identitySelector: "li", + update: () => setTrailing(["b", "a"]), + expectedTextAfterUpdate: "headba" + }, + { + name: "slot-hydrate-nested", + App: SlotNested, + expectedText: "123", + engaged: 3, + demoted: 0, + warnings: 0, + identitySelector: "span", + update: () => setNested([GY, GX]), + expectedTextAfterUpdate: "312", + survivorsAfterUpdate: ["1", "2", "3"] + } +]; diff --git a/packages/web/test/hydration/for-slot.spec.tsx b/packages/web/test/hydration/for-slot.spec.tsx new file mode 100644 index 000000000..7236e3fac --- /dev/null +++ b/packages/web/test/hydration/for-slot.spec.tsx @@ -0,0 +1,120 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Unified For under HYDRATION (H2 v1) — replays the server artifacts from + * test/harness/for-slot-scenarios.tsx and asserts what the generic parity + * harness cannot: that the slot actually ENGAGED, that hydrated rows are + * the server's own nodes, that structural updates then run through the + * slot (moved, not recreated), that mismatches reconcile at the fill, and + * that a demote mid-fill hands claims back cleanly. + */ +import { describe, expect, test, vi } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { flush, __unifiedForStats } from "solid-js"; +import { hydrate } from "@solidjs/web"; +import { forSlotScenarios, type ForSlotScenario } from "../harness/for-slot-scenarios.jsx"; + +const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +function loadArtifact(name: string): { shell: string; rest: string } { + const file = resolve(artifactsDir, `${name}.json`); + if (!existsSync(file)) { + throw new Error( + `Missing artifact for scenario "${name}". Run the server harness first: ` + + `vitest run --config vite.config.server.mjs test/server/hydration-harness.spec.tsx` + ); + } + return JSON.parse(readFileSync(file, "utf-8")); +} + +function applyChunk(container: HTMLDivElement, chunk: string) { + const scriptRe = /]*)>([\s\S]*?)<\/script>/g; + const scripts = [...chunk.matchAll(scriptRe)].map(m => m[1]); + container.innerHTML = chunk.replace(scriptRe, ""); + for (const s of scripts) (0, eval)(s); +} + +async function run(scenario: ForSlotScenario) { + const { shell, rest } = loadArtifact(scenario.name); + const container = document.createElement("div"); + document.body.appendChild(container); + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + let dispose: (() => void) | undefined; + try { + applyChunk(container, shell + rest); + // Server nodes BEFORE hydration, by initial text — identity oracle. + const serverRows = scenario.identitySelector + ? new Map( + [...container.querySelectorAll(scenario.identitySelector)].map(el => [el.textContent, el]) + ) + : null; + + const engaged0 = __unifiedForStats.engaged; + const demoted0 = __unifiedForStats.demoted; + dispose = hydrate(() => , container); + flush(); + await sleep(10); + flush(); + + expect(container.textContent, "hydrated text").toBe(scenario.expectedText); + expect(__unifiedForStats.engaged - engaged0, "slots engaged during hydrate").toBe( + scenario.engaged + ); + expect(__unifiedForStats.demoted - demoted0, "slots demoted during hydrate").toBe( + scenario.demoted + ); + expect(warn, "console.warn calls during hydrate").toHaveBeenCalledTimes(scenario.warnings); + + if (serverRows) { + // Every row present after hydration whose text existed on the server + // must BE the server node (claimed, not recreated). + for (const el of container.querySelectorAll(scenario.identitySelector!)) { + const server = serverRows.get(el.textContent); + if (server) expect(el, `row "${el.textContent}" is the server node`).toBe(server); + } + } + + if (scenario.update) { + const before = scenario.identitySelector + ? new Map( + [...container.querySelectorAll(scenario.identitySelector)].map(el => [ + el.textContent, + el + ]) + ) + : null; + scenario.update(); + flush(); + expect(container.textContent, "text after update").toBe(scenario.expectedTextAfterUpdate); + if (scenario.survivorsAfterUpdate && before) { + for (const text of scenario.survivorsAfterUpdate) { + const now = [...container.querySelectorAll(scenario.identitySelector!)].find( + el => el.textContent === text + ); + expect(now, `survivor "${text}" present`).toBeDefined(); + expect(now, `survivor "${text}" moved, not recreated`).toBe(before.get(text)); + } + } + // No demote may happen on the post-hydration update either. + expect(__unifiedForStats.demoted - demoted0).toBe(scenario.demoted); + } + } finally { + warn.mockRestore(); + dispose?.(); + await sleep(0); + container.remove(); + } +} + +describe("unified For — hydration (slot engages, claims server rows)", () => { + for (const scenario of forSlotScenarios) { + test(scenario.name, async () => { + await run(scenario); + }); + } +}); diff --git a/packages/web/test/server/hydration-harness.spec.tsx b/packages/web/test/server/hydration-harness.spec.tsx index cc67e7f74..520867660 100644 --- a/packages/web/test/server/hydration-harness.spec.tsx +++ b/packages/web/test/server/hydration-harness.spec.tsx @@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url"; import { renderToStream } from "@solidjs/web"; import type { RequestEvent, ResponseStub } from "@solidjs/web"; import { scenarios } from "../harness/scenarios.jsx"; +import { forSlotScenarios } from "../harness/for-slot-scenarios.jsx"; const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); mkdirSync(artifactsDir, { recursive: true }); @@ -88,3 +89,28 @@ describe("hydration parity harness — server render", () => { }); } }); + +// Unified For hydration scenarios (test/hydration/for-slot.spec.tsx consumes +// these artifacts). Kept out of `scenarios` because the mismatch cases +// legitimately diverge from the generic parity invariants (key-miss +// warnings, client-created rows) by design. +describe("unified For hydration scenarios — server render", () => { + for (const scenario of forSlotScenarios) { + test(scenario.name, async () => { + const { shell, rest } = await storage.run(makeEvent(), () => + collectChunks(() => ) + ); + const full = shell + rest; + const visible = full.replace(//g, "").replace(/<[^>]*>/g, ""); + for (const token of (scenario.serverText ?? scenario.expectedText) + .split(/\s+/) + .filter(Boolean)) { + expect(visible).toContain(token); + } + writeFileSync( + resolve(artifactsDir, `${scenario.name}.json`), + JSON.stringify({ name: scenario.name, shell, rest }, null, 2) + ); + }); + } +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 9137fb516..dfab7da80 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -473,7 +473,14 @@ module.exports = [ // still ride for post-hydration mounts. P0 audit sweep (ownsParent // guards on every bulk clear, empty-row placeholders, throw-safe // builds) adds ~120 B here; siblings/foreign-node safety is the cost. - limit: "19.88 KB", + // + // Unified For hydration claiming (H2 v1, 2026-09-05): 19.88 -> 20.31 KB, + // measured at 20.37 (hooks module split; CSR shakes it). Whole-parent + // lists now ENGAGE during hydration: + // id-parity owner, recorded claims (reversible demote hands them back to + // classic's re-run), and a fill commit that reconciles claimed rows + // against the region on mismatch. First-paint SSR lists get the slot. + limit: "20.38 KB", modifyEsbuildConfig }, { @@ -574,7 +581,10 @@ module.exports = [ // Unified For slot, default-on (2026-09-04): 26.43 -> 28.64 KB — the // slot bytes through For's module graph (see the hydrating no-stores // note). - limit: "28.75 KB", + // + // Unified For hydration claiming (2026-09-05): 28.75 -> 29.10 KB, + // measured at 29.10 (see the hydrating no-stores note). + limit: "29.22 KB", modifyEsbuildConfig }, { @@ -624,7 +634,12 @@ module.exports = [ // Unified For slot, default-on (2026-09-04): 12.97 -> 15.20 KB, measured // at 15.19 — the slot bytes through For's module graph (see the // hydrating no-stores note). - limit: "15.29 KB", + // + // Unified For hydration claiming (2026-09-05): 15.29 -> 15.49 KB, + // measured at 15.48. CSR pays only the hook GUARDS + slot field plumbing + // (~190 B): the claim/restore/fix-up bodies live in for-slot-hydration.ts, + // installed by enableHydration(), and shake out of this bundle (#2883). + limit: "15.49 KB", modifyEsbuildConfig }, { From 450eab5fff117172f7886de2926c356728222010 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 00:51:13 -0700 Subject: [PATCH 12/20] =?UTF-8?q?feat(web,solid):=20unified=20For=20hole?= =?UTF-8?q?=20seam=20=E2=80=94=20lists=20passed=20through=20props.children?= =?UTF-8?q?=20engage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A $for accessor reaching insert THROUGH a wrapper (`{props.children}` in a parent component compiles to insert(el, () => props.children)) now engages the slot for that hole, whole-parent and bounded alike. The slot is created inside the hosting effect's compute, so a children change tears it down (hole-mode cleanup removes its rows; existing classic content is cleaned via cleanChildren first, keeping insert's multi placeholder invariant). A post-engage demote can't spawn a second insert into a hole the outer effect owns — it flips holeClassic and bumps a lazily-created per-hole signal so the hosting effect re-runs on its classic path. children() introspection and fragment children stay classic. Hydration through a wrapper engages too (region = the claimed range; active-hydration guard on the hand-off clean). Tests: for.unified.children.spec (6: whole/bounded holes, dynamic children swap + re-engage, demote-in-hole handoff, children() classic, fragment classic) + slot-hydrate-through-children harness scenario. web 734 / server 750 / hydrate 174 / solid 585 / signals 1490 / universal 43. Floor +~110 B (seam lives in insert), app scenarios +67-147 B, budgets noted. Co-authored-by: Cursor --- .changeset/unified-for-slot.md | 7 +- packages/solid/src/client/for-slot.ts | 18 +- packages/web/src/client.ts | 55 +++++ .../web/test/for.unified.children.spec.tsx | 211 ++++++++++++++++++ .../slot-hydrate-through-children.json | 5 + .../web/test/harness/for-slot-scenarios.tsx | 29 +++ scripts/size/.size-limit.js | 23 +- 7 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 packages/web/test/for.unified.children.spec.tsx create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-through-children.json diff --git a/.changeset/unified-for-slot.md b/.changeset/unified-for-slot.md index 510465278..f3bbd7cbd 100644 --- a/.changeset/unified-for-slot.md +++ b/.changeset/unified-for-slot.md @@ -1,8 +1,13 @@ --- "solid-js": patch "@solidjs/web": patch +"@solidjs/signals": patch --- Unified For: keyed `` is driven by one persistent slot that owns both row bookkeeping and DOM placement — an intrusive row chain + incremental key map updated by a prefix/suffix/LIS pass inside an ordinary two-phase render effect — replacing the mapArray + reconcileArrays double pass. Structural operations (swap, reorder, insert, remove) run 1.2–7x faster across jfb and uibench; creation and clear stay at parity via flat-mode first fills (parallel arrays, structure materializes lazily on the first partial structural op). -Default-on with zero new API and zero compiler involvement: the slot rides For's own module graph (`$for.impl`), web's insert engages it with its renderer-ops singleton, and apps without For tree-shake it entirely (~2.1 KB in For-bearing bundles). Bulk-clear fast paths honor classic's ownership rules — `null` markers (trailing child with preceding siblings) and streamed foreign nodes are never wiped. Empty-rendering rows hold their position with a placeholder instead of demoting. Declines to classic mapArray: hydration claiming (post-hydration mounts engage), key functions, duplicate keys, dynamic top-level rows, `fallback`, non-array subjects; post-engage contract exits demote to classic under the original owner. +Default-on with zero new API and zero compiler involvement: the slot rides For's own module graph (`$for.impl`), web's insert engages it with its renderer-ops singleton, and apps without For tree-shake it entirely (~2.1 KB in For-bearing bundles). A For passed through a component's `{props.children}` engages too — the hole seam hands the wrapper's hole to the slot, tears it down cleanly when the children change, and routes a post-engage demote back through the hosting effect's classic path. Bulk-clear fast paths honor classic's ownership rules — `null` markers (trailing child with preceding siblings) and streamed foreign nodes are never wiped. Empty-rendering rows hold their position with a placeholder instead of demoting. + +Hydration: whole-parent lists engage during hydration and claim the server rows themselves. The slot's row parent takes the same id classic's mapArray owner spends (For peeks it via an `enableHydration()`-installed hook; mapArray gains an internal `lazy` option so the classic pass no longer claims first), so rows mint identical hydration keys; claims are recorded so a demote mid-fill hands them back and classic's re-run claims the same nodes; the fill commit reconciles against the region only on server/client mismatch. All hydration behavior lives in a module installed by `enableHydration()` — CSR bundles shake it. + +Declines to classic mapArray: anchored holes under hydration, key functions, duplicate keys, dynamic top-level rows, `fallback`, non-array subjects; post-engage contract exits demote to classic under the original owner. diff --git a/packages/solid/src/client/for-slot.ts b/packages/solid/src/client/for-slot.ts index 817207c5d..13b403092 100644 --- a/packages/solid/src/client/for-slot.ts +++ b/packages/solid/src/client/for-slot.ts @@ -393,7 +393,12 @@ export function unifiedForSlot( marker: Node | null | undefined, lateClassic: () => void, ops: SlotOps, - region?: Node[] + region?: Node[], + /** HOLE mode: engaged from inside a wrapper insert's compute (the + * `{props.children}` seam). The hosting effect owns the hole, so this + * slot removes its rows on cleanup (a children change or dispose) — in + * direct mode the parent element's removal covers that for free. */ + hole = false ): boolean { const meta = listFn.$for; // H4 pin: keyed-fn rows receive accessors in the classic contract — the @@ -526,9 +531,18 @@ export function unifiedForSlot( }; // The insert owner disposes slot.owner (and with it every row) through the - // owner tree — cleanup only has to silence the slot. + // owner tree — cleanup only has to silence the slot. HOLE mode also + // removes the rows: the hosting effect keeps the parent and re-fills it. onCleanup(() => { slot.dead = true; + if (hole) { + if (slot.flat !== null) removeFlatDom(); + else + for (let r = slot.head; r !== null; r = r.x) { + if (r.n !== null) ops.remove(r.n); + else if (r.ns !== null) for (const n of r.ns) ops.remove(n); + } + } }); effect( diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 1919bcca2..053405464 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -12,6 +12,7 @@ import { merge as mergeProps, flatten, createMemo, + createSignal, flush, enableHydration, enforceLoadingBoundary, @@ -984,11 +985,65 @@ export function insert(parent, accessor, marker, initial, options) { initial = [placeholder]; } let current = initial; + // Unified-For HOLE seam: a `$for` accessor reaching this hole THROUGH a + // wrapper (`{props.children}` in a parent component compiles to + // `insert(el, () => props.children)`) engages the slot for the hole. The + // slot is created inside this compute, so a children change tears it down + // (hole-mode cleanup removes its rows). A post-engage demote can't spawn a + // second insert into a hole this effect owns — instead it flips + // `holeClassic` and bumps `holeGen` (created lazily, only for holes that + // ever see a For) so this effect re-runs and takes its classic path. + let holeClassic = false; + let holeGen = null; effect( prev => { if (hydrationRt !== null) current = hydrationRt.reclaimRegion(current, parent, marker); + if (holeGen !== null) holeGen[0](); const value = normalize(accessor(), current, multi, true); if (typeof value !== "function") return value; + if (value.$for !== undefined && !holeClassic) { + if (holeGen === null) { + holeGen = createSignal(0); + holeGen[0](); + } + // Hand-off: whatever classic content this hole tracked goes away + // first (a For returning after other children). Multi holes keep + // insert's placeholder invariant — a surviving anchor the slot's + // rows land after, before the marker. Under an ACTIVE hydration of + // this parent the tracked range is the claimed server region: keep + // it for the slot's fill instead of cleaning. + const region = + hydrationRt !== null && isHydrating(parent) && Array.isArray(current) + ? current + : undefined; + let keep; + if (region !== undefined) keep = []; + else if (multi) { + const ph = document.createTextNode(""); + cleanChildren(parent, current, marker, ph); + keep = [ph]; + } else { + if (current !== undefined) cleanChildren(parent, current, undefined); + keep = []; + } + if ( + value.$for.impl( + parent, + value, + marker, + () => { + holeClassic = true; + holeGen[1](g => g + 1); + }, + domOps, + region, + true + ) + ) { + current = keep; + return INNER_OWNED; + } + } effect( () => ( hydrationRt !== null && (current = hydrationRt.reclaimRegion(current, parent, marker)), diff --git a/packages/web/test/for.unified.children.spec.tsx b/packages/web/test/for.unified.children.spec.tsx new file mode 100644 index 000000000..55c949236 --- /dev/null +++ b/packages/web/test/for.unified.children.spec.tsx @@ -0,0 +1,211 @@ +/** + * @vitest-environment jsdom + * + * Unified For through COMPONENT CHILDREN — the hole seam. A `` passed + * as `props.children` reaches the parent's insert through a wrapper + * accessor (`insert(el, () => props.children)`); the seam engages the slot + * for that hole when the resolved value is the `$for` accessor. + * + * Contract pinned here: + * - whole-parent and bounded (marker) holes engage; rows move, not rebuild + * - a children CHANGE tears the slot down cleanly (rows removed, new + * content in place, no leftovers) and a returning For re-engages + * - a demote INSIDE a hole hands the hole to the classic path via the + * hosting effect's re-run — no second insert fighting for the hole + * - `children()` introspection and fragment children stay classic + */ +import { beforeEach, describe, expect, test } from "vitest"; +import { createSignal, flush, For, children, __unifiedForStats } from "solid-js"; +import { render } from "@solidjs/web"; + +function Table(props: { children: any }) { + return ( + + {props.children} +
    + ); +} + +function Card(props: { children: any }) { + return ( +
    +
    h
    + {props.children} +
    f
    +
    + ); +} + +function Introspect(props: { children: any }) { + const c = children(() => props.children); + return
    {c()}
    ; +} + +function Wrap(props: { children: any }) { + return
    {props.children}
    ; +} + +const texts = (root: ParentNode, sel: string) => + [...root.querySelectorAll(sel)].map(el => el.textContent); + +describe("unified For through props.children (hole seam)", () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); + }); + + test("whole-parent hole engages; reorder moves the same rows", () => { + const [rows, setRows] = createSignal(["a", "b", "c"]); + const engaged0 = __unifiedForStats.engaged; + const demoted0 = __unifiedForStats.demoted; + dispose = render( + () => ( + + + {r => ( + + + + )} + +
    {r}
    + ), + container + ); + expect(__unifiedForStats.engaged).toBe(engaged0 + 1); + expect(texts(container, "tr")).toEqual(["a", "b", "c"]); + const before = new Map([...container.querySelectorAll("tr")].map(tr => [tr.textContent, tr])); + setRows(["c", "a", "b"]); + flush(); + expect(texts(container, "tr")).toEqual(["c", "a", "b"]); + for (const tr of container.querySelectorAll("tr")) + expect(tr, `row ${tr.textContent} moved, not rebuilt`).toBe(before.get(tr.textContent)); + setRows([]); + flush(); + expect(container.querySelector("tbody")!.innerHTML).toBe(""); + expect(__unifiedForStats.demoted).toBe(demoted0); + }); + + test("bounded hole (element marker) engages; siblings untouched through reorder and clear", () => { + const [rows, setRows] = createSignal(["a", "b", "c"]); + const engaged0 = __unifiedForStats.engaged; + dispose = render( + () => ( + + {r =>

    {r}

    }
    +
    + ), + container + ); + expect(__unifiedForStats.engaged).toBe(engaged0 + 1); + const section = container.querySelector("section")!; + expect(section.querySelector("header")!.textContent).toBe("h"); + expect(texts(section, "p")).toEqual(["a", "b", "c"]); + expect(section.lastElementChild!.tagName).toBe("FOOTER"); + setRows(["b", "c", "a"]); + flush(); + expect(texts(section, "p")).toEqual(["b", "c", "a"]); + // Rows sit strictly between header and footer. + expect(section.firstElementChild!.tagName).toBe("HEADER"); + expect(section.lastElementChild!.tagName).toBe("FOOTER"); + setRows([]); + flush(); + expect(section.querySelectorAll("p").length).toBe(0); + expect(section.querySelector("header")!.textContent).toBe("h"); + expect(section.querySelector("footer")!.textContent).toBe("f"); + }); + + test("children change tears the slot down cleanly; a returning For re-engages", () => { + const [rows, setRows] = createSignal(["a", "b"]); + const [show, setShow] = createSignal(true); + const engaged0 = __unifiedForStats.engaged; + dispose = render( + () => {show() ? {r => {r}} :

    none

    }
    , + container + ); + const div = container.querySelector("div")!; + expect(__unifiedForStats.engaged).toBe(engaged0 + 1); + expect(div.innerHTML).toBe("ab"); + setShow(false); + flush(); + expect(div.innerHTML).toBe("

    none

    "); // rows gone, no leftovers + setShow(true); + flush(); + expect(__unifiedForStats.engaged).toBe(engaged0 + 2); // fresh slot + expect(div.innerHTML).toBe("ab"); + setRows(["b", "a"]); + flush(); + expect(div.innerHTML).toBe("ba"); + }); + + test("demote inside a hole hands the hole to classic via the hosting effect", () => { + const [rows, setRows] = createSignal(["a", "b"]); + const demoted0 = __unifiedForStats.demoted; + dispose = render( + () => ( + + {(r: any) => (typeof r === "function" ? r : {r})} + + ), + container + ); + const div = container.querySelector("div")!; + expect(div.innerHTML).toBe("ab"); + // A function-top-level row arrives → slot demotes; the hole re-runs classic. + setRows(["a", () => dyn, "b"]); + flush(); + expect(__unifiedForStats.demoted).toBe(demoted0 + 1); + expect(div.innerHTML).toBe("adynb"); + // Classic now owns the hole: further updates keep working, no duplicates. + setRows(["b", "a"]); + flush(); + expect(div.innerHTML).toBe("ba"); + setRows([]); + flush(); + expect(div.innerHTML).toBe(""); + }); + + test("children() introspection stays classic and correct", () => { + const [rows, setRows] = createSignal(["a", "b"]); + const engaged0 = __unifiedForStats.engaged; + dispose = render( + () => ( + + {r => {r}} + + ), + container + ); + expect(__unifiedForStats.engaged).toBe(engaged0); + expect(container.querySelector("div")!.innerHTML).toBe("ab"); + setRows(["b", "a", "c"]); + flush(); + expect(container.querySelector("div")!.innerHTML).toBe( + "bac" + ); + }); + + test("fragment children (For beside siblings) stay classic and correct", () => { + const [rows, setRows] = createSignal(["a", "b"]); + const engaged0 = __unifiedForStats.engaged; + dispose = render( + () => ( + +

    t

    + {r => {r}} +
    + ), + container + ); + expect(__unifiedForStats.engaged).toBe(engaged0); + const div = container.querySelector("div")!; + expect(div.innerHTML).toBe("

    t

    ab"); + setRows(["b"]); + flush(); + expect(div.innerHTML).toBe("

    t

    b"); + }); +}); diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-through-children.json b/packages/web/test/harness/__artifacts__/slot-hydrate-through-children.json new file mode 100644 index 000000000..dc396ad3d --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-through-children.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-through-children", + "shell": "
    • a
    • b
    • c
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/for-slot-scenarios.tsx b/packages/web/test/harness/for-slot-scenarios.tsx index 398f40134..c0adb7682 100644 --- a/packages/web/test/harness/for-slot-scenarios.tsx +++ b/packages/web/test/harness/for-slot-scenarios.tsx @@ -171,7 +171,36 @@ function SlotNested() { ); } +// --------------------------------------------------------------------------- +// 9. For passed THROUGH a component's children (the hole seam) — the +// wrapper's `{props.children}` hole engages under hydration too. +function ListShell(props: { children: any }) { + return
      {props.children}
    ; +} +let setThrough!: (v: string[]) => void; +function SlotThroughChildren() { + const [items, set] = createSignal(["a", "b", "c"]); + setThrough = set; + return ( + + {item =>
  • {item}
  • }
    +
    + ); +} + export const forSlotScenarios: ForSlotScenario[] = [ + { + name: "slot-hydrate-through-children", + App: SlotThroughChildren, + expectedText: "abc", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li", + update: () => setThrough(["b", "c", "a"]), + expectedTextAfterUpdate: "bca", + survivorsAfterUpdate: ["a", "b", "c"] + }, { name: "slot-hydrate-basic", App: SlotBasic, diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index dfab7da80..6b8d2a2e2 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -397,7 +397,19 @@ module.exports = [ // insert's `$for.impl` call site plus the domOps singleton (the platform // web hands the slot). The slot algorithm itself rides For's module // graph in solid-js and tree-shakes out of For-less apps like this one. - limit: "10.90 KB", + // (P0 audit sweep: 10.89 -> 10.90, the ownership guards' share.) + // + // Hydration hooks split (2026-09-05): 10.90 -> 10.85 measured — For's + // id peek moved behind enableHydration() (sharedConfig hook), so CSR no + // longer carries the id formatter it never used. + // + // Unified For HOLE seam (2026-09-05): 10.85 -> 11.00 KB, measured at + // 10.996. A `$for` accessor reaching insert THROUGH a wrapper + // (`{props.children}` in a parent component) now engages the slot for + // that hole; the seam sits in insert's effect (every bundle), so the + // floor pays the guard + hand-off (~110 B). Lists passed through layout + // components — the most common real-world list shape — get the slot. + limit: "11.00 KB", modifyEsbuildConfig }, { @@ -480,7 +492,7 @@ module.exports = [ // id-parity owner, recorded claims (reversible demote hands them back to // classic's re-run), and a fill commit that reconciles claimed rows // against the region on mismatch. First-paint SSR lists get the slot. - limit: "20.38 KB", + limit: "20.53 KB", modifyEsbuildConfig }, { @@ -584,7 +596,7 @@ module.exports = [ // // Unified For hydration claiming (2026-09-05): 28.75 -> 29.10 KB, // measured at 29.10 (see the hydrating no-stores note). - limit: "29.22 KB", + limit: "29.29 KB", modifyEsbuildConfig }, { @@ -639,7 +651,10 @@ module.exports = [ // measured at 15.48. CSR pays only the hook GUARDS + slot field plumbing // (~190 B): the claim/restore/fix-up bodies live in for-slot-hydration.ts, // installed by enableHydration(), and shake out of this bundle (#2883). - limit: "15.49 KB", + // + // Unified For HOLE seam (2026-09-05): 15.49 -> 15.62 KB, measured at + // 15.62 (see the simple-app note; hydrating scenarios +67-147 B). + limit: "15.62 KB", modifyEsbuildConfig }, { From 5617087011469501d54572570f28def518ab7f74 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 01:12:04 -0700 Subject: [PATCH 13/20] test(web): add @jsxImportSource pragma to unified For specs Three of the unified For specs (children, reconcile-parity, siblings) were missing the `@jsxImportSource @solidjs/web` pragma the rest of the suite carries, so test-types failed with TS7026 (no JSX.IntrinsicElements) and the cascading For-typing errors. Tests only; no source change. Co-authored-by: Cursor --- packages/web/test/for.unified.children.spec.tsx | 1 + packages/web/test/for.unified.reconcile-parity.spec.tsx | 1 + packages/web/test/for.unified.siblings.spec.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/web/test/for.unified.children.spec.tsx b/packages/web/test/for.unified.children.spec.tsx index 55c949236..5aa4b2e68 100644 --- a/packages/web/test/for.unified.children.spec.tsx +++ b/packages/web/test/for.unified.children.spec.tsx @@ -1,4 +1,5 @@ /** + * @jsxImportSource @solidjs/web * @vitest-environment jsdom * * Unified For through COMPONENT CHILDREN — the hole seam. A `` passed diff --git a/packages/web/test/for.unified.reconcile-parity.spec.tsx b/packages/web/test/for.unified.reconcile-parity.spec.tsx index 251cec60b..867dfa7db 100644 --- a/packages/web/test/for.unified.reconcile-parity.spec.tsx +++ b/packages/web/test/for.unified.reconcile-parity.spec.tsx @@ -1,4 +1,5 @@ /** + * @jsxImportSource @solidjs/web * @vitest-environment jsdom * * RECONCILE PARITY MATRIX — the classic for.spec transition table (and then diff --git a/packages/web/test/for.unified.siblings.spec.tsx b/packages/web/test/for.unified.siblings.spec.tsx index bfe12e053..2484365e3 100644 --- a/packages/web/test/for.unified.siblings.spec.tsx +++ b/packages/web/test/for.unified.siblings.spec.tsx @@ -1,4 +1,5 @@ /** + * @jsxImportSource @solidjs/web * @vitest-environment jsdom * * P0 regression suite (external audit, 2026-09-04): the slot's bulk-clear From 9d272df076eff589936e8db878a0ac829295fde3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 01:18:59 -0700 Subject: [PATCH 14/20] =?UTF-8?q?feat(solid,web):=20unified=20For=20hydrat?= =?UTF-8?q?ion=20=E2=80=94=20anchored=20holes=20engage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hydrating client resolves anchored holes (trailing / bounded) to their end-marker NODE via getNextMarker, with the comment-bounded region as insert's initial — so the region is well-defined and a null marker never occurs under hydration. The hooks now engage for Node markers too: fresh rows anchor at the hole's end marker; hydrationRt.slotRegion hands the slot the region minus comment markers ( stays, as classic leaves it — reclaimRegion walks back to it). The seam's region hand-off is guarded on an ACTIVE hydration of the parent (post-hydration dynamic changes clean the hole as before). Scenarios: trailing (now engages, sibling survives reorder), bounded (siblings both sides), anchored-hole mismatch (leftover removed inside the hole only). web 734 / server 752 / hydrate 176 / solid 585 / signals 1490 / universal 43. Budgets: floor +22 B (guard), hydrating +29, store +153. Co-authored-by: Cursor --- .changeset/unified-for-slot.md | 4 +- .../solid/src/client/for-slot-hydration.ts | 19 ++++-- packages/web/src/client.ts | 24 +++++-- .../__artifacts__/slot-hydrate-bounded.json | 5 ++ .../slot-hydrate-trailing-mismatch-fewer.json | 5 ++ .../__artifacts__/slot-hydrate-trailing.json | 5 ++ .../web/test/harness/for-slot-scenarios.tsx | 63 ++++++++++++++++--- scripts/size/.size-limit.js | 16 ++++- 8 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-bounded.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-trailing-mismatch-fewer.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-trailing.json diff --git a/.changeset/unified-for-slot.md b/.changeset/unified-for-slot.md index f3bbd7cbd..2aa37e88b 100644 --- a/.changeset/unified-for-slot.md +++ b/.changeset/unified-for-slot.md @@ -8,6 +8,6 @@ Unified For: keyed `` is driven by one persistent slot that owns both row b Default-on with zero new API and zero compiler involvement: the slot rides For's own module graph (`$for.impl`), web's insert engages it with its renderer-ops singleton, and apps without For tree-shake it entirely (~2.1 KB in For-bearing bundles). A For passed through a component's `{props.children}` engages too — the hole seam hands the wrapper's hole to the slot, tears it down cleanly when the children change, and routes a post-engage demote back through the hosting effect's classic path. Bulk-clear fast paths honor classic's ownership rules — `null` markers (trailing child with preceding siblings) and streamed foreign nodes are never wiped. Empty-rendering rows hold their position with a placeholder instead of demoting. -Hydration: whole-parent lists engage during hydration and claim the server rows themselves. The slot's row parent takes the same id classic's mapArray owner spends (For peeks it via an `enableHydration()`-installed hook; mapArray gains an internal `lazy` option so the classic pass no longer claims first), so rows mint identical hydration keys; claims are recorded so a demote mid-fill hands them back and classic's re-run claims the same nodes; the fill commit reconciles against the region only on server/client mismatch. All hydration behavior lives in a module installed by `enableHydration()` — CSR bundles shake it. +Hydration: lists engage during hydration and claim the server rows themselves — whole-parent holes and comment-bounded anchored holes alike (the hydrating client resolves anchored holes to their `` marker with the bounded region). The slot's row parent takes the same id classic's mapArray owner spends (For peeks it via an `enableHydration()`-installed hook; mapArray gains an internal `lazy` option so the classic pass no longer claims first), so rows mint identical hydration keys; claims are recorded so a demote mid-fill hands them back and classic's re-run claims the same nodes; the fill commit reconciles against the region only on server/client mismatch. All hydration behavior lives in a module installed by `enableHydration()` — CSR bundles shake it. -Declines to classic mapArray: anchored holes under hydration, key functions, duplicate keys, dynamic top-level rows, `fallback`, non-array subjects; post-engage contract exits demote to classic under the original owner. +Declines to classic mapArray: key functions, duplicate keys, dynamic top-level rows, `fallback`, non-array subjects; post-engage contract exits demote to classic under the original owner. diff --git a/packages/solid/src/client/for-slot-hydration.ts b/packages/solid/src/client/for-slot-hydration.ts index ca438b64b..3cbd2b70c 100644 --- a/packages/solid/src/client/for-slot-hydration.ts +++ b/packages/solid/src/client/for-slot-hydration.ts @@ -3,15 +3,17 @@ * bundles never import this module, so the slot's null-guarded hook calls * fold away (#2883's pay-for-hydration discipline). * - * Contract: engage only whole-parent lists carrying an id-parity handle - * (`$for.hid`) and a region snapshot. Row templates then CLAIM server nodes + * Contract: engage lists carrying an id-parity handle (`$for.hid`) and a + * region snapshot — whole-parent holes (the parent's childNodes) and + * comment-bounded holes (the hydrating client resolves anchored holes to + * their `` end-marker node via getNextMarker, with the bounded + * region as `initial`). Row templates then CLAIM server nodes * exactly as classic's would — the slot's row parent takes the SAME id * classic's mapArray owner spends, so rows mint identical hydration keys. * Claims are RECORDED so a demote mid-fill hands them back: classic's re-run * mints the same ids and claims the same nodes (never a stranded claim). * The fill commit mutates only on MISMATCH (leftover server rows removed, * key-missed fresh rows inserted); the normal case is zero DOM writes. - * Anchored holes (null/element markers) stay classic under hydration. */ import { sharedConfig } from "./hydration.js"; import { installSlotHydration, type FlatPlan, type Slot } from "./for-slot.js"; @@ -23,7 +25,11 @@ const hooks = { region: Node[] | undefined ): { id: string } | null | false { if (!sharedConfig.hydrating) return false; - if (marker !== undefined || meta.hid === undefined || region === undefined) return null; + // Whole-parent (marker undefined) and comment-bounded holes (the + // compiled hydrating client resolves anchored holes to the `` + // marker NODE via getNextMarker, with the region as `initial`) both + // engage. A `null` marker never occurs under hydration; decline it. + if (marker === null || meta.hid === undefined || region === undefined) return null; return { id: meta.hid }; }, @@ -78,8 +84,9 @@ const hooks = { if (!ours.has(region[i]) && ops.contains(slot.parent, region[i])) ops.remove(region[i]); // Fresh rows (template key-missed → detached; the runtime already // warned) are inserted at their position, back to front so anchors are - // always attached. Whole-parent: the list ends at the parent's end. - let anchor: Node | null = null; + // always attached. The list ends at the hole's end marker (or the + // parent's end for whole-parent holes). + let anchor: Node | null = slot.end; for (let i = fp.nodes.length - 1; i >= 0; i--) { const nd = fp.nodes[i]; if (Array.isArray(nd)) { diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 053405464..8ba2adab1 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -867,6 +867,18 @@ export function installHydrationRuntime() { } else nodes = [...parent.childNodes]; return stripTextSeparators(nodes); }, + // Unified For: the hydration region handed to the slot — the hole's + // claimed nodes minus comment markers (`` stays in place exactly + // as classic leaves it; reclaimRegion walks back to it for $df swaps). + slotRegion(nodes) { + let out = null; + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].nodeType === 8) { + out ??= nodes.slice(0, i); + } else if (out !== null) out.push(nodes[i]); + } + return out ?? nodes; + }, // eventHandler(): replayed server events are deduped against the live // event queue during hydration. dedupEvent(e) { @@ -964,9 +976,13 @@ export function insert(parent, accessor, marker, initial, options) { ) ), domOps, - // Hydration: the claimed region snapshot (claimInitial ran above) — - // the slot's fill reconciles claimed rows against it (whole-parent). - hydrationRt !== null && Array.isArray(initial) ? initial : undefined + // Hydration: the claimed region snapshot — the parent's childNodes + // (claimInitial, whole-parent) or the comment-bounded hole range the + // compiled client resolved via getNextMarker (anchored holes). The + // slot's fill reconciles claimed rows against it. + hydrationRt !== null && isHydrating(parent) && Array.isArray(initial) + ? hydrationRt.slotRegion(initial) + : undefined ) ) return; @@ -1014,7 +1030,7 @@ export function insert(parent, accessor, marker, initial, options) { // it for the slot's fill instead of cleaning. const region = hydrationRt !== null && isHydrating(parent) && Array.isArray(current) - ? current + ? hydrationRt.slotRegion(current) : undefined; let keep; if (region !== undefined) keep = []; diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-bounded.json b/packages/web/test/harness/__artifacts__/slot-hydrate-bounded.json new file mode 100644 index 000000000..029c90f1a --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-bounded.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-bounded", + "shell": "
    • head
    • a
    • b
    • c
    • tail
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-trailing-mismatch-fewer.json b/packages/web/test/harness/__artifacts__/slot-hydrate-trailing-mismatch-fewer.json new file mode 100644 index 000000000..b07f84e95 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-trailing-mismatch-fewer.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-trailing-mismatch-fewer", + "shell": "
    • head
    • a
    • b
    • c
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-trailing.json b/packages/web/test/harness/__artifacts__/slot-hydrate-trailing.json new file mode 100644 index 000000000..c6a7ebef8 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-trailing.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-trailing", + "shell": "
    • head
    • a
    • b
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/for-slot-scenarios.tsx b/packages/web/test/harness/for-slot-scenarios.tsx index c0adb7682..a6ed0a965 100644 --- a/packages/web/test/harness/for-slot-scenarios.tsx +++ b/packages/web/test/harness/for-slot-scenarios.tsx @@ -132,10 +132,10 @@ function SlotEmpty() { } // --------------------------------------------------------------------------- -// 7. Trailing hole (preceding sibling → null marker): stays CLASSIC under -// hydration in v1; must hydrate cleanly and update. +// 7. Trailing hole (preceding sibling): the hydrating client resolves it to +// the `` end marker with the bounded region — ENGAGES. let setTrailing!: (v: string[]) => void; -function SlotTrailingClassic() { +function SlotTrailing() { const [items, set] = createSignal(["a", "b"]); setTrailing = set; return ( @@ -146,6 +146,32 @@ function SlotTrailingClassic() { ); } +// 7b. Bounded hole (siblings both sides) — engages; siblings untouched. +let setBounded!: (v: string[]) => void; +function SlotBounded() { + const [items, set] = createSignal(["a", "b", "c"]); + setBounded = set; + return ( +
      +
    • head
    • + {item =>
    • {item}
    • }
      +
    • tail
    • +
    + ); +} + +// 7c. Anchored-hole MISMATCH: server has more rows — leftover removed from +// the hole only; the sibling and the hole's comment markers survive. +function SlotTrailingFewer() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( +
      +
    • head
    • + {item =>
    • {item}
    • }
      +
    + ); +} + // --------------------------------------------------------------------------- // 8. Nested whole-parent lists: both engage; nested ids mint in parity. // Stable group objects: the outer reorder must MOVE rows (identity keys), @@ -264,15 +290,38 @@ export const forSlotScenarios: ForSlotScenario[] = [ expectedTextAfterUpdate: "a" }, { - name: "slot-hydrate-trailing-classic", - App: SlotTrailingClassic, + name: "slot-hydrate-trailing", + App: SlotTrailing, expectedText: "headab", - engaged: 0, + engaged: 1, demoted: 0, warnings: 0, identitySelector: "li", update: () => setTrailing(["b", "a"]), - expectedTextAfterUpdate: "headba" + expectedTextAfterUpdate: "headba", + survivorsAfterUpdate: ["head", "a", "b"] + }, + { + name: "slot-hydrate-bounded", + App: SlotBounded, + expectedText: "headabctail", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li", + update: () => setBounded(["c", "b", "a"]), + expectedTextAfterUpdate: "headcbatail", + survivorsAfterUpdate: ["head", "a", "b", "c", "tail"] + }, + { + name: "slot-hydrate-trailing-mismatch-fewer", + App: SlotTrailingFewer, + expectedText: "headab", + serverText: "headabc", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li" }, { name: "slot-hydrate-nested", diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 6b8d2a2e2..ece5df8a1 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -409,7 +409,10 @@ module.exports = [ // that hole; the seam sits in insert's effect (every bundle), so the // floor pays the guard + hand-off (~110 B). Lists passed through layout // components — the most common real-world list shape — get the slot. - limit: "11.00 KB", + // + // Anchored-hole hydration (2026-09-05): 11.00 -> 11.03 KB, measured at + // 11.02 — the active-hydration guard on the seam's region hand-off. + limit: "11.03 KB", modifyEsbuildConfig }, { @@ -492,7 +495,11 @@ module.exports = [ // id-parity owner, recorded claims (reversible demote hands them back to // classic's re-run), and a fill commit that reconciles claimed rows // against the region on mismatch. First-paint SSR lists get the slot. - limit: "20.53 KB", + // + // Anchored-hole hydration (2026-09-05): 20.53 -> 20.57 KB, measured at + // 20.56 — comment-bounded holes engage under hydration (hydrationRt + // hands the slot the marker-bounded region minus comment markers). + limit: "20.57 KB", modifyEsbuildConfig }, { @@ -596,7 +603,10 @@ module.exports = [ // // Unified For hydration claiming (2026-09-05): 28.75 -> 29.10 KB, // measured at 29.10 (see the hydrating no-stores note). - limit: "29.29 KB", + // + // Anchored-hole hydration (2026-09-05): 29.29 -> 29.45 KB, measured at + // 29.44 (see the hydrating no-stores note). + limit: "29.45 KB", modifyEsbuildConfig }, { From 53a405e6f972cf73174e8d974be4a6f1beeec56f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 02:19:30 -0700 Subject: [PATCH 15/20] =?UTF-8?q?fix(solid,web):=20unified=20For=20hydrati?= =?UTF-8?q?on=20=E2=80=94=20nested=20claim=20recording,=20synchronous=20ho?= =?UTF-8?q?le=20demote,=20DEV.unifiedFor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit round 3: - P1: one module-level recording STACK replaces per-slot registry shadows. The outermost record() installs the shadow once, every active log receives every deletion, an inner commitFill drops only its own log, and an outer restore() hands back everything claimed beneath it — including nested slots' committed claims, which classic's re-engaged nested lists mint again with the same ids. (Per-slot shadows broke both ways: the inner finally tore down the outer's shadow; committed inner claims were in no log.) Scenario: nested + Show-rooted later row → 5 engagements, 1 demote, zero warnings, all spans server nodes. - P2: the hole seam keeps the claimed region as the hosting effect's `current` under hydration, and a demote DURING a hydrating fill re-enters classic synchronously inside the hydration window (the deferred re-run landed after hydrate() flipped the flag and cloned). holeGen is ownedWrite (the bump may fire inside an owned scope). Scenario: through-children + Show-rooted row + server mismatch → rows are server nodes; the leftover survives with the runtime's unclaimed-node report — classic parity (the claim pass never removes leftovers), pinned as such. - __unifiedForStats is no longer a package export: counters ride DEV.unifiedFor (solid-js's dev diagnostics bag, undefined in prod). - Changeset qualifies the tree-shaking claim: the algorithm shakes; ~0.3 KB of engagement seam in insert is retained by every web bundle. Co-authored-by: Cursor --- .changeset/unified-for-slot.md | 2 +- .../solid/src/client/for-slot-hydration.ts | 38 ++++++--- packages/solid/src/client/for-slot.ts | 13 ++- packages/solid/src/index.ts | 14 ++-- packages/solid/src/server/index.ts | 7 +- packages/web/src/client.ts | 25 +++++- .../web/test/for.unified.children.spec.tsx | 33 ++++---- .../for.unified.reconcile-parity.spec.tsx | 13 +-- .../web/test/for.unified.siblings.spec.tsx | 15 ++-- packages/web/test/for.unified.spec.tsx | 24 ++---- .../slot-hydrate-nested-demote.json | 5 ++ .../slot-hydrate-through-demote-mismatch.json | 5 ++ .../web/test/harness/for-slot-scenarios.tsx | 83 +++++++++++++++++++ packages/web/test/hydration/for-slot.spec.tsx | 17 ++-- 14 files changed, 213 insertions(+), 81 deletions(-) create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-nested-demote.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-mismatch.json diff --git a/.changeset/unified-for-slot.md b/.changeset/unified-for-slot.md index 2aa37e88b..28233a995 100644 --- a/.changeset/unified-for-slot.md +++ b/.changeset/unified-for-slot.md @@ -6,7 +6,7 @@ Unified For: keyed `` is driven by one persistent slot that owns both row bookkeeping and DOM placement — an intrusive row chain + incremental key map updated by a prefix/suffix/LIS pass inside an ordinary two-phase render effect — replacing the mapArray + reconcileArrays double pass. Structural operations (swap, reorder, insert, remove) run 1.2–7x faster across jfb and uibench; creation and clear stay at parity via flat-mode first fills (parallel arrays, structure materializes lazily on the first partial structural op). -Default-on with zero new API and zero compiler involvement: the slot rides For's own module graph (`$for.impl`), web's insert engages it with its renderer-ops singleton, and apps without For tree-shake it entirely (~2.1 KB in For-bearing bundles). A For passed through a component's `{props.children}` engages too — the hole seam hands the wrapper's hole to the slot, tears it down cleanly when the children change, and routes a post-engage demote back through the hosting effect's classic path. Bulk-clear fast paths honor classic's ownership rules — `null` markers (trailing child with preceding siblings) and streamed foreign nodes are never wiped. Empty-rendering rows hold their position with a placeholder instead of demoting. +Default-on with zero new API and zero compiler involvement: the slot rides For's own module graph (`$for.impl`), web's insert engages it with its renderer-ops singleton, and the slot algorithm tree-shakes out of apps without For (~2.1 KB rides For-bearing bundles; ~0.3 KB of engagement seam in `insert` — the `$for` guards, renderer ops, and hole hand-off — is retained by every web bundle; hydration adds ~0.8 KB to hydrating bundles only). A For passed through a component's `{props.children}` engages too — the hole seam hands the wrapper's hole to the slot, tears it down cleanly when the children change, and routes a post-engage demote back through the hosting effect's classic path. Bulk-clear fast paths honor classic's ownership rules — `null` markers (trailing child with preceding siblings) and streamed foreign nodes are never wiped. Empty-rendering rows hold their position with a placeholder instead of demoting. Hydration: lists engage during hydration and claim the server rows themselves — whole-parent holes and comment-bounded anchored holes alike (the hydrating client resolves anchored holes to their `` marker with the bounded region). The slot's row parent takes the same id classic's mapArray owner spends (For peeks it via an `enableHydration()`-installed hook; mapArray gains an internal `lazy` option so the classic pass no longer claims first), so rows mint identical hydration keys; claims are recorded so a demote mid-fill hands them back and classic's re-run claims the same nodes; the fill commit reconciles against the region only on server/client mismatch. All hydration behavior lives in a module installed by `enableHydration()` — CSR bundles shake it. diff --git a/packages/solid/src/client/for-slot-hydration.ts b/packages/solid/src/client/for-slot-hydration.ts index 3cbd2b70c..8d4b72e98 100644 --- a/packages/solid/src/client/for-slot-hydration.ts +++ b/packages/solid/src/client/for-slot-hydration.ts @@ -18,6 +18,18 @@ import { sharedConfig } from "./hydration.js"; import { installSlotHydration, type FlatPlan, type Slot } from "./for-slot.js"; +/** RECORDING STACK. Nested lists hydrate INSIDE an outer row's build (row → + * inner insert → inner engage → inner fill, all synchronous), so recording + * must nest: the OUTERMOST record() installs the registry shadow once, every + * active log on the stack receives every deletion, an inner commitFill drops + * only its OWN log, and an outer restore() hands back everything claimed + * beneath it — including nested slots' already-committed claims, which the + * classic re-run's re-engaged nested lists will mint again with the same + * ids. (Per-slot shadows broke both: the inner `finally` tore down the + * outer's shadow, and committed inner claims were in no log at all.) */ +const logs: [string, Element][][] = []; +let shadowed = false; + const hooks = { engage( meta: any, @@ -34,21 +46,27 @@ const hooks = { }, record(slot: Slot, fn: () => T): T { - // Shadow the registry's `delete` for the duration of the build so every - // key the row templates consume is logged (with its node). const reg = sharedConfig.registry as Map | undefined; if (!reg) return fn(); - const log: [string, Element][] = (slot.hydLog ??= []); - const proto = Map.prototype.delete; - (reg as any).delete = function (this: Map, key: string): boolean { - const node = this.get(key); - if (node !== undefined) log.push([key, node]); - return proto.call(this, key); - }; + logs.push((slot.hydLog ??= [])); + const installed = !shadowed; + if (installed) { + shadowed = true; + const proto = Map.prototype.delete; + (reg as any).delete = function (this: Map, key: string): boolean { + const node = this.get(key); + if (node !== undefined) for (let i = 0; i < logs.length; i++) logs[i].push([key, node]); + return proto.call(this, key); + }; + } try { return fn(); } finally { - delete (reg as any).delete; // back to the prototype method + logs.pop(); + if (installed) { + delete (reg as any).delete; // back to the prototype method + shadowed = false; + } } }, diff --git a/packages/solid/src/client/for-slot.ts b/packages/solid/src/client/for-slot.ts index 13b403092..18c5c48ff 100644 --- a/packages/solid/src/client/for-slot.ts +++ b/packages/solid/src/client/for-slot.ts @@ -836,7 +836,12 @@ export function unifiedForSlot( return true; } -/** DEV-ONLY test probes: engagement / demotion / batch-clear counters. - * Increments are IS_DEV-gated — frozen at zero in prod bundles (the export - * itself is a few bytes; the double-underscore marks it non-API). */ -export const __unifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; +/** DEV-ONLY probes: engagement / demotion / batch-clear counters, exposed as + * `DEV.unifiedFor` (solid-js's dev diagnostics bag — undefined in prod). + * Increments are IS_DEV-gated; not a package export of its own. */ +export interface UnifiedForStats { + engaged: number; + demoted: number; + batchCleared: number; +} +export const __unifiedForStats: UnifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 292986ed7..c518366da 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -95,10 +95,9 @@ export type { export * from "./client/component.js"; export * from "./client/flow.js"; // Unified For slot: type surface for renderer integrators (web's insert -// passes its SlotOps) + the engagement/demotion test probe. The impl itself -// travels on `$for.impl` — not a user API. -export type { SlotOps } from "./client/for-slot.js"; -export { __unifiedForStats } from "./client/for-slot.js"; +// passes its SlotOps). The impl itself travels on `$for.impl` — not a user +// API. Dev counters ride `DEV.unifiedFor` (below), not a new export. +export type { SlotOps, UnifiedForStats } from "./client/for-slot.js"; export type { ArrayElement, Element } from "./types.js"; export { sharedConfig, @@ -149,7 +148,12 @@ export function getProjectionTrace( // dev import { IS_DEV } from "./client/core.js"; import { DEV as _DEV, type Dev } from "@solidjs/signals"; -export const DEV: Dev | undefined = IS_DEV ? _DEV : undefined; +import { __unifiedForStats, type UnifiedForStats } from "./client/for-slot.js"; +/** Dev diagnostics bag. `unifiedFor`: unified For engagement / demotion / + * batch-clear counters (test probes; dev builds only). */ +export const DEV: (Dev & { unifiedFor: UnifiedForStats }) | undefined = IS_DEV + ? Object.assign(_DEV!, { unifiedFor: __unifiedForStats }) + : undefined; // handle multiple instance check declare global { diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index c12cc909f..48145e176 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -104,10 +104,9 @@ export * from "./component.js"; // Flow controls export * from "./flow.js"; -// Unified For slot surface, server parity: the slot is client-only (server -// For renders arrays directly), but isomorphic imports must resolve. -export type { SlotOps } from "../client/for-slot.js"; -export const __unifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; +// Unified For slot type surface, server parity: the slot is client-only +// (server For renders arrays directly), but isomorphic type imports resolve. +export type { SlotOps, UnifiedForStats } from "../client/for-slot.js"; export type { ArrayElement, Element } from "../types.js"; // SSR coordination diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 8ba2adab1..96fa15cec 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -1019,7 +1019,9 @@ export function insert(parent, accessor, marker, initial, options) { if (typeof value !== "function") return value; if (value.$for !== undefined && !holeClassic) { if (holeGen === null) { - holeGen = createSignal(0); + // ownedWrite: the demote bump is internal machinery and may fire + // from inside an owned scope (a hydrating fill's demote). + holeGen = createSignal(0, { ownedWrite: true }); holeGen[0](); } // Hand-off: whatever classic content this hole tracked goes away @@ -1033,7 +1035,12 @@ export function insert(parent, accessor, marker, initial, options) { ? hydrationRt.slotRegion(current) : undefined; let keep; - if (region !== undefined) keep = []; + // Under hydration the tracked range STAYS the claimed region: if the + // slot demotes mid-fill, this effect's classic re-run reconciles + // against the real server rows (leftovers on mismatch get cleaned + // instead of surviving invisibly). After a successful engage the + // range is merely stale — cleanChildren skips nodes no longer ours. + if (region !== undefined) keep = region; else if (multi) { const ph = document.createTextNode(""); cleanChildren(parent, current, marker, ph); @@ -1042,6 +1049,8 @@ export function insert(parent, accessor, marker, initial, options) { if (current !== undefined) cleanChildren(parent, current, undefined); keep = []; } + const listFn = value; + const holeOwner = getOwner(); if ( value.$for.impl( parent, @@ -1049,7 +1058,17 @@ export function insert(parent, accessor, marker, initial, options) { marker, () => { holeClassic = true; - holeGen[1](g => g + 1); + if (sharedConfig.hydrating) { + // Demote DURING a hydrating fill: re-enter classic NOW, inside + // the hydration window — the deferred re-run below would land + // after hydrate() flips the flag and CLONE instead of claim. + // `() => listFn()` INVOKES the list (classic rows), so this + // insert cannot re-engage; `current` is the server region, so + // classic reconciles against the real rows (mismatch cleaned). + runWithOwner(holeOwner, () => + insert(parent, () => listFn(), marker, current, options) + ); + } else holeGen[1](g => g + 1); }, domOps, region, diff --git a/packages/web/test/for.unified.children.spec.tsx b/packages/web/test/for.unified.children.spec.tsx index 5aa4b2e68..3caa41c11 100644 --- a/packages/web/test/for.unified.children.spec.tsx +++ b/packages/web/test/for.unified.children.spec.tsx @@ -16,7 +16,8 @@ * - `children()` introspection and fragment children stay classic */ import { beforeEach, describe, expect, test } from "vitest"; -import { createSignal, flush, For, children, __unifiedForStats } from "solid-js"; +import { createSignal, flush, For, children, DEV } from "solid-js"; +const stats = DEV!.unifiedFor; import { render } from "@solidjs/web"; function Table(props: { children: any }) { @@ -61,8 +62,8 @@ describe("unified For through props.children (hole seam)", () => { test("whole-parent hole engages; reorder moves the same rows", () => { const [rows, setRows] = createSignal(["a", "b", "c"]); - const engaged0 = __unifiedForStats.engaged; - const demoted0 = __unifiedForStats.demoted; + const engaged0 = stats.engaged; + const demoted0 = stats.demoted; dispose = render( () => ( @@ -77,7 +78,7 @@ describe("unified For through props.children (hole seam)", () => { ), container ); - expect(__unifiedForStats.engaged).toBe(engaged0 + 1); + expect(stats.engaged).toBe(engaged0 + 1); expect(texts(container, "tr")).toEqual(["a", "b", "c"]); const before = new Map([...container.querySelectorAll("tr")].map(tr => [tr.textContent, tr])); setRows(["c", "a", "b"]); @@ -88,12 +89,12 @@ describe("unified For through props.children (hole seam)", () => { setRows([]); flush(); expect(container.querySelector("tbody")!.innerHTML).toBe(""); - expect(__unifiedForStats.demoted).toBe(demoted0); + expect(stats.demoted).toBe(demoted0); }); test("bounded hole (element marker) engages; siblings untouched through reorder and clear", () => { const [rows, setRows] = createSignal(["a", "b", "c"]); - const engaged0 = __unifiedForStats.engaged; + const engaged0 = stats.engaged; dispose = render( () => ( @@ -102,7 +103,7 @@ describe("unified For through props.children (hole seam)", () => { ), container ); - expect(__unifiedForStats.engaged).toBe(engaged0 + 1); + expect(stats.engaged).toBe(engaged0 + 1); const section = container.querySelector("section")!; expect(section.querySelector("header")!.textContent).toBe("h"); expect(texts(section, "p")).toEqual(["a", "b", "c"]); @@ -123,20 +124,20 @@ describe("unified For through props.children (hole seam)", () => { test("children change tears the slot down cleanly; a returning For re-engages", () => { const [rows, setRows] = createSignal(["a", "b"]); const [show, setShow] = createSignal(true); - const engaged0 = __unifiedForStats.engaged; + const engaged0 = stats.engaged; dispose = render( () => {show() ? {r => {r}} :

    none

    }
    , container ); const div = container.querySelector("div")!; - expect(__unifiedForStats.engaged).toBe(engaged0 + 1); + expect(stats.engaged).toBe(engaged0 + 1); expect(div.innerHTML).toBe("ab"); setShow(false); flush(); expect(div.innerHTML).toBe("

    none

    "); // rows gone, no leftovers setShow(true); flush(); - expect(__unifiedForStats.engaged).toBe(engaged0 + 2); // fresh slot + expect(stats.engaged).toBe(engaged0 + 2); // fresh slot expect(div.innerHTML).toBe("ab"); setRows(["b", "a"]); flush(); @@ -145,7 +146,7 @@ describe("unified For through props.children (hole seam)", () => { test("demote inside a hole hands the hole to classic via the hosting effect", () => { const [rows, setRows] = createSignal(["a", "b"]); - const demoted0 = __unifiedForStats.demoted; + const demoted0 = stats.demoted; dispose = render( () => ( @@ -159,7 +160,7 @@ describe("unified For through props.children (hole seam)", () => { // A function-top-level row arrives → slot demotes; the hole re-runs classic. setRows(["a", () => dyn, "b"]); flush(); - expect(__unifiedForStats.demoted).toBe(demoted0 + 1); + expect(stats.demoted).toBe(demoted0 + 1); expect(div.innerHTML).toBe("adynb"); // Classic now owns the hole: further updates keep working, no duplicates. setRows(["b", "a"]); @@ -172,7 +173,7 @@ describe("unified For through props.children (hole seam)", () => { test("children() introspection stays classic and correct", () => { const [rows, setRows] = createSignal(["a", "b"]); - const engaged0 = __unifiedForStats.engaged; + const engaged0 = stats.engaged; dispose = render( () => ( @@ -181,7 +182,7 @@ describe("unified For through props.children (hole seam)", () => { ), container ); - expect(__unifiedForStats.engaged).toBe(engaged0); + expect(stats.engaged).toBe(engaged0); expect(container.querySelector("div")!.innerHTML).toBe("ab"); setRows(["b", "a", "c"]); flush(); @@ -192,7 +193,7 @@ describe("unified For through props.children (hole seam)", () => { test("fragment children (For beside siblings) stay classic and correct", () => { const [rows, setRows] = createSignal(["a", "b"]); - const engaged0 = __unifiedForStats.engaged; + const engaged0 = stats.engaged; dispose = render( () => ( @@ -202,7 +203,7 @@ describe("unified For through props.children (hole seam)", () => { ), container ); - expect(__unifiedForStats.engaged).toBe(engaged0); + expect(stats.engaged).toBe(engaged0); const div = container.querySelector("div")!; expect(div.innerHTML).toBe("

    t

    ab"); setRows(["b"]); diff --git a/packages/web/test/for.unified.reconcile-parity.spec.tsx b/packages/web/test/for.unified.reconcile-parity.spec.tsx index 867dfa7db..ce199301d 100644 --- a/packages/web/test/for.unified.reconcile-parity.spec.tsx +++ b/packages/web/test/for.unified.reconcile-parity.spec.tsx @@ -25,7 +25,8 @@ * state-to-state transitions, not just canonical-to-X. */ import { beforeEach, describe, expect, test } from "vitest"; -import { createSignal, flush, For, __unifiedForStats } from "solid-js"; +import { createSignal, flush, For, DEV } from "solid-js"; +const stats = DEV!.unifiedFor; import { render } from "@solidjs/web"; type Shape = { @@ -173,15 +174,15 @@ for (const mode of ["slot", "classic"] as const) { for (const container of makeContainers(useIdx, shape)) { test(`${container.name}: full transition matrix`, () => { const [list, setList] = createSignal(CANON); - const engagedBefore = __unifiedForStats.engaged; - const demotedBefore = __unifiedForStats.demoted; + const engagedBefore = stats.engaged; + const demotedBefore = stats.demoted; const [el, dispose] = container.mount(list, null); try { // Mode sanity: slot engages exactly once, classic never. if (mode === "slot") { - expect(__unifiedForStats.engaged).toBe(engagedBefore + 1); + expect(stats.engaged).toBe(engagedBefore + 1); } else { - expect(__unifiedForStats.engaged).toBe(engagedBefore); + expect(stats.engaged).toBe(engagedBefore); } const expected = (arr: string[]) => container.wrap(arr.map(shape.html).join("")); expect(el.innerHTML).toBe(expected(CANON)); @@ -195,7 +196,7 @@ for (const mode of ["slot", "classic"] as const) { } // The whole matrix must run WITHOUT falling back to classic. if (mode === "slot") { - expect(__unifiedForStats.demoted).toBe(demotedBefore); + expect(stats.demoted).toBe(demotedBefore); } } finally { dispose(); diff --git a/packages/web/test/for.unified.siblings.spec.tsx b/packages/web/test/for.unified.siblings.spec.tsx index 2484365e3..39eab5f70 100644 --- a/packages/web/test/for.unified.siblings.spec.tsx +++ b/packages/web/test/for.unified.siblings.spec.tsx @@ -18,7 +18,8 @@ import { beforeEach, describe, expect, test } from "vitest"; // The packaged specifier, NOT ../src — compiled JSX resolves solid-js to // dist; probes must share that instance. -import { createSignal, flush, For, __unifiedForStats } from "solid-js"; +import { createSignal, flush, For, DEV } from "solid-js"; +const stats = DEV!.unifiedFor; import { render } from "@solidjs/web"; describe("unified For: preceding siblings survive bulk paths (P0)", () => { @@ -118,10 +119,10 @@ describe("unified For: preceding siblings survive bulk paths (P0)", () => { container ); // A row whose top level is a FUNCTION demotes to classic. - const before = __unifiedForStats.demoted; + const before = stats.demoted; setList(["a", () => dyn]); flush(); - expect(__unifiedForStats.demoted).toBe(before + 1); + expect(stats.demoted).toBe(before + 1); expect(container.querySelector("h1")).not.toBeNull(); expect(container.querySelector("h1")!.textContent).toBe("Title"); expect(container.querySelectorAll("span").length).toBe(1); @@ -197,10 +198,10 @@ describe("unified For: whole-parent ownership guard (foreign nodes survive)", () const section = container.querySelector("section")!; setList(["c", "b", "a"]); // materialize flush(); - const before = __unifiedForStats.batchCleared; + const before = stats.batchCleared; setList([]); flush(); - expect(__unifiedForStats.batchCleared).toBe(before + 1); + expect(stats.batchCleared).toBe(before + 1); expect(section.innerHTML).toBe(""); }); }); @@ -226,10 +227,10 @@ describe("unified For: empty-rendering rows hold position (no demote)", () => { ); const inputA = container.querySelector("input")!; inputA.value = "typed"; - const before = __unifiedForStats.demoted; + const before = stats.demoted; setList([a, b, { id: "c", hidden: true }]); flush(); - expect(__unifiedForStats.demoted).toBe(before); + expect(stats.demoted).toBe(before); expect(container.querySelector("input")).toBe(inputA); // same node expect(inputA.value).toBe("typed"); // state intact expect(container.querySelectorAll("input").length).toBe(2); diff --git a/packages/web/test/for.unified.spec.tsx b/packages/web/test/for.unified.spec.tsx index 8a69499d3..4782a824d 100644 --- a/packages/web/test/for.unified.spec.tsx +++ b/packages/web/test/for.unified.spec.tsx @@ -14,14 +14,8 @@ * in flight, revert restores committed). */ import { beforeEach, describe, expect, test } from "vitest"; -import { - createRoot, - createSignal, - createOptimisticStore, - flush, - For, - __unifiedForStats -} from "solid-js"; +import { createRoot, createSignal, createOptimisticStore, flush, For, DEV } from "solid-js"; +const stats = DEV!.unifiedFor; // IMPORTANT: the packaged specifier, NOT ../src — compiled JSX resolves // `solid-js`/`@solidjs/web` to dist (browser+development); the stats probe // must come from the SAME solid-js instance the compiled For runs on. No @@ -53,14 +47,14 @@ describe("unified For: engaged semantics parity", () => { } test("creates and ENGAGES the driver", () => { - const before = __unifiedForStats.engaged; + const before = stats.engaged; createRoot(dispose => { disposer = dispose; ; }); flush(); expect(div.innerHTML).toBe("abcd"); - expect(__unifiedForStats.engaged).toBe(before + 1); + expect(stats.engaged).toBe(before + 1); }); test("1 missing", () => { @@ -195,7 +189,7 @@ describe("unified For: element rows and moves preserve identity", () => { describe("unified For: demotion to classic", () => { beforeEach(() => { - __unifiedForStats.demoted = 0; + stats.demoted = 0; }); test("duplicate keys demote and still render correctly", () => { @@ -210,7 +204,7 @@ describe("unified For: demotion to classic", () => { expect(div.innerHTML).toBe("ab"); setList(["a", "a", "b"]); // duplicate identity → driver demotes flush(); - expect(__unifiedForStats.demoted).toBe(1); + expect(stats.demoted).toBe(1); expect(div.innerHTML).toBe("aab"); // Classic owns it from here on — still fully live. setList(["b", "a"]); @@ -230,7 +224,7 @@ describe("unified For: demotion to classic", () => { expect(div.innerHTML).toBe("a"); setList("not-an-array" as any); flush(); - expect(__unifiedForStats.demoted).toBe(1); + expect(stats.demoted).toBe(1); }); }); @@ -293,10 +287,10 @@ describe("unified For: batch clear engagement", () => { }); flush(); expect(div.childNodes.length).toBe(100); - const before = __unifiedForStats.batchCleared; + const before = stats.batchCleared; setList([]); flush(); expect(div.innerHTML).toBe(""); - expect(__unifiedForStats.batchCleared).toBe(before + 1); + expect(stats.batchCleared).toBe(before + 1); }); }); diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-nested-demote.json b/packages/web/test/harness/__artifacts__/slot-hydrate-nested-demote.json new file mode 100644 index 000000000..e83d26c38 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-nested-demote.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-nested-demote", + "shell": "
      • 12
      • 3
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-mismatch.json b/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-mismatch.json new file mode 100644 index 000000000..90b2b68ac --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-mismatch.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-through-demote-mismatch", + "shell": "
    • a
    • b
    • c
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/for-slot-scenarios.tsx b/packages/web/test/harness/for-slot-scenarios.tsx index a6ed0a965..6cf8c3270 100644 --- a/packages/web/test/harness/for-slot-scenarios.tsx +++ b/packages/web/test/harness/for-slot-scenarios.tsx @@ -214,7 +214,90 @@ function SlotThroughChildren() { ); } +// --------------------------------------------------------------------------- +// 10. NESTED lists + mid-fill demote (audit P1): the outer engages, row x's +// nested list engages AND COMMITS, then row y is -rooted → the outer +// demotes. Every claim beneath the outer — including the nested list's +// committed ones — must be handed back so classic's re-run (which +// re-engages the nested lists with the same ids) claims the same nodes. +const NX = { g: "x", items: ["1", "2"], special: false }; +const NY = { g: "y", items: ["3"], special: true }; +function SlotNestedDemote() { + const [groups] = createSignal([NX, NY]); + const inner = (g: typeof NX) => ( +
      + {item => {item}} +
    + ); + return ( +
      + + {group => + group.special ? ( + +
    • {inner(group)}
    • +
      + ) : ( +
    • {inner(group)}
    • + ) + } +
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 11. Through-children + mid-fill demote + server MISMATCH (audit P2). The +// demote re-enters classic SYNCHRONOUSLY inside the hydration window (a +// deferred re-run would clone instead of claim): rows a/b are the server +// nodes, zero warnings. The leftover server row `c` SURVIVES — classic's +// own hydration is a claim pass that never removes server leftovers, so +// this is exactly what a never-slotted app shows on the same mismatch +// (ruled: classic parity, not a slot defect). The hosting effect keeps +// the real range as `current`, so a later children change cleans it. +function SlotThroughDemoteMismatch() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( + + + {item => + item === "b" ? ( + +
  • {item}
  • +
    + ) : ( +
  • {item}
  • + ) + } +
    +
    + ); +} + export const forSlotScenarios: ForSlotScenario[] = [ + { + name: "slot-hydrate-nested-demote", + App: SlotNestedDemote, + expectedText: "123", + // Attempt 1: outer + nested x + nested y (the Show-rooted row's
  • + // template runs its hole insert before the outer sees the function and + // demotes). Classic re-run: nested x + nested y again. All five claim + // cleanly — the restore covered every nested claim beneath the outer. + engaged: 5, + demoted: 1, + warnings: 0, + identitySelector: "span" + }, + { + name: "slot-hydrate-through-demote-mismatch", + App: SlotThroughDemoteMismatch, + expectedText: "abc", // classic parity: the claim pass leaves server leftovers + serverText: "abc", + engaged: 1, + demoted: 1, + warnings: 1, // the runtime's honest "1 unclaimed server-rendered node" report + identitySelector: "li" + }, { name: "slot-hydrate-through-children", App: SlotThroughChildren, diff --git a/packages/web/test/hydration/for-slot.spec.tsx b/packages/web/test/hydration/for-slot.spec.tsx index 7236e3fac..2773e8e7b 100644 --- a/packages/web/test/hydration/for-slot.spec.tsx +++ b/packages/web/test/hydration/for-slot.spec.tsx @@ -13,7 +13,8 @@ import { describe, expect, test, vi } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { flush, __unifiedForStats } from "solid-js"; +import { flush, DEV } from "solid-js"; +const stats = DEV!.unifiedFor; import { hydrate } from "@solidjs/web"; import { forSlotScenarios, type ForSlotScenario } from "../harness/for-slot-scenarios.jsx"; @@ -54,20 +55,16 @@ async function run(scenario: ForSlotScenario) { ) : null; - const engaged0 = __unifiedForStats.engaged; - const demoted0 = __unifiedForStats.demoted; + const engaged0 = stats.engaged; + const demoted0 = stats.demoted; dispose = hydrate(() => , container); flush(); await sleep(10); flush(); expect(container.textContent, "hydrated text").toBe(scenario.expectedText); - expect(__unifiedForStats.engaged - engaged0, "slots engaged during hydrate").toBe( - scenario.engaged - ); - expect(__unifiedForStats.demoted - demoted0, "slots demoted during hydrate").toBe( - scenario.demoted - ); + expect(stats.engaged - engaged0, "slots engaged during hydrate").toBe(scenario.engaged); + expect(stats.demoted - demoted0, "slots demoted during hydrate").toBe(scenario.demoted); expect(warn, "console.warn calls during hydrate").toHaveBeenCalledTimes(scenario.warnings); if (serverRows) { @@ -101,7 +98,7 @@ async function run(scenario: ForSlotScenario) { } } // No demote may happen on the post-hydration update either. - expect(__unifiedForStats.demoted - demoted0).toBe(scenario.demoted); + expect(stats.demoted - demoted0).toBe(scenario.demoted); } } finally { warn.mockRestore(); From 6d0ddc22f04a84e00f58c9b24ed036f7f242d213 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 02:24:06 -0700 Subject: [PATCH 16/20] chore(size): ratchet after rebase over #3183 and audit round 3 Co-authored-by: Cursor --- scripts/size/.size-limit.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index ece5df8a1..c12892631 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -412,7 +412,11 @@ module.exports = [ // // Anchored-hole hydration (2026-09-05): 11.00 -> 11.03 KB, measured at // 11.02 — the active-hydration guard on the seam's region hand-off. - limit: "11.03 KB", + // + // Rebase over #3183 (responsive preloads) + audit round 3 (2026-09-05): + // 11.03 -> 11.07 KB, measured at 11.06 — upstream head.ts drift plus the + // hole seam's synchronous hydration re-entry branch. + limit: "11.07 KB", modifyEsbuildConfig }, { @@ -499,7 +503,10 @@ module.exports = [ // Anchored-hole hydration (2026-09-05): 20.53 -> 20.57 KB, measured at // 20.56 — comment-bounded holes engage under hydration (hydrationRt // hands the slot the marker-bounded region minus comment markers). - limit: "20.57 KB", + // + // Rebase over #3183 + audit round 3 (2026-09-05): 20.57 -> 20.59 KB, + // measured at 20.587 (nested claim-recording stack; sync re-entry). + limit: "20.59 KB", modifyEsbuildConfig }, { @@ -664,7 +671,10 @@ module.exports = [ // // Unified For HOLE seam (2026-09-05): 15.49 -> 15.62 KB, measured at // 15.62 (see the simple-app note; hydrating scenarios +67-147 B). - limit: "15.62 KB", + // + // Rebase over #3183 + audit round 3 (2026-09-05): 15.62 -> 15.66 KB, + // measured at 15.65 (see the simple-app note). + limit: "15.66 KB", modifyEsbuildConfig }, { From 017db37815419e1f303b9d17a83166b1f598a26d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 22:13:50 -0700 Subject: [PATCH 17/20] =?UTF-8?q?fix(web,solid):=20unified=20For=20hydrati?= =?UTF-8?q?on=20=E2=80=94=20hydrating=20demote=20writes=20the=20hosting=20?= =?UTF-8?q?effect's=20range;=20dev=20mismatch=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit r3 follow-up (P2 residue): the hydrating demote re-entered classic via a NESTED insert, which owned a private `current` — the hosting effect's range stayed frozen at the claimed region (holeClassic, no bump, never re-ran), and classic insert removes nothing on dispose, so rows classic appended after the demote survived a later children change ('noned'). The hosting effect's classic inner-effect block is now a shared local `classic(value, prev)`; the hydrating demote calls it under the hosting owner, so classic writes THIS insert's `current` and a children change cleans everything. Scenario: through-children + Show-rooted row → demote, append, swap children → 'none'. Design note taken: commitFill emits one dev warning when it repairs a server/client mismatch (element rows removed/inserted) — the slot is stricter than classic's claim pass and must not be silent about it. Text-row churn (fresh text swapping in for server text) is excluded; adopting server text nodes for primitive rows is the follow-up. Scenario warning counts updated (mismatch-fewer 1, mismatch-more 2, trailing-mismatch-fewer 1). Co-authored-by: Cursor --- .../solid/src/client/for-slot-hydration.ts | 31 +++++++++- packages/web/src/client.ts | 43 ++++++++------ .../slot-hydrate-through-demote-residue.json | 5 ++ .../web/test/harness/for-slot-scenarios.tsx | 59 +++++++++++++++++-- scripts/size/.size-limit.js | 4 +- 5 files changed, 114 insertions(+), 28 deletions(-) create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-residue.json diff --git a/packages/solid/src/client/for-slot-hydration.ts b/packages/solid/src/client/for-slot-hydration.ts index 8d4b72e98..93a90e8a1 100644 --- a/packages/solid/src/client/for-slot-hydration.ts +++ b/packages/solid/src/client/for-slot-hydration.ts @@ -16,6 +16,7 @@ * key-missed fresh rows inserted); the normal case is zero DOM writes. */ import { sharedConfig } from "./hydration.js"; +import { IS_DEV } from "./core.js"; import { installSlotHydration, type FlatPlan, type Slot } from "./for-slot.js"; /** RECORDING STACK. Nested lists hydrate INSIDE an outer row's build (row → @@ -97,9 +98,18 @@ const hooks = { else ours.add(nd); } // Leftovers: server rows the client no longer has, separator comments. + let removed = 0; + let inserted = 0; const region = slot.region!; for (let i = 0; i < region.length; i++) - if (!ours.has(region[i]) && ops.contains(slot.parent, region[i])) ops.remove(region[i]); + if (!ours.has(region[i]) && ops.contains(slot.parent, region[i])) { + ops.remove(region[i]); + // Element rows only: primitive rows currently re-create their text + // node (fresh text swaps in for the server's — correct DOM, not a + // mismatch). Adopting server text nodes for primitive rows is the + // follow-up that makes that path zero-write too. + if ((region[i] as any).nodeType === 1) removed++; + } // Fresh rows (template key-missed → detached; the runtime already // warned) are inserted at their position, back to front so anchors are // always attached. The list ends at the hole's end marker (or the @@ -109,14 +119,29 @@ const hooks = { const nd = fp.nodes[i]; if (Array.isArray(nd)) { for (let k = nd.length - 1; k >= 0; k--) { - if (!ops.contains(slot.parent, nd[k])) ops.insert(slot.parent, nd[k], anchor); + if (!ops.contains(slot.parent, nd[k])) { + ops.insert(slot.parent, nd[k], anchor); + if ((nd[k] as any).nodeType === 1) inserted++; + } anchor = nd[k]; } } else { - if (!ops.contains(slot.parent, nd)) ops.insert(slot.parent, nd, anchor); + if (!ops.contains(slot.parent, nd)) { + ops.insert(slot.parent, nd, anchor); + if ((nd as any).nodeType === 1) inserted++; + } anchor = nd; } } + // The slot REPAIRS a server/client mismatch (classic's claim pass leaves + // leftovers in place and reports them at hydration end); repairing + // silently would hide the mismatch, so say so once, in dev. + if (IS_DEV && (removed !== 0 || inserted !== 0)) + console.warn( + `Hydration mismatch in : the server rendered a different list than the client ` + + `(${removed} unclaimed server row node(s) removed, ${inserted} client row node(s) inserted). ` + + `The DOM was repaired, but server and client should render the same list.` + ); slot.region = undefined; slot.flat = { items: fp.items, owners: fp.owners, nodes: fp.nodes }; slot.size = fp.len; diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 96fa15cec..255cb891a 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -1011,6 +1011,24 @@ export function insert(parent, accessor, marker, initial, options) { // ever see a For) so this effect re-runs and takes its classic path. let holeClassic = false; let holeGen = null; + // The classic inner effect for a function-valued hole. Shared by the + // normal path and the hydrating demote re-entry so BOTH write this insert's + // `current` — a nested insert() would own a private range and leave the + // rows classic appends afterward invisible to this effect's cleanup. + const classic = (value, prev) => + effect( + () => ( + hydrationRt !== null && (current = hydrationRt.reclaimRegion(current, parent, marker)), + normalize(value, current, multi) + ), + inner => { + current = insertExpression(parent, inner, current, marker); + host && tagHost(current, host); + }, + prev !== undefined && !(options && options.schedule) + ? { ...options, schedule: true } + : options + ); effect( prev => { if (hydrationRt !== null) current = hydrationRt.reclaimRegion(current, parent, marker); @@ -1062,12 +1080,11 @@ export function insert(parent, accessor, marker, initial, options) { // Demote DURING a hydrating fill: re-enter classic NOW, inside // the hydration window — the deferred re-run below would land // after hydrate() flips the flag and CLONE instead of claim. - // `() => listFn()` INVOKES the list (classic rows), so this - // insert cannot re-engage; `current` is the server region, so - // classic reconciles against the real rows (mismatch cleaned). - runWithOwner(holeOwner, () => - insert(parent, () => listFn(), marker, current, options) - ); + // The SHARED classic effect (not a nested insert) so the rows + // classic manages from here on live in this effect's + // `current` and a later children change cleans them. + // normalize() unwraps the list (classic rows) — no re-engage. + runWithOwner(holeOwner, () => classic(listFn, undefined)); } else holeGen[1](g => g + 1); }, domOps, @@ -1079,19 +1096,7 @@ export function insert(parent, accessor, marker, initial, options) { return INNER_OWNED; } } - effect( - () => ( - hydrationRt !== null && (current = hydrationRt.reclaimRegion(current, parent, marker)), - normalize(value, current, multi) - ), - inner => { - current = insertExpression(parent, inner, current, marker); - host && tagHost(current, host); - }, - prev !== undefined && !(options && options.schedule) - ? { ...options, schedule: true } - : options - ); + classic(value, prev); return INNER_OWNED; }, value => { diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-residue.json b/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-residue.json new file mode 100644 index 000000000..23e9b861c --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-residue.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-through-demote-residue", + "shell": "
  • a
  • b
  • c
  • ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/for-slot-scenarios.tsx b/packages/web/test/harness/for-slot-scenarios.tsx index 6cf8c3270..3d4e5f28b 100644 --- a/packages/web/test/harness/for-slot-scenarios.tsx +++ b/packages/web/test/harness/for-slot-scenarios.tsx @@ -16,7 +16,7 @@ * * Mismatch scenarios diverge on `isServer` so one source renders both sides. */ -import { createSignal, For, Show } from "solid-js"; +import { createSignal, flush, For, Show } from "solid-js"; import { isServer } from "@solidjs/web"; export type ForSlotScenario = { @@ -274,7 +274,58 @@ function SlotThroughDemoteMismatch() { ); } +// --------------------------------------------------------------------------- +// 12. Through-children + mid-fill demote + LATER children change (audit r3 +// P2 follow-up): after the synchronous classic re-entry, rows classic +// appends must live in the HOSTING effect's range — swapping the children +// out afterwards must leave no list residue ("noned" was the leak). +function ShellWrap(props: { children: any }) { + return
    {props.children}
    ; +} +let residueItems!: (v: string[]) => void; +let residueShow!: (v: boolean) => void; +function SlotThroughDemoteResidue() { + const [items, setItems] = createSignal(["a", "b", "c"]); + const [show, setShow] = createSignal(true); + residueItems = setItems; + residueShow = setShow; + return ( + + {show() ? ( + + {item => + item === "b" ? ( + +
  • {item}
  • +
    + ) : ( +
  • {item}
  • + ) + } +
    + ) : ( +

    none

    + )} +
    + ); +} + export const forSlotScenarios: ForSlotScenario[] = [ + { + name: "slot-hydrate-through-demote-residue", + App: SlotThroughDemoteResidue, + expectedText: "abc", + engaged: 1, + demoted: 1, + warnings: 0, + identitySelector: "li", + update: () => { + residueItems(["a", "b", "c", "d"]); // classic (post-demote) appends d + flush(); + residueShow(false); // children change: the hosting effect must clean d too + }, + expectedTextAfterUpdate: "none" + }, { name: "slot-hydrate-nested-demote", App: SlotNestedDemote, @@ -339,7 +390,7 @@ export const forSlotScenarios: ForSlotScenario[] = [ serverText: "abc", engaged: 1, demoted: 0, - warnings: 0, + warnings: 1, // the slot's repair report (leftover server row removed) identitySelector: "li" }, { @@ -349,7 +400,7 @@ export const forSlotScenarios: ForSlotScenario[] = [ serverText: "ab", engaged: 1, demoted: 0, - warnings: 1 + warnings: 2 // the runtime's key-miss + the slot's repair report }, { name: "slot-hydrate-demote-mid-fill", @@ -403,7 +454,7 @@ export const forSlotScenarios: ForSlotScenario[] = [ serverText: "headabc", engaged: 1, demoted: 0, - warnings: 0, + warnings: 1, // the slot's repair report identitySelector: "li" }, { diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index c12892631..267180e3a 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -613,7 +613,7 @@ module.exports = [ // // Anchored-hole hydration (2026-09-05): 29.29 -> 29.45 KB, measured at // 29.44 (see the hydrating no-stores note). - limit: "29.45 KB", + limit: "29.47 KB", modifyEsbuildConfig }, { @@ -674,7 +674,7 @@ module.exports = [ // // Rebase over #3183 + audit round 3 (2026-09-05): 15.62 -> 15.66 KB, // measured at 15.65 (see the simple-app note). - limit: "15.66 KB", + limit: "15.67 KB", modifyEsbuildConfig }, { From 6801317b69c509cc0b144bf133eff8f88f3509ae Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 22:49:05 -0700 Subject: [PATCH 18/20] fix(web): unified For direct seam hands classic the bounded region on a hydrating demote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit r3 minor asymmetry: the direct path's lateClassic passed initial=[] for anchored holes, discarding the bounded region the slot had, so a primitive row in a demoting heterogeneous list had no positional text node to adopt. Now region ?? [] — same as the hole seam. Whole-parent still re-derives via claimInitial. (+2-7 B brotli, ratcheted.) Co-authored-by: Cursor --- packages/web/src/client.ts | 21 +++++++++++++-------- scripts/size/.size-limit.js | 6 +++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 255cb891a..0beadb09f 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -955,6 +955,14 @@ export function insert(parent, accessor, marker, initial, options) { if (typeof accessor === "function" && accessor.$for !== undefined) { const listAccessor = accessor; const owner = getOwner(); + // Hydration: the claimed region snapshot — the parent's childNodes + // (claimInitial, whole-parent) or the comment-bounded hole range the + // compiled client resolved via getNextMarker (anchored holes). The + // slot's fill reconciles claimed rows against it. + const region = + hydrationRt !== null && isHydrating(parent) && Array.isArray(initial) + ? hydrationRt.slotRegion(initial) + : undefined; if ( // Marker passes through UNTOUCHED: `undefined` = whole-parent insert, // `null` = trailing child with preceding siblings (classic MULTI mode @@ -971,18 +979,15 @@ export function insert(parent, accessor, marker, initial, options) { parent, () => listAccessor(), marker, - marker !== undefined ? [] : undefined, + // Anchored holes: hand classic the bounded region the slot had + // (a hydrating demote's primitive rows adopt positional text + // from it — same as the hole seam); whole-parent re-derives. + marker !== undefined ? (region ?? []) : undefined, options ) ), domOps, - // Hydration: the claimed region snapshot — the parent's childNodes - // (claimInitial, whole-parent) or the comment-bounded hole range the - // compiled client resolved via getNextMarker (anchored holes). The - // slot's fill reconciles claimed rows against it. - hydrationRt !== null && isHydrating(parent) && Array.isArray(initial) - ? hydrationRt.slotRegion(initial) - : undefined + region ) ) return; diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 267180e3a..c4a739b0c 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -506,7 +506,7 @@ module.exports = [ // // Rebase over #3183 + audit round 3 (2026-09-05): 20.57 -> 20.59 KB, // measured at 20.587 (nested claim-recording stack; sync re-entry). - limit: "20.59 KB", + limit: "20.60 KB", modifyEsbuildConfig }, { @@ -613,7 +613,7 @@ module.exports = [ // // Anchored-hole hydration (2026-09-05): 29.29 -> 29.45 KB, measured at // 29.44 (see the hydrating no-stores note). - limit: "29.47 KB", + limit: "29.48 KB", modifyEsbuildConfig }, { @@ -674,7 +674,7 @@ module.exports = [ // // Rebase over #3183 + audit round 3 (2026-09-05): 15.62 -> 15.66 KB, // measured at 15.65 (see the simple-app note). - limit: "15.67 KB", + limit: "15.68 KB", modifyEsbuildConfig }, { From 52f365e25afee218d765756517133b7dbb31cb08 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 22:52:48 -0700 Subject: [PATCH 19/20] =?UTF-8?q?test(web):=20pin=20text-row=20hydration?= =?UTF-8?q?=20mismatch=20=E2=80=94=20no=20orphaned=20or=20duplicated=20ser?= =?UTF-8?q?ver=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three harness scenarios (whole-parent fewer/more, anchored fewer): the fill removes every region node that isn't ours before inserting fresh text, so server text never survives beside its fresh twin. Exact textContent asserted (a surviving node would add characters). Co-authored-by: Cursor --- ...-hydrate-text-anchored-mismatch-fewer.json | 5 ++ .../slot-hydrate-text-mismatch-fewer.json | 5 ++ .../slot-hydrate-text-mismatch-more.json | 5 ++ .../web/test/harness/for-slot-scenarios.tsx | 60 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-text-anchored-mismatch-fewer.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-text-mismatch-fewer.json create mode 100644 packages/web/test/harness/__artifacts__/slot-hydrate-text-mismatch-more.json diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-text-anchored-mismatch-fewer.json b/packages/web/test/harness/__artifacts__/slot-hydrate-text-anchored-mismatch-fewer.json new file mode 100644 index 000000000..29b052b24 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-text-anchored-mismatch-fewer.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-text-anchored-mismatch-fewer", + "shell": "
    • head
    • abc
    • tail
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-text-mismatch-fewer.json b/packages/web/test/harness/__artifacts__/slot-hydrate-text-mismatch-fewer.json new file mode 100644 index 000000000..b06cd1570 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-text-mismatch-fewer.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-text-mismatch-fewer", + "shell": "
      abc
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-text-mismatch-more.json b/packages/web/test/harness/__artifacts__/slot-hydrate-text-mismatch-more.json new file mode 100644 index 000000000..1bf76aceb --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-text-mismatch-more.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-text-mismatch-more", + "shell": "
      ab
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/for-slot-scenarios.tsx b/packages/web/test/harness/for-slot-scenarios.tsx index 3d4e5f28b..7ce2e259b 100644 --- a/packages/web/test/harness/for-slot-scenarios.tsx +++ b/packages/web/test/harness/for-slot-scenarios.tsx @@ -310,7 +310,67 @@ function SlotThroughDemoteResidue() { ); } +// --------------------------------------------------------------------------- +// 13. TEXT-row mismatch, both directions: server text nodes must never +// survive beside their fresh twins (no orphan, no duplicate) — the fill +// removes every region node that isn't ours before inserting. +function SlotTextFewer() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( +
      + {item => item} +
    + ); +} +function SlotTextMore() { + const [items] = createSignal(isServer ? ["a", "b"] : ["a", "b", "c"]); + return ( +
      + {item => item} +
    + ); +} +// Anchored text rows (comment-bounded region with separators) — mismatch. +function SlotTextAnchoredFewer() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( +
      +
    • head
    • + {item => item} +
    • tail
    • +
    + ); +} + export const forSlotScenarios: ForSlotScenario[] = [ + { + name: "slot-hydrate-text-mismatch-fewer", + App: SlotTextFewer, + expectedText: "ab", + serverText: "abc", + engaged: 1, + demoted: 0, + warnings: 0 // text churn is excluded from the repair report (follow-up: adopt server text) + }, + { + name: "slot-hydrate-text-mismatch-more", + App: SlotTextMore, + expectedText: "abc", + serverText: "ab", + engaged: 1, + demoted: 0, + warnings: 0 + }, + { + name: "slot-hydrate-text-anchored-mismatch-fewer", + App: SlotTextAnchoredFewer, + expectedText: "headabtail", + serverText: "headabctail", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li" + }, { name: "slot-hydrate-through-demote-residue", App: SlotThroughDemoteResidue, From 0c262a4b7a9d4d93ae332517f33f441366183c6e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 5 Sep 2026 23:34:50 -0700 Subject: [PATCH 20/20] refactor(web): unified For hole demote is synchronous in all modes; rebase over the #3187 revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #3187 revert (eager Dynamic creation, #3291) removed insert's insertion-parent tracking; the hole seam and the shared classic() helper drop their withInsertionParent wrappers accordingly. Checking the slot against the revert surfaced a real simplification: the CSR hole demote deferred the classic re-run through a lazily-created signal, which left the hole EMPTY for a microtask (render() then a sync querySelector saw no rows — classic shows them synchronously). The hydration path already re-entered classic synchronously via the shared classic() effect, and nothing about that required hydration: the slot's demote() has removed its rows and disposed its owner, holeClassic steers a later children-change re-run, and the synchronous classic effect writes the shared `current`. One path now for CSR and hydration — no holeGen, no ownedWrite signal, no empty-hole microtask. Dynamic-rooted rows: pinned that they demote cleanly (Dynamic returns a memo, so the row's top level is a function regardless of eager/deferred element creation) with the DOM correct synchronously after render(). Budgets locked in DOWN: floor 11.07 -> 10.98, CSR 15.68 -> 15.61, hydrating 20.60 -> 20.54, store 29.48 -> 29.40. web 760 / server 758 / hydrate 182 / solid 585 / signals 1490 / universal 43. Co-authored-by: Cursor --- packages/web/src/client.ts | 37 +++++++------------ .../web/test/for.unified.children.spec.tsx | 30 ++++++++++++++- scripts/size/.size-limit.js | 23 ++++++++++-- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 0beadb09f..847ef8c05 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -12,7 +12,6 @@ import { merge as mergeProps, flatten, createMemo, - createSignal, flush, enableHydration, enforceLoadingBoundary, @@ -1010,12 +1009,11 @@ export function insert(parent, accessor, marker, initial, options) { // wrapper (`{props.children}` in a parent component compiles to // `insert(el, () => props.children)`) engages the slot for the hole. The // slot is created inside this compute, so a children change tears it down - // (hole-mode cleanup removes its rows). A post-engage demote can't spawn a - // second insert into a hole this effect owns — instead it flips - // `holeClassic` and bumps `holeGen` (created lazily, only for holes that - // ever see a For) so this effect re-runs and takes its classic path. + // (hole-mode cleanup removes its rows). A demote hands the hole to the + // SHARED classic effect synchronously (the slot has already removed its + // rows and disposed its owner) and flips `holeClassic` so a later + // children-change re-run takes the classic path directly. let holeClassic = false; - let holeGen = null; // The classic inner effect for a function-valued hole. Shared by the // normal path and the hydrating demote re-entry so BOTH write this insert's // `current` — a nested insert() would own a private range and leave the @@ -1037,16 +1035,9 @@ export function insert(parent, accessor, marker, initial, options) { effect( prev => { if (hydrationRt !== null) current = hydrationRt.reclaimRegion(current, parent, marker); - if (holeGen !== null) holeGen[0](); const value = normalize(accessor(), current, multi, true); if (typeof value !== "function") return value; if (value.$for !== undefined && !holeClassic) { - if (holeGen === null) { - // ownedWrite: the demote bump is internal machinery and may fire - // from inside an owned scope (a hydrating fill's demote). - holeGen = createSignal(0, { ownedWrite: true }); - holeGen[0](); - } // Hand-off: whatever classic content this hole tracked goes away // first (a For returning after other children). Multi holes keep // insert's placeholder invariant — a surviving anchor the slot's @@ -1080,17 +1071,17 @@ export function insert(parent, accessor, marker, initial, options) { value, marker, () => { + // Demote: the slot has removed its rows and disposed its owner; + // hand the hole to the SHARED classic effect NOW (synchronous in + // CSR and hydration alike — under hydration a deferred re-run + // would land after hydrate() flips the flag and clone instead + // of claim; in CSR it left the hole empty for a microtask). + // Shared, not a nested insert: the rows classic manages from + // here live in THIS effect's `current`, so a later children + // change cleans them. normalize() unwraps the list (classic + // rows) — no re-engage. holeClassic = true; - if (sharedConfig.hydrating) { - // Demote DURING a hydrating fill: re-enter classic NOW, inside - // the hydration window — the deferred re-run below would land - // after hydrate() flips the flag and CLONE instead of claim. - // The SHARED classic effect (not a nested insert) so the rows - // classic manages from here on live in this effect's - // `current` and a later children change cleans them. - // normalize() unwraps the list (classic rows) — no re-engage. - runWithOwner(holeOwner, () => classic(listFn, undefined)); - } else holeGen[1](g => g + 1); + runWithOwner(holeOwner, () => classic(listFn, undefined)); }, domOps, region, diff --git a/packages/web/test/for.unified.children.spec.tsx b/packages/web/test/for.unified.children.spec.tsx index 3caa41c11..69a7d86ee 100644 --- a/packages/web/test/for.unified.children.spec.tsx +++ b/packages/web/test/for.unified.children.spec.tsx @@ -18,7 +18,7 @@ import { beforeEach, describe, expect, test } from "vitest"; import { createSignal, flush, For, children, DEV } from "solid-js"; const stats = DEV!.unifiedFor; -import { render } from "@solidjs/web"; +import { render, Dynamic } from "@solidjs/web"; function Table(props: { children: any }) { return ( @@ -171,6 +171,34 @@ describe("unified For through props.children (hole seam)", () => { expect(div.innerHTML).toBe(""); }); + test("-rooted rows demote cleanly to classic (memo top level) and stay correct", () => { + // Dynamic returns a MEMO (its `component` may change), so the row's top + // level is a function whether element creation is eager (#3291 revert) + // or deferred (#3187) — the slot demotes, classic owns the hole. Pinned + // through the revert so the contract is explicit either way. + const [rows, setRows] = createSignal(["a", "b", "c"]); + const engaged0 = stats.engaged; + const demoted0 = stats.demoted; + dispose = render( + () => ( +
    + {r => {r}} +
    + ), + container + ); + expect(stats.engaged).toBe(engaged0 + 1); + expect(stats.demoted).toBe(demoted0 + 1); + expect(texts(container, "tr")).toEqual(["a", "b", "c"]); + setRows(["c", "a", "b"]); + flush(); + expect(texts(container, "tr")).toEqual(["c", "a", "b"]); + setRows([]); + flush(); + expect(container.querySelector("tbody")!.innerHTML).toBe(""); + expect(stats.demoted).toBe(demoted0 + 1); // one demote, no thrash + }); + test("children() introspection stays classic and correct", () => { const [rows, setRows] = createSignal(["a", "b"]); const engaged0 = stats.engaged; diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index c4a739b0c..5551423ae 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -416,7 +416,13 @@ module.exports = [ // Rebase over #3183 (responsive preloads) + audit round 3 (2026-09-05): // 11.03 -> 11.07 KB, measured at 11.06 — upstream head.ts drift plus the // hole seam's synchronous hydration re-entry branch. - limit: "11.07 KB", + // + // Rebase over the #3187 revert + synchronous hole demote (2026-09-05): + // 11.07 -> 10.98 KB, measured at 10.97. Insertion-parent tracking left + // insert with the revert, and the hole seam's lazy demote signal + // (holeGen) is gone — a demote hands the hole to the shared classic + // effect synchronously in CSR and hydration alike. Locked in. + limit: "10.98 KB", modifyEsbuildConfig }, { @@ -506,7 +512,10 @@ module.exports = [ // // Rebase over #3183 + audit round 3 (2026-09-05): 20.57 -> 20.59 KB, // measured at 20.587 (nested claim-recording stack; sync re-entry). - limit: "20.60 KB", + // + // #3187 revert + synchronous hole demote (2026-09-05): 20.60 -> 20.54 KB, + // measured at 20.53. Locked in. + limit: "20.54 KB", modifyEsbuildConfig }, { @@ -613,7 +622,10 @@ module.exports = [ // // Anchored-hole hydration (2026-09-05): 29.29 -> 29.45 KB, measured at // 29.44 (see the hydrating no-stores note). - limit: "29.48 KB", + // + // #3187 revert + synchronous hole demote (2026-09-05): 29.48 -> 29.40 KB, + // measured at 29.39. Locked in. + limit: "29.40 KB", modifyEsbuildConfig }, { @@ -674,7 +686,10 @@ module.exports = [ // // Rebase over #3183 + audit round 3 (2026-09-05): 15.62 -> 15.66 KB, // measured at 15.65 (see the simple-app note). - limit: "15.68 KB", + // + // #3187 revert + synchronous hole demote (2026-09-05): 15.68 -> 15.61 KB, + // measured at 15.60. Locked in. + limit: "15.61 KB", modifyEsbuildConfig }, {