diff --git a/.changeset/unified-for-slot.md b/.changeset/unified-for-slot.md new file mode 100644 index 000000000..64ee71b18 --- /dev/null +++ b/.changeset/unified-for-slot.md @@ -0,0 +1,18 @@ +--- +"solid-js": patch +"@solidjs/web": patch +"@solidjs/signals": patch +"@solidjs/universal": patch +--- + +Unified For: on web, keyed `` is driven by one persistent engine that owns both row bookkeeping and DOM placement — an intrusive row chain updated by a prefix/suffix walk plus a middle-window pass (mapArray's own matching, duplicates included) inside an ordinary two-phase render effect, with LIS placement at commit — 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 for identity-keyed rows (parallel arrays; the chain materializes lazily on the first partial structural op). + +**Semantics are mapArray's, unchanged.** The engine implements every `For` mode itself — `keyed` default (reference identity), `keyed={false}` (positional reuse, item accessor + plain index), `keyed={fn}` (key function, item accessor + index accessor), index accessors by row arity, legal duplicates (the second occurrence of a key reuses the second old row, in mapArray's pairing order), `fallback` as an owned empty-state row, array-like subjects — so there is no engage/decline/demote seam and no classic fallback path on web. **There is one list implementation.** The engine lives in `@solidjs/signals` (`list.ts`); `mapArray` _is_ its array output (same API and contract: same values, same array identity while structurally unchanged, `[fallback]` when empty, `_parentComputed` routing for row bodies), and a plain call of a `` accessor (`children()`, introspection, renderers that don't engage) returns the same. `For`'s rendered output drives the engine through a **node layer** — the only code that touches nodes — which solid-js builds over a renderer's `SlotOps`; web (`domOps`) and `@solidjs/universal` (`createRenderer` primitives) both consume it. The pre-engine mapArray is kept only as a test reference: the oracle harness compares the engine against it across every mode, dynamic rows, duplicates, fallback and hydration. Rows live under the ``'s creation owner (context, boundaries, lifetime follow the source position); a row that throws during its build disposes its owner before the error rides the boundary; empty-rendering rows render zero nodes (neighbor-anchored); the list end stays contiguous past foreign trailing nodes; removes only detach nodes still under the list's parent. + +Rows whose top level resolves to a function — a component returning ``/``/a conditional, a memo, a fragment with accessor children — are dynamic rows: built once (owned, untracked) and resolved by the engine's own compute, tracked, exactly the `flatten` read classic's insert effect performs for them. No per-row effect, no marker nodes, so user row code never runs twice; a flip splices only that row's range, reusing positional text nodes with a `.data` write; a NotReady thrown from a row's resolution parks the built plan and the retry reuses the rows. + +Delivery is zero-API and zero-compiler: `For` stamps a `$for` descriptor on its accessor, the engine rides `For`'s module graph, and web's `insert` engages it with a renderer-ops singleton (`domOps`); the engine itself is platform-free (opaque `SlotNode`, every node touch through `SlotOps`). `@solidjs/universal`'s `createRenderer` engages it too, with ops built from the renderer's own primitives (`insertNode`, `removeNode`, `getNextSibling`, `replaceText`, …; nodes are non-array objects, and text data is tracked for engine-created text nodes) — no new renderer options. A `For` passed through a component's `{props.children}` engages on both platforms. + +Hydration: lists engage during hydration and claim the server rows themselves (whole-parent and comment-bounded holes), minting the same ids classic's mapArray owner would (`For` peeks the id via an `enableHydration()`-installed hook; mapArray gains an internal `lazy` option). Primitive rows adopt the server's positional text nodes; the fill commit reconciles against the region only on server/client mismatch and reports the repair once in dev. Nothing can demote mid-fill, so claims are never handed back. Hydration code lives in a module installed by `enableHydration()` — CSR bundles shake it. + +Internal: `@solidjs/signals` exports the engine (`createListEngine`, `listArray`, the node-layer types) marked `@internal` for solid-js. diff --git a/packages/signals/src/index.ts b/packages/signals/src/index.ts index 605654c88..6769d479b 100644 --- a/packages/signals/src/index.ts +++ b/packages/signals/src/index.ts @@ -81,6 +81,30 @@ export type { } from "./signals.js"; export { affects } from "./affects.js"; export { mapArray, repeat, type Maybe } from "./map.js"; +/** @internal The list engine behind mapArray and : solid-js builds a + * node layer over its renderer ops and drives the RENDERED output; the + * ARRAY output is mapArray itself. */ +export { + createListEngine, + listArray, + IDENTICAL as LIST_IDENTICAL, + firstOf, + lastOf, + nodesOf, + firstNodeOf, + lastNodeOf, + firstNodeFrom, + type ListEngine, + type ListMeta, + type ListNodeLayer, + type ListSlot, + type ListRow, + type ListFlatPlan, + type ListPlan, + type ListNode, + type Nodes as ListNodes, + type Leaves as ListLeaves +} from "./list.js"; export * from "./store/index.js"; export { createLoadingBoundary, diff --git a/packages/signals/src/list.ts b/packages/signals/src/list.ts new file mode 100644 index 000000000..485ba1d19 --- /dev/null +++ b/packages/signals/src/list.ts @@ -0,0 +1,1335 @@ +/** + * The LIST ENGINE — row bookkeeping for a keyed list, in every mode, with two + * outputs sharing one implementation: + * + * - ARRAY output (`mapArray`, and a plain call of a `` accessor): a + * memo of the row values — the mapped array, same identity while the + * list is structurally unchanged, `[fallback]` when empty with one. + * - RENDERED output (`` engaged by a renderer): rows are placed and + * moved as nodes, through a NODE LAYER the renderer hands in. The engine + * itself never touches a node: `ListNodeLayer` is the whole surface, and + * when it is `null` (array output) nothing node-related is referenced — + * mapArray-only consumers never load a node layer. + * + * Modes (mapArray's contract, unchanged): + * - `keyed` default: rows keyed by item REFERENCE; row fn gets the item + * (and an index accessor when its arity asks for one). + * - `keyed: false`: rows reused by POSITION; row fn gets an item accessor + * and a plain index; tail append/remove only. + * - `keyed: fn`: rows keyed by `fn(item)`; row fn gets an item accessor + * (and an index accessor by arity). + * - duplicates are legal (the second occurrence of a key reuses the second + * old row — the chained index map, per pass). + * - `fallback` renders as the empty state, owned like a row. + * + * STRUCTURE: an intrusive doubly-linked chain of rows, updated by a + * prefix/suffix walk plus a middle-window pass, with an LIS at commit for + * move-minimal placement. FLAT MODE (create economics): keyed lists fill as + * parallel arrays (no Row objects, no chain) and materialize the chain lazily + * on the first partial structural op — measured +5-10% on 10k create/clear, + * parity at 1k (2026-09-07). Array output and `keyed: false` are chain-only. + * + * PHASE DISCIPLINE (rendered output): the COMPUTE half reads, diffs, writes + * row/index signals (owned writes), and may create fresh rows as DETACHED + * nodes, 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; a re-compute before the effect discards the + * superseded plan's fresh rows and diffs again from committed state. Array + * output commits inline (a memo), as mapArray always has. + * + * ROW OWNERSHIP: ONE list owner under the list's CREATION owner; rows inherit + * context, boundaries and lifetime from where the list was written. Untracked + * reads inside row bodies resolve via `_parentComputed` = the list's own + * computation, so store lookups see pending writes. + * + * DYNAMIC ROWS (rendered output; classic's list-effect model): a row whose + * top level resolves to a FUNCTION is created once (owned, untracked) and + * RESOLVED by the engine's compute, tracked, every run. No per-row effect, + * no marker nodes; a flip splices only that row's range. + */ +import { + cleanup, + computed, + createOwner, + runWithOwner, + setSignal, + signal, + type Owner, + type Signal +} from "./core/index.js"; +import { setStrictRead } from "./core/core.js"; +import { CONFIG_AUTO_DISPOSE } from "./core/constants.js"; +import { attrHooks } from "./core/attribution-hooks.js"; +import { getOwner } from "./core/index.js"; +import { accessor, type Accessor } from "./signals.js"; +import { $TRACK } from "./store/index.js"; + +/** Renderer node — OPAQUE to the engine (every node touch rides the layer). */ +export type ListNode = object; +/** A row's nodes: one, several, or NONE (empty-rendering rows). */ +export type Nodes = ListNode | ListNode[] | null; +export type Leaves = any[]; + +type RowOwner = { dispose(self?: boolean): void; _parentComputed?: any }; + +/** Lazy key marker: a key-fn row computes `kf(item)` the first time a diff + * needs it — never during the fill that created it (mapArray's timing). */ +const UNKEYED = {} as const; + +export interface ListRow { + /** Row key: the item (identity mode), `keyFn(item)` (or UNKEYED until + * first needed), or null (by index). */ + k: any; + /** The row's CURRENT item (identity-first comparisons; key-fn rows update + * it on reuse). */ + item: any; + o: RowOwner; + /** Item signal (accessor-row modes) / index signal (arity ≥ 2), else null. */ + it: any; + ix: any; + /** Single-root fast form... */ + n: ListNode | null; + /** ...or the fragment form; both null = zero nodes (or unresolved fresh dynamic). */ + ns: ListNode[] | null; + /** DYNAMIC row: the unresolved value re-read TRACKED every run; null = static. */ + f: any; + /** The row fn's RAW result — the ARRAY output. */ + v: any; + p: ListRow | null; + x: ListRow | null; + /** True once placed (rendered) / committed (array). */ + live: boolean; + /** Needs placement this commit (fresh or displaced). */ + mv: boolean; + /** -1 marks a row leaving in the pending plan (dynamic scan skips it). */ + g: number; +} + +export interface ListPlan { + order: ListRow[]; + removes: ListRow[]; + before: ListRow | null; + after: ListRow | null; + len: number; + upd: [ListRow, Leaves][] | null; + fb: ListRow | null; + /** Set when a RESOLUTION throw (NotReady) parked this plan: the target + * items, so the retry reuses the built rows instead of rebuilding. */ + target?: any[]; +} + +export interface ListFlat { + items: any[]; + owners: RowOwner[]; + nodes: Nodes[]; + /** Raw row values (the ARRAY view of a rendered list). */ + vals: any[]; + fns: any[] | null; + ixs: any[] | null; + its: any[] | null; +} + +export interface ListFlatPlan { + ff: 1; + mode: "fill" | "replace" | "clear" | "dyn"; + items: any[]; + owners: RowOwner[]; + nodes: Nodes[]; + vals: any[]; + fns: any[] | null; + ixs: any[] | null; + its: any[] | null; + len: number; + upd: [number, Leaves][] | null; + fb: ListRow | null; + target?: any[]; +} + +/** The list descriptor (`` stamps it on its accessor as `$for`). */ +export interface ListMeta { + each: () => any; + row: (...args: any[]) => any; + keyed?: boolean | ((item: any) => any); + fallback?: () => any; + /** Creation owner: rows live under it. */ + owner: Owner | null; + /** Hydration only: the explicit ids the row parent and the array computed + * take (the two slots the server's mapArray spent at this position). */ + hid?: string; + hid2?: string; + /** Dev: strict-read name for row bodies. */ + name?: string; + /** @internal defer the first pass to the first read. */ + lazy?: boolean; +} + +/** THE NODE LAYER — everything that touches renderer nodes. A renderer + * builds one over its own primitives (web: DOM; universal: `createRenderer` + * ops) and hands it to the engine. `null` = array output. */ +export interface ListNodeLayer { + /** Turn a row fn's raw result into nodes under the row owner (flatten + * fragments there). A FUNCTION-valued result is a DYNAMIC row: return null + * and report the value through `dynamic()`. */ + build(v: any, o: RowOwner): Nodes; + /** The dynamic value recorded by the last `build()`, or null. */ + dynamic(): any; + /** Resolve a dynamic value to leaves — TRACKED (the classic flatten read). */ + resolve(f: any): Leaves; + toNodes(leaves: Leaves): Nodes; + same(leaves: Leaves, cur: Nodes): boolean; + /** Commit-side splice of a dynamic row's range before `anchor`. */ + splice(slot: ListSlot, cur: Nodes, leaves: Leaves, anchor: ListNode | null): Nodes; + place(slot: ListSlot, nd: Nodes, anchor: ListNode | null, tagIt: boolean): void; + detach(slot: ListSlot, nd: Nodes): void; + /** The node after the list (contiguity anchor), read BEFORE removes. */ + endAnchor(slot: ListSlot): ListNode | null; + /** True when the list is the parent's whole child list (bulk clears). */ + ownsParent(slot: ListSlot): boolean; + clear(slot: ListSlot): void; + /** True when `node` is a direct child of the parent (hydration placement). */ + inParent(slot: ListSlot, node: ListNode): boolean; + /** Hydrating fill commit (claim pass): adopts positional server nodes + * into `nodes` IN PLACE and detects mismatch; null when not hydrating. */ + commitFill: ((slot: ListSlot, nodes: Nodes[]) => void) | null; +} + +export interface ListSlot { + head: ListRow | null; + tail: ListRow | null; + /** Chain size (rows; the fallback is NOT a chain row). */ + size: number; + /** Host parent (rendered) or null (array). */ + parent: ListNode | null; + /** Placement anchor: the end marker, or null (append at parent end). */ + end: ListNode | null; + /** True ONLY for whole-parent inserts (marker === undefined). */ + whole: boolean; + owner: RowOwner; + flat: ListFlat | null; + pending: ListPlan | ListFlatPlan | null; + dead: boolean; + /** True once any dynamic row was built — gates the per-run resolve scan. */ + dyn: boolean; + /** Live fallback row, else null. */ + fb: ListRow | null; + /** Node layer, or null for array output. */ + layer: ListNodeLayer | null; + /** HYDRATING FILL in progress; cleared by the first commit. */ + hyd: boolean; + /** Hydration: the claimed region snapshot. */ + region: ListNode[] | undefined; + /** Dev: the list's own computation (attribution's list-churn census). */ + node: any; + // ── Mode. + row: (...args: any[]) => any; + kf: ((item: any) => any) | undefined; + bi: boolean; + ac: boolean; + ixs: boolean; + fallback: (() => any) | undefined; +} + +const pureOptions = { ownedWrite: true }; +const EMPTY: any[] = []; + +export const firstOf = (nd: Nodes): ListNode | null => + nd === null ? null : Array.isArray(nd) ? nd[0] : nd; +export const lastOf = (nd: Nodes): ListNode | null => + nd === null ? null : Array.isArray(nd) ? nd[nd.length - 1] : nd; +export const nodesOf = (r: ListRow): Nodes => (r.n !== null ? r.n : r.ns); + +/** First node of the list (skipping zero-node rows), or null when none. */ +export function firstNodeOf(slot: ListSlot): ListNode | null { + const f = slot.flat; + if (f !== null) { + for (let i = 0; i < f.nodes.length; i++) { + const n = firstOf(f.nodes[i]); + if (n !== null) return n; + } + return null; + } + return firstNodeFrom(slot.head); +} +/** Last node of the list (skipping zero-node rows), or null when none. */ +export function lastNodeOf(slot: ListSlot): ListNode | null { + const f = slot.flat; + if (f !== null) { + for (let i = f.nodes.length - 1; i >= 0; i--) { + const n = lastOf(f.nodes[i]); + if (n !== null) return n; + } + return null; + } + for (let r = slot.tail; r !== null; r = r.p) { + const n = lastOf(nodesOf(r)); + if (n !== null) return n; + } + return null; +} +/** First node at or after row `r` in chain order (zero-node rows skipped). */ +export function firstNodeFrom(r: ListRow | null): ListNode | null { + for (; r !== null; r = r.x) { + const n = firstOf(nodesOf(r)); + if (n !== null) return n; + } + return null; +} + +function setNodes(r: ListRow, nd: Nodes): void { + if (Array.isArray(nd)) { + r.n = null; + r.ns = nd; + } else { + r.n = nd; + r.ns = null; + } +} + +// Row build: one shared thunk for the owned row call (no per-row closure). +// Arguments travel through module slots that the thunk reads synchronously +// on entry; RESULTS are written to module slots only AFTER user code returns. +// User code may build nested lists (a inside a row engages its engine +// synchronously), so anything written before the row fn runs is not safe — +// results are LIFO-safe because the outermost build writes last. +let bpFn: (...args: any[]) => any; +let bpA0: any; +let bpA1: any; +let bpV: any = null; +let bpF: any = null; +let bpN: Nodes = null; +const callRow = () => (bpA1 === undefined ? bpFn(bpA0) : bpFn(bpA0, bpA1)); +const callFallback = () => bpFn(); // zero arguments, as mapArray called it + +/** Build a row body under its own owner (untracked + owned). Returns the + * row OWNER; the nodes (null: zero-node, dynamic, or array output), raw + * value and dynamic value come back through `bpN` / `bpV` / `bpF`, all + * written after user code. A throw disposes the row's owner. Arity-exact + * (mapArray passes one argument to arity-1 mappers, none to the fallback). */ +function buildParts( + rowFn: (...args: any[]) => any, + a0: any, + a1: any, + layer: ListNodeLayer | null, + fallback: boolean +): RowOwner { + const o: RowOwner = createOwner() as unknown as RowOwner; + let v: any; + let nd: Nodes = null; + try { + bpFn = rowFn; + bpA0 = a0; + bpA1 = a1; + v = runWithOwner(o as any, fallback ? callFallback : callRow); + if (layer !== null) nd = layer.build(v, o); + } catch (e) { + o.dispose(); + throw e; + } + bpV = v; + bpN = nd; + bpF = layer !== null ? layer.dynamic() : null; + return o; +} + +function buildRow(slot: ListSlot, item: any, j: number, key: any): ListRow { + const it = slot.ac ? signal(item, pureOptions) : null; + const ix = slot.ixs ? signal(j, pureOptions) : null; + const o = buildParts( + slot.row, + it !== null ? accessor(it) : item, + slot.bi ? j : ix !== null ? accessor(ix) : undefined, + slot.layer, + false + ); + const nd = bpN; + return { + k: key, + item, + o, + it, + ix, + n: Array.isArray(nd) ? null : nd, + ns: Array.isArray(nd) ? nd : null, + f: bpF, + v: bpV, + p: null, + x: null, + live: false, + mv: true, + g: 0 + }; +} + +/** Lossless representation change: committed flat arrays → chain. Pure + * bookkeeping over committed rows (phase-safe); item/index signals carry + * over; key-fn keys stay lazy (computed by the first diff that needs them). */ +function materialize(slot: ListSlot): void { + const f = slot.flat!; + const kf = slot.kf; + const n = f.items.length; + let prev: ListRow | null = null; + for (let i = 0; i < n; i++) { + const nd = f.nodes[i]; + const r: ListRow = { + k: kf !== undefined ? UNKEYED : f.items[i], + item: f.items[i], + o: f.owners[i], + it: f.its !== null ? f.its[i] : null, + ix: f.ixs !== null ? f.ixs[i] : null, + n: Array.isArray(nd) ? null : nd, + ns: Array.isArray(nd) ? nd : null, + f: f.fns !== null ? f.fns[i] : null, + v: f.vals[i], + p: prev, + x: null, + live: true, + mv: false, + g: 0 + }; + if (prev !== null) prev.x = r; + else slot.head = r; + prev = r; + } + slot.tail = prev; + slot.size = n; + slot.flat = null; +} + +// LIS scratch (module-level, reused — markMoves runs NO user code). +let lisTails: number[] = []; +let lisTailIdx: number[] = []; +let lisPrev: number[] = []; + +/** Mark rows that KEEP their position (LIS of old-middle indices); + * everything else gets `mv = true`. `oldPos[j]` is -1 for fresh rows. */ +function markMoves(order: ListRow[], 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 = tlen; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (lisTails[mid] < v) lo = mid + 1; + else hi = mid; + } + lisTails[lo] = v; + lisPrev[i] = lo > 0 ? lisTailIdx[lo - 1] : -1; + lisTailIdx[lo] = i; + if (lo === tlen) tlen++; + } + 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) { + order[at].mv = false; + at = lisPrev[at]; + } +} + +export const IDENTICAL = 0 as const; +export type ListOut = ListPlan | ListFlatPlan | typeof IDENTICAL; + +function sameItems(a: ArrayLike, b: any[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < b.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +export interface ListEngine { + slot: ListSlot; + compute(): ListOut; + commit(out: ListOut): void; + /** ARRAY output: the raw row values in order; `[fallback]` when empty with one. */ + values(): any[]; + /** Bumped by every committing pass — a plain call of a RENDERED list's + * accessor tracks it to re-read `values()` (one engine, two views). */ + version: Signal; + /** The tracked ARRAY view of this engine: `values()` re-read per commit. */ + array(): any[]; + /** Rendered-output teardown (the engaging insert's cleanup). */ + teardown(): void; +} + +/** Create the engine for one list. `layer === null` → array output. */ +export function createListEngine( + meta: ListMeta, + parent: ListNode | null, + marker: ListNode | null | undefined, + layer: ListNodeLayer | null, + region: ListNode[] | undefined, + hole: boolean, + ownerOpts: { id: string } | undefined, + hyd: boolean +): ListEngine { + const kf = typeof meta.keyed === "function" ? meta.keyed : undefined; + const bi = meta.keyed === false; + const rowFn = + __DEV__ && meta.name + ? (...args: any[]) => { + setStrictRead(meta.name!); + try { + return meta.row(...args); + } finally { + setStrictRead(false); + } + } + : meta.row; + const slot: ListSlot = { + head: null, + tail: null, + size: 0, + parent, + end: marker ?? null, + whole: marker === undefined, + // List owner under the CREATION owner — rows see the context, boundaries + // and lifetime of the list's source position. Under hydration it takes + // the parity id. + owner: runWithOwner(meta.owner, () => createOwner(ownerOpts)) as unknown as RowOwner, + flat: null, + pending: null, + dead: false, + dyn: false, + fb: null, + layer, + hyd, + region, + node: null, + row: rowFn, + kf, + bi, + ac: bi || kf !== undefined, + ixs: meta.row.length > 1 && !bi, + fallback: meta.fallback + }; + // The engine dies with the list's CREATION owner (mapArray's computed is + // owned there and freezes at its last value): a rendered list whose + // owner was disposed must stop updating even while its insertion owner + // lives on. Registered on the creation owner itself, not the list owner — + // the list owner's own cleanups run on every bulk `dispose(false)`. + const die = (): void => { + slot.dead = true; + }; + if (meta.owner !== null) runWithOwner(meta.owner, () => cleanup(die)); + const version = signal(0, pureOptions); + // Flat mode covers every keyed mode of the RENDERED output; `keyed: false` + // (positional) and array output are chain-first. + const flatOk = !slot.bi && layer !== null; + /** A row's key, computed lazily for key-fn rows (mapArray never keys a + * row during the fill that creates it). */ + const rowKey = (r: ListRow): any => (r.k === UNKEYED ? (r.k = kf!(r.item)) : r.k); + + const dropPending = (): void => { + const p = slot.pending; + if (p !== null) { + if ((p as ListFlatPlan).ff === 1) { + const { owners } = p as ListFlatPlan; + for (let j = 0; j < owners.length; j++) owners[j].dispose(); + } else { + const { order, removes } = p as ListPlan; + for (let j = 0; j < order.length; j++) if (!order[j].live) order[j].o.dispose(); + for (let j = 0; j < removes.length; j++) removes[j].g = 0; + } + if (p.fb !== null && p.fb !== slot.fb) p.fb.o.dispose(); + slot.pending = null; + } + }; + + const removeFlatDom = (): void => { + const f = slot.flat!; + if (layer!.ownsParent(slot)) layer!.clear(slot); + else for (let i = 0; i < f.nodes.length; i++) layer!.detach(slot, f.nodes[i]); + }; + + /** Resolve the fresh dynamic rows of a plan (tracked). */ + const resolveFreshFlat = (fp: ListFlatPlan): void => { + const fns = fp.fns!; + for (let j = 0; j < fns.length; j++) + if (fns[j] !== null) fp.nodes[j] = layer!.toNodes(layer!.resolve(fns[j])); + }; + const resolveFreshRows = (order: ListRow[], fb: ListRow | null): void => { + for (let j = 0; j < order.length; j++) { + const r = order[j]; + if (!r.live && r.f !== null) setNodes(r, layer!.toNodes(layer!.resolve(r.f))); + } + if (fb !== null && !fb.live && fb.f !== null) + setNodes(fb, layer!.toNodes(layer!.resolve(fb.f))); + }; + + /** The fallback row for an empty list (built owned, like a row). */ + const buildFallback = (): ListRow => { + const o = buildParts(slot.fallback!, undefined, undefined, layer, true); + const nd = bpN; + return { + k: null, + item: null, + o, + it: null, + ix: null, + n: Array.isArray(nd) ? null : nd, + ns: Array.isArray(nd) ? nd : null, + f: bpF, + v: bpV, + p: null, + x: null, + live: false, + mv: true, + g: 0 + }; + }; + + /** Build the flat arrays for `itemsSnap` (rendered output, keyed modes). */ + const buildFlat = (itemsSnap: any[], mode: "fill" | "replace"): ListFlatPlan => { + const len = itemsSnap.length; + const owners: RowOwner[] = new Array(len); + const nodes: Nodes[] = new Array(len); + const vals: any[] = new Array(len); + const ixs: any[] | null = slot.ixs ? new Array(len) : null; + const its: any[] | null = slot.ac ? new Array(len) : null; + let fns: any[] | null = null; + const fp: ListFlatPlan = { + ff: 1, + mode, + items: itemsSnap, + owners, + nodes, + vals, + fns, + ixs, + its, + len, + upd: null, + fb: null + }; + try { + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < len; j++) { + let a0: any = itemsSnap[j]; + let a1: any; + if (its !== null) a0 = accessor((its[j] = signal(a0, pureOptions))); + if (ixs !== null) a1 = accessor((ixs[j] = signal(j, pureOptions))); + owners[j] = buildParts(slot.row, a0, a1, layer, false); + vals[j] = bpV; + if (bpF !== null) { + if (fns === null) fns = new Array(len).fill(null); + fns[j] = bpF; + } else nodes[j] = bpN; + } + }); + if (fns !== null) { + // Dynamic rows: initial resolution, TRACKED (outside the owner + // wrapper). Park first so a NotReady keeps the built rows. + slot.dyn = true; + fp.fns = fns; + fp.target = itemsSnap; + slot.pending = fp; + resolveFreshFlat(fp); + } + } catch (e) { + // A throw mid-BUILD (untracked, mapArray parity): dispose the rows + // built so far. A throw from RESOLUTION (fp is parked) keeps them. + if (slot.pending !== fp) for (let d = 0; d < len; d++) owners[d]?.dispose(); + throw e; + } + return fp; + }; + + /** Chain fill from empty: every row fresh. Removes carry the fallback row. */ + const fillChain = (arr: ArrayLike, removes: ListRow[]): ListPlan => { + const len = arr.length; + // Read the items TRACKED before entering the owner wrapper (reads inside + // runWithOwner are untracked — a store index write must re-run us). + const snap: any[] = new Array(len); + for (let j = 0; j < len; j++) snap[j] = arr[j]; + const order: ListRow[] = new Array(len); + let anyDyn = false; + try { + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < len; j++) { + const item = snap[j]; + const built = buildRow(slot, item, j, kf !== undefined ? UNKEYED : item); + if (built.f !== null) anyDyn = true; + order[j] = built; + } + }); + } catch (e) { + for (let j = 0; j < len; j++) order[j]?.o.dispose(); + throw e; + } + const plan: ListPlan = (slot.pending = { + order, + removes, + before: null, + after: null, + len, + upd: null, + fb: null + }); + if (anyDyn) { + slot.dyn = true; + plan.target = snap; + resolveFreshRows(order, null); + } + return plan; + }; + + /** Re-read every committed dynamic row (tracked — keeps the subscription + * alive) and collect the changed ones. Rows leaving in the pending plan + * (g === -1) skip. */ + const scanChain = (): [ListRow, Leaves][] | null => { + let upd: [ListRow, Leaves][] | null = null; + for (let r = slot.head; r !== null; r = r.x) { + if (r.f === null || r.g === -1) continue; + const leaves = layer!.resolve(r.f); + if (!layer!.same(leaves, nodesOf(r))) (upd ??= []).push([r, leaves]); + } + const fb = slot.fb; + if (fb !== null && fb.f !== null && fb.g !== -1) { + const leaves = layer!.resolve(fb.f); + if (!layer!.same(leaves, nodesOf(fb))) (upd ??= []).push([fb, leaves]); + } + return upd; + }; + const scanFlat = (): [number, Leaves][] | null => { + const f = slot.flat!; + const fns = f.fns; + if (fns === null) return null; + let upd: [number, Leaves][] | null = null; + for (let j = 0; j < fns.length; j++) { + if (fns[j] === null) continue; + const leaves = layer!.resolve(fns[j]); + if (!layer!.same(leaves, f.nodes[j])) (upd ??= []).push([j, leaves]); + } + return upd; + }; + + const teardown = (): void => { + slot.dead = true; + if (hole && layer !== null) { + if (slot.flat !== null) removeFlatDom(); + else for (let r = slot.head; r !== null; r = r.x) layer.detach(slot, nodesOf(r)); + if (slot.fb !== null) layer.detach(slot, nodesOf(slot.fb)); + } + slot.owner.dispose(); + }; + + /** All committed chain rows, as a removes list. */ + const allRows = (): ListRow[] => { + const out: ListRow[] = []; + for (let r = slot.head; r !== null; r = r.x) { + r.g = -1; + out.push(r); + } + return out; + }; + + /** Structural half of the compute: items → plan (or IDENTICAL). */ + const structural = (arr: ArrayLike): ListOut => { + const len = arr.length; + // A plan parked by a resolution throw whose target still matches: reuse + // its built rows (no rebuild, no re-invocation) and re-resolve. + const parked = slot.pending; + if (parked !== null && parked.target !== undefined && sameItems(arr, parked.target)) { + if ((parked as ListFlatPlan).ff === 1) resolveFreshFlat(parked as ListFlatPlan); + else resolveFreshRows((parked as ListPlan).order, parked.fb); + return parked; + } + dropPending(); + // ── FALLBACK: the empty state, when the list has one. + if (len === 0 && slot.fallback !== undefined) { + if (slot.fb !== null) return IDENTICAL; + if (slot.flat !== null) materialize(slot); // leave flat through the chain + const removes = allRows(); + // Owned like a row: under the list owner (context, boundaries, and the + // hydration id chain — mapArray renders its fallback there too). + const fb = runWithOwner(slot.owner as any, buildFallback) as ListRow; + const plan: ListPlan = (slot.pending = { + order: [], + removes, + before: null, + after: null, + len: 0, + upd: null, + fb + }); + if (fb.f !== null) { + slot.dyn = true; + plan.target = []; + resolveFreshRows(plan.order, fb); + } + return plan; + } + const fbCur = slot.fb; + if (fbCur !== null) { + // Items arrived over the fallback: drop it, fill fresh (chain). + fbCur.g = -1; + return fillChain(arr, [fbCur]); + } + // ── FLAT MODE: aligned lists stay flat (zero work); clears and + // no-survivor replaces stay flat (bulk swap); only a PARTIAL structural + // op materializes the chain. + if (slot.flat !== null) { + const fi = slot.flat.items; + if (len === fi.length && sameItems(arr, fi)) return IDENTICAL; + if (len === 0) + return (slot.pending = { + ff: 1, + mode: "clear", + items: [], + owners: [], + nodes: [], + vals: [], + fns: null, + ixs: null, + its: null, + len: 0, + upd: null, + fb: null + }); + // Survivors are judged by KEY (a key-fn list re-minting its objects + // keeps every row; the chain's prefix walk then writes the item + // signals). The aligned check above is identity-based on purpose. + let survivor = false; + { + const old = new Set(); + for (let j = 0; j < fi.length; j++) old.add(kf !== undefined ? kf(fi[j]) : fi[j]); + for (let j = 0; j < len; j++) + if (old.has(kf !== undefined ? kf(arr[j]) : arr[j])) { + survivor = true; + break; + } + } + if (!survivor) { + const snap: any[] = new Array(len); + for (let j = 0; j < len; j++) snap[j] = arr[j]; + return (slot.pending = buildFlat(snap, "replace")); + } + materialize(slot); + } + // ── FILL from empty. + if (slot.head === null) { + if (len === 0) { + if (!slot.hyd) return IDENTICAL; + // Empty hydrating fill still commits (clears the hydration state). + return (slot.pending = { + ff: 1, + mode: "fill", + items: [], + owners: [], + nodes: [], + vals: [], + fns: null, + ixs: null, + its: null, + len: 0, + upd: null, + fb: null + }); + } + if (flatOk) { + const snap: any[] = new Array(len); + for (let j = 0; j < len; j++) snap[j] = arr[j]; + return (slot.pending = buildFlat(snap, "fill")); + } + return fillChain(arr, []); + } + // ── BY INDEX (`keyed: false`): positional reuse, tail append/remove. + if (slot.bi) { + const size = slot.size; + const common = len < size ? len : size; + let r: ListRow | null = slot.head; + for (let j = 0; j < common; j++, r = r!.x) setSignal(r!.it, (r!.item = arr[j])); + if (len === size) return IDENTICAL; + if (len > size) { + const tail: any[] = new Array(len - size); + for (let j = size; j < len; j++) tail[j - size] = arr[j]; // tracked reads + const order: ListRow[] = new Array(len - size); + let anyDyn = false; + try { + runWithOwner(slot.owner as any, () => { + for (let j = size; j < len; j++) { + const built = buildRow(slot, tail[j - size], j, null); + if (built.f !== null) anyDyn = true; + order[j - size] = built; + } + }); + } catch (e) { + for (let j = 0; j < order.length; j++) order[j]?.o.dispose(); + throw e; + } + const plan: ListPlan = (slot.pending = { + order, + removes: [], + before: slot.tail, + after: null, + len, + upd: null, + fb: null + }); + if (anyDyn) { + slot.dyn = true; + plan.target = Array.prototype.slice.call(arr); + resolveFreshRows(order, null); + } + return plan; + } + const removes: ListRow[] = []; + for (let q: ListRow | null = r; q !== null; q = q.x) { + q.g = -1; + removes.push(q); + } + return (slot.pending = { + order: [], + removes, + before: r!.p, + after: null, + len, + upd: null, + fb: null + }); + } + // ── KEYED (identity / key fn): prefix walk. + // Identity FIRST, then keys (mapArray's `items[i] === newItems[i] || + // key(a) === key(b)`): an in-place key mutation on the same object keeps + // its row, and no key fn runs when references match. + let cursor: ListRow | null = slot.head; + let i = 0; + while ( + cursor !== null && + i < len && + (cursor.item === arr[i] || (kf !== undefined && rowKey(cursor) === kf(arr[i]))) + ) { + if (slot.ac) setSignal(cursor.it, (cursor.item = 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; + const dif = len - slot.size; + while ( + tailCursor !== null && + oldRemain > 0 && + end >= i && + (tailCursor.item === arr[end] || (kf !== undefined && rowKey(tailCursor) === kf(arr[end]))) + ) { + if (slot.ac) setSignal(tailCursor.it, (tailCursor.item = arr[end])); + if (slot.ixs && dif !== 0) setSignal(tailCursor.ix, end); + tailCursor = tailCursor.p; + end--; + oldRemain--; + } + const after = oldRemain === 0 ? cursor : tailCursor!.x; // first suffix row + // ── Middle window: new keys → positions (chained for duplicates, + // scanning backwards so occurrences pair up in natural order), then old + // middle rows claim their new positions. + const width = end - i + 1; + const midItems: any[] = new Array(width); + for (let j = 0; j < width; j++) midItems[j] = arr[i + j]; // tracked reads + const keys: any[] = kf !== undefined ? new Array(width) : midItems; + const newIndices = new Map(); + const newNext: number[] = new Array(width); + for (let j = width - 1; j >= 0; j--) { + const key = kf !== undefined ? (keys[j] = kf(midItems[j])) : midItems[j]; + const prev = newIndices.get(key); + newNext[j] = prev === undefined ? -1 : prev; + newIndices.set(key, j); + } + const order: ListRow[] = new Array(width); + const oldPos: number[] = new Array(width); + const removes: ListRow[] = []; + { + let r: ListRow | null = cursor; + for (let c = 0; c < oldRemain; c++, r = r!.x) { + const row = r!; + const key = rowKey(row); + const j = newIndices.get(key); + if (j !== undefined && j !== -1) { + order[j] = row; + oldPos[j] = c; + newIndices.set(key, newNext[j]); + } else { + row.g = -1; + removes.push(row); + } + } + } + // ── Reused rows: write item/index signals; fresh rows: build (owned). + let anyDyn = false; + try { + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < width; j++) { + const row = order[j]; + if (row !== undefined) { + if (slot.ac) setSignal(row.it, (row.item = midItems[j])); + if (slot.ixs) setSignal(row.ix, i + j); + } else { + const built = buildRow(slot, midItems[j], i + j, keys[j]); + if (built.f !== null) anyDyn = true; + order[j] = built; + oldPos[j] = -1; + } + } + }); + } catch (e) { + // A row fn threw mid-BUILD: fresh rows chain to the PERSISTENT list + // owner and would leak — dispose before the throw rides the boundary; + // leaving rows stay committed (un-mark them). + for (let j = 0; j < width; j++) { + const r = order[j]; + if (r !== undefined && !r.live) r.o.dispose(); + } + for (let j = 0; j < removes.length; j++) removes[j].g = 0; + throw e; + } + markMoves(order, oldPos); + const plan: ListPlan = (slot.pending = { + order, + removes, + before, + after, + len, + upd: null, + fb: null + }); + if (anyDyn) { + slot.dyn = true; + plan.target = width === len ? midItems : Array.prototype.slice.call(arr); + resolveFreshRows(order, null); + } + return plan; + }; + + const compute = (): ListOut => { + if (slot.dead) return IDENTICAL; + if (__DEV__) slot.node = getOwner(); + // Read FIRST (phase separation): a NotReady here leaves the list + // untouched and rides the boundary like any compute throw. Array-likes + // are accepted the way mapArray duck-types them. + const items = meta.each(); + const arr: ArrayLike = items == null || items === false ? EMPTY : items; + (arr as any)[$TRACK]; // store arrays: top-level structural tracking + const out = structural(arr); + if (!slot.dyn) return out; + // ── Dynamic rows: re-read every committed one (keeps the subscription + // alive) and attach the changed ranges. + if (out === IDENTICAL) { + if (slot.flat !== null) { + const upd = scanFlat(); + if (upd === null) return IDENTICAL; + const f = slot.flat; + return (slot.pending = { + ff: 1, + mode: "dyn", + items: f.items, + owners: [], + nodes: f.nodes, + vals: f.vals, + fns: f.fns, + ixs: f.ixs, + its: f.its, + len: f.items.length, + upd, + fb: slot.fb + }); + } + const upd = scanChain(); + if (upd === null) return IDENTICAL; + return (slot.pending = { + order: [], + removes: [], + before: slot.tail, + after: null, + len: slot.size, + upd, + fb: slot.fb + }); + } + if ((out as ListFlatPlan).ff !== 1) (out as ListPlan).upd = scanChain(); + return out; + }; + + const commit = (out: ListOut): void => { + if (out === IDENTICAL) return; + if (out !== slot.pending) return; // superseded mid-flight + slot.pending = null; + // The node after the list, read BEFORE removes. + const endA = layer !== null ? layer.endAnchor(slot) : null; + // Fallback leaving: detach + dispose before anything is placed. + const fbOld = slot.fb; + if (fbOld !== null && out.fb !== fbOld) { + if (layer !== null) layer.detach(slot, nodesOf(fbOld)); + fbOld.o.dispose(); + slot.fb = null; + } + if ((out as ListFlatPlan).ff === 1) { + const fp = out as ListFlatPlan; + if (fp.mode === "dyn") { + const f = slot.flat!; + const upd = fp.upd!; + for (let u = upd.length - 1; u >= 0; u--) { + const j = upd[u][0]; + let anchor: ListNode | null = null; + for (let q = j + 1; q < f.nodes.length && anchor === null; q++) + anchor = firstOf(f.nodes[q]); + if (anchor === null) anchor = endA; + f.nodes[j] = layer!.splice(slot, f.nodes[j], upd[u][1], anchor); + } + return; + } + 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; + slot.dyn = false; + setSignal(version, version._value + 1); + return; + } + if (fp.mode === "replace") { + removeFlatDom(); + const f = slot.flat!; + for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); + slot.dyn = fp.fns !== null; + // Dev attribution: a full replace is a list-identity churn (every + // row exited, every row entered). + if (__DEV__ && attrHooks !== null && f.items.length !== 0 && fp.items.length !== 0) + attrHooks.listChurn(slot.node, f.items, fp.items, fp.len, kf !== undefined); + } + // Hydrating fill: a claim pass, not a placement pass (positional + // server nodes adopted in place; nothing moves). + if (slot.hyd && layer!.commitFill !== null) { + layer!.commitFill(slot, fp.nodes); + slot.hyd = false; + slot.region = undefined; + } else for (let i = 0; i < fp.nodes.length; i++) layer!.place(slot, fp.nodes[i], endA, true); + slot.flat = { + items: fp.items, + owners: fp.owners, + nodes: fp.nodes, + vals: fp.vals, + fns: fp.fns, + ixs: fp.ixs, + its: fp.its + }; + slot.size = fp.len; + setSignal(version, version._value + 1); + return; + } + const plan = out as ListPlan; + const { order, removes, before, after } = plan; + // Dev attribution (mapArray's list-identity census): rows that exited AND + // rows that entered in one pass — the shape that signals unstable keys. + if (__DEV__ && attrHooks !== null && removes.length !== 0) { + let created: any[] | undefined; + for (let j = 0; j < order.length; j++) + if (!order[j].live) (created ??= []).push(order[j].item); + if (created !== undefined) { + const removed: any[] = new Array(removes.length); + for (let j = 0; j < removes.length; j++) removed[j] = removes[j].item; + attrHooks.listChurn(slot.node, removed, created, plan.len, kf !== undefined); + } + } + if (layer !== null) { + // Batch clear: N→0 on an OWNED whole-parent list is one clear + one + // bulk owner dispose. + if ( + plan.len === 0 && + plan.fb === null && + before === null && + after === null && + layer.ownsParent(slot) + ) { + layer.clear(slot); + slot.owner.dispose(false); + slot.head = slot.tail = null; + slot.size = 0; + slot.dyn = false; + return; + } + // Full replace (no survivors, owned whole parent): one bulk detach. + if ( + before === null && + after === null && + removes.length === slot.size && + removes.length > 0 && + layer.ownsParent(slot) + ) { + layer.clear(slot); + for (let j = 0; j < removes.length; j++) { + removes[j].live = false; + removes[j].o.dispose(); + } + } else { + for (let j = 0; j < removes.length; j++) { + const r = removes[j]; + if (r.live) layer.detach(slot, nodesOf(r)); + r.o.dispose(); + } + } + // Hydrating fill (chain modes — keyed:false, fallback): ONE claim/ + // adoption path with flat fills. Positional server nodes are adopted + // into the rows in place; claimed/adopted nodes then skip placement. + const hydrating = slot.hyd; + if (hydrating && layer.commitFill !== null) { + const list: Nodes[] = new Array(order.length); + for (let j = 0; j < order.length; j++) list[j] = nodesOf(order[j]); + if (plan.fb !== null) list.push(nodesOf(plan.fb)); + layer.commitFill(slot, list); + for (let j = 0; j < order.length; j++) setNodes(order[j], list[j]); + if (plan.fb !== null) setNodes(plan.fb, list[order.length]); + slot.region = undefined; + } + // Place fresh/moved rows back-to-front so anchors are always final. + // Direct insert per row, deliberately — NOT fragment-batched runs + // (browsers charge per MOVE; LIS + direct placement is move-minimal). + let anchor: ListNode | null = after !== null ? firstNodeFrom(after) : null; + if (anchor === null) anchor = endA; + for (let j = order.length - 1; j >= 0; j--) { + const r = order[j]; + const first = firstOf(nodesOf(r)); + if (r.mv) { + if (!(hydrating && first !== null && layer.inParent(slot, first))) + layer.place(slot, nodesOf(r), anchor, !r.live); + r.live = true; + r.mv = false; + } + if (first !== null) anchor = first; + } + slot.hyd = false; + } else { + for (let j = 0; j < removes.length; j++) removes[j].o.dispose(); + for (let j = 0; j < order.length; j++) { + order[j].live = true; + order[j].mv = false; + } + } + // 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; + // RECLAIM: a retained row whose node is no longer in the parent comes back + // on the next structural pass. This is OUR runtime's doing, not (only) + // user code's: an element held in a variable and rendered both in a row + // and in a elsewhere is moved out by insertExpression when the + // Show turns on and detached by its cleanup when it turns off — the row + // would otherwise keep a permanent hole. Classic recovers through its + // liveness walk (one parentNode read per common node); so does the + // engine, chain-wide, on structural commits only. There is no cheaper + // signal: the alternative is an ownership check in every insert path. + if (layer !== null && plan.len !== 0) { + let a: ListNode | null = endA; + for (let r = slot.tail; r !== null; r = r.p) { + const first = firstOf(nodesOf(r)); + if (first === null) continue; + if (!layer.inParent(slot, first)) layer.place(slot, nodesOf(r), a, false); + a = first; + } + } + // Fallback arriving: place after the (now empty) list. + const fb = plan.fb; + if (fb !== null && fb !== fbOld) { + if (layer !== null) { + const first = firstOf(nodesOf(fb)); + if (!(first !== null && layer.inParent(slot, first))) + layer.place(slot, nodesOf(fb), endA, true); + } + fb.live = true; + fb.mv = false; + slot.fb = fb; + } + // Dynamic rows whose resolution changed: splice each range, back to + // front so a row's anchor (its successor's first node) is final. + const upd = plan.upd; + if (upd !== null) + for (let u = upd.length - 1; u >= 0; u--) { + const r = upd[u][0]; + let a: ListNode | null = r === slot.fb ? endA : firstNodeFrom(r.x); + if (a === null) a = endA; + setNodes(r, layer!.splice(slot, nodesOf(r), upd[u][1], a)); + } + setSignal(version, version._value + 1); + }; + + const values = (): any[] => { + if (slot.fb !== null) return [slot.fb.v]; + if (slot.flat !== null) return slot.flat.vals; + const out: any[] = new Array(slot.size); + let i = 0; + for (let r = slot.head; r !== null; r = r.x) out[i++] = r.v; + return out; + }; + + const versionAcc = accessor(version); + const array = (): any[] => { + versionAcc(); + return values(); + }; + + return { slot, compute, commit, values, version, array, teardown }; +} + +/** ARRAY output as a memo — `mapArray`'s contract: same array identity while + * the list is structurally unchanged; `[fallback]` when empty with one. + * Commits inline (rows are created and disposed in the compute). */ +export function listArray(meta: ListMeta): Accessor { + const e = createListEngine( + meta, + null, + undefined, + null, + undefined, + false, + meta.hid !== undefined ? { id: meta.hid } : undefined, + false + ); + let last: any[] | undefined; + // Created under the list's CREATION owner — a builds its array output + // lazily on the first read, and a computed created under the READER would + // be disposed with that reader's next run. + // Under hydration the computed takes the second parity id the server's + // mapArray spent (owner, then computed), so it consumes no fresh slot. + const node = runWithOwner(meta.owner, () => + computed( + (): any[] => { + const out = e.compute(); + if (out === IDENTICAL && last !== undefined) return last; + e.commit(out); + return (last = e.values()); + }, + __DEV__ || meta.hid2 !== undefined || meta.lazy + ? { + id: meta.hid2, + lazy: meta.lazy, + name: __DEV__ ? meta.name : undefined + } + : undefined + ) + )!; + // Untracked reads inside row bodies resolve via the list's own computation + // (store lookups see pending writes) — mapArray's routing. ARRAY output + // only: the rendered output READS row-created memos (dynamic rows) from its + // compute, and routing rows through it would give those memos a height + // above the compute — a height inversion that double-runs the compute (and + // rebuilds the pass's fresh rows) whenever a dynamic row flips in the same + // flush as a list change. + e.slot.owner._parentComputed = node; + node._config &= ~CONFIG_AUTO_DISPOSE; + return accessor(node); +} diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index f5b7aea58..44ab19c21 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -1,8 +1,10 @@ import { setStrictRead } from "./core/core.js"; +import { listArray } from "./list.js"; import { computed, CONFIG_AUTO_DISPOSE, createOwner, + getOwner, runWithOwner, setSignal, signal, @@ -10,8 +12,6 @@ import { type Signal } from "./core/index.js"; import { accessor, type Accessor } from "./signals.js"; -import { $TRACK } from "./store/index.js"; -import { attrHooks } from "./core/attribution-hooks.js"; export type Maybe = T | void | null | undefined | false; @@ -68,278 +68,25 @@ 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 defer the first mapping pass to the first read. */ + lazy?: boolean; + } ): Accessor { - const keyFn = typeof options?.keyed === "function" ? options.keyed : undefined; - const indexes = map.length > 1; - const wrappedMap = - __DEV__ && options?.name - ? (((...args: any[]) => { - setStrictRead(options!.name!); - try { - return (map as any)(...args); - } finally { - setStrictRead(false); - } - }) as typeof map) - : map; - const data: MapData = { - _owner: createOwner(), - _len: 0, - _list: list, - _items: [], - _map: wrappedMap, - _mappings: [], - _nodes: [], - _key: keyFn, - _rows: keyFn || options?.keyed === false ? [] : undefined, - _indexes: indexes && options?.keyed !== false ? [] : undefined, - _byIndex: options?.keyed === false, - _fallback: options?.fallback - }; - const node = computed( - updateKeyedMap.bind(data as MapData), - __DEV__ && options?.name ? { name: options.name } : 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; - node._config &= ~CONFIG_AUTO_DISPOSE; - return accessor(node); -} - -const pureOptions = { ownedWrite: true }; -// 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 -// removals are deferred — and commits to `this` only after every mapper -// succeeded. An aborted pass disposes just the owners it created and leaves -// `_items`/`_mappings`/`_nodes`/`_rows`/`_indexes`/`_len` exactly as they -// were, so the retry diffs against uncorrupted state. Consequence of the -// strong-abort ordering: removed rows now dispose AFTER the pass's new rows -// are created (you cannot destroy state before knowing the pass will land). -function updateKeyedMap(this: MapData): any[] { - const newItems = this._list() || [], - newLen = newItems.length; - (newItems as any)[$TRACK]; // top level tracking - - runWithOwner(this._owner, () => { - let i: number, - j: number, - rows: Signal[] | undefined, - indexes: Signal[] | undefined, - // Mappers write freshly-created row/index signals into the STAGE - // arrays (`rows`/`indexes`), never into `this._rows`/`this._indexes`. - mapper = this._rows - ? this._byIndex - ? () => { - rows![j] = signal(newItems[j], pureOptions); - return this._map(accessor(rows![j]), j); - } - : () => { - rows![j] = signal(newItems[j], pureOptions); - indexes && (indexes[j] = signal(j, pureOptions)); - return this._map( - accessor(rows![j]), - indexes ? accessor(indexes[j]) : (undefined as any) - ); - } - : this._indexes - ? () => { - const item = newItems[j]; - indexes![j] = signal(j, pureOptions); - return this._map(item, accessor(indexes![j])); - } - : () => { - const item = newItems[j]; - return (this._map as (value: Item) => MappedItem)(item); - }; - - // fast path for empty arrays - if (newLen === 0) { - if (this._len !== 0) { - this._owner.dispose(false); - this._nodes = []; - this._items = []; - this._mappings = []; - this._len = 0; - this._rows && (this._rows = []); - this._indexes && (this._indexes = []); - } - if (this._fallback && !this._mappings[0]) { - // an aborted fallback attempt leaves an owner without a mapping; - // dispose it before re-creating - this._nodes[0]?.dispose(); - this._mappings[0] = runWithOwner( - (this._nodes[0] = createOwner()), - this._fallback - ); - } - } - // fast path for new create - else if (this._len === 0) { - const mappings: MappedItem[] = new Array(newLen); - const nodes: Root[] = new Array(newLen); - rows = this._rows && new Array(newLen); - indexes = this._indexes && new Array(newLen); - - try { - for (j = 0; j < newLen; j++) - mappings[j] = runWithOwner((nodes[j] = createOwner()), mapper)!; - } catch (err) { - for (i = 0; i <= j!; i++) nodes[i]?.dispose(); - throw err; - } - - // commit - if (this._nodes[0]) this._nodes[0].dispose(); // previous fallback - this._mappings = mappings; - this._nodes = nodes; - rows && (this._rows = rows); - indexes && (this._indexes = indexes); - this._items = newItems.slice(0); - this._len = newLen; - } else { - let start: number, - end: number, - newEnd: number, - item: Item, - key: any, - newIndices: Map, - newIndicesNext: number[], - removed: Root[] | undefined, - created: Root[] | undefined, - // Dev (attribution engine installed): the items behind the exited and - // entered rows, for the list-identity census. - removedItems: Item[] | undefined, - createdItems: Item[] | undefined; - - // skip common prefix - for ( - start = 0, end = Math.min(this._len, newLen); - start < end && - (this._items[start] === newItems[start] || - (this._rows && compare(this._key, this._items[start], newItems[start]))); - start++ - ) { - if (this._rows) setSignal(this._rows[start], newItems[start]); - } - - // skip common suffix — counted only; retained entries land in one pass - // at commit instead of being staged and copied twice - for ( - end = this._len - 1, newEnd = newLen - 1; - end >= start && - newEnd >= start && - (this._items[end] === newItems[newEnd] || - (this._rows && compare(this._key, this._items[end], newItems[newEnd]))); - end--, newEnd-- - ); - - // no structural change (every position matched in place at equal - // length — the common post-reconcile shape): keep the same mapped - // array identity so downstream consumers don't re-run at all - if (start === newLen && this._len === newLen) { - this._items = newItems.slice(0); - return; - } - - const dif = newLen - this._len; - const temp: MappedItem[] = new Array(newLen); - const tempNodes: Root[] = new Array(newLen); - rows = this._rows ? new Array(newLen) : undefined; - indexes = this._indexes ? new Array(newLen) : undefined; - - // 0) prepare a map of all indices in the changed window of newItems, - // scanning backwards so we encounter them in natural order - newIndices = new Map(); - newIndicesNext = new Array(newEnd + 1); - for (j = newEnd; j >= start; j--) { - item = newItems[j]; - key = this._key ? this._key(item) : item; - i = newIndices.get(key)!; - newIndicesNext[j] = i === undefined ? -1 : i; - newIndices.set(key, j); - } - - // 1) step through the old changed window and see if items can be found - // in the new set; if so, stage them at their new positions; if not, - // queue them for disposal at commit - for (i = start; i <= end; i++) { - item = this._items[i]; - key = this._key ? this._key(item) : item; - j = newIndices.get(key)!; - if (j !== undefined && j !== -1) { - temp[j] = this._mappings[i]; - tempNodes[j] = this._nodes[i]; - rows && (rows[j] = this._rows![i]); - indexes && (indexes[j] = this._indexes![i]); - j = newIndicesNext[j]; - newIndices.set(key, j); - } else { - (removed ??= []).push(this._nodes[i]); - if (__DEV__ && attrHooks !== null) (removedItems ??= []).push(item); - } - } - - // 2) create new rows into the temp arrays; an abort disposes only these - try { - for (j = start; j <= newEnd; j++) { - if (tempNodes[j] !== undefined) continue; - (created ??= []).push((tempNodes[j] = createOwner())); - if (__DEV__ && attrHooks !== null) (createdItems ??= []).push(newItems[j]); - temp[j] = runWithOwner(tempNodes[j], mapper)!; - } - } catch (err) { - if (created) for (i = 0; i < created.length; i++) created[i].dispose(); - throw err; - } - - // 3) commit: land the retained prefix and suffix plus the staged window - // into the fresh arrays, swap them in (new identity for downstream - // change propagation), then dispose exited rows - for (i = 0; i < start; i++) { - temp[i] = this._mappings[i]; - tempNodes[i] = this._nodes[i]; - rows && (rows[i] = this._rows![i]); - indexes && (indexes[i] = this._indexes![i]); - } - for (j = start; j <= newEnd; j++) { - if (rows) setSignal(rows[j], newItems[j]); - if (indexes) setSignal(indexes[j], j); - } - for (j = newEnd + 1; j < newLen; j++) { - temp[j] = this._mappings[j - dif]; - tempNodes[j] = this._nodes[j - dif]; - if (rows) { - rows[j] = this._rows![j - dif]; - setSignal(rows[j], newItems[j]); - } - if (indexes) { - indexes[j] = this._indexes![j - dif]; - if (dif !== 0) setSignal(indexes[j], j); - } - } - this._mappings = temp; - this._nodes = tempNodes; - rows && (this._rows = rows); - indexes && (this._indexes = indexes); - this._len = newLen; - // save a copy of the mapped items for the next update - this._items = newItems.slice(0); - if (removed) for (i = 0; i < removed.length; i++) removed[i].dispose(); - if (__DEV__ && attrHooks !== null && removedItems !== undefined && createdItems !== undefined) - attrHooks.listChurn( - this._owner._parentComputed!, - removedItems, - createdItems, - newLen, - this._key !== undefined - ); - } - }); - - return this._mappings; + // The list ENGINE's array output (list.ts) — one implementation shared with + // 's rendered output. + return listArray({ + each: list, + row: map as any, + keyed: options?.keyed, + fallback: options?.fallback, + owner: getOwner(), + name: options?.name, + lazy: options?.lazy + }) as Accessor; } /** @@ -467,10 +214,6 @@ function updateRepeat(this: RepeatData): any[] { return this._mappings; } -function compare(key: ((i: any) => any) | undefined, a: Item, b: Item): boolean { - return key ? key(a) === key(b) : true; -} - interface RepeatData { _owner: Root; _len: number; @@ -482,18 +225,3 @@ interface RepeatData { _from?: Accessor; _fallback?: Accessor; } - -interface MapData { - _owner: Root; - _len: number; - _list: Accessor>; - _items: Item[]; - _mappings: MappedItem[]; - _nodes: Root[]; - _map: (value: any, index: any) => any; - _key: ((i: any) => any) | undefined; - _rows?: Signal[]; - _indexes?: Signal[]; - _byIndex: boolean; - _fallback?: Accessor; -} diff --git a/packages/solid/src/client/flow.ts b/packages/solid/src/client/flow.ts index 4494fca13..856edf91a 100644 --- a/packages/solid/src/client/flow.ts +++ b/packages/solid/src/client/flow.ts @@ -2,13 +2,14 @@ import { children, IS_DEV } from "../client/core.js"; import { createMemo, untrack, - mapArray, repeat, + listArray, createRevealOrder, getOwner, 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"; @@ -27,12 +28,6 @@ type KeyedConditionalRenderChildren< T, F extends KeyedConditionalRenderCallback = KeyedConditionalRenderCallback > = SolidElement | NonZeroParams; -type ForOptions = { - keyed?: boolean | ((item: T) => any); - fallback?: Accessor; - name?: string; -}; - const narrowedError = (name: string) => IS_DEV ? `Attempting to access a stale value from <${name}> that could possibly be undefined. This may occur because you are reading the accessor returned from the component at a time where it has already been unmounted. We recommend cleaning up any stale timers or async, or reading from the initial condition.` @@ -84,27 +79,56 @@ export function For(props: { keyed?: boolean | ((item: T[number]) => any); children: (item: any, index: any) => U; }): SolidElement { - const options: ForOptions = - "fallback" in props - ? { keyed: props.keyed, fallback: () => props.fallback } - : { keyed: props.keyed }; - if (IS_DEV) options.name = ""; const owner = getOwner(); - let mapped: (() => any) | undefined; - const create = () => - runWithOwner(owner, () => - mapArray(() => props.each, props.children as any, options as any) - ) as () => any; // Hydration id parity (#3161): hydration ids mint at CREATION time, and - // the server spends the list's id slot at For's source position — so a - // hydrating client must create the map HERE, not on first read. Deferred - // creation ran at insert's hole evaluation, AFTER later siblings had - // already claimed their template keys, shifting every hydration id after - // 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(); - const list = () => (mapped ?? (mapped = create()))(); + // the server spends the list's id slot at For's source position — so the + // client must consume that slot HERE (deferred creation ran after later + // siblings had claimed their keys, shifting every id after the list). The + // consumed id is handed to the engine, whose row parent takes it + // explicitly, so rows mint the same hydration keys the server's did. + let hid: string | undefined; + let hid2: string | undefined; + if (sharedConfig.hydrating) { + // The server's For runs mapArray, which spends TWO id slots at this + // position: its internal owner (the rows' parent) and then its computed. + // Consume both, and hand both to the engine — its row owner and (for a + // plain call) its array computed take them explicitly — so no sibling + // after the list shifts and no extra slot is ever burned. + hid = sharedConfig.getNextContextId?.(); + hid2 = sharedConfig.getNextContextId?.(); + } + // Unified-For: the returned value IS a data structure — a callable carrying + // the list descriptor. A renderer that understands `$for` (web, universal) + // owns rows and placement in one persistent engine, in every For mode; + // everything else (children(), introspection, renderers that don't engage) + // CALLS it and gets the same engine's ARRAY output — mapArray's contract, + // one implementation. mapArray remains the public primitive and the spec. + const meta: any = { + each: () => props.each, + row: props.children, + keyed: props.keyed, + fallback: "fallback" in props ? () => props.fallback : undefined, + // For's CREATION owner: the engine's rows live under it (mapArray's own + // parent), so context/boundaries/lifetime follow the 's source + // position, not wherever the accessor is later inserted. + owner, + // The engine rides For's OWN module graph; a renderer's insert() + // engages it by passing its SlotOps. + impl: unifiedForSlot, + hid, + hid2 + }; + if (IS_DEV) meta.name = ""; + // ONE engine per list. A plain call (children(), introspection, renderers + // that don't engage) reads the engine's ARRAY output — mapArray's contract. + // If the list is already RENDERED, the call reads that engine's array view + // (tracked per commit); otherwise an array engine is created lazily under + // For's owner, and a later render inserts its output the classic way. + const list = () => + meta.rendered !== undefined + ? meta.rendered.array() + : (meta.arr ?? (meta.arr = listArray(meta)))(); + (list as any).$for = meta; 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..bd0a03633 --- /dev/null +++ b/packages/solid/src/client/for-slot-hydration.ts @@ -0,0 +1,108 @@ +/** + * 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 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. + * Nothing can demote mid-fill (see Slot.hyd), so claims are never handed + * back. The fill commit performs NO DOM writes: primitive rows ADOPT the + * server's text nodes (a claim, not a mutation), and a server/client + * mismatch is DETECTED (one dev warning) but not recovered — classic's + * claim pass leaves the server DOM as sent, and so does the engine. + */ +import { sharedConfig } from "./hydration.js"; +import { IS_DEV } from "./core.js"; +import { installSlotHydration, type Slot, type SlotNode } from "./for-slot.js"; +import type { ListNodes } from "@solidjs/signals"; + +const hooks = { + engage( + meta: any, + marker: SlotNode | null | undefined, + region: SlotNode[] | undefined + ): { id: string } | false { + if (!sharedConfig.hydrating) return false; + // 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`) hydrate. + // Without a parity id or a region there is nothing to claim against + // (a `null` marker never occurs under hydration): fill as a CSR list. + if (marker === null || meta.hid === undefined || region === undefined) return false; + return { id: meta.hid }; + }, + + commitFill(slot: Slot, list: ListNodes[]): void { + // This module IS the web hydration binding: nodes are DOM nodes here. + const parent = slot.parent as Node; + const region = slot.region as Node[]; + const nodes = list as (Node | Node[] | null)[]; + // Primitive rows: ADOPT the positional server text node (classic's + // normalizeIncomingArray rule) — node identity for text rows, and NO + // data write: classic never rewrites text during hydration, so live + // pre-hydration edits/selection survive and the server's text stands. + // Rows and region walk in lockstep, skipping the server's separator + // comments; the walk stops at the first misaligned element (a mismatch — + // detected below). + let cursor = 0; + adopt: for (let i = 0; i < nodes.length; i++) { + const nd = nodes[i]; + if (nd === null) continue; // zero-node row + const arr = Array.isArray(nd) ? nd : null; + const n = arr !== null ? arr.length : 1; + for (let k = 0; k < n; k++) { + const c = arr !== null ? arr[k] : (nd as Node); + let s = region[cursor]; + while (s !== undefined && s.nodeType === 8) s = region[++cursor]; + if (s === undefined) break adopt; + cursor++; + if (s === c) continue; + if (c.nodeType !== 3 || s.nodeType !== 3 || c.parentNode === parent) break adopt; + if (arr !== null) arr[k] = s; + else nodes[i] = s; + } + } + // MISMATCH: detect, don't recover (ruling 2026-09-07). Classic's claim + // pass leaves unclaimed server rows in place and never inserts a row + // whose template key-missed (it lands on the next update); the engine + // does the same. The runtime already reports unclaimed ELEMENTS and + // key-missed templates at hydration end; the one blind spot is TEXT rows + // (never in the registry), so detection here covers exactly those. + if (IS_DEV) { + const ours = new Set(); + let detached = 0; + for (let i = 0; i < nodes.length; i++) { + const nd = nodes[i]; + if (nd === null) continue; + if (Array.isArray(nd)) + for (const n of nd) { + ours.add(n); + if (n.nodeType === 3 && n.parentNode !== parent) detached++; + } + else { + ours.add(nd); + if (nd.nodeType === 3 && nd.parentNode !== parent) detached++; + } + } + let leftover = 0; + for (let i = 0; i < region.length; i++) + if (region[i].nodeType === 3 && !ours.has(region[i])) leftover++; + if (leftover !== 0 || detached !== 0) + console.warn( + `Hydration mismatch in : the server rendered a different list than the client ` + + `(${leftover} server text row(s) unclaimed, ${detached} client text row(s) not in the DOM). ` + + `Server and client should render the same list; the DOM was left as the server sent it.` + ); + } + } +}; + +/** 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 new file mode 100644 index 000000000..ef44f0d00 --- /dev/null +++ b/packages/solid/src/client/for-slot.ts @@ -0,0 +1,300 @@ +/** + * Unified For — the NODE LAYER for the list engine's RENDERED output. + * + * The engine (`@solidjs/signals` list.ts) owns row bookkeeping in every For + * mode and never touches a node: it calls back into a `ListNodeLayer`. This + * module builds that layer over a renderer's `SlotOps` — web hands in + * `domOps`, `@solidjs/universal` hands in ops built from its `createRenderer` + * primitives — and wires the engine to a two-phase render effect (compute + * diffs and builds detached rows; the effect places). Nothing here is DOM: + * every node touch rides the ops, so one node layer serves both renderers. + * + * DELIVERY (module-graph, no registration): `For` stamps `$for.impl` with + * `unifiedForSlot`; a renderer's insert() engages it with ITS ops. Apps whose + * lists are never rendered (children()/introspection only) never load this + * module — a plain call of the For accessor is the engine's ARRAY output. + * + * DYNAMIC ROWS (classic's list-effect model): a row whose top level resolves + * to a FUNCTION is created once and RESOLVED by the engine's compute, + * tracked — the `flatten` read classic's insert effect performs for such + * rows. `build()` reports the function; `resolve()`/`same()`/`splice()` + * carry the per-run diff and the commit-time range splice (text nodes + * reused with a data write). + */ +import { + createListEngine, + createRenderEffect, + firstNodeOf, + flatten, + lastNodeOf, + onCleanup, + runWithOwner, + type ListFlatPlan, + type ListLeaves, + type ListEngine, + type ListMeta, + type ListNode, + type ListNodeLayer, + type ListNodes, + type ListSlot +} from "@solidjs/signals"; +import { IS_DEV } from "./core.js"; + +export type SlotNode = ListNode; +export type Slot = ListSlot; +export type FlatPlan = ListFlatPlan; + +/** RENDERER OPS — the node layer's entire platform surface. A renderer's + * insert() hands ONE module-level singleton (web: `domOps`), so every call + * site stays monomorphic. */ +export interface SlotOps { + insert(parent: SlotNode, node: SlotNode, anchor: SlotNode | null): void; + remove(node: SlotNode): void; + createText(text: string): SlotNode; + isNode(v: unknown): boolean; + /** Whole-parent bulk clear (batch-clear / full-replace fast paths). */ + clear(parent: SlotNode): void; + /** Ownership marker for multi-slot parents (web: `$$SLOT`). */ + tag(node: SlotNode, marker: SlotNode): void; + /** True when `node` is a direct child of `parent` (ownership guard). */ + contains(parent: SlotNode, node: SlotNode): boolean; + /** True when `first`/`last` are the parent's first/last children. */ + owns(parent: SlotNode, first: SlotNode, last: SlotNode): boolean; + /** Next sibling (list-end anchor: classic's contiguity rule). */ + next(node: SlotNode): SlotNode | null; + /** Text node data, or undefined for non-text nodes (dynamic-row diff). */ + textOf(node: SlotNode): string | undefined; + /** Write text data if `node` is a text node; false otherwise (commit). */ + setText(node: SlotNode, text: string): boolean; + /** Optional: called for every node the engine inserts (web: host tagging + * for portals' event retargeting). */ + placed?(node: SlotNode): void; +} + +/** HYDRATION HOOKS — installed by enableHydration() (for-slot-hydration.ts), + * null in CSR bundles so every hydration path folds away. */ +export interface SlotHydration { + /** Engage-time decision: `false` = not hydrating; `{ id }` = hydrating + * fill with the parity owner id. */ + engage( + meta: any, + marker: SlotNode | null | undefined, + region: SlotNode[] | undefined + ): { id: string } | false; + /** Hydrating fill commit: adopt positional server nodes into `nodes` in + * place and detect mismatch (no DOM writes). */ + commitFill(slot: Slot, nodes: ListNodes[]): 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; + +const FLATTEN_OPTS = { skipNonRendered: true, doNotUnwrap: true } as const; +const RESOLVE_OPTS = { skipNonRendered: true } as const; +const EMPTY: ListLeaves = []; +const toLeaves = (v: any): ListLeaves => (v === undefined ? EMPTY : Array.isArray(v) ? v : [v]); + +/** Materialize leaves as DETACHED nodes; none → null (zero-node row). */ +function leavesToNodes(leaves: ListLeaves, ops: SlotOps): ListNodes { + const n = leaves.length; + if (n === 0) return null; + if (n === 1) { + const c = leaves[0]; + return ops.isNode(c) ? (c as SlotNode) : ops.createText(String(c)); + } + const ns: SlotNode[] = new Array(n); + for (let i = 0; i < n; i++) { + const c = leaves[i]; + ns[i] = ops.isNode(c) ? (c as SlotNode) : ops.createText(String(c)); + } + return ns; +} + +/** Detach only what is still OURS (classic's `parentNode === parent` guard). */ +function detachOne(slot: Slot, ops: SlotOps, n: SlotNode): void { + if (ops.contains(slot.parent!, n)) ops.remove(n); +} + +/** Node layers are per renderer ops singleton — cached so every list on a + * renderer shares one layer object (monomorphic call sites). */ +const layers = new WeakMap(); + +/** Build the ListNodeLayer over a renderer's ops. */ +export function nodeLayer(ops: SlotOps): ListNodeLayer { + let layer = layers.get(ops); + if (layer !== undefined) return layer; + let dyn: any = null; + layer = { + build(v, o) { + // `dyn` is written only AFTER any user code (flatten runs getters that + // may build nested lists): the outermost build writes last. + if (ops.isNode(v)) { + dyn = null; + return v as SlotNode; + } + const t = typeof v; + if (t === "string" || t === "number") { + dyn = null; + return ops.createText(String(v)); + } + if (t === "function") { + dyn = v; // dynamic: resolved tracked by the engine + return null; + } + // Fragments / nested arrays: flatten owned, leaving accessor leaves + // unresolved — a resolving wrapper comes back when there are any, and + // the row is dynamic; otherwise the leaves are static. + v = runWithOwner(o as any, () => flatten(v, FLATTEN_OPTS)); + if (typeof v === "function") { + dyn = v; + return null; + } + dyn = null; + return leavesToNodes(toLeaves(v), ops); + }, + dynamic: () => dyn, + resolve: f => toLeaves(flatten(f, RESOLVE_OPTS)), + toNodes: leaves => leavesToNodes(leaves, ops), + same(leaves, cur) { + const n = leaves.length; + if (cur === null) return n === 0; + const arr = Array.isArray(cur) ? cur : null; + if (n !== (arr !== null ? arr.length : 1)) return false; + for (let i = 0; i < n; i++) { + const c = arr !== null ? arr[i] : (cur as SlotNode); + const l = leaves[i]; + if (ops.isNode(l)) { + if (l !== c) return false; + } else if (ops.textOf(c) !== String(l)) return false; + } + return true; + }, + splice(slot, cur, leaves, anchor) { + // Reuse positional text nodes with a data write, detach what didn't + // survive (guarded), place the new range before `anchor`. + const parent = slot.parent!; + const arr: SlotNode[] = cur === null ? [] : Array.isArray(cur) ? cur : [cur]; + const n = leaves.length; + const out: SlotNode[] = new Array(n); + for (let i = 0; i < n; i++) { + const l = leaves[i]; + if (ops.isNode(l)) out[i] = l as SlotNode; + else { + const s = String(l); + const c = arr[i]; + out[i] = c !== undefined && ops.setText(c, s) ? c : ops.createText(s); + } + } + for (let i = 0; i < arr.length; i++) + if (out.indexOf(arr[i]) === -1) detachOne(slot, ops, arr[i]); + const tag = slot.end; + for (let i = n - 1; i >= 0; i--) { + ops.insert(parent, out[i], anchor); + if (tag) ops.tag(out[i], tag); + if (ops.placed !== undefined) ops.placed(out[i]); + anchor = out[i]; + } + return n === 0 ? null : n === 1 ? out[0] : out; + }, + place(slot, nd, anchor, tagIt) { + if (nd === null) return; + const tag = slot.end; + const parent = slot.parent!; + if (Array.isArray(nd)) { + for (let i = 0; i < nd.length; i++) { + ops.insert(parent, nd[i], anchor); + if (tag && tagIt) ops.tag(nd[i], tag); + if (ops.placed !== undefined) ops.placed(nd[i]); + } + } else { + ops.insert(parent, nd, anchor); + if (tag && tagIt) ops.tag(nd, tag); + if (ops.placed !== undefined) ops.placed(nd); + } + }, + detach(slot, nd) { + if (nd === null) return; + if (Array.isArray(nd)) for (let i = 0; i < nd.length; i++) detachOne(slot, ops, nd[i]); + else detachOne(slot, ops, nd); + }, + endAnchor(slot) { + // The node AFTER the list — classic's contiguity rule (`tail.nextSibling` + // while the tail is still ours); else the end marker / parent end. + const last = lastNodeOf(slot); + return last !== null && ops.contains(slot.parent!, last) ? ops.next(last) : slot.end; + }, + ownsParent(slot) { + // Whole-parent bulk ops are safe only when our window IS the parent's + // entire child list — classic's ownsAllChildren ruling. + if (!slot.whole || slot.fb !== null) return false; + const first = firstNodeOf(slot); + const last = lastNodeOf(slot); + return first !== null && last !== null && ops.owns(slot.parent!, first, last); + }, + clear(slot) { + if (IS_DEV) __unifiedForStats.batchCleared++; + ops.clear(slot.parent!); + }, + inParent: (slot, node) => ops.contains(slot.parent!, node), + commitFill: null + }; + layers.set(ops, layer); + return layer; +} + +/** Rendered output — For stamps this as `$for.impl`; a renderer's insert() + * engages it with its SlotOps. Returns false when the list already has an + * ARRAY engine (its accessor was called before being rendered): ONE engine + * per list — the renderer then inserts the accessor's array output the + * classic way, so rows are never built twice. */ +export function unifiedForSlot( + parent: SlotNode, + listFn: any, + marker: SlotNode | null | undefined, + ops: SlotOps, + region?: SlotNode[], + /** HOLE mode: engaged from inside a wrapper insert's compute (the + * `{props.children}` seam). The hosting effect owns the hole, so the + * engine 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: ListMeta & { arr?: unknown; rendered?: unknown } = listFn.$for; + if (meta.arr !== undefined || meta.rendered !== undefined) return false; + // HYDRATION: decided by the installed hooks (null in CSR bundles). A + // hydrating engage hands back the parity owner id so the engine's rows + // mint the same hydration keys the server's did. + let ownerOpts: { id: string } | undefined; + let hyd = false; + let layer = nodeLayer(ops); + if (slotHydration !== null) { + const h = slotHydration.engage(meta, marker, region); + if (h !== false) { + ownerOpts = h; + hyd = true; + // A hydrating list commits its first fill as a claim pass. + layer = { ...layer, commitFill: slotHydration.commitFill }; + } + } + const e = createListEngine(meta, parent, marker, layer, region, hole, ownerOpts, hyd); + // A later plain call of the accessor reads THIS engine's array view. + meta.rendered = e; + if (IS_DEV) __unifiedForStats.engaged++; + onCleanup(() => { + meta.rendered = undefined; + e.teardown(); + }); + createRenderEffect(e.compute, e.commit, transparentOptions); + return true; +} + +/** DEV-ONLY probes: engagement / bulk-clear counters, exposed as `DEV.unifiedFor`. */ +export interface UnifiedForStats { + engaged: number; + batchCleared: number; +} +export const __unifiedForStats: UnifiedForStats = { engaged: 0, batchCleared: 0 }; 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/solid/src/index.ts b/packages/solid/src/index.ts index 19847df49..2d188a94f 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -94,6 +94,10 @@ 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 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, @@ -144,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 0bec743d2..48145e176 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -104,6 +104,9 @@ export * from "./component.js"; // Flow controls export * from "./flow.js"; +// 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/universal/src/universal.ts b/packages/universal/src/universal.ts index ff8c13427..1ee69b842 100644 --- a/packages/universal/src/universal.ts +++ b/packages/universal/src/universal.ts @@ -24,6 +24,11 @@ export interface RendererOptions { getParentNode(node: NodeType): NodeType | undefined; getFirstChild(node: NodeType): NodeType | undefined; getNextSibling(node: NodeType): NodeType | undefined; + /** Node predicate for the unified For engine. Default: any non-array + * object (insertExpression's own assumption). Renderers whose nodes are + * not objects must supply this — and text-data tracking then requires + * object text nodes (`replaceText` is the only way to write text). */ + isNode?(value: unknown): boolean; } /** @@ -112,8 +117,58 @@ export function createRenderer({ setProperty, getParentNode, getFirstChild, - getNextSibling + getNextSibling, + isNode = v => v !== null && typeof v === "object" && !Array.isArray(v) }) { + // Unified For ENGINE ops for this renderer (solid-js `SlotOps`): the + // engine is platform-free and touches nodes only through these. Built from + // the renderer's own primitives; two things the primitives don't expose — + // a node predicate and text READS — are covered without new options: a + // node is any non-array object (insertExpression's own assumption), and + // text data is remembered for the text nodes the engine itself creates. + const textData = new WeakMap(); + const slotOps = { + insert(p, node, anchor) { + insertNode(p, node, anchor === null ? undefined : anchor); + }, + remove(node) { + const p = getParentNode(node); + if (p) removeNode(p, node); + }, + createText(text) { + const n = createTextNode(text); + if (typeof n === "object") textData.set(n, text); + return n; + }, + isNode, + clear(p) { + let c; + while ((c = getFirstChild(p))) removeNode(p, c); + }, + tag() {}, + contains(p, node) { + return getParentNode(node) === p; + }, + owns(p, first, last) { + return getFirstChild(p) === first && getNextSibling(last) == null; + }, + next(node) { + const n = getNextSibling(node); + return n === undefined ? null : n; + }, + textOf(node) { + return typeof node === "object" && isTextNode(node) ? textData.get(node) : undefined; + }, + setText(node, text) { + if (typeof node !== "object" || !isTextNode(node)) return false; + if (textData.get(node) !== text) { + replaceText(node, text); + textData.set(node, text); + } + return true; + } + }; + function insert(parent, accessor, marker, initial, options) { const onUpdate = options && options.onUpdate; let effectOptions = options; @@ -124,6 +179,14 @@ export function createRenderer({ effectOptions = named(effectOptions, "renderer insert"); const multi = marker !== undefined; if (multi && !initial) initial = []; + // Unified For: a `$for` list descriptor brings the engine with it (For's + // module graph); engage it with this renderer's ops. Same contract as web. + if (typeof accessor === "function" && accessor.$for !== undefined) { + if (Array.isArray(initial) && initial.length !== 0) + cleanChildren(parent, initial, multi ? marker : undefined); + // `false` = the list already has an array engine: classic insert below. + if (accessor.$for.impl(parent, accessor, marker, slotOps)) return; + } if (typeof accessor !== "function") { accessor = normalize(accessor, multi, true); if (typeof accessor !== "function") { @@ -142,6 +205,19 @@ export function createRenderer({ prev => { const value = normalize(accessor(), multi, true); if (typeof value !== "function") return value; + // HOLE seam: a `$for` accessor reaching this hole through a wrapper + // (`{props.children}`) engages the engine for the hole; a children + // change tears it down (hole-mode cleanup removes its rows). + if ( + value.$for !== undefined && + value.$for.arr === undefined && + value.$for.rendered === undefined + ) { + if (current !== undefined) cleanChildren(parent, current, multi ? marker : undefined); + current = []; + value.$for.impl(parent, value, marker, slotOps, undefined, true); + return INNER_OWNED; + } effect( () => normalize(value, multi), inner => { diff --git a/packages/universal/test/for-engine.spec.js b/packages/universal/test/for-engine.spec.js new file mode 100644 index 000000000..92f974d87 --- /dev/null +++ b/packages/universal/test/for-engine.spec.js @@ -0,0 +1,219 @@ +import * as r from "./custom.js"; +import { createMemo, createRoot, createSignal, flush, For, DEV } from "solid-js"; +import { referenceMapArray as mapArray } from "../../web/test/reference/mapArray.js"; + +/** + * Unified For ENGINE through a custom renderer: `insert` engages `$for` with + * ops built from the renderer's primitives. mapArray + insert through the + * SAME renderer is the oracle. + */ +const stats = () => DEV.unifiedFor; + +function span(text) { + const n = r.createElement("span"); + n.textContent = text; + return n; +} + +function mount(build) { + const parent = document.createElement("div"); + let dispose; + createRoot(d => { + dispose = d; + r.insert(parent, build()); + }); + flush(); + return { parent, dispose }; +} + +describe("universal renderer: unified For engine", () => { + it("engages, reorders by moving nodes, and matches the mapArray oracle", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + const engaged0 = stats().engaged; + const E = mount(() => + r.createComponent(For, { + get each() { + return list(); + }, + children: item => span(item) + }) + ); + const O = mount(() => mapArray(list, item => span(item))); + expect(stats().engaged).toBe(engaged0 + 1); + expect(E.parent.innerHTML).toBe(O.parent.innerHTML); + const [a, b, c] = Array.from(E.parent.children); + for (const step of [["c", "a", "b"], ["b", "c"], ["x", "b", "c", "a"], [], ["z"], ["z", "z"]]) { + setList(step); + flush(); + expect(E.parent.innerHTML, step.join(",")).toBe(O.parent.innerHTML); + } + setList(["a", "b", "c"]); + flush(); + expect(E.parent.innerHTML).toBe("abc"); + // Identity through a pure move. + const [na, nb, nc] = Array.from(E.parent.children); + setList(["c", "b", "a"]); + flush(); + expect(Array.from(E.parent.children)).toEqual([nc, nb, na]); + void [a, b, c]; + E.dispose(); + O.dispose(); + }); + + it("keyed={false}, keyed={fn}, index accessors, fallback — oracle-equal", () => { + const [list, setList] = createSignal([ + { id: 1, v: "a" }, + { id: 2, v: "b" } + ]); + const rowIdx = (item, i) => span(`${item.v}:${i()}`); + const rowAcc = (item, i) => span(`${item().v}:${typeof i === "function" ? i() : i}`); + const E1 = mount(() => + r.createComponent(For, { + get each() { + return list(); + }, + children: rowIdx + }) + ); + const O1 = mount(() => mapArray(list, rowIdx)); + const E2 = mount(() => + r.createComponent(For, { + get each() { + return list(); + }, + keyed: false, + children: rowAcc + }) + ); + const O2 = mount(() => mapArray(list, rowAcc, { keyed: false })); + const key = x => x.id; + const E3 = mount(() => + r.createComponent(For, { + get each() { + return list(); + }, + keyed: key, + children: rowAcc + }) + ); + const O3 = mount(() => mapArray(list, rowAcc, { keyed: key })); + const fb = () => span("none"); + const E4 = mount(() => + r.createComponent(For, { + get each() { + return list(); + }, + get fallback() { + return fb(); + }, + children: item => span(item.v) + }) + ); + const O4 = mount(() => mapArray(list, item => span(item.v), { fallback: fb })); + const pairs = [ + [E1, O1], + [E2, O2], + [E3, O3], + [E4, O4] + ]; + const check = label => { + for (const [e, o] of pairs) expect(e.parent.innerHTML, label).toBe(o.parent.innerHTML); + }; + check("init"); + setList([ + { id: 2, v: "B" }, + { id: 1, v: "a" }, + { id: 3, v: "c" } + ]); + flush(); + check("reorder + remint + add"); + setList([]); + flush(); + check("clear → fallback"); + expect(E4.parent.innerHTML).toBe("none"); + setList([{ id: 3, v: "c" }]); + flush(); + check("refill"); + for (const [e, o] of pairs) { + e.dispose(); + o.dispose(); + } + }); + + it("dynamic rows (function top level) resolve through the renderer's ops — no DOM assumption", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + const [big, setBig] = createSignal(false); + let calls = 0; + // A row whose top level is a MEMO (conditional content) — the node layer + // resolves it tracked through the engine's compute; flips splice the range. + const row = item => { + calls++; + return createMemo(() => (big() ? span(item.toUpperCase()) : span(item))); + }; + const E = mount(() => + r.createComponent(For, { + get each() { + return list(); + }, + children: row + }) + ); + const O = mount(() => mapArray(list, row)); + expect(E.parent.innerHTML).toBe(O.parent.innerHTML); + expect(E.parent.innerHTML).toBe("abc"); + expect(calls).toBe(6); // 3 per side + setBig(true); + flush(); + expect(E.parent.innerHTML).toBe(O.parent.innerHTML); + expect(E.parent.innerHTML).toBe("ABC"); + expect(calls).toBe(6); // a flip re-runs the memo, never the row fn + setList(["c", "a", "d"]); + flush(); + expect(E.parent.innerHTML).toBe(O.parent.innerHTML); + expect(E.parent.innerHTML).toBe("CAD"); + expect(calls).toBe(8); // one fresh row per side + setBig(false); + flush(); + expect(E.parent.innerHTML).toBe("cad"); + E.dispose(); + O.dispose(); + }); + + it("engages through a component's children hole and tears down on a children change", () => { + const [list, setList] = createSignal(["a", "b"]); + const [show, setShow] = createSignal(true); + const Wrap = props => { + const div = r.createElement("div"); + r.insert(div, () => props.children); + return div; + }; + const engaged0 = stats().engaged; + const { parent, dispose } = mount(() => + r.createComponent(Wrap, { + get children() { + return show() + ? r.createComponent(For, { + get each() { + return list(); + }, + children: item => span(item) + }) + : span("none"); + } + }) + ); + const div = parent.firstChild; + expect(stats().engaged).toBe(engaged0 + 1); + expect(div.innerHTML).toBe("ab"); + setList(["b", "a", "c"]); + flush(); + expect(div.innerHTML).toBe("bac"); + setShow(false); + flush(); + expect(div.innerHTML).toBe("none"); + setShow(true); + flush(); + expect(div.innerHTML).toBe("bac"); + dispose(); + }); +}); diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 656c4e7ee..f9f34e57b 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -20,6 +20,60 @@ import { } from "solid-js"; import { effect, memo, tagElement } from "./render.js"; +// 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; + }, + contains(parent: Node, node: Node): boolean { + return node.parentNode === parent; + }, + owns(parent: Node, first: Node, last: Node): boolean { + return parent.firstChild === first && parent.lastChild === last; + }, + next(node: Node): Node | null { + return node.nextSibling; + }, + textOf(node: Node): string | undefined { + return node.nodeType === 3 ? (node as Text).data : undefined; + }, + setText(node: Node, text: string): boolean { + if (node.nodeType !== 3) return false; + if ((node as Text).data !== text) (node as Text).data = text; + return true; + } +}; +// Host-aware ops (portals): one per host, tagging every placed node with +// `_$host` for delegated-event retargeting. +const hostOpsCache = new WeakMap(); +function hostOps(host: any) { + let ops = hostOpsCache.get(host); + if (ops === undefined) { + ops = { ...domOps, placed: (node: Node) => tagHost(node, host) }; + hostOpsCache.set(host, ops); + } + return ops; +} + import { JSX } from "../jsx/jsx.js"; import type { RequestEventLocals } from "./server.js"; @@ -893,6 +947,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) { @@ -959,6 +1025,32 @@ 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: a list value carrying the `$for` descriptor brings the + // engine WITH it (For's module graph); insert engages it by handing over + // web's domOps. The engine implements every For mode — there is no + // classic fallback on web (calling the descriptor IS the mapArray path, + // for children() and non-engaging renderers). + if (typeof accessor === "function" && accessor.$for !== 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). + const region = + hydrationRt !== null && isHydrating(parent) && Array.isArray(initial) + ? hydrationRt.slotRegion(initial) + : undefined; + // A caller-provided initial range (non-hydrating) is CONSUMED, as the + // classic path reconciles it away before the list lands. + if (region === undefined && Array.isArray(initial) && initial.length !== 0) + cleanChildren(parent, initial, multi ? marker : undefined); + // Marker passes through UNTOUCHED: `undefined` = whole-parent insert, + // `null` = trailing child with preceding siblings (classic MULTI mode), + // Node = bounded hole. The engine's bulk paths key off this distinction. + // Host-aware inserts (portals) tag every placed node for event + // retargeting, exactly as insertExpression's callers do. + // `false` = the list already has an ARRAY engine (its accessor was called + // before being rendered): insert its array output the classic way below. + if (accessor.$for.impl(parent, accessor, marker, host ? hostOps(host) : domOps, region)) return; + } if (typeof accessor !== "function") { accessor = normalize(accessor, initial, multi, true); if (typeof accessor !== "function") { @@ -973,24 +1065,54 @@ 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 engine for the hole. The + // engine is created inside this compute, so a children change tears it + // down (hole-mode cleanup removes its rows). + 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); const value = normalize(accessor(), current, multi, true); if (typeof value !== "function") return value; - 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 - ); + if ( + value.$for !== undefined && + value.$for.arr === undefined && + value.$for.rendered === undefined + ) { + // Hand-off: whatever classic content this hole tracked goes away + // first (a For returning after other children). The engine anchors + // on the marker itself, so no placeholder node is kept (classic's + // multi-mode placeholder is insert's own positioning aid). Under an + // ACTIVE hydration of this parent the tracked range is the claimed + // server region: keep it for the engine's fill instead of cleaning. + const region = + hydrationRt !== null && isHydrating(parent) && Array.isArray(current) + ? hydrationRt.slotRegion(current) + : undefined; + if (region !== undefined) current = region; + else { + if (current !== undefined) cleanChildren(parent, current, multi ? marker : undefined); + current = []; + } + value.$for.impl(parent, value, marker, host ? hostOps(host) : domOps, region, true); + return INNER_OWNED; + } + classic(value, prev); return INNER_OWNED; }, value => { diff --git a/packages/web/test/for.unified.audit.spec.tsx b/packages/web/test/for.unified.audit.spec.tsx new file mode 100644 index 000000000..86f318a27 --- /dev/null +++ b/packages/web/test/for.unified.audit.spec.tsx @@ -0,0 +1,694 @@ +/** @jsxImportSource @solidjs/web */ +/** + * Unified For — external audit regressions (PR #3281, 2026-09-07). One test + * per finding, each pinned against classic's behavior (arity-2 rows decline + * the slot and run classic mapArray, so they serve as the in-test oracle). + */ +import { describe, expect, test, beforeEach } from "vitest"; +import { + createContext, + createMemo, + createRoot, + createSignal, + flush, + onCleanup, + useContext, + DEV, + For, + Errored, + Show +} from "solid-js"; +import { referenceMapArray as refMapArray } from "./reference/mapArray.js"; +const mapArray: (...a: any[]) => any = refMapArray as any; +import { mapArray as engineMapArray } from "solid-js"; +import { insert, render } from "@solidjs/web"; + +const stats = () => DEV!.unifiedFor; +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + +let container: HTMLDivElement; +let dispose: (() => void) | undefined; +beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); +}); + +describe("P1-1 rows live under For's CREATION owner (mapArray parity)", () => { + test("context: rows see the creator's provider, not the inserter's", () => { + const Ctx = createContext("none"); + const [items] = createSignal([1]); + let slotSaw = ""; + let classicSaw = ""; + const Reader = (p: { list: any }) => ( + +
{p.list}
+
+ ); + const Creator = () => { + // Eager creation in the creator scope; inserted later by Reader. + const slot = ( + + {i => { + slotSaw = useContext(Ctx); + return {i}; + }} + + ); + // Oracle: mapArray directly (every engages the engine on web). + const classic = mapArray(items, (i: number) => { + classicSaw = useContext(Ctx); + return {i}; + }); + return ( + <> + + + + ); + }; + const engaged0 = stats().engaged; + dispose = render( + () => ( + + + + ), + container + ); + flush(); + expect(stats().engaged).toBe(engaged0 + 1); + expect(classicSaw).toBe("creator"); + expect(slotSaw).toBe("creator"); + }); + + test("row cleanups run when the inserting scope disposes (no leak until For's owner dies)", () => { + const [items] = createSignal([1, 2]); + const [show, setShow] = createSignal(true); + let cleaned = 0; + const Creator = () => { + const list = ( + + {i => { + onCleanup(() => cleaned++); + return {i}; + }} + + ); + return {
{list}
}
; + }; + dispose = render(() => , container); + flush(); + expect(container.querySelectorAll("span").length).toBe(2); + setShow(false); + flush(); + expect(cleaned).toBe(2); + }); +}); + +describe("P1-2 dynamic rows: resolved by the slot, never demoted, never double-invoked", () => { + test("component rows returning a conditional run ONCE each and stay engaged", () => { + const [items, setItems] = createSignal([1, 2, 3]); + let calls = 0; + const [big, setBig] = createSignal(1); + const Item = (p: { v: number }) => { + calls++; + return ( + big()} fallback={{p.v}}> + {{p.v}} + + ); + }; + const engaged0 = stats().engaged; + dispose = render( + () => ( +
+ {i => } +
+ ), + container + ); + flush(); + const div = container.firstChild as HTMLElement; + expect(stats().engaged).toBe(engaged0 + 1); + expect(calls).toBe(3); + expect(div.innerHTML).toBe("123"); + // A row flips: only its range is spliced; siblings keep node identity. + const b3 = div.children[2]; + setBig(2); + flush(); + expect(div.innerHTML).toBe("123"); + expect(div.children[2]).toBe(b3); + expect(calls).toBe(3); + // Reorder + append with dynamic rows in play. + setItems([3, 1, 2, 4]); + flush(); + expect(div.innerHTML).toBe("3124"); + expect(div.children[0]).toBe(b3); + expect(calls).toBe(4); + }); + + test("a LATE dynamic row does not remount the surviving rows", () => { + const [items, setItems] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+ {(r: any) => (typeof r === "function" ? r : {r})} +
+ ), + container + ); + flush(); + const div = container.firstChild as HTMLElement; + const [a, b] = Array.from(div.children); + const dyn = () => dyn; + setItems(["a", dyn, "b"]); + flush(); + expect(div.innerHTML).toBe("adynb"); + expect(div.children[0]).toBe(a); + expect(div.children[2]).toBe(b); + }); + + test("fragment rows with accessor leaves update text IN PLACE (.data write, node identity kept)", () => { + const [n, setN] = createSignal(1); + const [items] = createSignal(["x"]); + dispose = render( + () => ( +
+ + {r => ( + <> + {r} + {n()} + + )} + +
+ ), + container + ); + flush(); + const div = container.firstChild as HTMLElement; + expect(div.innerHTML).toBe("x1"); + const text = div.childNodes[1]; + const bold = div.childNodes[0]; + setN(2); + flush(); + expect(div.innerHTML).toBe("x2"); + expect(div.childNodes[1]).toBe(text); + expect(div.childNodes[0]).toBe(bold); + }); + + test("dynamic row resolving to nothing renders ZERO nodes and keeps its position", () => { + const [items, setItems] = createSignal(["a", "b"]); + const [show, setShow] = createSignal(true); + dispose = render( + () => ( +
+ {r => {{r}}} +
+ ), + container + ); + flush(); + const div = container.firstChild as HTMLElement; + expect(div.innerHTML).toBe("ab"); + setShow(false); + flush(); + expect(div.innerHTML).toBe(""); + expect(div.childNodes.length).toBe(0); // classic parity: nothing rendered, no placeholders + setItems(["b", "a"]); + flush(); + setShow(true); + flush(); + expect(div.innerHTML).toBe("ba"); + }); + + test("NotReady from a row's resolution keeps the built rows (one invocation, classic parity)", async () => { + const [items] = createSignal([1, 2]); + let calls = 0; + const data = createMemo(async () => sleep(5).then(() => "ok")); + const Item = (p: { v: number }) => { + calls++; + // The row's TOP LEVEL is a memo that reads the async value: the slot's + // resolve throws NotReady; the rows themselves must survive the retry. + return createMemo(() => ( + + {p.v}:{data()} + + )) as any; + }; + dispose = render( + () => ( +
+ {i => } +
+ ), + container + ); + flush(); + await sleep(20); + flush(); + const div = container.firstChild as HTMLElement; + expect(Array.from(div.children).map(b => b.textContent)).toEqual(["1:ok", "2:ok"]); + expect(calls).toBe(2); + }); +}); + +describe("P1-3 list end anchor is contiguous (classic's tail.nextSibling rule)", () => { + test("appending after a foreign trailing node keeps the list contiguous", () => { + const [items, setItems] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+ {i => {i}} +
+ ), + container + ); + flush(); + const parent = container.firstChild as HTMLElement; + parent.appendChild(document.createElement("hr")); + setItems(["a", "b", "c"]); + flush(); + expect(parent.innerHTML).toBe("abc
"); + // Replace (no survivors) also stays before the foreign node. + setItems(["x", "y"]); + flush(); + expect(parent.innerHTML).toBe("xy
"); + // Structural replace of a materialized chain too. + setItems(["y", "x", "z"]); + flush(); + expect(parent.innerHTML).toBe("yxz
"); + setItems(["q"]); + flush(); + expect(parent.innerHTML).toBe("q
"); + }); +}); + +describe("P1-4 removes are parent-guarded", () => { + test("a row node the user migrated elsewhere is left alone", () => { + const [items, setItems] = createSignal(["a", "b", "c"]); + dispose = render( + () => ( +
+ {i => {i}} +
+ ), + container + ); + flush(); + const parent = container.firstChild as HTMLElement; + const other = document.createElement("aside"); + other.appendChild(parent.firstChild!); // migrate a + setItems(["b", "c"]); + flush(); + expect(other.innerHTML).toBe("a"); + expect(parent.innerHTML).toBe("bc"); + // Chain mode as well (materialized by the partial op above). + other.appendChild(parent.firstChild!); // migrate b + setItems(["c"]); + flush(); + expect(other.innerHTML).toBe("ab"); + expect(parent.innerHTML).toBe("c"); + }); +}); + +describe("P1-5 a throwing row disposes its own owner", () => { + function throwScenario(classic: boolean) { + const [items, setItems] = createSignal([1]); + const cleanedRows: number[] = []; + let caught = 0; + const row = (i: number) => { + onCleanup(() => cleanedRows.push(i)); + if (i === 2) throw new Error("row 2"); + return {i}; + }; + dispose = render( + () => ( + { + caught++; + return

err

; + }} + > +
+ {classic ? (i, _idx) => row(i) : i => row(i)} +
+
+ ), + container + ); + flush(); + expect(container.innerHTML).toBe("
1
"); + setItems([1, 2]); + flush(); + expect(caught).toBe(1); + return [...cleanedRows].sort(); + } + + test("cleanups registered before the throw run (row owner disposed on the throw path)", () => { + const slot = throwScenario(false); + expect(slot).toContain(2); + dispose!(); + container = document.createElement("div"); + // Whatever the boundary does with the surviving row, the slot matches classic. + const classic = throwScenario(true); + expect(slot).toEqual(classic); + }); +}); + +// ─── Audit 2 (PR #3308, 2026-09-08) ──────────────────────────────────────── + +describe("#3308 P1-1 nested lists: row ownership survives reentrant builds", () => { + test("removing an outer row disposes THAT row (and its nested list), nothing else", () => { + type G = { id: string; items: string[] }; + const g1: G = { id: "g1", items: ["a", "b"] }; + const g2: G = { id: "g2", items: ["c"] }; + const [groups, setGroups] = createSignal([g1, g2]); + const cleaned: string[] = []; + dispose = render( + () => ( +
    + + {g => { + onCleanup(() => cleaned.push(`outer:${g.id}`)); + return ( +
  • + + {item => { + onCleanup(() => cleaned.push(`inner:${g.id}:${item}`)); + return {item}; + }} + +
  • + ); + }} +
    +
+ ), + container + ); + flush(); + expect(container.textContent).toBe("abc"); + setGroups([g2]); + flush(); + expect(container.textContent).toBe("c"); + // Exactly g1 and its nested rows were disposed — not g2's. + expect(cleaned.sort()).toEqual(["inner:g1:a", "inner:g1:b", "outer:g1"]); + }); +}); + +describe("#3308 P1-2 key-fn semantics match mapArray", () => { + test("no key fn calls during the fill; identity beats keys (in-place key mutation keeps the row)", () => { + type It = { id: number; v: string }; + const a: It = { id: 1, v: "a" }, + b: It = { id: 2, v: "b" }; + const [list, setList] = createSignal([a, b]); + let eKeys = 0, + oKeys = 0; + const eKey = (x: It) => (eKeys++, x.id); + const oKey = (x: It) => (oKeys++, x.id); + const host = document.createElement("div"); + dispose = render( + () => ( + <> +
+ + {it => {it().v}} + +
+
+ {mapArray( + list, + (it: () => It) => ( + {it().v} + ), + { keyed: oKey } + )} +
+ + ), + host + ); + flush(); + const e = host.querySelector("#e")!, + o = host.querySelector("#o")!; + expect(eKeys).toBe(0); // the fill keys nothing (mapArray parity) + expect(oKeys).toBe(0); + const [ea] = Array.from(e.querySelectorAll("span")); + // Same objects, one key mutated IN PLACE: identity matches first → the + // row stays; no rebuild. + a.id = 99; + setList([a, b]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.querySelectorAll("span")[0]).toBe(ea); + }); +}); + +describe("#3308 P1-3 the engine dies with For's creation owner", () => { + test("after the creation owner is disposed, source updates no longer touch the DOM (frozen, like mapArray)", () => { + const [list, setList] = createSignal(["a", "b"]); + let forAcc!: any; + const disposeCreator = createRoot(d => { + forAcc = {item => {item}}; + return d; + }); + // Rendered under a DIFFERENT owner that outlives the creator. + dispose = render(() =>
{forAcc}
, container); + flush(); + const div = container.firstChild as HTMLElement; + expect(div.innerHTML).toBe("ab"); + disposeCreator(); + setList(["c"]); + flush(); + expect(div.innerHTML).toBe("ab"); // frozen at the last value + }); +}); + +describe("#3308 P1-4 one engine per list: calling AND rendering an accessor", () => { + test("called first (children-style) then rendered: rows built once, both views live", () => { + const [list, setList] = createSignal(["a", "b"]); + let calls = 0; + let seen: any[] = []; + dispose = render(() => { + const acc = ( + + {item => { + calls++; + return {item}; + }} + + ) as any; + createMemo(() => (seen = acc())); // introspection + return
{acc}
; + }, container); + flush(); + const div = container.firstChild as HTMLElement; + expect(calls).toBe(2); + expect(div.innerHTML).toBe("ab"); + expect(seen.length).toBe(2); + setList(["b", "c", "a"]); + flush(); + expect(calls).toBe(3); + expect(div.innerHTML).toBe("bca"); + expect(seen.map((n: any) => n.textContent)).toEqual(["b", "c", "a"]); + }); + + test("rendered first then called: the call reads the rendered engine's array view", () => { + const [list, setList] = createSignal(["a", "b"]); + let calls = 0; + let seen: any[] = []; + let acc!: any; + dispose = render(() => { + acc = ( + + {item => { + calls++; + return {item}; + }} + + ) as any; + return
{acc}
; + }, container); + flush(); + const div = container.firstChild as HTMLElement; + const stop = createRoot(d => { + createMemo(() => (seen = acc())); + return d; + }); + flush(); + expect(calls).toBe(2); + expect(seen.map((n: any) => n.textContent)).toEqual(["a", "b"]); + setList(["c", "a"]); + flush(); + expect(calls).toBe(3); + expect(div.innerHTML).toBe("ca"); + expect(seen.map((n: any) => n.textContent)).toEqual(["c", "a"]); + stop(); + }); +}); + +describe("#3308 P1-5 reclaim: OUR runtime moving a row's node (element shared with a )", () => { + test("an element rendered in a row AND in a Show elsewhere returns to the list on the next structural pass", () => { + // The Show turning on moves the element out of the row (insertExpression); + // turning off detaches it (cleanup). The list must recover on its next + // structural pass — classic does, through its liveness walk. + const shared = document.createElement("b"); + shared.textContent = "S"; + const [list, setList] = createSignal(["a", shared, "c"]); + const [show, setShow] = createSignal(false); + const row = (item: any) => (typeof item === "string" ? {item} : item); + const host = document.createElement("div"); + dispose = render( + () => ( + <> +
+ {row} +
+ + + ), + host + ); + flush(); + const e = host.querySelector("#e")!, + other = host.querySelector("#other")!; + expect(e.innerHTML).toBe("aSc"); + setShow(true); // OUR insert moves the shared element into the aside + flush(); + expect(other.contains(shared)).toBe(true); + expect(e.innerHTML).toBe("ac"); + setShow(false); // Show's cleanup detaches it: the row now points at a detached node + flush(); + expect(shared.parentNode).toBe(null); + setList(["a", shared, "c", "d"]); // next structural pass reclaims it + flush(); + expect(e.innerHTML).toBe("aScd"); + }); +}); + +describe("#3308 P1-5 retained rows whose node migrated are reclaimed", () => { + test("a middle row moved elsewhere by user code comes back on the next structural pass (classic parity)", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + const host = document.createElement("div"); + dispose = render( + () => ( + <> +
+ {item => {item}} +
+
+ {mapArray(list, (item: string) => ( + {item} + ))} +
+ + ), + host + ); + flush(); + const e = host.querySelector("#e")!, + o = host.querySelector("#o")!; + const aside = document.createElement("aside"); + aside.appendChild(e.children[1]); // migrate the engine's b + aside.appendChild(o.children[1]); // and the oracle's + setList(["a", "b", "c", "d"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("abcd"); + expect(aside.childNodes.length).toBe(0); + }); +}); + +describe("#3308 P1-6 insert contracts: host tagging and initial range", () => { + test("a host-aware insert tags every placed row node with _$host", () => { + const [list, setList] = createSignal(["a", "b"]); + const hostNode = document.createElement("main"); + const parent = document.createElement("div"); + dispose = createRoot(d => { + insert( + parent, + ({item => {item}}) as any, + undefined, + undefined, + { + host: () => hostNode + } + ); + return d; + }); + flush(); + for (const n of Array.from(parent.children)) expect((n as any)._$host).toBe(hostNode); + setList(["a", "b", "c"]); + flush(); + expect((parent.children[2] as any)._$host).toBe(hostNode); + }); + + test("a caller-provided initial range is consumed, not left beside the list", () => { + const [list] = createSignal(["a"]); + const parent = document.createElement("div"); + const stale = document.createElement("i"); + parent.appendChild(stale); + dispose = createRoot(d => { + insert(parent, ({item => {item}}) as any, undefined, [ + stale + ]); + return d; + }); + flush(); + expect(parent.innerHTML).toBe("a"); + }); +}); + +describe("#3308 P2 fallback is called with zero arguments (mapArray parity)", () => { + test("mapArray fallback sees arguments.length === 0", () => { + const [list] = createSignal([]); + let argc = -1; + dispose = render(() => { + const m = engineMapArray(list, (x: string) => x, { + fallback: function () { + argc = arguments.length; + return "none"; + } + }); + createMemo(() => m()); + return null; + }, container); + flush(); + expect(argc).toBe(0); + }); +}); + +describe("P2-1 duplicate keys are rows (mapArray's chained pairing, no demotion)", () => { + test("two fresh copies of one identity become two rows; removing one keeps the other", () => { + const o1 = { id: 1 }, + o2 = { id: 2 }, + o3 = { id: 3 }; + const [items, setItems] = createSignal([o1]); + dispose = render( + () => ( +
+ {(i: any) => {i.id}} +
+ ), + container + ); + flush(); + const parent = container.firstChild as HTMLElement; + setItems([o1, o2, o3, o2]); // o2 twice, both fresh + flush(); + expect(parent.innerHTML).toBe("1232"); + const [e1, e2a, e3, e2b] = Array.from(parent.children); + setItems([o3, o2, o1]); + flush(); + expect(parent.innerHTML).toBe("321"); + expect(parent.children[0]).toBe(e3); + expect(parent.children[1]).toBe(e2a); // first occurrence pairs with the first old row + expect(parent.children[2]).toBe(e1); + expect(parent.contains(e2b)).toBe(false); + }); +}); 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..e169580bb --- /dev/null +++ b/packages/web/test/for.unified.children.spec.tsx @@ -0,0 +1,312 @@ +/** + * @jsxImportSource @solidjs/web + * @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 + * - duplicates and array-like subjects INSIDE a hole stay on the engine + * - `children()` introspection and fragment children stay classic + */ +import { beforeEach, describe, expect, test } from "vitest"; +import { createSignal, flush, For, children, DEV } from "solid-js"; +const stats = DEV!.unifiedFor; +import { render, Dynamic } 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 = stats.engaged; + dispose = render( + () => ( + + + {r => ( + + + + )} + +
{r}
+ ), + container + ); + 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"]); + 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(""); + }); + + test("bounded hole (element marker) engages; siblings untouched through reorder and clear", () => { + const [rows, setRows] = createSignal(["a", "b", "c"]); + const engaged0 = stats.engaged; + dispose = render( + () => ( + + {r =>

