diff --git a/.changeset/unified-for-slot.md b/.changeset/unified-for-slot.md new file mode 100644 index 000000000..28233a995 --- /dev/null +++ b/.changeset/unified-for-slot.md @@ -0,0 +1,13 @@ +--- +"solid-js": patch +"@solidjs/web": patch +"@solidjs/signals": patch +--- + +Unified For: keyed `` is driven by one persistent slot that owns both row bookkeeping and DOM placement — an intrusive row chain + incremental key map updated by a prefix/suffix/LIS pass inside an ordinary two-phase render effect — replacing the mapArray + reconcileArrays double pass. Structural operations (swap, reorder, insert, remove) run 1.2–7x faster across jfb and uibench; creation and clear stay at parity via flat-mode first fills (parallel arrays, structure materializes lazily on the first partial structural op). + +Default-on with zero new API and zero compiler involvement: the slot rides For's own module graph (`$for.impl`), web's insert engages it with its renderer-ops singleton, and the slot algorithm tree-shakes out of apps without For (~2.1 KB rides For-bearing bundles; ~0.3 KB of engagement seam in `insert` — the `$for` guards, renderer ops, and hole hand-off — is retained by every web bundle; hydration adds ~0.8 KB to hydrating bundles only). A For passed through a component's `{props.children}` engages too — the hole seam hands the wrapper's hole to the slot, tears it down cleanly when the children change, and routes a post-engage demote back through the hosting effect's classic path. Bulk-clear fast paths honor classic's ownership rules — `null` markers (trailing child with preceding siblings) and streamed foreign nodes are never wiped. Empty-rendering rows hold their position with a placeholder instead of demoting. + +Hydration: lists engage during hydration and claim the server rows themselves — whole-parent holes and comment-bounded anchored holes alike (the hydrating client resolves anchored holes to their `` marker with the bounded region). The slot's row parent takes the same id classic's mapArray owner spends (For peeks it via an `enableHydration()`-installed hook; mapArray gains an internal `lazy` option so the classic pass no longer claims first), so rows mint identical hydration keys; claims are recorded so a demote mid-fill hands them back and classic's re-run claims the same nodes; the fill commit reconciles against the region only on server/client mismatch. All hydration behavior lives in a module installed by `enableHydration()` — CSR bundles shake it. + +Declines to classic mapArray: key functions, duplicate keys, dynamic top-level rows, `fallback`, non-array subjects; post-engage contract exits demote to classic under the original owner. diff --git a/packages/signals/src/map.ts b/packages/signals/src/map.ts index 255489fb0..eedd612c9 100644 --- a/packages/signals/src/map.ts +++ b/packages/signals/src/map.ts @@ -67,7 +67,17 @@ export function mapArray( | ((value: Item, index: Accessor) => MappedItem) | ((value: Accessor, index: number) => MappedItem) | ((value: Accessor, index: Accessor) => MappedItem), - options?: { keyed?: boolean | ((item: Item) => any); fallback?: Accessor; name?: string } + options?: { + keyed?: boolean | ((item: Item) => any); + fallback?: Accessor; + name?: string; + /** @internal solid-js For, hydration only: create the internal owner NOW — + * it spends the list's hydration id slot at For's source position — but + * defer the first mapping pass to the first read. Lets a renderer that + * engages the unified For slot claim the server rows itself; the classic + * fallback still claims them on first read, minting identical ids. */ + lazy?: boolean; + } ): Accessor { const keyFn = typeof options?.keyed === "function" ? options.keyed : undefined; const indexes = map.length > 1; @@ -96,7 +106,10 @@ export function mapArray( _byIndex: options?.keyed === false, _fallback: options?.fallback }; - const node = computed(updateKeyedMap.bind(data as MapData)); + const node = computed( + updateKeyedMap.bind(data as MapData), + options?.lazy ? LAZY_OPTIONS : undefined + ); // Untracked reads inside the internal owner resolve via _parentComputed; routing // them through node lets store-proxy lookups see pending writes (not stale _value). data._owner._parentComputed = node; @@ -105,6 +118,7 @@ export function mapArray( } const pureOptions = { ownedWrite: true }; +const LAZY_OPTIONS = { lazy: true } as const; // Exception safety (#2903): a map callback can throw NotReadyError mid-pass // (async read), and the computed re-runs the whole pass after settle. Every // pass therefore STAGES its work — new rows are created into temp arrays and diff --git a/packages/solid/src/client/flow.ts b/packages/solid/src/client/flow.ts index 4494fca13..1af851b6b 100644 --- a/packages/solid/src/client/flow.ts +++ b/packages/solid/src/client/flow.ts @@ -9,6 +9,7 @@ import { runWithOwner } from "@solidjs/signals"; import { createErrorBoundary, createLoadingBoundary, sharedConfig } from "./hydration.js"; +import { unifiedForSlot } from "./for-slot.js"; import type { Accessor, RevealOrder } from "@solidjs/signals"; export type { RevealOrder }; import type { Element as SolidElement } from "../types.js"; @@ -103,8 +104,43 @@ export function For(props: { // the list (the siblings hydrated detached: dead buttons). Outside // hydration the laziness stands: an unread list never builds its // mapArray at all. - if (sharedConfig.hydrating) mapped = create(); + // Unified-For id parity: the slot's rows must mint the SAME hydration ids + // classic's would. Classic rows hang under mapArray's internal owner, which + // is the next child of For's owner at creation — peek that id BEFORE the + // eager create() consumes it, and hand it to the slot (`$for.hid`) so its + // row parent can be created with the identical explicit id. + let hid: string | undefined; + if (sharedConfig.hydrating) { + // Installed by enableHydration() (CSR bundles never carry the id peek). + hid = sharedConfig.peekNextContextId?.(); + // Lazy pass: the map's owner still spends the id slot HERE (the #3161 + // fix), but the first mapping pass waits for the first read — so when + // the renderer engages the slot, the slot's rows claim the server nodes + // instead of an eager classic pass claiming them first. Classic readers + // (universal, declines) still claim on first read with identical ids. + (options as any).lazy = true; + mapped = create(); + } const list = () => (mapped ?? (mapped = create()))(); + // Unified-For seam (DESIGN-UNIFIED-FOR §4): the returned value IS a data + // structure — a callable carrying the list descriptor. A renderer that + // understands `$for` may own rows and placement in one persistent + // structure (no mapArray, no value diff); everything else (children(), + // universal renderers, introspection) calls it and gets classic mapArray + // rows. Eligibility mirrors the classic contract the driver can honor: + // reference identity or key-fn rows (`keyed !== false`), no fallback, and + // no index parameter (row arity < 2). + if (props.keyed !== false && !("fallback" in props) && props.children.length < 2) + (list as any).$for = { + each: () => props.each, + row: props.children, + keyed: props.keyed, + // The slot rides For's OWN module graph: apps without For tree-shake + // it; a renderer's insert() engages it by passing its SlotOps. + impl: unifiedForSlot, + // Hydration only: the id classic's row parent would carry. + hid + }; return list as unknown as SolidElement; } diff --git a/packages/solid/src/client/for-slot-hydration.ts b/packages/solid/src/client/for-slot-hydration.ts new file mode 100644 index 000000000..93a90e8a1 --- /dev/null +++ b/packages/solid/src/client/for-slot-hydration.ts @@ -0,0 +1,154 @@ +/** + * 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. + * Claims are RECORDED so a demote mid-fill hands them back: classic's re-run + * mints the same ids and claims the same nodes (never a stranded claim). + * The fill commit mutates only on MISMATCH (leftover server rows removed, + * key-missed fresh rows inserted); the normal case is zero DOM writes. + */ +import { sharedConfig } from "./hydration.js"; +import { IS_DEV } from "./core.js"; +import { installSlotHydration, type FlatPlan, type Slot } from "./for-slot.js"; + +/** RECORDING STACK. Nested lists hydrate INSIDE an outer row's build (row → + * inner insert → inner engage → inner fill, all synchronous), so recording + * must nest: the OUTERMOST record() installs the registry shadow once, every + * active log on the stack receives every deletion, an inner commitFill drops + * only its OWN log, and an outer restore() hands back everything claimed + * beneath it — including nested slots' already-committed claims, which the + * classic re-run's re-engaged nested lists will mint again with the same + * ids. (Per-slot shadows broke both: the inner `finally` tore down the + * outer's shadow, and committed inner claims were in no log at all.) */ +const logs: [string, Element][][] = []; +let shadowed = false; + +const hooks = { + engage( + meta: any, + marker: Node | null | undefined, + region: Node[] | undefined + ): { id: string } | null | 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`) both + // engage. A `null` marker never occurs under hydration; decline it. + if (marker === null || meta.hid === undefined || region === undefined) return null; + return { id: meta.hid }; + }, + + record(slot: Slot, fn: () => T): T { + const reg = sharedConfig.registry as Map | undefined; + if (!reg) return fn(); + logs.push((slot.hydLog ??= [])); + const installed = !shadowed; + if (installed) { + shadowed = true; + const proto = Map.prototype.delete; + (reg as any).delete = function (this: Map, key: string): boolean { + const node = this.get(key); + if (node !== undefined) for (let i = 0; i < logs.length; i++) logs[i].push([key, node]); + return proto.call(this, key); + }; + } + try { + return fn(); + } finally { + logs.pop(); + if (installed) { + delete (reg as any).delete; // back to the prototype method + shadowed = false; + } + } + }, + + restore(slot: Slot): void { + // Nothing was placed (compute never writes DOM); only registry keys were + // consumed. Hand them all back, and un-complete the nodes. + slot.hyd = false; + const reg = sharedConfig.registry as Map | undefined; + const log = slot.hydLog; + if (reg && log !== null) { + for (let i = 0; i < log.length; i++) { + reg.set(log[i][0], log[i][1]); + (sharedConfig as any).completed?.delete(log[i][1]); + } + } + slot.hydLog = null; + }, + + commitFill(slot: Slot, fp: FlatPlan): void { + // Past this point the slot owns the rows for good: drop the claim log. + slot.hyd = false; + slot.hydLog = null; + const ops = slot.ops; + const ours = new Set(); + for (let i = 0; i < fp.nodes.length; i++) { + const nd = fp.nodes[i]; + if (Array.isArray(nd)) for (const n of nd) ours.add(n); + else ours.add(nd); + } + // Leftovers: server rows the client no longer has, separator comments. + let removed = 0; + let inserted = 0; + const region = slot.region!; + for (let i = 0; i < region.length; i++) + if (!ours.has(region[i]) && ops.contains(slot.parent, region[i])) { + ops.remove(region[i]); + // Element rows only: primitive rows currently re-create their text + // node (fresh text swaps in for the server's — correct DOM, not a + // mismatch). Adopting server text nodes for primitive rows is the + // follow-up that makes that path zero-write too. + if ((region[i] as any).nodeType === 1) removed++; + } + // Fresh rows (template key-missed → detached; the runtime already + // warned) are inserted at their position, back to front so anchors are + // always attached. The list ends at the hole's end marker (or the + // parent's end for whole-parent holes). + let anchor: Node | null = slot.end; + for (let i = fp.nodes.length - 1; i >= 0; i--) { + const nd = fp.nodes[i]; + if (Array.isArray(nd)) { + for (let k = nd.length - 1; k >= 0; k--) { + if (!ops.contains(slot.parent, nd[k])) { + ops.insert(slot.parent, nd[k], anchor); + if ((nd[k] as any).nodeType === 1) inserted++; + } + anchor = nd[k]; + } + } else { + if (!ops.contains(slot.parent, nd)) { + ops.insert(slot.parent, nd, anchor); + if ((nd as any).nodeType === 1) inserted++; + } + anchor = nd; + } + } + // The slot REPAIRS a server/client mismatch (classic's claim pass leaves + // leftovers in place and reports them at hydration end); repairing + // silently would hide the mismatch, so say so once, in dev. + if (IS_DEV && (removed !== 0 || inserted !== 0)) + console.warn( + `Hydration mismatch in : the server rendered a different list than the client ` + + `(${removed} unclaimed server row node(s) removed, ${inserted} client row node(s) inserted). ` + + `The DOM was repaired, but server and client should render the same list.` + ); + slot.region = undefined; + slot.flat = { items: fp.items, owners: fp.owners, nodes: fp.nodes }; + slot.size = fp.len; + } +}; + +/** 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..18c5c48ff --- /dev/null +++ b/packages/solid/src/client/for-slot.ts @@ -0,0 +1,847 @@ +/** + * Unified For SLOT (DESIGN-UNIFIED-FOR.md). + * + * One persistent structure owns both the row bookkeeping AND the node + * placement for a keyed : an intrusive doubly-linked chain of rows plus + * an incrementally-maintained key→row Map, per engaged list. The update is + * pull-based — an ordinary two-phase render effect reads `each()`, diffs + * against its own committed chain (prefix walk, suffix walk, middle + * partition + LIS), and commits placement — no delivery seam, no message + * channel, no second diff: mapArray and reconcileArrays are both bypassed + * for engaged lists. + * + * DELIVERY (module-graph, no registration): this module rides For's OWN + * import graph — For stamps `$for.impl` with `unifiedForSlot`, and a + * renderer's insert() engages it by calling the impl with ITS `SlotOps` + * singleton (web: `domOps`). Apps without For tree-shake the slot entirely; + * renderers that ignore `$for` call the accessor and get classic mapArray. + * The slot itself is platform-free — every node touch rides the ops. + * + * PHASE DISCIPLINE (the H1 bet, validated by the spike suites): the COMPUTE + * half reads, diffs, and may create fresh rows as DETACHED DOM (same + * legality as template cloning in classic computes), but never touches the + * live document or the committed chain. The EFFECT half is the only writer + * of both. Under a held transition the effect doesn't run until reveal, so + * the slot can never half-apply speculative state; a re-compute before the + * effect discards the superseded plan's fresh rows and diffs again from + * committed state. + * + * ROW OWNERSHIP (mapArray's own diet, copied): the slot carries ONE owner + * created at engage time under the insert context — rows inherit context + * through it, survive compute reruns because they never chain to the + * per-run scope, and the whole slot tears down automatically with the + * component (no manual cleanup walk). Per row: `createOwner()` + + * `runWithOwner` (untracked + owned — no createRoot closure protocol), and + * a `nodeType` fast path that skips flatten entirely for the compiled + * single-root shape. Bulk teardown (clear) is `owner.dispose(false)`. + * + * SPIKE SCOPE — declines (pre-engage) or late-classic demotes (post-engage) + * rather than implements: hydration claiming (H2), `keyed` functions + * (accessor-row contract, H4), duplicate keys, rows whose top level is a + * FUNCTION (dynamic top-level content), empty-rendering rows, and non-array + * subjects. Every decline lands on the classic mapArray path. + */ +import { + createOwner, + createRenderEffect, + flatten, + onCleanup, + runWithOwner +} from "@solidjs/signals"; +import { IS_DEV } from "./core.js"; + +/** HYDRATION HOOKS — installed by enableHydration() (for-slot-hydration.ts), + * null in CSR bundles so every hydration path here folds away (#2883's + * discipline: pay for hydration only when you hydrate). */ +export interface SlotHydration { + /** Engage-time decision: `false` = not hydrating (normal engage); `null` = + * decline to classic; `{ id }` = hydrating engage with the parity owner id. */ + engage( + meta: any, + marker: Node | null | undefined, + region: Node[] | undefined + ): { id: string } | null | false; + /** Run a build pass recording the registry keys its templates consume. */ + record(slot: Slot, fn: () => T): T; + /** Demote mid-fill: hand recorded claims back for classic's re-run. */ + restore(slot: Slot): void; + /** Hydrating fill commit: reconcile claimed rows against the region. */ + commitFill(slot: Slot, fp: FlatPlan): void; +} +let slotHydration: SlotHydration | null = null; +export function installSlotHydration(h: SlotHydration): void { + slotHydration = h; +} + +// The two-phase render effect in web's `effect()` shape: transparent + sync. +const transparentOptions = { transparent: true, sync: true } as const; +function effect(fn: (prev?: T) => T, effectFn: (value: T, prev?: T) => void): void { + createRenderEffect(fn, effectFn, transparentOptions); +} + +type RowOwner = { dispose(self?: boolean): void }; + +export interface Row { + /** Row key — the item reference itself (identity mode only in the spike). */ + k: any; + /** Row owner (context carrier + disposer). */ + o: RowOwner; + /** Single-root fast form (the common compiled shape)... */ + n: Node | null; + /** ...or the fragment form (multi-root rows); exactly one of n/ns is set. */ + ns: Node[] | null; + p: Row | null; + x: Row | null; + /** True once the effect phase has placed the row into live DOM. */ + live: boolean; + /** Needs placement this commit (fresh or displaced) — set by compute, + * cleared by the commit. Replaces a per-pass Set. */ + mv: boolean; + /** Reuse stamp for the current pass (duplicate detection without Sets). */ + g: number; +} + +interface Plan { + /** Final row order for the CHANGED middle window only. */ + order: Row[]; + /** Committed rows leaving the list — detach + dispose at commit. */ + removes: Row[]; + /** Chain splice boundaries: last untouched prefix row / first untouched + * suffix row (null = list edge). */ + before: Row | null; + after: Row | null; + /** List length after this plan applies. */ + len: number; + /** Count of freshly built rows in `order` (dispose-on-supersede set). */ + fresh: number; +} + +/** FLAT MODE (lazy structure — the mount-regression fix): first fills carry + * NO Row objects, no chain, no key map — just parallel arrays (mapArray's + * own mount economics). The structure MATERIALIZES once, lazily, on the + * first PARTIAL structural op (the moment the chain/LIS wins start paying); + * aligned ticks, clears, and no-survivor full replaces stay flat forever. */ +export interface Flat { + /** Committed item snapshot (identity keys). */ + items: any[]; + owners: RowOwner[]; + nodes: (Node | Node[])[]; +} + +export interface FlatPlan { + ff: 1; + mode: "fill" | "replace" | "clear"; + items: any[]; + owners: RowOwner[]; + nodes: (Node | Node[])[]; + len: number; +} + +/** RENDERER OPS — the slot's entire platform surface. The slot never touches + * DOM directly: the engaging insert() hands it ONE module-level singleton + * (web: `domOps` below), so every call site stays monomorphic and V8 inlines + * the indirection. This is what lets the slot ride For's own module graph + * (pay-for-use via tree-shaking, no compiler emission, no registration API) + * and what gives universal renderers a direct adoption path: pass your ops. */ +export interface SlotOps { + insert(parent: Node, node: Node, anchor: Node | null): void; + remove(node: Node): void; + createText(text: string): Node; + isNode(v: unknown): boolean; + /** Whole-parent bulk clear (batch-clear / full-replace fast paths). */ + clear(parent: Node): void; + /** Ownership marker for multi-slot parents (web: `$$SLOT`). */ + tag(node: Node, marker: Node): void; + /** True when `node` is a direct child of `parent` (hydration fix-up). */ + contains(parent: Node, node: Node): boolean; +} + +export interface Slot { + head: Row | null; + tail: Row | null; + size: number; + map: Map; + parent: Node; + /** Placement anchor: the end marker Node, or null (append at parent end). */ + end: Node | null; + /** True ONLY for whole-parent inserts (marker === undefined). A `null` + * marker is classic MULTI mode — trailing child with preceding siblings — + * and must NEVER take a `textContent = ""` bulk path (P0). */ + whole: boolean; + owner: RowOwner; + flat: Flat | null; + pending: Plan | FlatPlan | null; + dead: boolean; + ops: SlotOps; + /** HYDRATING FILL in progress (engaged during hydration; cleared by the + * first commit). While set: row templates CLAIM server nodes, registry + * deletions are recorded so a demote can hand them back, and the commit + * reconciles claimed rows against the region instead of placing. */ + hyd: boolean; + /** Registry entries consumed during the hydrating fill (key, node). */ + hydLog: [string, Element][] | null; + /** Hydration: the claimed region snapshot (whole-parent childNodes). */ + region: Node[] | undefined; +} + +/** Whole-parent bulk ops (`ops.clear`) are safe only when our window IS the + * parent's entire child list — classic's ownsAllChildren ruling: streaming + * appends foreign nodes (late-flushed s) that must survive a clear. */ +function ownsParent(slot: Slot): boolean { + if (!slot.whole) return false; + let first: Node | null = null; + let last: Node | null = null; + const f = slot.flat; + if (f !== null) { + const n = f.nodes.length; + if (n === 0) return false; + const n0 = f.nodes[0]; + first = Array.isArray(n0) ? n0[0] : n0; + const nl = f.nodes[n - 1]; + last = Array.isArray(nl) ? nl[nl.length - 1] : nl; + } else if (slot.head !== null) { + first = firstNode(slot.head); + const t = slot.tail!; + last = t.n !== null ? t.n : t.ns![t.ns!.length - 1]; + } else return false; + return slot.parent.firstChild === first && slot.parent.lastChild === last; +} + +/** Pass generation counter (Row.g stamps). */ +let gen = 0; + +const firstNode = (r: Row): Node => (r.n !== null ? r.n : r.ns![0]); + +/** Insert (fresh) or move (live) a row's nodes before `anchor`. */ +function placeRow(slot: Slot, r: Row, anchor: Node | null): void { + const tag = slot.end; + const ops = slot.ops; + if (r.n !== null) { + ops.insert(slot.parent, r.n, anchor); + if (tag && !r.live) ops.tag(r.n, tag); + } else { + const ns = r.ns!; + for (let i = 0; i < ns.length; i++) { + ops.insert(slot.parent, ns[i], anchor); + if (tag && !r.live) ops.tag(ns[i], tag); + } + } + if (!r.live) { + r.live = true; + slot.map.set(r.k, r); + } +} + +function removeRow(r: Row, ops: SlotOps): void { + if (r.live) { + if (r.n !== null) ops.remove(r.n); + else for (const n of r.ns!) ops.remove(n); + } + r.o.dispose(); +} + +const FLATTEN_OPTS = { skipNonRendered: true, doNotUnwrap: true } as const; + +/** Build a row under its own owner (untracked + owned via runWithOwner — + * mapArray's per-row shape). Fast path: compiled single-root rows return an + * element directly and skip flatten. Detached nodes only — placement is the + * commit's job. Returns null when the row shape is outside the slot + * contract. MUST run inside `runWithOwner(slot.owner, ...)` so the row + * owner chains to the slot (context + auto-teardown). */ +/** Row-body build core: owner + detached nodes, no bookkeeping. Returns + * [owner, node|nodes] or null (shape outside contract). Shared by the flat + * fill (arrays only) and structural buildRow (wraps into a Row). */ +function buildParts( + rowFn: (item: any) => any, + item: any, + ops: SlotOps +): [RowOwner, Node | Node[]] | null { + const o: RowOwner = createOwner() as unknown as RowOwner; + let v = runWithOwner(o as any, () => rowFn(item)); + if (ops.isNode(v)) return [o, v as Node]; + const t = typeof v; + if (t === "string" || t === "number") return [o, ops.createText(String(v))]; + // Slow path: fragments / nested arrays / signals — flatten (still owned). + v = runWithOwner(o as any, () => flatten(v, FLATTEN_OPTS)); + if (Array.isArray(v) && v.length > 0) { + const ns: Node[] = new Array(v.length); + for (let i = 0; i < v.length; i++) { + const c = v[i]; + if (typeof c === "function") return (o.dispose(), null); + ns[i] = ops.isNode(c) ? (c as Node) : ops.createText(String(c)); + } + return [o, ns]; + } + if (ops.isNode(v)) return [o, v as Node]; + // Empty-rendering rows (null/undefined/false/true/"" or an empty flatten) + // hold their position with an empty text node — classic's own multi-mode + // trick. Demoting here would tear down SIBLING rows' DOM state (focused + // inputs, scroll) to rebuild through classic; a placeholder is strictly + // better and keeps the row addressable for reorders. + if (v == null || typeof v === "boolean" || v === "" || (Array.isArray(v) && v.length === 0)) + return [o, ops.createText("")]; + o.dispose(); + return null; // function top level (dynamic content) / unrenderable → classic +} + +function buildRow(rowFn: (item: any) => any, item: any, ops: SlotOps): Row | null { + const parts = buildParts(rowFn, item, ops); + if (parts === null) return null; + const nd = parts[1]; + return Array.isArray(nd) + ? { k: item, o: parts[0], n: null, ns: nd, p: null, x: null, live: false, mv: true, g: 0 } + : { k: item, o: parts[0], n: nd, ns: null, p: null, x: null, live: false, mv: true, g: 0 }; +} + +/** Lossless representation change: committed flat arrays → chain + map. + * Runs in COMPUTE (phase-safe: it derives bookkeeping from COMMITTED state, + * touches no DOM, and stays valid if the pass aborts). Returns false on + * duplicate identity keys (classic owns duplicates → demote). */ +function materialize(slot: Slot): boolean { + const f = slot.flat!; + const n = f.items.length; + let prev: Row | null = null; + for (let i = 0; i < n; i++) { + const nd = f.nodes[i]; + const r: Row = Array.isArray(nd) + ? { + k: f.items[i], + o: f.owners[i], + n: null, + ns: nd, + p: prev, + x: null, + live: true, + mv: false, + g: 0 + } + : { + k: f.items[i], + o: f.owners[i], + n: nd, + ns: null, + p: prev, + x: null, + live: true, + mv: false, + g: 0 + }; + if (slot.map.has(r.k)) { + // Roll back the partial chain bookkeeping; demote handles teardown. + slot.map.clear(); + slot.head = slot.tail = null; + return false; + } + slot.map.set(r.k, r); + if (prev !== null) prev.x = r; + else slot.head = r; + prev = r; + } + slot.tail = prev; + slot.size = n; + slot.flat = null; + return true; +} + +// LIS scratch (module-level, reused — stablePositions runs NO user code, so +// reentrancy is impossible mid-call). +let lisTails: number[] = []; +let lisTailIdx: number[] = []; +let lisPrev: number[] = []; + +/** Mark rows that KEEP their DOM position (longest increasing subsequence of + * old-middle indices); everything else gets `mv = true`. `oldPos[j]` is -1 + * for fresh rows (already stamped mv by buildRow). */ +function markMoves(order: Row[], oldPos: number[]): void { + const len = oldPos.length; + if (lisPrev.length < len) lisPrev = new Array(len); + let tlen = 0; + for (let i = 0; i < len; i++) { + const v = oldPos[i]; + if (v === -1) continue; + let lo = 0, + hi = 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++; + } + // Everything moves unless proven stable. + for (let i = 0; i < len; i++) if (oldPos[i] !== -1) order[i].mv = true; + let at = tlen > 0 ? lisTailIdx[tlen - 1] : -1; + while (at !== -1) { + order[at].mv = false; + at = lisPrev[at]; + } +} + +const IDENTICAL = 0 as const; +const DEMOTE = 1 as const; +type ComputeOut = Plan | FlatPlan | typeof IDENTICAL | typeof DEMOTE; + +/** THE unified For slot — For stamps this as `$for.impl`; a renderer's + * insert() engages it with its SlotOps. Returns false to decline (classic + * path); lateClassic re-enters classic insert after a post-engage demote. */ +export function unifiedForSlot( + parent: Node, + listFn: any, + marker: Node | null | undefined, + lateClassic: () => void, + ops: SlotOps, + region?: Node[], + /** HOLE mode: engaged from inside a wrapper insert's compute (the + * `{props.children}` seam). The hosting effect owns the hole, so this + * slot removes its rows on cleanup (a children change or dispose) — in + * direct mode the parent element's removal covers that for free. */ + hole = false +): boolean { + const meta = listFn.$for; + // H4 pin: keyed-fn rows receive accessors in the classic contract — the + // slot binds raw items, so engaging would hand user code the wrong shape. + if (typeof meta.keyed === "function") return false; + // HYDRATION (H2): decided by the installed hooks (null in CSR bundles — + // the branch folds away). A hydrating engage hands back the parity owner + // id so the slot's rows mint the same hydration keys classic's would. + let ownerOpts: { id: string } | undefined; + let hyd = false; + if (slotHydration !== null) { + const h = slotHydration.engage(meta, marker, region); + if (h === null) return false; + if (h !== false) { + ownerOpts = h; + hyd = true; + } + } + + const slot: Slot = { + head: null, + tail: null, + size: 0, + map: new Map(), + parent, + end: marker ?? null, + whole: marker === undefined, + // Slot owner under the INSERT context: rows inherit context through it, + // survive compute reruns, and tear down automatically with the + // component — cleanup needs no row walk. Under hydration it takes the + // explicit parity id (no consumption of the insert owner's counter). + owner: createOwner(ownerOpts) as unknown as RowOwner, + flat: null, + pending: null, + dead: false, + ops, + hyd, + hydLog: null, + region + }; + if (IS_DEV) __unifiedForStats.engaged++; + + const dropPending = (): void => { + if (slot.pending !== null) { + if ((slot.pending as FlatPlan).ff === 1) { + // A superseded flat plan placed nothing — dispose all its owners. + const { owners } = slot.pending as FlatPlan; + for (let j = 0; j < owners.length; j++) owners[j].dispose(); + } else { + const { order } = slot.pending as Plan; + for (let j = 0; j < order.length; j++) if (!order[j].live) order[j].o.dispose(); + } + slot.pending = null; + } + }; + + const removeFlatDom = (): void => { + const f = slot.flat!; + if (ownsParent(slot)) ops.clear(slot.parent); + else + for (let i = 0; i < f.nodes.length; i++) { + const nd = f.nodes[i]; + if (Array.isArray(nd)) for (const n of nd) ops.remove(n); + else ops.remove(nd); + } + }; + + const demote = (): void => { + // Late-classic (contract carried from the patch-driver era): tear the + // slot down whole, then re-enter classic insert under the ORIGINAL owner. + if (IS_DEV) __unifiedForStats.demoted++; + slot.dead = true; + dropPending(); + // Demote DURING a hydrating fill: hand recorded claims back so classic's + // re-run (same parity ids) claims the same server nodes. + if (slot.hyd) slotHydration!.restore(slot); + if (slot.flat !== null) { + removeFlatDom(); + const f = slot.flat; + for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); + slot.flat = null; + } + for (let r = slot.head; r !== null; r = r.x) { + if (r.n !== null) ops.remove(r.n); + else if (r.ns !== null) for (const n of r.ns) ops.remove(n); + } + slot.owner.dispose(false); // bulk: every row owner is a child + slot.head = slot.tail = null; + slot.size = 0; + slot.map.clear(); + lateClassic(); + }; + + /** Build the flat arrays for `arr` (tracked snapshot already taken by the + * caller). Returns null when a row shape is outside the contract. */ + const buildFlat = (itemsSnap: any[]): FlatPlan | null => { + const len = itemsSnap.length; + const owners: RowOwner[] = new Array(len); + const nodes: (Node | Node[])[] = new Array(len); + let failed = false; + const build = (): void => { + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < len; j++) { + const parts = buildParts(meta.row, itemsSnap[j], ops); + if (parts === null) { + // Dispose what this pass created before demoting. + for (let d = 0; d < j; d++) owners[d].dispose(); + failed = true; + return; + } + owners[j] = parts[0]; + nodes[j] = parts[1]; + } + }); + }; + try { + // Hydrating fill: row templates claim server nodes; record the claims + // so a demote can hand them back to classic's re-run. + if (slot.hyd) slotHydration!.record(slot, build); + else build(); + } catch (e) { + // A row fn threw mid-pass: rows built so far chain to the PERSISTENT + // slot owner (by design — they survive compute reruns), so they'd leak + // until slot death. Dispose, then let the throw ride the boundary. + for (let d = 0; d < len; d++) owners[d]?.dispose(); + throw e; + } + if (failed) return null; + return { ff: 1, mode: "fill", items: itemsSnap, owners, nodes, len }; + }; + + // The insert owner disposes slot.owner (and with it every row) through the + // owner tree — cleanup only has to silence the slot. HOLE mode also + // removes the rows: the hosting effect keeps the parent and re-fills it. + onCleanup(() => { + slot.dead = true; + if (hole) { + if (slot.flat !== null) removeFlatDom(); + else + for (let r = slot.head; r !== null; r = r.x) { + if (r.n !== null) ops.remove(r.n); + else if (r.ns !== null) for (const n of r.ns) ops.remove(n); + } + } + }); + + effect( + (): ComputeOut => { + if (slot.dead) return IDENTICAL; + // Read FIRST (phase separation, design H5): a NotReady here leaves the + // slot untouched and rides the boundary like any compute throw. + const items = meta.each(); + if (items != null && items !== false && !Array.isArray(items)) return DEMOTE; + const arr: readonly any[] = items == null || items === false ? [] : items; + // A superseded plan's fresh rows were never placed — discard, then + // diff again from COMMITTED state (retry against uncorrupted state). + dropPending(); + const len = arr.length; + // ── FLAT MODE (lazy structure): aligned lists stay flat (zero work); + // clears and no-survivor replaces stay flat (bulk swap); only a + // PARTIAL structural op materializes the chain — once, amortized into + // the op the chain's wins then repay. + if (slot.flat !== null) { + const fi = slot.flat.items; + if (len === fi.length) { + let aligned = true; + for (let j = 0; j < len; j++) + if (arr[j] !== fi[j]) { + aligned = false; + break; + } + if (aligned) return IDENTICAL; + } + if (len === 0) + return (slot.pending = { + ff: 1, + mode: "clear", + items: [], + owners: [], + nodes: [], + len: 0 + }); + // Survivor probe (once, on the rare structural event): no shared + // identities = full replace — swap flat wholesale, never build rows. + let survivor = false; + { + const old = new Set(fi); + for (let j = 0; j < len; j++) + if (old.has(arr[j])) { + survivor = true; + break; + } + } + if (!survivor) { + const snap: any[] = new Array(len); + for (let j = 0; j < len; j++) snap[j] = arr[j]; + const plan = buildFlat(snap); + if (plan === null) return DEMOTE; + plan.mode = "replace"; + return (slot.pending = plan); + } + // Partial structure: materialize the chain from committed flat state + // (phase-safe: pure bookkeeping over committed rows, no DOM) and + // fall through to the structural walk. + if (!materialize(slot)) return DEMOTE; // duplicate identity keys + } + // ── FLAT FILL: an empty slot fills with arrays only (mapArray's mount + // economics — no Rows, no chain, no map). + if (slot.head === null && slot.size === 0) { + if (len === 0) { + if (!slot.hyd) return IDENTICAL; + // Empty hydrating fill still commits: clears the hydration state + // and removes any server rows the client no longer has. + return (slot.pending = { ff: 1, mode: "fill", items: [], owners: [], nodes: [], len: 0 }); + } + const snap: any[] = new Array(len); + for (let j = 0; j < len; j++) snap[j] = arr[j]; + const plan = buildFlat(snap); + return plan === null ? DEMOTE : (slot.pending = plan); + } + // ── Prefix walk. + let cursor = slot.head; + let i = 0; + while (cursor !== null && i < len && cursor.k === arr[i]) { + cursor = cursor.x; + i++; + } + if (i === len && cursor === null) return IDENTICAL; + const before = cursor === null ? slot.tail : cursor.p; // last prefix row + // ── Suffix walk. + let tailCursor = slot.tail; + let end = len - 1; + let oldRemain = slot.size - i; + while (tailCursor !== null && oldRemain > 0 && end >= i && tailCursor.k === arr[end]) { + tailCursor = tailCursor.p; + end--; + oldRemain--; + } + const after = oldRemain === 0 ? cursor : tailCursor!.x; // first suffix row + const passGen = ++gen; + // ── Old middle rows, keyed for reuse (map probe stamps duplicates). + const oldMid: Row[] = new Array(oldRemain); + { + let r = cursor; + for (let c = 0; c < oldRemain; c++) { + oldMid[c] = r!; + r = r!.x; + } + } + const oldIndexOf = new Map(); + for (let j = 0; j < oldRemain; j++) { + if (oldIndexOf.has(oldMid[j].k)) return DEMOTE; // duplicate keys + oldIndexOf.set(oldMid[j].k, j); + } + // ── New middle: reuse by key; build the rest (detached, owned by the + // slot owner — untracked via runWithOwner inside buildRow). + const width = end - i + 1; + // Read the window TRACKED, before entering the owner wrapper: inside + // runWithOwner reads are untracked, and an untracked store read + // resolves the COMMITTED backing while this flush's setter writes are + // still pending — `length` (a written node) says N+1 while the unread + // index N falls back to committed undefined. mapArray solves the same + // tear with `_owner._parentComputed` routing; the driver hoists the + // reads instead. + const midItems: any[] = new Array(width); + for (let j = 0; j < width; j++) midItems[j] = arr[i + j]; + const order: Row[] = new Array(width); + const oldPos: number[] = new Array(width); + let fresh = 0; + let demoteFlag = false; + try { + runWithOwner(slot.owner as any, () => { + for (let j = 0; j < width; j++) { + const item = midItems[j]; + const at = oldIndexOf.get(item); + if (at !== undefined) { + const row = oldMid[at]; + if (row.g === passGen) { + demoteFlag = true; // duplicate incoming key + return; + } + row.g = passGen; + order[j] = row; + oldPos[j] = at; + } else if (slot.map.has(item)) { + // Same identity alive outside the middle window = duplicate key + // across the prefix/suffix boundary. Classic owns duplicates. + demoteFlag = true; + return; + } else { + const built = buildRow(meta.row, item, ops); + if (built === null) { + demoteFlag = true; // dynamic row shape (function top level) + return; + } + fresh++; + order[j] = built; + oldPos[j] = -1; + } + } + }); + } catch (e) { + // A row fn threw mid-pass: fresh rows chain to the PERSISTENT slot + // owner and would leak until slot death — dispose before the throw + // rides the boundary. (Reused rows stay live; their g-stamps are + // reset by the next pass's fresh passGen.) + for (let j = 0; j < width; j++) { + const r = order[j]; + if (r !== undefined && !r.live) r.o.dispose(); + } + throw e; + } + if (demoteFlag) { + // Partial build: dispose what this pass created before demoting. + for (let j = 0; j < width; j++) { + const r = order[j]; + if (r !== undefined && !r.live) r.o.dispose(); + } + return DEMOTE; + } + markMoves(order, oldPos); + const removes: Row[] = []; + for (let j = 0; j < oldRemain; j++) if (oldMid[j].g !== passGen) removes.push(oldMid[j]); + return (slot.pending = { order, removes, before, after, len, fresh }); + }, + out => { + if (out === IDENTICAL) return; + if (out === DEMOTE) return demote(); + if ((out as FlatPlan).ff === 1) { + const fp = out as FlatPlan; + if (fp !== slot.pending) return; // superseded mid-flight + slot.pending = null; + if (fp.mode === "clear") { + removeFlatDom(); + const f = slot.flat!; + for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); + slot.flat = null; + slot.size = 0; + if (IS_DEV) __unifiedForStats.batchCleared++; + return; + } + if (fp.mode === "replace") { + removeFlatDom(); + const f = slot.flat!; + for (let i = 0; i < f.owners.length; i++) f.owners[i].dispose(); + } + // Hydrating fill: a claim pass, not a placement pass — the hooks + // reconcile claimed rows against the region (mismatch only). + if (slot.hyd) return slotHydration!.commitFill(slot, fp); + // fill / replace: append the new window before the end anchor. + const tag = slot.end; + for (let i = 0; i < fp.nodes.length; i++) { + const nd = fp.nodes[i]; + if (Array.isArray(nd)) + for (const n of nd) { + ops.insert(slot.parent, n, slot.end); + if (tag) ops.tag(n, tag); + } + else { + ops.insert(slot.parent, nd, slot.end); + if (tag) ops.tag(nd, tag); + } + } + slot.flat = { items: fp.items, owners: fp.owners, nodes: fp.nodes }; + slot.size = fp.len; + return; + } + const plan = out as Plan; + if (plan !== slot.pending) return; // superseded mid-flight + slot.pending = null; + const { order, removes, before, after } = plan; + // Batch clear (design §5.2): N→0 on an OWNED whole-parent slot is one + // `textContent = ''` + one bulk owner dispose — no per-row work. + // ownsParent guards both the null-marker MULTI case (preceding + // siblings, P0) and foreign nodes streaming appended to our parent. + if (plan.len === 0 && before === null && after === null && ownsParent(slot)) { + if (IS_DEV) __unifiedForStats.batchCleared++; + ops.clear(slot.parent); + slot.owner.dispose(false); + slot.map.clear(); + slot.head = slot.tail = null; + slot.size = 0; + return; + } + // Full replace (no survivors, owned whole parent): bulk-detach the old + // rows with one textContent write, dispose them without per-node + // removes, and let the placement walk below append the fresh window. + // Covers the jfb `replace` / `runlots`-over-rows shapes. + if ( + before === null && + after === null && + removes.length === slot.size && + removes.length > 0 && + ownsParent(slot) + ) { + ops.clear(slot.parent); + for (let j = 0; j < removes.length; j++) { + removes[j].live = false; + removes[j].o.dispose(); + } + slot.map.clear(); + } else { + // 1. Removes: detach + dispose + unmap. + for (let j = 0; j < removes.length; j++) { + removeRow(removes[j], ops); + slot.map.delete(removes[j].k); + } + } + // 2. Place fresh/moved rows back-to-front so anchors are always final. + let anchor: Node | null = after !== null ? firstNode(after) : slot.end; + for (let j = order.length - 1; j >= 0; j--) { + const r = order[j]; + if (r.mv) { + placeRow(slot, r, anchor); + r.mv = false; + } + anchor = firstNode(r); + } + // 3. Splice the chain: [before] → order… → [after]. + let prev = before; + for (let j = 0; j < order.length; j++) { + const r = order[j]; + r.p = prev; + if (prev !== null) prev.x = r; + else slot.head = r; + prev = r; + } + if (prev !== null) prev.x = after; + else slot.head = after; + if (after !== null) after.p = prev; + else slot.tail = prev; + slot.size = plan.len; + } + ); + return true; +} + +/** DEV-ONLY probes: engagement / demotion / batch-clear counters, exposed as + * `DEV.unifiedFor` (solid-js's dev diagnostics bag — undefined in prod). + * Increments are IS_DEV-gated; not a package export of its own. */ +export interface UnifiedForStats { + engaged: number; + demoted: number; + batchCleared: number; +} +export const __unifiedForStats: UnifiedForStats = { engaged: 0, demoted: 0, batchCleared: 0 }; diff --git a/packages/solid/src/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 326b45a0f..c518366da 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/web/src/client.ts b/packages/web/src/client.ts index e346c051d..847ef8c05 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -19,6 +19,35 @@ import { } from "solid-js"; import { effect, memo } 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; + } +}; + import { JSX } from "../jsx/jsx.js"; import type { RequestEventLocals } from "./server.js"; @@ -837,6 +866,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) { @@ -903,6 +944,53 @@ export function insert(parent, accessor, marker, initial, options) { const host = options && options.host; if (multi && !initial) initial = []; if (hydrationRt !== null) initial = hydrationRt.claimInitial(parent, multi, initial); + // Unified-For seam (DESIGN-UNIFIED-FOR §4): a list value carrying the + // `$for` descriptor brings the slot impl WITH it (For's module graph); + // insert engages it by handing over web's domOps. `false` declines to + // classic (the descriptor is also a callable — calling it IS the classic + // mapArray path). The lateClassic thunk serves ENGAGED lists that later + // leave the slot's contract: it re-enters this insert under the ORIGINAL + // owner with a bare accessor (no `$for` marker). + if (typeof accessor === "function" && accessor.$for !== undefined) { + const listAccessor = accessor; + const owner = getOwner(); + // Hydration: the claimed region snapshot — the parent's childNodes + // (claimInitial, whole-parent) or the comment-bounded hole range the + // compiled client resolved via getNextMarker (anchored holes). The + // slot's fill reconciles claimed rows against it. + const region = + hydrationRt !== null && isHydrating(parent) && Array.isArray(initial) + ? hydrationRt.slotRegion(initial) + : undefined; + if ( + // Marker passes through UNTOUCHED: `undefined` = whole-parent insert, + // `null` = trailing child with preceding siblings (classic MULTI mode + // — the compiler emits it for `

