Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
62de2f5
feat(web,solid): unified For driver spike — one structure owns rows a…
ryansolid Sep 2, 2026
1c84368
perf(web): unified For batch clear — whole-parent N→0 rides one textC…
ryansolid Sep 2, 2026
f223064
perf(web): unified For row diet — mapArray's owner shape, flatten fas…
ryansolid Sep 3, 2026
3184a3c
perf(web): unified For full-replace fast path — no-survivor windows b…
ryansolid Sep 3, 2026
b52a0af
feat(web): ownerless-rows measurement flag + the create-floor finding
ryansolid Sep 3, 2026
69b8b25
perf(web): unified For lazy structure — flat first fills, materialize…
ryansolid Sep 4, 2026
73ce20d
refactor(web): unified For slot behind renderer ops — platform handed…
ryansolid Sep 4, 2026
4d7b4ce
feat(solid,web): unified For default-on — the slot rides For's module…
ryansolid Sep 4, 2026
5cb96d2
fix(solid,web): unified For P0 sweep — ownership-safe bulk clears, em…
ryansolid Sep 5, 2026
671bb98
test(web): reconcile parity matrix — slot vs live classic oracle acro…
ryansolid Sep 5, 2026
a7ed39d
feat(solid,web,signals): unified For hydration — slot claims server r…
ryansolid Sep 5, 2026
450eab5
feat(web,solid): unified For hole seam — lists passed through props.c…
ryansolid Sep 5, 2026
5617087
test(web): add @jsxImportSource pragma to unified For specs
ryansolid Sep 5, 2026
9d272df
feat(solid,web): unified For hydration — anchored holes engage
ryansolid Sep 5, 2026
53a405e
fix(solid,web): unified For hydration — nested claim recording, synch…
ryansolid Sep 5, 2026
6d0ddc2
chore(size): ratchet after rebase over #3183 and audit round 3
ryansolid Sep 5, 2026
017db37
fix(web,solid): unified For hydration — hydrating demote writes the h…
ryansolid Sep 6, 2026
6801317
fix(web): unified For direct seam hands classic the bounded region on…
ryansolid Sep 6, 2026
52f365e
test(web): pin text-row hydration mismatch — no orphaned or duplicate…
ryansolid Sep 6, 2026
0c262a4
refactor(web): unified For hole demote is synchronous in all modes; r…
ryansolid Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/unified-for-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"solid-js": patch
"@solidjs/web": patch
"@solidjs/signals": patch
---

Unified For: keyed `<For>` 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.
18 changes: 16 additions & 2 deletions packages/signals/src/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,17 @@ export function mapArray<Item, MappedItem>(
| ((value: Item, index: Accessor<number>) => MappedItem)
| ((value: Accessor<Item>, index: number) => MappedItem)
| ((value: Accessor<Item>, index: Accessor<number>) => MappedItem),
options?: { keyed?: boolean | ((item: Item) => any); fallback?: Accessor<any>; name?: string }
options?: {
keyed?: boolean | ((item: Item) => any);
fallback?: Accessor<any>;
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<MappedItem[]> {
const keyFn = typeof options?.keyed === "function" ? options.keyed : undefined;
const indexes = map.length > 1;
Expand Down Expand Up @@ -96,7 +106,10 @@ export function mapArray<Item, MappedItem>(
_byIndex: options?.keyed === false,
_fallback: options?.fallback
};
const node = computed(updateKeyedMap.bind(data as MapData<unknown, unknown>));
const node = computed(
updateKeyedMap.bind(data as MapData<unknown, unknown>),
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;
Expand All @@ -105,6 +118,7 @@ export function mapArray<Item, MappedItem>(
}

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
Expand Down
38 changes: 37 additions & 1 deletion packages/solid/src/client/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,8 +104,43 @@ export function For<T extends readonly any[], U extends SolidElement>(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;
}

Expand Down
154 changes: 154 additions & 0 deletions packages/solid/src/client/for-slot-hydration.ts
Original file line number Diff line number Diff line change
@@ -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<T>(slot: Slot, fn: () => T): T {
const reg = sharedConfig.registry as Map<string, Element> | 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<string, Element>, 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<string, Element> | 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<Node>();
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 <For>: 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);
}
Loading
Loading