{r}

}
+
+ ), + container + ); + 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"]); + 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 = stats.engaged; + dispose = render( + () => {show() ? {r => {r}} :

none

}
, + container + ); + const div = container.querySelector("div")!; + 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(stats.engaged).toBe(engaged0 + 2); // fresh slot + expect(div.innerHTML).toBe("ab"); + setRows(["b", "a"]); + flush(); + expect(div.innerHTML).toBe("ba"); + }); + + test("function-top-level rows stay engaged inside a hole (dynamic rows, no demote)", () => { + const [rows, setRows] = createSignal(["a", "b"]); + 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 → resolved by the slot's own compute + // (classic's list-effect model); the slot stays engaged. + const dyn = () => dyn; + setRows(["a", dyn, "b"]); + flush(); + expect(div.innerHTML).toBe("adynb"); + setRows(["b", dyn, "a"]); + flush(); + expect(div.innerHTML).toBe("bdyna"); + setRows([]); + flush(); + expect(div.innerHTML).toBe(""); + }); + + test("duplicate identity keys inside a hole: two rows, engine stays engaged", () => { + const a = { id: "a" }, + b = { id: "b" }; + const [rows, setRows] = createSignal([a, b]); + dispose = render( + () => ( + + {(r: any) => {r.id}} + + ), + container + ); + const div = container.querySelector("div")!; + expect(div.innerHTML).toBe("ab"); + setRows([a, b, a]); + flush(); + expect(div.innerHTML).toBe("aba"); + setRows([b, a]); + flush(); + expect(div.innerHTML).toBe("ba"); + setRows([]); + flush(); + expect(div.innerHTML).toBe(""); + }); + + test("-rooted rows stay engaged (memo top level → dynamic row) 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 resolves it tracked in its own compute — + // no demote, no second invocation of the row — and reorders as usual. + const [rows, setRows] = createSignal(["a", "b", "c"]); + const engaged0 = stats.engaged; + dispose = render( + () => ( + + {r => {r}} +
+ ), + container + ); + expect(stats.engaged).toBe(engaged0 + 1); + expect(texts(container, "tr")).toEqual(["a", "b", "c"]); + const trs = Array.from(container.querySelectorAll("tr")); + setRows(["c", "a", "b"]); + flush(); + expect(texts(container, "tr")).toEqual(["c", "a", "b"]); + // Reorder moved the SAME elements (row identity survived). + expect(Array.from(container.querySelectorAll("tr"))).toEqual([trs[2], trs[0], trs[1]]); + setRows([]); + flush(); + expect(container.querySelector("tbody")!.innerHTML).toBe(""); + }); + + test("array-like subject in a hole (string → characters), replace and children swap stay clean", () => { + // mapArray duck-types anything with `length` + indices; the engine does + // the same. The hole's range must stay exact through a replace and a + // children change (no orphan placeholder, no leaked rows). + const [rows, setRows] = createSignal("ab"); + const [show, setShow] = createSignal(true); + dispose = render( + () => ( + + {show() ? ( + {(r: any) => {r}} + ) : ( +