{list}

`), Node = bounded + // hole. The slot's bulk-clear paths key off this distinction (P0: + // collapsing null→undefined wiped preceding siblings). + accessor.$for.impl( + parent, + accessor, + marker, + () => + runWithOwner(owner, () => + insert( + parent, + () => listAccessor(), + marker, + // Anchored holes: hand classic the bounded region the slot had + // (a hydrating demote's primitive rows adopt positional text + // from it — same as the hole seam); whole-parent re-derives. + marker !== undefined ? (region ?? []) : undefined, + options + ) + ), + domOps, + region + ) + ) + return; + } if (typeof accessor !== "function") { accessor = normalize(accessor, initial, multi, true); if (typeof accessor !== "function") { @@ -917,24 +1005,94 @@ export function insert(parent, accessor, marker, initial, options) { initial = [placeholder]; } let current = initial; + // Unified-For HOLE seam: a `$for` accessor reaching this hole THROUGH a + // wrapper (`{props.children}` in a parent component compiles to + // `insert(el, () => props.children)`) engages the slot for the hole. The + // slot is created inside this compute, so a children change tears it down + // (hole-mode cleanup removes its rows). A demote hands the hole to the + // SHARED classic effect synchronously (the slot has already removed its + // rows and disposed its owner) and flips `holeClassic` so a later + // children-change re-run takes the classic path directly. + let holeClassic = false; + // The classic inner effect for a function-valued hole. Shared by the + // normal path and the hydrating demote re-entry so BOTH write this insert's + // `current` — a nested insert() would own a private range and leave the + // rows classic appends afterward invisible to this effect's cleanup. + const classic = (value, prev) => + effect( + () => ( + hydrationRt !== null && (current = hydrationRt.reclaimRegion(current, parent, marker)), + normalize(value, current, multi) + ), + inner => { + current = insertExpression(parent, inner, current, marker); + host && tagHost(current, host); + }, + prev !== undefined && !(options && options.schedule) + ? { ...options, schedule: true } + : options + ); effect( prev => { if (hydrationRt !== null) current = hydrationRt.reclaimRegion(current, parent, marker); 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 && !holeClassic) { + // Hand-off: whatever classic content this hole tracked goes away + // first (a For returning after other children). Multi holes keep + // insert's placeholder invariant — a surviving anchor the slot's + // rows land after, before the marker. Under an ACTIVE hydration of + // this parent the tracked range is the claimed server region: keep + // it for the slot's fill instead of cleaning. + const region = + hydrationRt !== null && isHydrating(parent) && Array.isArray(current) + ? hydrationRt.slotRegion(current) + : undefined; + let keep; + // Under hydration the tracked range STAYS the claimed region: if the + // slot demotes mid-fill, this effect's classic re-run reconciles + // against the real server rows (leftovers on mismatch get cleaned + // instead of surviving invisibly). After a successful engage the + // range is merely stale — cleanChildren skips nodes no longer ours. + if (region !== undefined) keep = region; + else if (multi) { + const ph = document.createTextNode(""); + cleanChildren(parent, current, marker, ph); + keep = [ph]; + } else { + if (current !== undefined) cleanChildren(parent, current, undefined); + keep = []; + } + const listFn = value; + const holeOwner = getOwner(); + if ( + value.$for.impl( + parent, + value, + marker, + () => { + // Demote: the slot has removed its rows and disposed its owner; + // hand the hole to the SHARED classic effect NOW (synchronous in + // CSR and hydration alike — under hydration a deferred re-run + // would land after hydrate() flips the flag and clone instead + // of claim; in CSR it left the hole empty for a microtask). + // Shared, not a nested insert: the rows classic manages from + // here live in THIS effect's `current`, so a later children + // change cleans them. normalize() unwraps the list (classic + // rows) — no re-engage. + holeClassic = true; + runWithOwner(holeOwner, () => classic(listFn, undefined)); + }, + domOps, + region, + true + ) + ) { + current = keep; + return INNER_OWNED; + } + } + classic(value, prev); return INNER_OWNED; }, value => { 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..69a7d86ee --- /dev/null +++ b/packages/web/test/for.unified.children.spec.tsx @@ -0,0 +1,241 @@ +/** + * @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 + * - a demote INSIDE a hole hands the hole to the classic path via the + * hosting effect's re-run — no second insert fighting for the hole + * - `children()` introspection and fragment children stay classic + */ +import { beforeEach, describe, expect, test } from "vitest"; +import { createSignal, flush, For, children, 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; + const demoted0 = stats.demoted; + 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(""); + expect(stats.demoted).toBe(demoted0); + }); + + test("bounded hole (element marker) engages; siblings untouched through reorder and clear", () => { + const [rows, setRows] = createSignal(["a", "b", "c"]); + const engaged0 = 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("demote inside a hole hands the hole to classic via the hosting effect", () => { + const [rows, setRows] = createSignal(["a", "b"]); + const demoted0 = stats.demoted; + dispose = render( + () => ( + + {(r: any) => (typeof r === "function" ? r : {r})} + + ), + container + ); + const div = container.querySelector("div")!; + expect(div.innerHTML).toBe("ab"); + // A function-top-level row arrives → slot demotes; the hole re-runs classic. + setRows(["a", () => dyn, "b"]); + flush(); + expect(stats.demoted).toBe(demoted0 + 1); + expect(div.innerHTML).toBe("adynb"); + // Classic now owns the hole: further updates keep working, no duplicates. + setRows(["b", "a"]); + flush(); + expect(div.innerHTML).toBe("ba"); + setRows([]); + flush(); + expect(div.innerHTML).toBe(""); + }); + + test("-rooted rows demote cleanly to classic (memo top level) and stay correct", () => { + // Dynamic returns a MEMO (its `component` may change), so the row's top + // level is a function whether element creation is eager (#3291 revert) + // or deferred (#3187) — the slot demotes, classic owns the hole. Pinned + // through the revert so the contract is explicit either way. + const [rows, setRows] = createSignal(["a", "b", "c"]); + const engaged0 = stats.engaged; + const demoted0 = stats.demoted; + dispose = render( + () => ( + + {r => {r}} +
+ ), + container + ); + expect(stats.engaged).toBe(engaged0 + 1); + expect(stats.demoted).toBe(demoted0 + 1); + expect(texts(container, "tr")).toEqual(["a", "b", "c"]); + setRows(["c", "a", "b"]); + flush(); + expect(texts(container, "tr")).toEqual(["c", "a", "b"]); + setRows([]); + flush(); + expect(container.querySelector("tbody")!.innerHTML).toBe(""); + expect(stats.demoted).toBe(demoted0 + 1); // one demote, no thrash + }); + + test("children() introspection stays classic and correct", () => { + const [rows, setRows] = createSignal(["a", "b"]); + const engaged0 = stats.engaged; + 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.reconcile-parity.spec.tsx b/packages/web/test/for.unified.reconcile-parity.spec.tsx new file mode 100644 index 000000000..ce199301d --- /dev/null +++ b/packages/web/test/for.unified.reconcile-parity.spec.tsx @@ -0,0 +1,275 @@ +/** + * @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 (default-on unified For) + * classic — arity-2 rows (`(item, _i) =>`): the index param declines the + * `$for` stamp pre-engage, so the SAME semantics run through + * keyed mapArray + reconcileArrays. Identical expected output — + * a live oracle, not a snapshot. + * + * Each mode runs the full matrix in three container shapes, because the P0 + * audit proved anchoring is where list bugs hide: + * whole — For is the sole child (marker undefined; bulk-clear paths) + * trailing— preceding sibling, For last (marker null; classic MULTI) + * bounded — siblings on both sides (marker = element node) + * + * And in three row shapes (text / element / static fragment — fragments + * exercise the multi-node ns rows). + * + * The differential section renders slot and classic off ONE signal and + * asserts DOM equality after every step of a cumulative no-reset sequence — + * state-to-state transitions, not just canonical-to-X. + */ +import { beforeEach, describe, expect, test } from "vitest"; +import { createSignal, flush, For, DEV } from "solid-js"; +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; +}; + +function makeContainers(useIdx: boolean, shape: Shape): Container[] { + const rowFn: any = useIdx ? shape.rowIdx : shape.row; + return [ + { + name: "whole", + mount: (list, _row) => { + const host = document.createElement("div"); + const dispose = render( + () => ( +
+ {rowFn} +
+ ), + host + ); + return [host.querySelector("section")!, dispose]; + }, + wrap: rows => rows + }, + { + name: "trailing (null marker)", + mount: (list, _row) => { + const host = document.createElement("div"); + const dispose = render( + () => ( +
+ pre + {rowFn} +
+ ), + host + ); + return [host.querySelector("section")!, dispose]; + }, + wrap: rows => `pre${rows}` + }, + { + name: "bounded (element marker)", + mount: (list, _row) => { + const host = document.createElement("div"); + const dispose = render( + () => ( +
+ pre + {rowFn} + post +
+ ), + host + ); + return [host.querySelector("section")!, dispose]; + }, + wrap: rows => `pre${rows}post` + } + ]; +} + +for (const mode of ["slot", "classic"] as const) { + const useIdx = mode === "classic"; + for (const shape of SHAPES) { + describe(`reconcile parity [${mode}] [${shape.name} rows]`, () => { + for (const container of makeContainers(useIdx, shape)) { + test(`${container.name}: full transition matrix`, () => { + const [list, setList] = createSignal(CANON); + const engagedBefore = stats.engaged; + const demotedBefore = stats.demoted; + const [el, dispose] = container.mount(list, null); + try { + // Mode sanity: slot engages exactly once, classic never. + if (mode === "slot") { + expect(stats.engaged).toBe(engagedBefore + 1); + } else { + expect(stats.engaged).toBe(engagedBefore); + } + const expected = (arr: string[]) => container.wrap(arr.map(shape.html).join("")); + expect(el.innerHTML).toBe(expected(CANON)); + for (const [label, target] of TRANSITIONS) { + setList(target); + flush(); + expect(el.innerHTML, `${label} (forward)`).toBe(expected(target)); + setList(CANON); + flush(); + expect(el.innerHTML, `${label} (reset)`).toBe(expected(CANON)); + } + // The whole matrix must run WITHOUT falling back to classic. + if (mode === "slot") { + expect(stats.demoted).toBe(demotedBefore); + } + } finally { + dispose(); + } + }); + } + }); + } +} + +describe("reconcile parity: differential (slot vs classic, one signal, no resets)", () => { + // Cumulative state-to-state sequence — every step diffs against the + // PREVIOUS state, so this covers transitions the canonical matrix cannot. + const SEQUENCE: string[][] = [ + ["a", "b", "c", "d", "e"], + ["e", "d", "c", "b", "a"], // reversal + ["e", "c", "a"], // remove evens (of reversed) + ["x", "e", "c", "a", "y"], // grow both ends + ["y", "x", "e", "c", "a"], // rotate + ["a", "c", "e", "x", "y"], // reversal again + [], // clear + ["m", "n"], // refill small + ["n", "m"], // swap pair + ["n", "q", "m"], // insert middle + ["q"], // collapse to middle survivor + ["q", "r", "s", "t", "u", "v", "w"], // grow long + ["w", "q", "s", "u", "t", "r", "v"], // shuffle + ["v", "w"], // heavy shrink, tail survivors + ["f", "g", "h"], // full replace + ["h", "g", "f"], // reverse the replacement + ["a", "b", "c", "d", "e"] // back to canon + ]; + + for (const shape of SHAPES) { + test(`${shape.name} rows: DOM identical after every step`, () => { + const [list, setList] = createSignal(SEQUENCE[0]); + const slotHost = document.createElement("div"); + const classicHost = document.createElement("div"); + const rowSlot: any = shape.row; + const rowClassic: any = shape.rowIdx; + const disposeSlot = render( + () => ( +
+ pre + {rowSlot} + post +
+ ), + slotHost + ); + const disposeClassic = render( + () => ( +
+ pre + {rowClassic} + post +
+ ), + classicHost + ); + try { + expect(slotHost.innerHTML).toBe(classicHost.innerHTML); + for (let i = 1; i < SEQUENCE.length; i++) { + setList(SEQUENCE[i]); + flush(); + expect(slotHost.innerHTML, `step ${i}: ${SEQUENCE[i].join(",") || "(empty)"}`).toBe( + classicHost.innerHTML + ); + } + } finally { + disposeSlot(); + disposeClassic(); + } + }); + } +}); 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..39eab5f70 --- /dev/null +++ b/packages/web/test/for.unified.siblings.spec.tsx @@ -0,0 +1,258 @@ +/** + * @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("demote (flat) with preceding sibling: classic rebuild keeps the sibling", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+

Title

+ + {(item: any) => (typeof item === "function" ? item : {item})} + +
+ ), + container + ); + // A row whose top level is a FUNCTION demotes to classic. + const before = stats.demoted; + setList(["a", () => dyn]); + flush(); + expect(stats.demoted).toBe(before + 1); + expect(container.querySelector("h1")).not.toBeNull(); + expect(container.querySelector("h1")!.textContent).toBe("Title"); + expect(container.querySelectorAll("span").length).toBe(1); + expect(container.querySelector("b")!.textContent).toBe("dyn"); + }); +}); + +describe("unified For: whole-parent ownership guard (foreign nodes survive)", () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); + }); + + // For as the SOLE child of a compiled element — the true whole-parent + // shape (`insert(_el$, comp)`, marker undefined). `render(() => )` + // wraps the accessor and runs classic; the compiled shape is the one that + // engages with whole-parent ownership. + test("streamed foreign node survives a flat clear", () => { + const [list, setList] = createSignal(["a", "b"]); + dispose = render( + () => ( +
+ {item => {item}} +
+ ), + container + ); + const section = container.querySelector("section")!; + // Streaming appends a foreign node (late-flushed ) to our parent. + const link = document.createElement("link"); + section.appendChild(link); + setList([]); + flush(); + expect(section.contains(link)).toBe(true); + expect(section.querySelectorAll("span").length).toBe(0); + }); + + test("streamed foreign node survives a chain batch clear", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + dispose = render( + () => ( +
+ {item => {item}} +
+ ), + container + ); + const section = container.querySelector("section")!; + setList(["b", "a", "c"]); // materialize the chain + flush(); + const link = document.createElement("link"); + section.appendChild(link); + setList([]); + flush(); + expect(section.contains(link)).toBe(true); + expect(section.querySelectorAll("span").length).toBe(0); + }); + + test("owned whole-parent clear still takes the bulk path", () => { + const [list, setList] = createSignal(["a", "b", "c"]); + dispose = render( + () => ( +
+ {item => {item}} +
+ ), + container + ); + const section = container.querySelector("section")!; + setList(["c", "b", "a"]); // materialize + flush(); + const before = stats.batchCleared; + setList([]); + flush(); + expect(stats.batchCleared).toBe(before + 1); + expect(section.innerHTML).toBe(""); + }); +}); + +describe("unified For: empty-rendering rows hold position (no demote)", () => { + let container: HTMLDivElement; + let dispose: (() => void) | undefined; + + beforeEach(() => { + dispose?.(); + dispose = undefined; + container = document.createElement("div"); + }); + + test("a null row arriving late does NOT demote — sibling input state survives", () => { + type R = { id: string; hidden?: boolean }; + const a: R = { id: "a" }; + const b: R = { id: "b" }; + const [list, setList] = createSignal([a, b]); + dispose = render( + () => {(it: R) => (it.hidden ? null : )}, + container + ); + const inputA = container.querySelector("input")!; + inputA.value = "typed"; + const before = stats.demoted; + setList([a, b, { id: "c", hidden: true }]); + flush(); + expect(stats.demoted).toBe(before); + expect(container.querySelector("input")).toBe(inputA); // same node + expect(inputA.value).toBe("typed"); // state intact + expect(container.querySelectorAll("input").length).toBe(2); + }); + + test("null rows participate in reorders and removals", () => { + type R = { id: string; hidden?: boolean }; + const a: R = { id: "a" }; + const gap: R = { id: "gap", hidden: true }; + const b: R = { id: "b" }; + const [list, setList] = createSignal([a, gap, b]); + dispose = render( + () => {(it: R) => (it.hidden ? null : {it.id})}, + container + ); + expect(container.querySelectorAll("span").length).toBe(2); + setList([b, gap, a]); + flush(); + const spans = [...container.querySelectorAll("span")].map(s => s.textContent); + expect(spans).toEqual(["b", "a"]); + setList([a, b]); + flush(); + expect([...container.querySelectorAll("span")].map(s => s.textContent)).toEqual(["a", "b"]); + }); +}); diff --git a/packages/web/test/for.unified.spec.tsx b/packages/web/test/for.unified.spec.tsx new file mode 100644 index 000000000..4782a824d --- /dev/null +++ b/packages/web/test/for.unified.spec.tsx @@ -0,0 +1,296 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * Unified-For driver SPIKE suite (DESIGN-UNIFIED-FOR.md). + * + * Three jobs: + * 1. Semantics parity on the engaged path — the classic for.spec matrix + * (permutations, inserts, removes, clear/refill) must hold verbatim. + * 2. Contract edges — fragment rows, multi-slot mode, duplicate-key and + * non-array DEMOTION to classic (correct rendering after demote). + * 3. H1 — holds/transitions: an optimistic store's held update must not + * half-apply the slot (old DOM until reveal, optimistic writes visible + * in flight, revert restores committed). + */ +import { beforeEach, describe, expect, test } from "vitest"; +import { createRoot, createSignal, createOptimisticStore, flush, For, 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: demotion to classic", () => { + beforeEach(() => { + stats.demoted = 0; + }); + + test("duplicate keys demote and still render correctly", () => { + const [list, setList] = createSignal(["a", "b"]); + let div!: HTMLDivElement; + createRoot(() => { +
+ {item => {item}} +
; + }); + flush(); + expect(div.innerHTML).toBe("ab"); + setList(["a", "a", "b"]); // duplicate identity → driver demotes + flush(); + expect(stats.demoted).toBe(1); + expect(div.innerHTML).toBe("aab"); + // Classic owns it from here on — still fully live. + setList(["b", "a"]); + flush(); + expect(div.innerHTML).toBe("ba"); + }); + + test("non-array subject demotes to classic single-value insert", () => { + const [list, setList] = createSignal(["a"]); + let div!: HTMLDivElement; + createRoot(() => { +
+ {(item: any) => {item}} +
; + }); + flush(); + expect(div.innerHTML).toBe("a"); + setList("not-an-array" as any); + flush(); + expect(stats.demoted).toBe(1); + }); +}); + +describe("unified For: H1 — holds and optimism", () => { + test("held async update never half-applies; in-flight push holds with the flight", async () => { + const container = document.createElement("div"); + let resolveTruth!: () => void; + const gate = new Promise(r => (resolveTruth = r)); + + let push!: () => void; + createRoot(() => { + const [s, ss] = createOptimisticStore<{ id: string }[]>( + async function* (draft) { + yield [{ id: "a" }, { id: "b" }]; + await gate; + yield [{ id: "c" }, { id: "d" }, { id: "e" }]; + }, + [{ id: "a" }, { id: "b" }] + ); + push = () => + ss(draft => { + draft.push({ id: "opt" }); + }); + insert( + container, + () => ({(item: any) => {item.id}}) as any + ); + }); + flush(); + await sleep(10); + expect(container.innerHTML).toBe("ab"); + + // Optimistic structural write DURING the store's own truth flight: the + // bare write rides the FLIGHT'S transaction (#3146 declared ownership) + // and holds with it — no flash, no half-applied frame. (Classic mapArray + // behaves identically — pinned by the classic probe twin of this suite.) + push(); + flush(); + await sleep(10); + expect(container.innerHTML).toBe("ab"); + + // Truth lands: committed topology replaces both the old rows and the + // optimistic row at the reveal — no intermediate half-applied frame. + resolveTruth(); + await sleep(20); + flush(); + expect(container.innerHTML).toBe("cde"); + }); +}); + +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__/reactive-ref-lone-spread-id-parity.json b/packages/web/test/harness/__artifacts__/reactive-ref-lone-spread-id-parity.json index 72add67c0..2528f2881 100644 --- a/packages/web/test/harness/__artifacts__/reactive-ref-lone-spread-id-parity.json +++ b/packages/web/test/harness/__artifacts__/reactive-ref-lone-spread-id-parity.json @@ -2,4 +2,4 @@ "name": "reactive-ref-lone-spread-id-parity", "shell": "
spread
", "rest": "" -} +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-basic.json b/packages/web/test/harness/__artifacts__/slot-hydrate-basic.json new file mode 100644 index 000000000..af23e82ec --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-basic.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-basic", + "shell": "
  • a
  • b
  • c
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-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-demote-mid-fill.json b/packages/web/test/harness/__artifacts__/slot-hydrate-demote-mid-fill.json new file mode 100644 index 000000000..e8f7a76d3 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-demote-mid-fill.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-demote-mid-fill", + "shell": "
  • a
  • b
  • c
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-empty.json b/packages/web/test/harness/__artifacts__/slot-hydrate-empty.json new file mode 100644 index 000000000..54e25d9bf --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-empty.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-empty", + "shell": "
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-fewer.json b/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-fewer.json new file mode 100644 index 000000000..7ac2b1846 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-fewer.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-mismatch-fewer", + "shell": "
    • a
    • b
    • c
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-more.json b/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-more.json new file mode 100644 index 000000000..7b7ed67db --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-mismatch-more.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-mismatch-more", + "shell": "
    • a
    • b
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-nested-demote.json b/packages/web/test/harness/__artifacts__/slot-hydrate-nested-demote.json new file mode 100644 index 000000000..e83d26c38 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-nested-demote.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-nested-demote", + "shell": "
      • 12
      • 3
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-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-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-demote-mismatch.json b/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-mismatch.json new file mode 100644 index 000000000..90b2b68ac --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-mismatch.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-through-demote-mismatch", + "shell": "
    • a
    • b
    • c
    ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-residue.json b/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-residue.json new file mode 100644 index 000000000..23e9b861c --- /dev/null +++ b/packages/web/test/harness/__artifacts__/slot-hydrate-through-demote-residue.json @@ -0,0 +1,5 @@ +{ + "name": "slot-hydrate-through-demote-residue", + "shell": "
  • a
  • b
  • c
  • ", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/__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..7ce2e259b --- /dev/null +++ b/packages/web/test/harness/for-slot-scenarios.tsx @@ -0,0 +1,532 @@ +/** + * @jsxImportSource @solidjs/web + * + * Unified For — HYDRATION scenarios (H2 v1). Rendered by the server harness + * (test/server/hydration-harness.spec.tsx → __artifacts__) and hydrated by + * test/hydration/for-slot.spec.tsx, which asserts slot-specific invariants + * on top of the generic parity ones: + * + * - whole-parent keyed lists ENGAGE during hydration (engaged counter) + * - rows are the SERVER nodes (identity), no key-miss warnings + * - the first post-hydration STRUCTURAL update runs through the slot + * - server/client MISMATCH reconciles at the fill commit (both directions) + * - a demote DURING the hydrating fill hands claims back — classic's + * re-run claims the same nodes (the "never strand a claim" invariant) + * - anchored holes (null/element markers) stay classic under hydration + * + * Mismatch scenarios diverge on `isServer` so one source renders both sides. + */ +import { createSignal, flush, For, Show } from "solid-js"; +import { isServer } from "@solidjs/web"; + +export type ForSlotScenario = { + name: string; + App: () => any; + /** container.textContent after hydration settles */ + expectedText: string; + /** server-visible text when it legitimately differs (mismatch cases) */ + serverText?: string; + /** how many slots must ENGAGE during hydrate() (0 = classic expected) */ + engaged: number; + /** how many slots must DEMOTE during hydrate() */ + demoted: number; + /** expected console.warn calls during hydrate (key misses on real mismatch) */ + warnings: number; + /** selector for row nodes that must be the SERVER nodes after hydration */ + identitySelector?: string; + /** post-hydration update + expectations */ + update?: () => void; + expectedTextAfterUpdate?: string; + /** after update: these server nodes (by initial text) must survive as the + * same node objects (moved, not recreated) */ + survivorsAfterUpdate?: string[]; +}; + +// --------------------------------------------------------------------------- +// 1. Basic whole-parent list; post-hydration REORDER (structural, slot path) +let setBasic!: (v: string[]) => void; +function SlotBasic() { + const [items, set] = createSignal(["a", "b", "c"]); + setBasic = set; + return ( +
      + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 2. Text rows (no template keys) — fresh text replaces server text at the +// fill commit; post-hydration append. +let setText!: (v: string[]) => void; +function SlotTextRows() { + const [items, set] = createSignal(["a", "b", "c"]); + setText = set; + return ( +
      + {item => item} +
    + ); +} + +// --------------------------------------------------------------------------- +// 3. Mismatch: server has MORE rows than the client — leftover removed. +function SlotFewer() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( +
      + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 4. Mismatch: client has MORE rows than the server — fresh row inserted +// (one key-miss warning is the expected, honest signal). +function SlotMore() { + const [items] = createSignal(isServer ? ["a", "b"] : ["a", "b", "c"]); + return ( +
      + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 5. Demote DURING the hydrating fill: row "b" renders a (function +// top level) after row "a" already CLAIMED. The slot must hand a's claim +// back so classic's re-run claims the same server node — no warnings, +// no phantom rows, and the classic path then owns the list. +let setDemote!: (v: string[]) => void; +function SlotDemoteMidFill() { + const [items, set] = createSignal(["a", "b", "c"]); + setDemote = set; + return ( +
      + + {item => + item === "b" ? ( + +
    • {item}
    • +
      + ) : ( +
    • {item}
    • + ) + } +
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 6. Empty list on both sides; post-hydration first row. +let setEmpty!: (v: string[]) => void; +function SlotEmpty() { + const [items, set] = createSignal([]); + setEmpty = set; + return ( +
      + {item =>
    • {item}
    • }
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 7. Trailing hole (preceding sibling): 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 + mid-fill demote (audit P1): the outer engages, row x's +// nested list engages AND COMMITS, then row y is -rooted → the outer +// demotes. Every claim beneath the outer — including the nested list's +// committed ones — must be handed back so classic's re-run (which +// re-engages the nested lists with the same ids) claims the same nodes. +const NX = { g: "x", items: ["1", "2"], special: false }; +const NY = { g: "y", items: ["3"], special: true }; +function SlotNestedDemote() { + const [groups] = createSignal([NX, NY]); + const inner = (g: typeof NX) => ( +
      + {item => {item}} +
    + ); + return ( +
      + + {group => + group.special ? ( + +
    • {inner(group)}
    • +
      + ) : ( +
    • {inner(group)}
    • + ) + } +
      +
    + ); +} + +// --------------------------------------------------------------------------- +// 11. Through-children + mid-fill demote + server MISMATCH (audit P2). The +// demote re-enters classic SYNCHRONOUSLY inside the hydration window (a +// deferred re-run would clone instead of claim): rows a/b are the server +// nodes, zero warnings. The leftover server row `c` SURVIVES — classic's +// own hydration is a claim pass that never removes server leftovers, so +// this is exactly what a never-slotted app shows on the same mismatch +// (ruled: classic parity, not a slot defect). The hosting effect keeps +// the real range as `current`, so a later children change cleans it. +function SlotThroughDemoteMismatch() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( + + + {item => + item === "b" ? ( + +
  • {item}
  • +
    + ) : ( +
  • {item}
  • + ) + } +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// 12. Through-children + mid-fill demote + LATER children change (audit r3 +// P2 follow-up): after the synchronous classic re-entry, rows classic +// appends must live in the HOSTING effect's range — swapping the children +// out afterwards must leave no list residue ("noned" was the leak). +function ShellWrap(props: { children: any }) { + return
    {props.children}
    ; +} +let residueItems!: (v: string[]) => void; +let residueShow!: (v: boolean) => void; +function SlotThroughDemoteResidue() { + const [items, setItems] = createSignal(["a", "b", "c"]); + const [show, setShow] = createSignal(true); + residueItems = setItems; + residueShow = setShow; + return ( + + {show() ? ( + + {item => + item === "b" ? ( + +
  • {item}
  • +
    + ) : ( +
  • {item}
  • + ) + } +
    + ) : ( +

    none

    + )} +
    + ); +} + +// --------------------------------------------------------------------------- +// 13. TEXT-row mismatch, both directions: server text nodes must never +// survive beside their fresh twins (no orphan, no duplicate) — the fill +// removes every region node that isn't ours before inserting. +function SlotTextFewer() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( +
      + {item => item} +
    + ); +} +function SlotTextMore() { + const [items] = createSignal(isServer ? ["a", "b"] : ["a", "b", "c"]); + return ( +
      + {item => item} +
    + ); +} +// Anchored text rows (comment-bounded region with separators) — mismatch. +function SlotTextAnchoredFewer() { + const [items] = createSignal(isServer ? ["a", "b", "c"] : ["a", "b"]); + return ( +
      +
    • head
    • + {item => item} +
    • tail
    • +
    + ); +} + +export const forSlotScenarios: ForSlotScenario[] = [ + { + name: "slot-hydrate-text-mismatch-fewer", + App: SlotTextFewer, + expectedText: "ab", + serverText: "abc", + engaged: 1, + demoted: 0, + warnings: 0 // text churn is excluded from the repair report (follow-up: adopt server text) + }, + { + name: "slot-hydrate-text-mismatch-more", + App: SlotTextMore, + expectedText: "abc", + serverText: "ab", + engaged: 1, + demoted: 0, + warnings: 0 + }, + { + name: "slot-hydrate-text-anchored-mismatch-fewer", + App: SlotTextAnchoredFewer, + expectedText: "headabtail", + serverText: "headabctail", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li" + }, + { + name: "slot-hydrate-through-demote-residue", + App: SlotThroughDemoteResidue, + expectedText: "abc", + engaged: 1, + demoted: 1, + warnings: 0, + identitySelector: "li", + update: () => { + residueItems(["a", "b", "c", "d"]); // classic (post-demote) appends d + flush(); + residueShow(false); // children change: the hosting effect must clean d too + }, + expectedTextAfterUpdate: "none" + }, + { + name: "slot-hydrate-nested-demote", + App: SlotNestedDemote, + expectedText: "123", + // Attempt 1: outer + nested x + nested y (the Show-rooted row's
  • + // template runs its hole insert before the outer sees the function and + // demotes). Classic re-run: nested x + nested y again. All five claim + // cleanly — the restore covered every nested claim beneath the outer. + engaged: 5, + demoted: 1, + warnings: 0, + identitySelector: "span" + }, + { + name: "slot-hydrate-through-demote-mismatch", + App: SlotThroughDemoteMismatch, + expectedText: "abc", // classic parity: the claim pass leaves server leftovers + serverText: "abc", + engaged: 1, + demoted: 1, + warnings: 1, // the runtime's honest "1 unclaimed server-rendered node" report + identitySelector: "li" + }, + { + name: "slot-hydrate-through-children", + App: SlotThroughChildren, + expectedText: "abc", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li", + update: () => setThrough(["b", "c", "a"]), + expectedTextAfterUpdate: "bca", + survivorsAfterUpdate: ["a", "b", "c"] + }, + { + name: "slot-hydrate-basic", + App: SlotBasic, + expectedText: "abc", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li", + update: () => setBasic(["c", "a", "b"]), + expectedTextAfterUpdate: "cab", + survivorsAfterUpdate: ["a", "b", "c"] + }, + { + name: "slot-hydrate-text-rows", + App: SlotTextRows, + expectedText: "abc", + engaged: 1, + demoted: 0, + warnings: 0, + update: () => setText(["a", "b", "c", "d"]), + expectedTextAfterUpdate: "abcd" + }, + { + name: "slot-hydrate-mismatch-fewer", + App: SlotFewer, + expectedText: "ab", + serverText: "abc", + engaged: 1, + demoted: 0, + warnings: 1, // the slot's repair report (leftover server row removed) + identitySelector: "li" + }, + { + name: "slot-hydrate-mismatch-more", + App: SlotMore, + expectedText: "abc", + serverText: "ab", + engaged: 1, + demoted: 0, + warnings: 2 // the runtime's key-miss + the slot's repair report + }, + { + name: "slot-hydrate-demote-mid-fill", + App: SlotDemoteMidFill, + expectedText: "abc", + engaged: 1, + demoted: 1, + warnings: 0, + identitySelector: "li", + update: () => setDemote(["a", "b", "c", "d"]), + expectedTextAfterUpdate: "abcd" + }, + { + name: "slot-hydrate-empty", + App: SlotEmpty, + expectedText: "", + engaged: 1, + demoted: 0, + warnings: 0, + update: () => setEmpty(["a"]), + expectedTextAfterUpdate: "a" + }, + { + name: "slot-hydrate-trailing", + App: SlotTrailing, + expectedText: "headab", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li", + update: () => setTrailing(["b", "a"]), + expectedTextAfterUpdate: "headba", + survivorsAfterUpdate: ["head", "a", "b"] + }, + { + name: "slot-hydrate-bounded", + App: SlotBounded, + expectedText: "headabctail", + engaged: 1, + demoted: 0, + warnings: 0, + identitySelector: "li", + update: () => setBounded(["c", "b", "a"]), + expectedTextAfterUpdate: "headcbatail", + survivorsAfterUpdate: ["head", "a", "b", "c", "tail"] + }, + { + name: "slot-hydrate-trailing-mismatch-fewer", + App: SlotTrailingFewer, + expectedText: "headab", + serverText: "headabc", + engaged: 1, + demoted: 0, + warnings: 1, // the slot's repair report + identitySelector: "li" + }, + { + name: "slot-hydrate-nested", + App: SlotNested, + expectedText: "123", + engaged: 3, + demoted: 0, + warnings: 0, + identitySelector: "span", + update: () => setNested([GY, GX]), + expectedTextAfterUpdate: "312", + survivorsAfterUpdate: ["1", "2", "3"] + } +]; diff --git a/packages/web/test/hydration/for-slot.spec.tsx b/packages/web/test/hydration/for-slot.spec.tsx new file mode 100644 index 000000000..2773e8e7b --- /dev/null +++ b/packages/web/test/hydration/for-slot.spec.tsx @@ -0,0 +1,117 @@ +/** + * @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; + + const engaged0 = stats.engaged; + const demoted0 = stats.demoted; + dispose = hydrate(() => , container); + flush(); + await sleep(10); + flush(); + + expect(container.textContent, "hydrated text").toBe(scenario.expectedText); + expect(stats.engaged - engaged0, "slots engaged during hydrate").toBe(scenario.engaged); + expect(stats.demoted - demoted0, "slots demoted during hydrate").toBe(scenario.demoted); + expect(warn, "console.warn calls during hydrate").toHaveBeenCalledTimes(scenario.warnings); + + if (serverRows) { + // Every row present after hydration whose text existed on the server + // must BE the server node (claimed, not recreated). + for (const el of container.querySelectorAll(scenario.identitySelector!)) { + const server = serverRows.get(el.textContent); + if (server) expect(el, `row "${el.textContent}" is the server node`).toBe(server); + } + } + + if (scenario.update) { + const before = scenario.identitySelector + ? new Map( + [...container.querySelectorAll(scenario.identitySelector)].map(el => [ + el.textContent, + el + ]) + ) + : null; + scenario.update(); + flush(); + expect(container.textContent, "text after update").toBe(scenario.expectedTextAfterUpdate); + if (scenario.survivorsAfterUpdate && before) { + for (const text of scenario.survivorsAfterUpdate) { + const now = [...container.querySelectorAll(scenario.identitySelector!)].find( + el => el.textContent === text + ); + expect(now, `survivor "${text}" present`).toBeDefined(); + expect(now, `survivor "${text}" moved, not recreated`).toBe(before.get(text)); + } + } + // No demote may happen on the post-hydration update either. + expect(stats.demoted - demoted0).toBe(scenario.demoted); + } + } finally { + warn.mockRestore(); + dispose?.(); + await sleep(0); + container.remove(); + } +} + +describe("unified For — hydration (slot engages, claims server rows)", () => { + for (const scenario of forSlotScenarios) { + test(scenario.name, async () => { + await run(scenario); + }); + } +}); diff --git a/packages/web/test/server/hydration-harness.spec.tsx b/packages/web/test/server/hydration-harness.spec.tsx index cc67e7f74..520867660 100644 --- a/packages/web/test/server/hydration-harness.spec.tsx +++ b/packages/web/test/server/hydration-harness.spec.tsx @@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url"; import { renderToStream } from "@solidjs/web"; import type { RequestEvent, ResponseStub } from "@solidjs/web"; import { scenarios } from "../harness/scenarios.jsx"; +import { forSlotScenarios } from "../harness/for-slot-scenarios.jsx"; const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); mkdirSync(artifactsDir, { recursive: true }); @@ -88,3 +89,28 @@ describe("hydration parity harness — server render", () => { }); } }); + +// Unified For hydration scenarios (test/hydration/for-slot.spec.tsx consumes +// these artifacts). Kept out of `scenarios` because the mismatch cases +// legitimately diverge from the generic parity invariants (key-miss +// warnings, client-created rows) by design. +describe("unified For hydration scenarios — server render", () => { + for (const scenario of forSlotScenarios) { + test(scenario.name, async () => { + const { shell, rest } = await storage.run(makeEvent(), () => + collectChunks(() => ) + ); + const full = shell + rest; + const visible = full.replace(//g, "").replace(/<[^>]*>/g, ""); + for (const token of (scenario.serverText ?? scenario.expectedText) + .split(/\s+/) + .filter(Boolean)) { + expect(visible).toContain(token); + } + writeFileSync( + resolve(artifactsDir, `${scenario.name}.json`), + JSON.stringify({ name: scenario.name, shell, rest }, null, 2) + ); + }); + } +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 0c66b3c20..5551423ae 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -390,7 +390,39 @@ module.exports = [ // reaches (asciiLowerCase, qualifierValue), which shifts esbuild's // identifier allocation over the same-length output — brotli layout // drift, 31 B. Ratcheted to the next 0.01 kB per this file's rule. - limit: "10.74 KB", + + // + // Unified For slot, default-on (2026-09-04): 10.73 -> 10.89 KB, measured + // at 10.884. The floor has NO For — this is the ENGAGEMENT SEAM only: + // insert's `$for.impl` call site plus the domOps singleton (the platform + // web hands the slot). The slot algorithm itself rides For's module + // graph in solid-js and tree-shakes out of For-less apps like this one. + // (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. + limit: "10.98 KB", modifyEsbuildConfig }, { @@ -455,7 +487,35 @@ 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. + limit: "20.54 KB", modifyEsbuildConfig }, { @@ -552,7 +612,20 @@ module.exports = [ // rc.6 P1 store sweep (#3282/#3283/#3284): 26.37 -> 26.43 KB, measured // at 26.42 macOS / 26424 B Linux CI (the usual +4-7 B Linux delta) — // see the createStore note; this scenario retains all of it. - limit: "26.43 KB", + // + // Unified For slot, default-on (2026-09-04): 26.43 -> 28.64 KB — the + // slot bytes through For's module graph (see the hydrating no-stores + // note). + // + // 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. + limit: "29.40 KB", modifyEsbuildConfig }, { @@ -598,7 +671,25 @@ 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. + limit: "15.61 KB", modifyEsbuildConfig }, {