none

+ )} +
+ ), + container + ); + const sec = container.querySelector("section")!; + expect(sec.innerHTML).toBe("
h
ab
f
"); + expect(sec.childNodes.length).toBe(4); // no orphan placeholder + setRows(["x", "y"]); // REPLACE (not reorder): old rows must go + flush(); + expect(sec.innerHTML).toBe("
h
xy
f
"); + expect(sec.childNodes.length).toBe(4); + setShow(false); + flush(); + expect(sec.innerHTML).toBe("
h

none

f
"); + setShow(true); + flush(); + expect(sec.innerHTML).toBe("
h
xy
f
"); + + // Whole-parent hole, replace only. + dispose(); + const [rows2, setRows2] = createSignal("ab"); + dispose = render( + () => ( + + {(r: any) => {r}} + + ), + container + ); + const div = container.querySelector("div")!; + expect(div.innerHTML).toBe("ab"); + setRows2(["x", "y"]); + flush(); + expect(div.innerHTML).toBe("xy"); + }); + + test("children() introspection stays classic and correct", () => { + const [rows, setRows] = createSignal(["a", "b"]); + const engaged0 = stats.engaged; + dispose = render( + () => ( + + {r => {r}} + + ), + container + ); + expect(stats.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 = stats.engaged; + dispose = render( + () => ( + +

t

+ {r => {r}} +
+ ), + container + ); + expect(stats.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/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.modes.spec.tsx b/packages/web/test/for.unified.modes.spec.tsx new file mode 100644 index 000000000..8df68afb5 --- /dev/null +++ b/packages/web/test/for.unified.modes.spec.tsx @@ -0,0 +1,368 @@ +/** @jsxImportSource @solidjs/web */ +/** + * Unified For ENGINE — every For mode, with mapArray + insert as the live + * oracle (no on the oracle side: every engages the engine on web). + * + * identity + index accessor (item, i) => … with i() [arity 2] + * keyed={false} (item, i) => … item() accessor, i a number + * keyed={fn} (item, i) => … item() accessor, i() accessor + * fallback empty-state row, owned like a row + * + * Each mode: DOM equality with the oracle after every step of a cumulative + * sequence, node identity where the mode promises it, signal updates where + * the mode promises them, and row-fn invocation counts equal on both sides. + */ +import { beforeEach, describe, expect, test } from "vitest"; +import { createSignal, flush, For, DEV, Show } from "solid-js"; +import { referenceMapArray as refMapArray } from "./reference/mapArray.js"; +const mapArray: (...a: any[]) => any = refMapArray as any; +import { render } from "@solidjs/web"; + +const stats = () => DEV!.unifiedFor; + +let container: HTMLDivElement; +let dispose: (() => void) | undefined; +beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); +}); + +type Item = { id: string; name: string }; +const mk = (id: string, name = id.toUpperCase()): Item => ({ id, name }); + +/** Mount engine + oracle side by side off one signal; returns both hosts. */ +function pair(list: () => any[], engine: () => any, oracle: () => any): [HTMLElement, HTMLElement] { + const host = document.createElement("div"); + dispose = render( + () => ( + <> +
+ pre + {engine()} + post +
+
+ pre + {oracle()} + post +
+ + ), + host + ); + flush(); + return [host.querySelector("#e")!, host.querySelector("#o")!]; +} + +const texts = (el: HTMLElement) => Array.from(el.querySelectorAll("span")).map(s => s.textContent); + +describe("identity keys + index accessor (arity 2)", () => { + test("indices track position through reorders; nodes keep identity; oracle-equal", () => { + const a = mk("a"), + b = mk("b"), + c = mk("c"), + d = mk("d"); + const [list, setList] = createSignal([a, b, c]); + let eCalls = 0, + oCalls = 0; + const engaged0 = stats().engaged; + const [e, o] = pair( + list, + () => ( + + {(item, i) => { + eCalls++; + return ( + + {item.name}:{i()} + + ); + }} + + ), + () => + mapArray(list, (item: Item, i: any) => { + oCalls++; + return ( + + {item.name}:{i()} + + ); + }) + ); + expect(stats().engaged).toBe(engaged0 + 1); + expect(e.innerHTML).toBe(o.innerHTML); + expect(texts(e)).toEqual(["A:0", "B:1", "C:2"]); + const [sa, sb, sc] = Array.from(e.querySelectorAll("span")); + const steps: Item[][] = [ + [c, a, b], + [b, c], + [d, b, c, a], + [a, d], + [], + [b, a], + [b, a, b] // duplicate: second b gets its own row and index + ]; + for (const step of steps) { + setList(step); + flush(); + expect(e.innerHTML, step.map(x => x.id).join(",")).toBe(o.innerHTML); + expect(texts(e)).toEqual(step.map((x, k) => `${x.name}:${k}`)); + } + // a and b never left between steps 1-4; their nodes survived those moves. + setList([a, b, c]); + flush(); + expect(texts(e)).toEqual(["A:0", "B:1", "C:2"]); + expect(eCalls).toBe(oCalls); + // Identity check on a bounded move sequence. + const [na, nb] = Array.from(e.querySelectorAll("span")); + setList([b, a, c]); + flush(); + expect(Array.from(e.querySelectorAll("span"))[0]).toBe(nb); + expect(Array.from(e.querySelectorAll("span"))[1]).toBe(na); + expect(texts(e)).toEqual(["B:0", "A:1", "C:2"]); + void [sa, sb, sc]; + }); +}); + +describe("keyed={false} (by index)", () => { + test("rows are reused by POSITION: item() updates in place, index is a number, tail grows/shrinks", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + let eCalls = 0, + oCalls = 0; + const [e, o] = pair( + list, + () => ( + + {(item, i) => { + eCalls++; + return ( + + {item()}:{i} + + ); + }} + + ), + () => + mapArray( + list, + (item: () => string, i: number) => { + oCalls++; + return ( + + {item()}:{i} + + ); + }, + { keyed: false } + ) + ); + expect(e.innerHTML).toBe(o.innerHTML); + const nodes0 = Array.from(e.querySelectorAll("span")); + // Reverse: no node moves — the same three nodes show new text. + setList(["c", "b", "a"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(texts(e)).toEqual(["c:0", "b:1", "a:2"]); + expect(Array.from(e.querySelectorAll("span"))).toEqual(nodes0); + expect(eCalls).toBe(3); + // Grow: two fresh rows appended; the first three untouched. + setList(["x", "y", "z", "p", "q"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(texts(e)).toEqual(["x:0", "y:1", "z:2", "p:3", "q:4"]); + expect(Array.from(e.querySelectorAll("span")).slice(0, 3)).toEqual(nodes0); + expect(eCalls).toBe(5); + // Shrink: tail rows leave; survivors keep identity. + setList(["m"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(texts(e)).toEqual(["m:0"]); + expect(e.querySelector("span")).toBe(nodes0[0]); + setList([]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.querySelectorAll("span").length).toBe(0); + setList(["n", "o"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(eCalls).toBe(oCalls); + }); +}); + +describe("keyed={fn}", () => { + test("rows keyed by fn(item): same key → same node + item() update; reorder moves; duplicates by key", () => { + const [list, setList] = createSignal([mk("a"), mk("b"), mk("c")]); + let eCalls = 0, + oCalls = 0; + const key = (x: Item) => x.id; + const [e, o] = pair( + list, + () => ( + + {(item, i) => { + eCalls++; + return ( + + {item().name}:{i()} + + ); + }} + + ), + () => + mapArray( + list, + (item: () => Item, i: any) => { + oCalls++; + return ( + + {item().name}:{i()} + + ); + }, + { keyed: key } + ) + ); + expect(e.innerHTML).toBe(o.innerHTML); + const [na, nb, nc] = Array.from(e.querySelectorAll("span")); + // New objects, same keys, one renamed: no new rows; b's text updates in place. + setList([mk("a"), mk("b", "Bee"), mk("c")]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(texts(e)).toEqual(["A:0", "Bee:1", "C:2"]); + expect(Array.from(e.querySelectorAll("span"))).toEqual([na, nb, nc]); + expect(eCalls).toBe(3); + // Reorder by key with fresh objects: nodes MOVE, indices update. + setList([mk("c"), mk("a"), mk("b")]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(Array.from(e.querySelectorAll("span"))).toEqual([nc, na, nb]); + expect(texts(e)).toEqual(["C:0", "A:1", "B:2"]); + expect(eCalls).toBe(3); + // Duplicate key: a second "a" row is created. mapArray's SUFFIX walk runs + // before the middle window, so the old `a` row pairs with the LAST new + // `a` (A2) and A1 is the fresh row — the engine pairs identically. + setList([mk("a", "A1"), mk("a", "A2"), mk("b")]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(texts(e)).toEqual(["A1:0", "A2:1", "B:2"]); + expect(e.querySelectorAll("span")[1]).toBe(na); + const nA1 = e.querySelectorAll("span")[0]; + expect(eCalls).toBe(4); + // Remove one duplicate: the PREFIX walk pairs the surviving `a` with the + // row at position 0 (A1's); the old `a` row (na) is the one that leaves. + setList([mk("a", "A3"), mk("b")]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(texts(e)).toEqual(["A3:0", "B:1"]); + expect(e.querySelectorAll("span")[0]).toBe(nA1); + expect(e.contains(na)).toBe(false); + expect(eCalls).toBe(oCalls); + }); +}); + +describe("fallback", () => { + test("empty → fallback; items replace it; clear brings it back; oracle-equal throughout", () => { + const [list, setList] = createSignal([]); + const [e, o] = pair( + list, + () => ( + none}> + {item => {item}} + + ), + () => mapArray(list, (item: string) => {item}, { fallback: () => none }) + ); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("prenonepost"); + setList(["a", "b"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("preabpost"); + setList(["b", "a", "c"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + setList([]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("prenonepost"); + setList(["z"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("prezpost"); + }); + + test("dynamic fallback content (a Show) updates in place while shown", () => { + const [list, setList] = createSignal([]); + const [busy, setBusy] = createSignal(true); + const [e, o] = pair( + list, + () => ( + empty}> + {loading} +
+ } + > + {item => {item}} +
+ ), + () => + mapArray(list, (item: string) => {item}, { + fallback: () => ( + empty}> + {loading} + + ) + }) + ); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("preloadingpost"); + setBusy(false); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("preemptypost"); + setList(["a"]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("preapost"); + setBusy(true); // fallback not shown: no effect on the DOM + flush(); + expect(e.innerHTML).toBe("preapost"); + setList([]); + flush(); + expect(e.innerHTML).toBe(o.innerHTML); + expect(e.innerHTML).toBe("preloadingpost"); + }); + + test("whole-parent fallback with the flat path: fill → clear → fallback → fill", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+ none}> + {item => {item}} + +
+ ), + container + ); + flush(); + const sec = container.firstChild as HTMLElement; + expect(sec.innerHTML).toBe("ab"); + setList([]); + flush(); + expect(sec.innerHTML).toBe("none"); + setList(["c"]); + flush(); + expect(sec.innerHTML).toBe("c"); + setList(["c", "d"]); + flush(); + expect(sec.innerHTML).toBe("cd"); + }); +}); diff --git a/packages/web/test/for.unified.oracle.spec.tsx b/packages/web/test/for.unified.oracle.spec.tsx new file mode 100644 index 000000000..f07dcf81d --- /dev/null +++ b/packages/web/test/for.unified.oracle.spec.tsx @@ -0,0 +1,348 @@ +/** @jsxImportSource @solidjs/web */ +/** + * Unified For ENGINE — the ORACLE HARNESS. + * + * mapArray + insert (no ) is the specification. For every mode, both + * implementations render the SAME seeded random sequence off ONE signal, and + * after every step we compare: + * - DOM (innerHTML) — identical + * - node retention — the SAME set of keys kept their DOM node across the + * step on both sides (identity is per side; retention is comparable) + * - row-fn invocation counts — identical (no double invocation, no extra + * rebuilds) + * - cleanup SET per step — the same rows are disposed on both sides. + * RULING: the ORDER among rows disposed in one step is not a For + * contract (mapArray's own order is an internal artifact: old-index + * order for partial removes, reverse creation order via dispose(false) + * on a clear), and the engine disposes at commit rather than in the + * compute (hold safety), so order is compared as a multiset. + * + * Row shapes are mixed per item: element rows, zero-node rows (null), + * fragment rows, and DYNAMIC rows (a whose condition flips during the + * run). Operations include shuffles, inserts, removes, duplicate inserts, + * clears, full replaces, and same-key/new-object updates (key-fn mode). + */ +import { describe, expect, test } from "vitest"; +import { createMemo, createSignal, flush, For, onCleanup, Show } from "solid-js"; +import { referenceMapArray as refMapArray } from "./reference/mapArray.js"; +const mapArray: (...a: any[]) => any = refMapArray as any; +import { render } from "@solidjs/web"; + +type Item = { id: number; v: number }; +const SEED_STEPS = 220; + +/** Small deterministic PRNG (LCG). */ +function rng(seed: number) { + let s = seed >>> 0; + return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; +} + +function shuffle(arr: T[], r: () => number): T[] { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(r() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +/** Produce the next list from the current one. Items are drawn from a pool + * of stable objects (identity mode) or re-minted with the same id (key-fn + * mode exercises same-key/new-object updates). */ +function nextList(cur: Item[], pool: Item[], r: () => number, remint: boolean): Item[] { + const pick = () => pool[Math.floor(r() * pool.length)]; + const op = r(); + let out: Item[]; + if (op < 0.14) out = shuffle(cur, r); + else if (op < 0.28) + out = [ + ...cur.slice(0, Math.floor(r() * (cur.length + 1))), + pick(), + ...cur.slice(Math.floor(r() * (cur.length + 1))) + ]; + else if (op < 0.42) out = cur.filter(() => r() > 0.3); + else if (op < 0.5) + out = cur.length ? [...cur, cur[Math.floor(r() * cur.length)]] : [pick()]; // duplicate + else if (op < 0.56) out = []; + else if (op < 0.66) + out = Array.from({ length: 1 + Math.floor(r() * 7) }, pick); // replace + else if (op < 0.74) out = cur.slice().reverse(); + else if (op < 0.82) { + out = cur.slice(); + if (out.length > 1) { + const i = Math.floor(r() * out.length), + j = Math.floor(r() * out.length); + [out[i], out[j]] = [out[j], out[i]]; + } + } else if (op < 0.9) + out = [...cur.slice(1), ...cur.slice(0, 1)]; // rotate + else out = [pick(), ...cur]; + if (remint) out = out.map(x => (r() < 0.3 ? { id: x.id, v: x.v + 1 } : x)); + return out; +} + +type Mode = "identity" | "identity-index" | "byindex" | "keyfn"; + +/** Per-side probes: invocation counter, cleanup log. */ +type Probe = { calls: number; cleaned: number[] }; + +/** Row renderer shared by both sides. Shape by id: 0 mod 4 → zero nodes, + * 1 mod 4 → fragment, 2 mod 4 → DYNAMIC (Show), else element. */ +function rowFor(mode: Mode, probe: Probe, dyn: () => boolean) { + const body = (it: Item, idx: (() => number) | number, key: number) => { + probe.calls++; + onCleanup(() => probe.cleaned.push(key)); + const i = typeof idx === "function" ? idx() : idx; + if (it.id % 4 === 0) return null; + if (it.id % 4 === 1) + return ( + <> + {it.v} + {i} + + ); + if (it.id % 4 === 2) + return ( + {it.v}}> + {it.v} + + ); + return ( + + {it.v}:{i} + + ); + }; + switch (mode) { + case "identity": + return (it: Item) => body(it, 0, it.id); + case "identity-index": + return (it: Item, i: () => number) => body(it, i, it.id); + case "byindex": + return (it: () => Item, i: number) => body(it(), i, i); + case "keyfn": + return (it: () => Item, i: () => number) => body(it(), i, it().id); + } +} + +const keyFn = (x: Item) => x.id; + +function mount(mode: Mode, list: () => Item[], probe: Probe, dyn: () => boolean, oracle: boolean) { + const row: any = rowFor(mode, probe, dyn); + const host = document.createElement("div"); + const dispose = render( + () => ( +
+ pre + {oracle ? ( + mapArray( + list, + row, + mode === "byindex" ? { keyed: false } : mode === "keyfn" ? { keyed: keyFn } : undefined + ) + ) : mode === "byindex" ? ( + + {row} + + ) : mode === "keyfn" ? ( + + {row} + + ) : ( + {row} + )} + post +
+ ), + host + ); + return { el: host.firstElementChild as HTMLElement, dispose }; +} + +/** key → root node, for retention comparison (data-k on every row root). */ +function keyNodes(el: HTMLElement): Map { + const m = new Map(); + for (const n of el.querySelectorAll("[data-k]")) { + const k = n.getAttribute("data-k")!; + (m.get(k) ?? m.set(k, []).get(k)!).push(n); + } + return m; +} +/** Which keys kept (all of) their nodes across a step. */ +function retained(before: Map, after: Map): string[] { + const out: string[] = []; + for (const [k, prev] of before) { + const now = after.get(k); + if (now && now.length === prev.length && now.every((n, i) => n === prev[i])) out.push(k); + } + return out.sort(); +} + +/** ARRAY output: a plain call of the For accessor (children(), introspection, + * non-engaging renderers) must return mapArray's array — same values, same + * identity while structurally unchanged, `[fallback]` when empty. */ +describe("oracle: array output (plain call of the For accessor)", () => { + for (const mode of ["identity", "identity-index", "byindex", "keyfn"] as Mode[]) { + test(`${mode}: values, identity stability, invocation counts match mapArray`, () => { + const r = rng(mode.length * 131); + const pool: Item[] = Array.from({ length: 10 }, (_, id) => ({ id, v: 0 })); + const [list, setList] = createSignal(pool.slice(0, 4)); + const [dyn] = createSignal(true); + const pe: Probe = { calls: 0, cleaned: [] }; + const po: Probe = { calls: 0, cleaned: [] }; + const rowE: any = rowFor(mode, pe, dyn); + const rowO: any = rowFor(mode, po, dyn); + let eAcc!: () => any[]; + let oAcc!: () => any[]; + const [tick, setTick] = createSignal(0); + const readsE: any[][] = []; + const readsO: any[][] = []; + const dispose = render(() => { + eAcc = + mode === "byindex" + ? (( + + {rowE} + + ) as any) + : mode === "keyfn" + ? (( + + {rowE} + + ) as any) + : (({rowE}) as any); + oAcc = mapArray( + list, + rowO, + mode === "byindex" ? { keyed: false } : mode === "keyfn" ? { keyed: keyFn } : undefined + ); + // Two readers per side that also depend on `tick`, so a tick-only + // change re-reads without a structural change (identity must hold). + createMemo(() => { + tick(); + readsE.push(eAcc()); + }); + createMemo(() => { + tick(); + readsO.push(oAcc()); + }); + return null; + }, document.createElement("div")); + try { + flush(); + const same = (label: string) => { + const e = readsE[readsE.length - 1], + o = readsO[readsO.length - 1]; + expect(e.length, `${label} length`).toBe(o.length); + for (let i = 0; i < e.length; i++) { + const ev = e[i], + ov = o[i]; + // Values are what the row fn returned: nodes (compare by outerHTML + // / text), functions (Show memos), or null. + expect(typeof ev, `${label}[${i}] type`).toBe(typeof ov); + if (ev && ev.nodeType) expect(ev.outerHTML ?? ev.data).toBe(ov.outerHTML ?? ov.data); + else if (Array.isArray(ev)) expect(ev.length).toBe(ov.length); + } + expect(pe.calls, `${label} invocations`).toBe(po.calls); + }; + same("init"); + let cur = list(); + for (let step = 0; step < 60; step++) { + if (step % 5 === 2) { + // Tick without a list change: both sides must return the SAME + // array identity as before. + const eBefore = readsE[readsE.length - 1]; + const oBefore = readsO[readsO.length - 1]; + setTick(t => t + 1); + flush(); + expect(readsE[readsE.length - 1], `step ${step} identity`).toBe(eBefore); + expect(readsO[readsO.length - 1], `step ${step} oracle identity`).toBe(oBefore); + continue; + } + cur = nextList(cur, pool, r, mode === "keyfn"); + setList(cur); + flush(); + same(`step ${step}`); + } + } finally { + dispose(); + } + }); + } + + test("fallback: empty list yields [fallback] and back", () => { + const [list, setList] = createSignal([]); + let eAcc!: () => any[]; + let oAcc!: () => any[]; + const dispose = render(() => { + eAcc = ( + none}> + {(s: string) => {s}} + + ) as any; + oAcc = mapArray(list, (s: string) => {s}, { fallback: () => none }); + return null; + }, document.createElement("div")); + try { + flush(); + const html = (a: any[]) => a.map(n => n.outerHTML).join(""); + expect(html(eAcc())).toBe(html(oAcc())); + expect(html(eAcc())).toBe("none"); + setList(["x", "y"]); + flush(); + expect(html(eAcc())).toBe(html(oAcc())); + expect(html(eAcc())).toBe("xy"); + setList([]); + flush(); + expect(html(eAcc())).toBe("none"); + } finally { + dispose(); + } + }); +}); + +for (const mode of ["identity", "identity-index", "byindex", "keyfn"] as Mode[]) { + describe(`oracle: ${mode}`, () => { + for (const seed of [1, 2, 3]) { + test(`seed ${seed}: DOM, retention, invocations, cleanup order match mapArray for ${SEED_STEPS} steps`, () => { + const r = rng(seed * 7919); + const pool: Item[] = Array.from({ length: 12 }, (_, id) => ({ id, v: 0 })); + const [list, setList] = createSignal(pool.slice(0, 5)); + const [dyn, setDyn] = createSignal(true); + const pe: Probe = { calls: 0, cleaned: [] }; + const po: Probe = { calls: 0, cleaned: [] }; + const E = mount(mode, list, pe, dyn, false); + const O = mount(mode, list, po, dyn, true); + try { + flush(); + expect(E.el.innerHTML).toBe(O.el.innerHTML); + expect(pe.calls).toBe(po.calls); + let cur = list(); + for (let step = 0; step < SEED_STEPS; step++) { + const label = `step ${step}`; + const eBefore = keyNodes(E.el); + const oBefore = keyNodes(O.el); + if (step % 9 === 4) setDyn(d => !d); // dynamic rows flip (same flush as a list change every so often) + if (step % 9 !== 4 || r() < 0.5) { + cur = nextList(cur, pool, r, mode === "keyfn"); + setList(cur); + } + flush(); + expect(E.el.innerHTML, `${label} DOM`).toBe(O.el.innerHTML); + expect(retained(eBefore, keyNodes(E.el)), `${label} retention`).toEqual( + retained(oBefore, keyNodes(O.el)) + ); + expect(pe.calls, `${label} invocations`).toBe(po.calls); + expect(pe.cleaned.slice().sort(), `${label} cleanup set`).toEqual( + po.cleaned.slice().sort() + ); + } + } finally { + E.dispose(); + O.dispose(); + } + }); + } + }); +} 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..3e1fc92d4 --- /dev/null +++ b/packages/web/test/for.unified.reconcile-parity.spec.tsx @@ -0,0 +1,268 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * RECONCILE PARITY MATRIX — the classic for.spec transition table (and then + * some), driven through BOTH implementations: + * + * slot — arity-1 keyed rows (the engine, flat-mode eligible) + * slot-idx — arity-2 rows (`(item, i) =>`): the engine in index mode + * (per-row index signals, chain from the first fill) + * classic — the ORACLE: mapArray + insert directly, no (every + * engages the engine on web). Identical expected output — a + * live specification, 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, DEV } from "solid-js"; +import { referenceMapArray as refMapArray } from "./reference/mapArray.js"; +const mapArray: (...a: any[]) => any = refMapArray as any; +const stats = DEV!.unifiedFor; +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; +}; + +type Mode = "slot" | "slot-idx" | "classic"; + +/** The list expression for a mode: the engine (arity-1 rows), the engine in + * index mode (arity-2 rows), or the ORACLE — mapArray + insert directly, no + * (every engages the engine on web). */ +function listFor(mode: Mode, list: () => string[], shape: Shape): any { + if (mode === "classic") return mapArray(list, shape.row as any); + return {mode === "slot-idx" ? shape.rowIdx : shape.row}; +} + +function makeContainers(mode: Mode, shape: Shape): Container[] { + return [ + { + name: "whole", + mount: (list, _row) => { + const host = document.createElement("div"); + const dispose = render(() =>
{listFor(mode, list, shape)}
, 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 + {listFor(mode, list, shape)} +
+ ), + 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 + {listFor(mode, list, shape)} + post +
+ ), + host + ); + return [host.querySelector("section")!, dispose]; + }, + wrap: rows => `pre${rows}post` + } + ]; +} + +for (const mode of ["slot", "slot-idx", "classic"] as const) { + for (const shape of SHAPES) { + describe(`reconcile parity [${mode}] [${shape.name} rows]`, () => { + for (const container of makeContainers(mode, shape)) { + test(`${container.name}: full transition matrix`, () => { + const [list, setList] = createSignal(CANON); + const engagedBefore = stats.engaged; + const [el, dispose] = container.mount(list, null); + try { + // Mode sanity: the engine engages exactly once, the oracle never. + expect(stats.engaged).toBe(engagedBefore + (mode === "classic" ? 0 : 1)); + 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)); + } + } 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 disposeSlot = render( + () => ( +
+ pre + {listFor("slot", list, shape)} + post +
+ ), + slotHost + ); + const disposeClassic = render( + () => ( +
+ pre + {listFor("classic", list, shape)} + 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(); + } + }); + } +}); 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..fb4bbca51 --- /dev/null +++ b/packages/web/test/for.unified.selection.probe.spec.tsx @@ -0,0 +1,80 @@ +/** + * @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"; +// 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", () => { + 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"); + }); +}); 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..7ce0e5a4d --- /dev/null +++ b/packages/web/test/for.unified.siblings.spec.tsx @@ -0,0 +1,280 @@ +/** + * @jsxImportSource @solidjs/web + * @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, DEV } from "solid-js"; +const stats = DEV!.unifiedFor; +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("duplicate identity keys with a preceding sibling: rows render, sibling untouched", () => { + const a = { id: "a" }, + b = { id: "b" }; + const [list, setList] = createSignal([a, b]); + dispose = render( + () => ( +
+

Title

+ {(item: any) => {item.id}} +
+ ), + container + ); + const h1 = container.querySelector("h1")!; + setList([a, b, a]); // duplicate identity (flat → chain materializes; duplicates are rows) + flush(); + expect(container.querySelector("h1")).toBe(h1); + expect(container.firstElementChild!.innerHTML).toBe( + "

Title

aba" + ); + setList([]); + flush(); + expect(container.firstElementChild!.innerHTML).toBe("

Title

"); + }); + + test("function-top-level row with preceding sibling: dynamic row, no demote", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+

Title

+ + {(item: any) => (typeof item === "function" ? item : {item})} + +
+ ), + container + ); + setList(["a", () => dyn]); + flush(); + expect(container.querySelector("h1")!.textContent).toBe("Title"); + expect(container.querySelectorAll("span").length).toBe(1); + expect(container.querySelector("b")!.textContent).toBe("dyn"); + expect(container.firstElementChild!.innerHTML).toBe("

Title

adyn"); + }); +}); + +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 = stats.batchCleared; + setList([]); + flush(); + expect(stats.batchCleared).toBe(before + 1); + expect(section.innerHTML).toBe(""); + }); +}); + +describe("unified For: empty-rendering rows render ZERO nodes and hold position", () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); + }); + + test("a null row arriving late renders nothing — 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"; + setList([a, b, { id: "c", hidden: true }]); + flush(); + expect(container.querySelector("input")).toBe(inputA); // same node + expect(inputA.value).toBe("typed"); // state intact + expect(container.querySelectorAll("input").length).toBe(2); + expect(container.childNodes.length).toBe(2); // classic parity: the null row adds no node + }); + + 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); + expect(container.childNodes.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/packages/web/test/for.unified.spec.tsx b/packages/web/test/for.unified.spec.tsx new file mode 100644 index 000000000..7719d72c5 --- /dev/null +++ b/packages/web/test/for.unified.spec.tsx @@ -0,0 +1,303 @@ +/** + * @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 keys and + * array-like subjects owned by the engine (no demotion exists). + * 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, 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 +// 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)); + +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 = stats.engaged; + createRoot(dispose => { + disposer = dispose; + ; + }); + flush(); + expect(div.innerHTML).toBe("abcd"); + expect(stats.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: the engine owns the classic contract (no demotion)", () => { + test("duplicate identity keys render as separate rows and reorder", () => { + const [list, setList] = createSignal(["a", "b"]); + let div!: HTMLDivElement; + createRoot(() => { +
+ {item => {item}} +
; + }); + flush(); + expect(div.innerHTML).toBe("ab"); + const [a0, b0] = Array.from(div.children); + setList(["a", "a", "b"]); // duplicate identity: a second row for "a" + flush(); + expect(div.innerHTML).toBe("aab"); + // The first occurrence reuses the existing row (mapArray's pairing order). + expect(div.children[0]).toBe(a0); + expect(div.children[2]).toBe(b0); + setList(["b", "a", "a"]); + flush(); + expect(div.innerHTML).toBe("baa"); + expect(div.children[0]).toBe(b0); + expect(div.children[1]).toBe(a0); + setList(["b", "a"]); + flush(); + expect(div.innerHTML).toBe("ba"); + expect(div.children[1]).toBe(a0); + }); + + test("array-like subject is duck-typed (a string renders its characters, as mapArray does)", () => { + const [list, setList] = createSignal(["a"]); + let div!: HTMLDivElement; + createRoot(() => { +
+ {(item: any) => {item}} +
; + }); + flush(); + expect(div.innerHTML).toBe("a"); + setList("xyz" as any); + flush(); + expect(div.innerHTML).toBe("xyz"); + setList(["a"]); + flush(); + expect(div.innerHTML).toBe("a"); + }); +}); + +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"); + }); +}); + +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 = stats.batchCleared; + setList([]); + flush(); + expect(div.innerHTML).toBe(""); + expect(stats.batchCleared).toBe(before + 1); + }); +}); diff --git a/packages/web/test/harness/__artifacts__/classic-fallback-oracle.json b/packages/web/test/harness/__artifacts__/classic-fallback-oracle.json new file mode 100644 index 000000000..41ee5c02c --- /dev/null +++ b/packages/web/test/harness/__artifacts__/classic-fallback-oracle.json @@ -0,0 +1,5 @@ +{ + "name": "classic-fallback-oracle", + "shell": "
  • none
", + "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-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-byindex-text.json b/packages/web/test/harness/__artifacts__/slot-hydrate-byindex-text.json new file mode 100644 index 000000000..9bef54ca5 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-byindex-text.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-byindex-text", + "shell": "
    abc
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-byindex.json b/packages/web/test/harness/__artifacts__/slot-hydrate-byindex.json new file mode 100644 index 000000000..5bbfca0ee --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-byindex.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-byindex", + "shell": "
  • a0
  • b1
  • c2
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-dynamic-row.json b/packages/web/test/harness/__artifacts__/slot-hydrate-dynamic-row.json new file mode 100644 index 000000000..e9d1197fc --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-dynamic-row.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-dynamic-row", + "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-fallback-text.json b/packages/web/test/harness/__artifacts__/slot-hydrate-fallback-text.json new file mode 100644 index 000000000..0270288a2 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-fallback-text.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-fallback-text", + "shell": "
      none
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-fallback.json b/packages/web/test/harness/__artifacts__/slot-hydrate-fallback.json new file mode 100644 index 000000000..82356a3e8 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-fallback.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-fallback", + "shell": "
    • none
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-indexed.json b/packages/web/test/harness/__artifacts__/slot-hydrate-indexed.json new file mode 100644 index 000000000..6f6a629a0 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-indexed.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-indexed", + "shell": "
    • a0
    • b1
    • c2
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-introspected-then-siblings.json b/packages/web/test/harness/__artifacts__/slot-hydrate-introspected-then-siblings.json new file mode 100644 index 000000000..a19415b2f --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-introspected-then-siblings.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-introspected-then-siblings", + "shell": "
    • a
    • b
    • c
    count: 0
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-keyfn.json b/packages/web/test/harness/__artifacts__/slot-hydrate-keyfn.json new file mode 100644 index 000000000..945650aeb --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-keyfn.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-keyfn", + "shell": "
    • a0
    • b1
    • c2
    ", + "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-dynamic.json b/packages/web/test/harness/__artifacts__/slot-hydrate-nested-dynamic.json new file mode 100644 index 000000000..83698244d --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-nested-dynamic.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-nested-dynamic", + "shell": "
      • 12
      • 3
    ", + "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-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-differs.json b/packages/web/test/harness/__artifacts__/slot-hydrate-text-differs.json new file mode 100644 index 000000000..9129d1fd9 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-text-differs.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-text-differs", + "shell": "
      abc
    ", + "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/__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-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/__artifacts__/slot-hydrate-through-dynamic-mismatch.json b/packages/web/test/harness/__artifacts__/slot-hydrate-through-dynamic-mismatch.json new file mode 100644 index 000000000..36ba02fc1 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-through-dynamic-mismatch.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-through-dynamic-mismatch", + "shell": "
    • a
    • b
    • c
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-through-dynamic-residue.json b/packages/web/test/harness/__artifacts__/slot-hydrate-through-dynamic-residue.json new file mode 100644 index 000000000..66695e160 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-through-dynamic-residue.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-through-dynamic-residue", + "shell": "
  • a
  • b
  • c
  • ", + "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/__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 new file mode 100644 index 000000000..bc2bca486 --- /dev/null +++ b/packages/web/test/harness/for-slot-scenarios.tsx @@ -0,0 +1,732 @@ +/** + * @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 { children, createSignal, flush, For, Show } from "solid-js"; +import { referenceMapArray as refMapArray } from "../reference/mapArray.js"; +const mapArray: (...a: any[]) => any = refMapArray as any; +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; + /** 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; + /** selector for a parent whose TEXT child nodes must be the server's text + * nodes after hydration (primitive rows adopt, never replace) */ + textIdentityParent?: 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. DYNAMIC row under hydration: row "b" renders a (function top +// level). The slot resolves it tracked in its own compute during the +// fill — the Show's template claims its server node like any other row; +// no demote, no warnings, and the slot keeps owning the list. +let setDemote!: (v: string[]) => void; +function SlotDynamicRow() { + 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): the hydrating client resolves it to +// the `` end marker with the bounded region — ENGAGES. +let setTrailing!: (v: string[]) => void; +function SlotTrailing() { + const [items, set] = createSignal(["a", "b"]); + setTrailing = set; + return ( +
      +
    • head
    • + {item =>
    • {item}
    • }
      +
    + ); +} + +// 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), +// 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}} +
      +
    • + )} +
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 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}
  • }
    +
    + ); +} + +// --------------------------------------------------------------------------- +// 10. NESTED lists + a -rooted outer row: the outer engages, row x's +// nested list engages and commits, row y is dynamic (resolved by the +// outer slot's compute, its nested list engaging inside that resolve). +// Three slots, zero demotes, every node the server's. +const NX = { g: "x", items: ["1", "2"], special: false }; +const NY = { g: "y", items: ["3"], special: true }; +function SlotNestedDynamic() { + const [groups] = createSignal([NX, NY]); + const inner = (g: typeof NX) => ( +
      + {item => {item}} +
    + ); + return ( +
      + + {group => + group.special ? ( + +
    • {inner(group)}
    • +
      + ) : ( +
    • {inner(group)}
    • + ) + } +
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 11. Through-children + a dynamic row + server MISMATCH: the slot stays +// engaged (the -rooted row resolves in the fill), rows a/b are the +// server nodes, and the leftover server row `c` is REMOVED by the fill +// commit's repair, reported once (the slot repairs what classic would +// leave in place and report at hydration end). +function SlotThroughDynamicMismatch() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( + + + {item => + item === "b" ? ( + +
  • {item}
  • +
    + ) : ( +
  • {item}
  • + ) + } +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// 12. Through-children + a dynamic row + LATER children change: rows the +// engaged slot appends live in the hole; swapping the children out +// afterwards must leave no list residue ("noned" was the classic-path +// leak this scenario originally caught). +function ShellWrap(props: { children: any }) { + return
    {props.children}
    ; +} +let residueItems!: (v: string[]) => void; +let residueShow!: (v: boolean) => void; +function SlotThroughDynamicResidue() { + const [items, setItems] = createSignal(["a", "b", "c"]); + const [show, setShow] = createSignal(true); + residueItems = setItems; + residueShow = setShow; + return ( + + {show() ? ( + + {item => + item === "b" ? ( + +
  • {item}
  • +
    + ) : ( +
  • {item}
  • + ) + } +
    + ) : ( +

    none

    + )} +
    + ); +} + +// --------------------------------------------------------------------------- +// 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
    • +
    + ); +} + +// --------------------------------------------------------------------------- +// 13. MODES under hydration (chain fills, not flat): key-function rows, +// index-accessor rows, keyed={false} rows, and a server-rendered fallback. +type KItem = { id: string; name: string }; +const K1: KItem = { id: "1", name: "a" }; +const K2: KItem = { id: "2", name: "b" }; +const K3: KItem = { id: "3", name: "c" }; +let setKeyFn!: (v: KItem[]) => void; +function SlotKeyFn() { + const [items, set] = createSignal([K1, K2, K3]); + setKeyFn = set; + return ( +
      + x.id}> + {(item, i) => ( +
    • + {item().name} + {i()} +
    • + )} +
      +
    + ); +} +let setIndexed!: (v: string[]) => void; +function SlotIndexed() { + const [items, set] = createSignal(["a", "b", "c"]); + setIndexed = set; + return ( +
      + + {(item, i) => ( +
    • + {item} + {i()} +
    • + )} +
      +
    + ); +} +let setByIndex!: (v: string[]) => void; +function SlotByIndex() { + const [items, set] = createSignal(["a", "b", "c"]); + setByIndex = set; + return ( +
      + + {(item, i) => ( +
    • + {item()} + {i} +
    • + )} +
      +
    + ); +} +let setFallback!: (v: string[]) => void; +function SlotFallback() { + const [items, set] = createSignal([]); + setFallback = set; + return ( +
      + none}> + {item =>
    • {item}
    • } +
      +
    + ); +} + +function ClassicFallback() { + const [items] = createSignal([]); + const mapped = mapArray(items, (item: string) =>
  • {item}
  • , { + fallback: () =>
  • none
  • + }); + return
      {mapped}
    ; +} + +// --------------------------------------------------------------------------- +// 14. Audit 2 (#3308): chain-mode hydration (keyed={false} text rows and a +// primitive fallback) must adopt server text — never duplicate it; a +// children()-introspected For must not shift sibling ids; client text +// that differs from the server's is NOT rewritten (classic adopts by +// identity and leaves the server text standing). +let setByIndexText!: (v: string[]) => void; +function SlotByIndexText() { + const [items, set] = createSignal(["a", "b", "c"]); + setByIndexText = set; + return ( +
      + + {(item: any) => item as any /* dynamic text row: the accessor itself */} + +
    + ); +} +function SlotFallbackText() { + const [items] = createSignal([]); + return ( +
      + + {item =>
    • {item}
    • } +
      +
    + ); +} +function IntrospectShell(props: { children: any }) { + const c = children(() => props.children); + return
      {c()}
    ; +} +let bumpAfterIntrospected!: () => void; +function SlotIntrospectedThenSiblings() { + const [count, setCount] = createSignal(0); + bumpAfterIntrospected = () => setCount(c => c + 1); + return ( + <> + + {item =>
  • {item}
  • }
    +
    + +
    count: {count()}
    + + ); +} +function SlotTextDiffers() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["A", "B", "C"]); + return ( +
      + {item => item} +
    + ); +} + +export const forSlotScenarios: ForSlotScenario[] = [ + { + name: "slot-hydrate-byindex-text", + App: SlotByIndexText, + expectedText: "abc", + engaged: 1, + warnings: 0, + textIdentityParent: "ul", + update: () => setByIndexText(["a", "b", "c", "d"]), + expectedTextAfterUpdate: "abcd" + }, + { + name: "slot-hydrate-fallback-text", + App: SlotFallbackText, + expectedText: "none", + engaged: 1, + warnings: 0, + textIdentityParent: "ul" + }, + { + name: "slot-hydrate-introspected-then-siblings", + App: SlotIntrospectedThenSiblings, + expectedText: "abcbumpcount: 0", + engaged: 0, // children() called the accessor: array engine, classic insert + warnings: 0, + identitySelector: "li, button, pre", + update: () => bumpAfterIntrospected(), + expectedTextAfterUpdate: "abcbumpcount: 1" + }, + { + name: "slot-hydrate-text-differs", + App: SlotTextDiffers, + expectedText: "abc", // server text stands (identity adoption, no rewrite) + serverText: "abc", + engaged: 1, + warnings: 0, + textIdentityParent: "ul" + }, + { + name: "classic-fallback-oracle", + App: ClassicFallback, + expectedText: "none", + engaged: 0, + warnings: 0, + identitySelector: "li" + }, + { + name: "slot-hydrate-keyfn", + App: SlotKeyFn, + expectedText: "a0b1c2", + engaged: 1, + warnings: 0, + identitySelector: "li", + update: () => setKeyFn([K3, { id: "1", name: "A" }, K2]), + expectedTextAfterUpdate: "c0A1b2" + }, + { + name: "slot-hydrate-indexed", + App: SlotIndexed, + expectedText: "a0b1c2", + engaged: 1, + warnings: 0, + identitySelector: "li", + update: () => setIndexed(["c", "a", "b"]), + expectedTextAfterUpdate: "c0a1b2" + }, + { + name: "slot-hydrate-byindex", + App: SlotByIndex, + expectedText: "a0b1c2", + engaged: 1, + warnings: 0, + identitySelector: "li", + update: () => setByIndex(["c", "a", "b", "d"]), + expectedTextAfterUpdate: "c0a1b2d3" + }, + { + name: "slot-hydrate-fallback", + App: SlotFallback, + expectedText: "none", + engaged: 1, + warnings: 0, + identitySelector: "li", + update: () => setFallback(["x", "y"]), + expectedTextAfterUpdate: "xy" + }, + { + name: "slot-hydrate-text-mismatch-fewer", + App: SlotTextFewer, + expectedText: "abc", // detect, don't recover: the server's leftover text row stays + serverText: "abc", + engaged: 1, + warnings: 1 // the engine's mismatch detection + }, + { + name: "slot-hydrate-text-mismatch-more", + App: SlotTextMore, + expectedText: "ab", // detect, don't recover: the extra client row lands on the next update (classic parity) + serverText: "ab", + engaged: 1, + warnings: 1 // the engine's mismatch detection + }, + { + name: "slot-hydrate-text-anchored-mismatch-fewer", + App: SlotTextAnchoredFewer, + expectedText: "headabctail", // leftover stays + serverText: "headabctail", + engaged: 1, + warnings: 1, // the engine's mismatch detection + identitySelector: "li" + }, + { + name: "slot-hydrate-through-dynamic-residue", + App: SlotThroughDynamicResidue, + expectedText: "abc", + engaged: 1, + warnings: 0, + identitySelector: "li", + update: () => { + residueItems(["a", "b", "c", "d"]); // the engaged slot appends d + flush(); + residueShow(false); // children change: the hole cleanup must remove d too + }, + expectedTextAfterUpdate: "none" + }, + { + name: "slot-hydrate-nested-dynamic", + App: SlotNestedDynamic, + expectedText: "123", + // Outer + nested x + nested y (engaging inside the outer's resolve of + // the Show-rooted row). No demote, so no second pass. + engaged: 3, + warnings: 0, + identitySelector: "span" + }, + { + name: "slot-hydrate-through-dynamic-mismatch", + App: SlotThroughDynamicMismatch, + expectedText: "abc", // leftover stays (classic parity) + serverText: "abc", + engaged: 1, + warnings: 1, // the runtime's unclaimed-node report + identitySelector: "li" + }, + { + name: "slot-hydrate-through-children", + App: SlotThroughChildren, + expectedText: "abc", + engaged: 1, + warnings: 0, + identitySelector: "li", + update: () => setThrough(["b", "c", "a"]), + expectedTextAfterUpdate: "bca", + survivorsAfterUpdate: ["a", "b", "c"] + }, + { + name: "slot-hydrate-basic", + App: SlotBasic, + expectedText: "abc", + engaged: 1, + 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, + warnings: 0, + textIdentityParent: "ul", + update: () => setText(["a", "b", "c", "d"]), + expectedTextAfterUpdate: "abcd" + }, + { + name: "slot-hydrate-mismatch-fewer", + App: SlotFewer, + expectedText: "abc", // detect, don't recover: leftover server row stays (classic parity) + serverText: "abc", + engaged: 1, + warnings: 1, // the runtime's unclaimed-node report + identitySelector: "li" + }, + { + name: "slot-hydrate-mismatch-more", + App: SlotMore, + expectedText: "ab", // the key-missed client row is not inserted (classic parity) + serverText: "ab", + engaged: 1, + warnings: 1 // the runtime's key-miss report + }, + { + name: "slot-hydrate-dynamic-row", + App: SlotDynamicRow, + expectedText: "abc", + engaged: 1, + warnings: 0, + identitySelector: "li", + update: () => setDemote(["a", "b", "c", "d"]), + expectedTextAfterUpdate: "abcd" + }, + { + name: "slot-hydrate-empty", + App: SlotEmpty, + expectedText: "", + engaged: 1, + warnings: 0, + update: () => setEmpty(["a"]), + expectedTextAfterUpdate: "a" + }, + { + name: "slot-hydrate-trailing", + App: SlotTrailing, + expectedText: "headab", + engaged: 1, + warnings: 0, + identitySelector: "li", + update: () => setTrailing(["b", "a"]), + expectedTextAfterUpdate: "headba", + survivorsAfterUpdate: ["head", "a", "b"] + }, + { + name: "slot-hydrate-bounded", + App: SlotBounded, + expectedText: "headabctail", + engaged: 1, + 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: "headabc", // leftover stays + serverText: "headabc", + engaged: 1, + warnings: 1, // the runtime's unclaimed-node report + identitySelector: "li" + }, + { + name: "slot-hydrate-nested", + App: SlotNested, + expectedText: "123", + engaged: 3, + 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..14d734458 --- /dev/null +++ b/packages/web/test/hydration/for-slot.spec.tsx @@ -0,0 +1,129 @@ +/** + * @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, DEV } from "solid-js"; +const stats = DEV!.unifiedFor; +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; + + // Server TEXT nodes before hydration (primitive rows must adopt them). + const serverTexts = scenario.textIdentityParent + ? [...container.querySelector(scenario.textIdentityParent)!.childNodes].filter( + n => n.nodeType === 3 + ) + : null; + + const engaged0 = stats.engaged; + dispose = hydrate(() => , container); + flush(); + await sleep(10); + flush(); + + expect(container.textContent, "hydrated text").toBe(scenario.expectedText); + expect(stats.engaged - engaged0, "slots engaged during hydrate").toBe(scenario.engaged); + 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 (serverTexts) { + const now = [...container.querySelector(scenario.textIdentityParent!)!.childNodes].filter( + n => n.nodeType === 3 + ); + expect(now.length, "text row count").toBe(serverTexts.length); + for (let i = 0; i < now.length; i++) + expect(now[i], `text row ${i} is the server text node`).toBe(serverTexts[i]); + } + + 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)); + } + } + } + } 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/reference/mapArray.ts b/packages/web/test/reference/mapArray.ts new file mode 100644 index 000000000..c253b603e --- /dev/null +++ b/packages/web/test/reference/mapArray.ts @@ -0,0 +1,284 @@ +/** + * REFERENCE mapArray — the pre-engine implementation, frozen as the ORACLE + * for the unified For engine's differential tests. Test-only; never shipped. + * Written against solid-js's PUBLIC API (createMemo/createSignal/createOwner/ + * runWithOwner/$TRACK) so it runs on the same reactive core as the engine + * under test. Semantics are mapArray's as of 2026-09-07 (before mapArray + * became the engine's array output). + */ +import { createMemo, createOwner, createSignal, runWithOwner, $TRACK } from "solid-js"; +type Accessor = () => T; +export type Maybe = T | void | null | undefined | false; +const pureOptions = { ownedWrite: true }; + +export function referenceMapArray( + list: Accessor>, + map: + | ((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; + lazy?: boolean; + } +): Accessor { + const keyFn = typeof options?.keyed === "function" ? options.keyed : undefined; + const indexes = map.length > 1; + const wrappedMap = map; + const data: MapData = { + _owner: createOwner(), + _len: 0, + _list: list, + _items: [], + _map: wrappedMap, + _mappings: [], + _nodes: [], + _key: keyFn, + _rows: keyFn || options?.keyed === false ? [] : undefined, + _indexes: indexes && options?.keyed !== false ? [] : undefined, + _byIndex: options?.keyed === false, + _fallback: options?.fallback + }; + return createMemo(updateKeyedMap.bind(data as MapData)); +} + +function updateKeyedMap(this: MapData): any[] { + const newItems = this._list() || [], + newLen = newItems.length; + (newItems as any)[$TRACK]; // top level tracking + + runWithOwner(this._owner, () => { + let i: number, + j: number, + rows: any[] | undefined, + indexes: any[] | undefined, + // Mappers write freshly-created row/index signals into the STAGE + // arrays (`rows`/`indexes`), never into `this._rows`/`this._indexes`. + mapper = this._rows + ? this._byIndex + ? () => { + rows![j] = createSignal(newItems[j] as any, pureOptions as any) as any; + return (this._map as any)(rows![j][0], j); + } + : () => { + rows![j] = createSignal(newItems[j] as any, pureOptions as any) as any; + indexes && (indexes[j] = createSignal(j, pureOptions as any) as any); + return (this._map as any)(rows![j][0], indexes ? indexes[j][0] : (undefined as any)); + } + : this._indexes + ? () => { + const item = newItems[j]; + indexes![j] = createSignal(j, pureOptions as any); + return this._map(item, indexes![j][0]); + } + : () => { + const item = newItems[j]; + return (this._map as (value: Item) => MappedItem)(item); + }; + + // fast path for empty arrays + if (newLen === 0) { + if (this._len !== 0) { + this._owner.dispose(false); + this._nodes = []; + this._items = []; + this._mappings = []; + this._len = 0; + this._rows && (this._rows = []); + this._indexes && (this._indexes = []); + } + if (this._fallback && !this._mappings[0]) { + // an aborted fallback attempt leaves an owner without a mapping; + // dispose it before re-creating + this._nodes[0]?.dispose(); + this._mappings[0] = runWithOwner( + (this._nodes[0] = createOwner() as any), + this._fallback + ); + } + } + // fast path for new create + else if (this._len === 0) { + const mappings: MappedItem[] = new Array(newLen); + const nodes: any[] = new Array(newLen); + rows = this._rows && new Array(newLen); + indexes = this._indexes && new Array(newLen); + + try { + for (j = 0; j < newLen; j++) + mappings[j] = runWithOwner((nodes[j] = createOwner() as any), mapper)!; + } catch (err) { + for (i = 0; i <= j!; i++) nodes[i]?.dispose(); + throw err; + } + + // commit + if (this._nodes[0]) this._nodes[0].dispose(); // previous fallback + this._mappings = mappings; + this._nodes = nodes; + rows && (this._rows = rows); + indexes && (this._indexes = indexes); + this._items = newItems.slice(0); + this._len = newLen; + } else { + let start: number, + end: number, + newEnd: number, + item: Item, + key: any, + newIndices: Map, + newIndicesNext: number[], + removed: any[] | undefined, + created: any[] | undefined; + + // skip common prefix + for ( + start = 0, end = Math.min(this._len, newLen); + start < end && + (this._items[start] === newItems[start] || + (this._rows && compare(this._key, this._items[start], newItems[start]))); + start++ + ) { + if (this._rows) this._rows[start][1](newItems[start]); + } + + // skip common suffix — counted only; retained entries land in one pass + // at commit instead of being staged and copied twice + for ( + end = this._len - 1, newEnd = newLen - 1; + end >= start && + newEnd >= start && + (this._items[end] === newItems[newEnd] || + (this._rows && compare(this._key, this._items[end], newItems[newEnd]))); + end--, newEnd-- + ); + + // no structural change (every position matched in place at equal + // length — the common post-reconcile shape): keep the same mapped + // array identity so downstream consumers don't re-run at all + if (start === newLen && this._len === newLen) { + this._items = newItems.slice(0); + return; + } + + const dif = newLen - this._len; + const temp: MappedItem[] = new Array(newLen); + const tempNodes: any[] = new Array(newLen); + rows = this._rows ? new Array(newLen) : undefined; + indexes = this._indexes ? new Array(newLen) : undefined; + + // 0) prepare a map of all indices in the changed window of newItems, + // scanning backwards so we encounter them in natural order + newIndices = new Map(); + newIndicesNext = new Array(newEnd + 1); + for (j = newEnd; j >= start; j--) { + item = newItems[j]; + key = this._key ? this._key(item) : item; + i = newIndices.get(key)!; + newIndicesNext[j] = i === undefined ? -1 : i; + newIndices.set(key, j); + } + + // 1) step through the old changed window and see if items can be found + // in the new set; if so, stage them at their new positions; if not, + // queue them for disposal at commit + for (i = start; i <= end; i++) { + item = this._items[i]; + key = this._key ? this._key(item) : item; + j = newIndices.get(key)!; + if (j !== undefined && j !== -1) { + temp[j] = this._mappings[i]; + tempNodes[j] = this._nodes[i]; + rows && (rows[j] = this._rows![i]); + indexes && (indexes[j] = this._indexes![i]); + j = newIndicesNext[j]; + newIndices.set(key, j); + } else (removed ??= []).push(this._nodes[i]); + } + + // 2) create new rows into the temp arrays; an abort disposes only these + try { + for (j = start; j <= newEnd; j++) { + if (tempNodes[j] !== undefined) continue; + (created ??= []).push((tempNodes[j] = createOwner() as any)); + temp[j] = runWithOwner(tempNodes[j], mapper)!; + } + } catch (err) { + if (created) for (i = 0; i < created.length; i++) created[i].dispose(); + throw err; + } + + // 3) commit: land the retained prefix and suffix plus the staged window + // into the fresh arrays, swap them in (new identity for downstream + // change propagation), then dispose exited rows + for (i = 0; i < start; i++) { + temp[i] = this._mappings[i]; + tempNodes[i] = this._nodes[i]; + rows && (rows[i] = this._rows![i]); + indexes && (indexes[i] = this._indexes![i]); + } + for (j = start; j <= newEnd; j++) { + if (rows) rows[j][1](newItems[j]); + if (indexes) indexes[j][1](j); + } + for (j = newEnd + 1; j < newLen; j++) { + temp[j] = this._mappings[j - dif]; + tempNodes[j] = this._nodes[j - dif]; + if (rows) { + rows[j] = this._rows![j - dif]; + rows[j][1](newItems[j]); + } + if (indexes) { + indexes[j] = this._indexes![j - dif]; + if (dif !== 0) indexes[j][1](j); + } + } + this._mappings = temp; + this._nodes = tempNodes; + rows && (this._rows = rows); + indexes && (this._indexes = indexes); + this._len = newLen; + // save a copy of the mapped items for the next update + this._items = newItems.slice(0); + if (removed) for (i = 0; i < removed.length; i++) removed[i].dispose(); + } + }); + + return this._mappings; +} + +/** + * Reactively renders a callback `count` times, reusing previously-rendered + * entries when only the count changes. Underlying helper for ``. + * + * - `options.from` — start index (default `0`); useful for offset/windowed + * rendering. + * - `options.fallback` — accessor returning a value to show when count is `0`. + * + * @example + * ```ts + * const view = repeat(count, i => `Item ${i}`, { fallback: () => "empty" }); + * ``` + * + * @description https://docs.solidjs.com/reference/reactive-utilities/repeat + */ +function compare(key: ((i: any) => any) | undefined, a: Item, b: Item): boolean { + return key ? key(a) === key(b) : true; +} + +interface MapData { + _owner: any; + _len: number; + _list: Accessor>; + _items: Item[]; + _mappings: MappedItem[]; + _nodes: any[]; + _map: (value: any, index: any) => any; + _key: ((i: any) => any) | undefined; + _rows?: any[]; + _indexes?: any[]; + _byIndex: boolean; + _fallback?: Accessor; +} 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 2c4150507..e5b27d055 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -423,7 +423,56 @@ module.exports = [ // KB, measured at 10.751 macOS (+21 B; brotli drift — the minified core // shrank, see the createStore note). Linux CI has measured ~23 B above // macOS on this scenario, hence the extra 0.02 kB. - limit: "10.78 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. + // (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. + // + // 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. + // + // 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. + // + // 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. + // + // External audit fixes (2026-09-07): 10.98 -> 11.05 KB, measured at + // 11.04 (+61 B). Four renderer ops on web's domOps singleton (owns / + // next / textOf / setText) — retained by insert's object literal even + // without For. Not slot bytes. + // + // ENGINE (2026-09-07): 11.05 -> 11.00 KB, measured at 10.97 (-72 B: the + // decline/demote/late-classic seam left insert). Cumulative vs next: + // 10.73 -> 10.97 = +236 B (the $for engage check + hole seam + domOps). + limit: "11.00 KB", + // + // #3308 audit round (2026-09-08): 11.00 -> 11.05 KB, measured at 11.04 + // (+44 B: host-aware ops for portals + initial-range consumption in the + // engagement path). Cumulative vs next: +276 B. + limit: "11.05 KB", + // + // Rebase drift (2026-09-08, next @ 94fe5b46): 11.05 -> 11.06 KB (+5 B). + limit: "11.06 KB", modifyEsbuildConfig }, { @@ -488,7 +537,72 @@ 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. 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. + // + // 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. + // + // 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). + // + // 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). + // + // #3187 revert + synchronous hole demote (2026-09-05): 20.60 -> 20.54 KB, + // measured at 20.53. Locked in. + // + // External audit fixes (2026-09-07): 20.54 -> 21.45 KB, measured at + // 21.42 (+~880 B, net of the claim-recording stack this round DELETED — + // nothing can demote mid-fill any more). Dynamic rows (function-top-level + // rows resolved tracked by the slot compute: no shape demote, no double + // invocation, NotReady-parked plan reuse, in-place text splice), rows + // under For's creation owner, contiguous list-end anchor, parent-guarded + // removes, throw-safe row build, fresh-duplicate detection, positional + // server-text adoption in the hydrating fill commit. + // + // ENGINE (2026-09-07, unified-for-engine): 21.45 -> 22.10 KB, measured at + // 22.04. CUMULATIVE vs next: 17.49 -> 22.04 = +4.56 KB. The slot is THE + // keyed-For engine on web: every mode (identity / keyed:false / keyed:fn + // / index accessors / duplicates / fallback) implemented once on the + // chain; the engage/decline/demote/late-classic seam is deleted (-0.4 KB), + // the modes + fallback + duplicate chaining cost +0.9 KB. Flat mode is + // gated to identity arity-1 rows (measured: +5-10% on 10k create/clear). + limit: "22.10 KB", + // + // ONE ENGINE (2026-09-07): 22.10 -> 21.40 KB, measured at 21.35. The + // engine answers a plain call of the For accessor with mapArray's array + // (children()/introspection), so For no longer imports mapArray and it + // shakes out of For-bearing bundles. Cumulative vs next: +3.86 KB. + limit: "21.40 KB", + // + // ONE ENGINE IN SIGNALS (2026-09-08): 21.40 -> 21.60 KB, measured at + // 21.56. mapArray IS the engine's array output; solid-js keeps only the + // node layer (SlotOps → ListNodeLayer) + effect wiring. The layer boundary + // costs ~+220 B on For-only apps; For + mapArray apps DROP ~510 B (no + // second list implementation). Cumulative vs next: +4.07 KB. + limit: "21.60 KB", + // + // #3308 audit round (2026-09-08): 21.60 -> 22.05 KB, measured at 22.00. + // Reentrancy-safe row build, identity-first lazy keys (+ row.item), + // engine dies with the creation owner, one engine per list (version + // signal + tracked array view), retained-row reclaim sweep, host tagging, + // chain-mode hydration adoption, zero-arg fallback. Cumulative: +4.51 KB. + limit: "22.05 KB", modifyEsbuildConfig }, { @@ -591,7 +705,47 @@ module.exports = [ // scenario's layout; the createStore scenario carrying the same change // came in UNDER its pre-fix size — see its note). The usual +4-7 B // Linux delta leaves ~20 B headroom. - limit: "26.45 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). + // + // Unified For hydration claiming (2026-09-05): 28.75 -> 29.10 KB, + // measured at 29.10 (see the hydrating no-stores note). + // + // Anchored-hole hydration (2026-09-05): 29.29 -> 29.45 KB, measured at + // 29.44 (see the hydrating no-stores note). + // + // #3187 revert + synchronous hole demote (2026-09-05): 29.48 -> 29.40 KB, + // measured at 29.39. Locked in. + // + // Rebase drift (2026-09-06, #3287 textarea spread et al.): 29.40 -> 29.42 + // KB, measured at 29.42 (+16 B). Not slot bytes. + // + // Rebase drift (2026-09-07, #3296 store adoption fixes): 29.42 -> 29.50 + // KB, measured at 29.49 (+73 B). Store-module bytes from next, not slot. + // + // External audit fixes (2026-09-07): 29.50 -> 30.38 KB, measured at + // 30.35 (+~850 B) — the slot's dynamic-row/ownership/anchor work above. + // + // ENGINE (2026-09-07): 30.38 -> 30.85 KB, measured at 30.79. Cumulative + // vs next: 26.36 -> 30.79 = +4.43 KB. See the hydrating-app note. + // + // Flat mode for every keyed mode (its/ixs arrays) + detect-only hydration + // mismatch (2026-09-07): 30.85 -> 30.95 KB, measured at 30.91. + // Cumulative vs next: +4.55 KB. + limit: "30.95 KB", + // + // ONE ENGINE (2026-09-07): 30.95 -> 30.35 KB, measured at 30.28 + // (mapArray shakes out). Cumulative vs next: +3.93 KB. + limit: "30.35 KB", + // + // ONE ENGINE IN SIGNALS (2026-09-08): 30.35 -> 30.55 KB, measured at + // 30.50. Cumulative vs next: +4.15 KB. + limit: "30.55 KB", + // + // #3308 audit round (2026-09-08): 30.55 -> 30.95 KB, measured at 30.89. + limit: "30.95 KB", modifyEsbuildConfig }, { @@ -637,7 +791,58 @@ 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). + // + // 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). + // + // 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). + // + // Rebase over #3183 + audit round 3 (2026-09-05): 15.62 -> 15.66 KB, + // measured at 15.65 (see the simple-app note). + // + // #3187 revert + synchronous hole demote (2026-09-05): 15.68 -> 15.61 KB, + // measured at 15.60. Locked in. + // + // Rebase drift (2026-09-06, #3287 textarea spread et al.): 15.61 -> 15.63 + // KB, measured at 15.63 (+16 B). Not slot bytes. + // + // External audit fixes (2026-09-07): 15.63 -> 16.50 KB, measured at + // 16.48 (+~850 B) — dynamic rows (classic's list-effect model inside the + // slot), creation-owner rows, contiguous end anchor, guarded removes, + // throw-safe build, fresh-duplicate detection. Structural, not golfable: + // the empty-leaves normalization and node-selector dedupes bought ~80 B. + // + // ENGINE (2026-09-07, unified-for-engine): 16.50 -> 17.05 KB, measured at + // 17.00. CUMULATIVE vs next: 12.90 -> 17.00 = +4.10 KB. See the + // hydrating-app note for the breakdown. + // + // Flat mode for every keyed mode — item/index signals ride the parallel + // arrays, key-based survivor probe, keys computed at materialize + // (2026-09-07): 17.05 -> 17.25 KB, measured at 17.18 (+178 B). Closes the + // jfb-shallow (key-fn rows) 10k create/clear regression to parity. + // Cumulative vs next: 12.90 -> 17.18 = +4.28 KB. + limit: "17.25 KB", + // + // ONE ENGINE (2026-09-07): 17.25 -> 16.55 KB, measured at 16.52 — For + // drops its mapArray import (array output comes from the engine), so + // mapArray shakes out of For-bearing bundles. Cumulative vs next: + // 12.90 -> 16.52 = +3.62 KB. Locked in. + // + // ONE ENGINE IN SIGNALS (2026-09-08): -> 16.80 KB, measured at 16.75. + // mapArray is the engine (see the hydrating-app note). Cumulative vs + // next: 12.90 -> 16.75 = +3.85 KB; For + mapArray apps: +3.87 (was +4.38). + limit: "16.80 KB", + // + // #3308 audit round (2026-09-08): 16.80 -> 17.30 KB, measured at 17.24 + // (+460 B; see the hydrating-app note). Cumulative vs next: +4.34 KB. + limit: "17.30 KB", modifyEsbuildConfig }, {