feat: unified For is the keyed-For engine — all modes, no seam, mapArray as the oracle - #3308
feat: unified For is the keyed-For engine — all modes, no seam, mapArray as the oracle#3308ryansolid wants to merge 33 commits into
Conversation
🦋 Changeset detectedLatest commit: 19b3caa The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
…nership, lifecycle, one engine, parity) - P1-1 Reentrancy: buildParts returns the row OWNER and writes its result slots (nodes / raw value / dynamic value) only AFTER user code; the node layer's `dyn` likewise. A <For> nested in a row engages synchronously and used to clobber the outer row's owner (pinned: removing an outer row disposes exactly that row + its nested rows). - P1-2 Key-fn parity: identity-first comparison (`row.item === next || key(row) === key(next)`), keys computed LAZILY on the first diff that needs them — no key fn runs during a fill (mapArray's timing); an in-place key mutation keeps its row. Rows carry `item`. - P1-3 The engine dies with the list's CREATION owner (cleanup registered there — not on the list owner, whose cleanups run on bulk dispose(false)): a rendered list whose <For> owner was disposed freezes at its last value. - P1-4 ONE engine per list: `$for.rendered` / `$for.arr`. Rendered first → a plain call reads that engine's tracked array view (version signal bumped per commit); called first → the renderer inserts the array output the classic way (impl returns false). Rows are never built twice. - P1-5 Retained-row reclaim: a chain-wide liveness sweep on structural commits re-places rows whose node user code migrated (classic's reconcile skips only LIVE common nodes). Costs ~20 µs per structural op at 1k rows (removefirst 0.015 → 0.035 ms; still 2-4x ahead of classic) — flagged. - P1-6 Host tagging: `SlotOps.placed` hook; web's host-aware inserts use per-host ops that tag `_$host`. A caller-provided non-hydrating initial range is consumed before engagement (web + universal). - P1-7 Chain-mode hydration (keyed:false, fallback) runs the same positional adoption commit as flat fills; adopted nodes skip placement. Fallback placement skips adopted nodes. - P1-8 Ids: For consumes both server slots and hands them to the engine — row owner (hid) and the array computed (hid2) — no fresh slot burned. - P1-9 Universal: `RendererOptions.isNode` (default: non-array object); text-data tracking guards non-object nodes. - P2 Fallback called with zero arguments; hydration text adoption no longer writes data (server text stands; live edits survive). Tests: audit spec gains one test per finding (nested ownership, key timing + in-place mutation vs the reference, creation-owner freeze, both one-engine orders, migrated-row reclaim vs the reference, host tagging, initial range, fallback arity); hydration scenarios for keyed:false text rows, primitive fallback, children()-introspected For + siblings, differing text. web 819, server 783, hydrate 191, solid 585, universal 47, signals 1503. Size: For app CSR +460 B (cumulative vs next +4.34 KB), floor +44 B. Co-authored-by: Cursor <cursoragent@cursor.com>
|
…nd placement
The $for seam: keyed For returns a callable carrying { each, row, keyed };
an armed insert offers it to the driver, which keeps an intrusive row chain
+ incremental key map per list and updates via prefix/suffix/LIS in a
two-phase render effect (compute diffs + builds detached rows; effect is
the only writer of chain and live DOM — holds can never half-apply, H1).
Engaged-path parity pinned by for.unified.spec (permutation matrix,
fragments, multi-slot, demotes) and a classic H1 probe twin; web 683 and
solid 580 green.
Co-authored-by: Cursor <cursoragent@cursor.com>
…ontent write Co-authored-by: Cursor <cursoragent@cursor.com>
…t path, zero-Set passes Per-row createOwner + runWithOwner (untracked+owned) replaces the createRoot closure protocol; compiled single-root rows skip flatten via a nodeType fast path; per-pass Sets become row flags (mv) + a generation stamp; LIS scratch is module-reused; the slot owner chains rows under the insert context (auto-teardown, batch clear = one dispose(false)). Tear fix the diet exposed: the middle window must be read TRACKED before entering the owner wrapper — untracked store reads resolve committed backing mid-flush while length already reports the pending write (row built for undefined). Selection/structural probe suite pins it. jfb: main suite at parity; reorder matrix flips the creation ops from 0.6-0.9x to 1.5-2.3x faster (prepend100 2.33x, append100 1.66x). Co-authored-by: Cursor <cursoragent@cursor.com>
…ulk-detach like clear Co-authored-by: Cursor <cursoragent@cursor.com>
Owner tax measured at ~5% of 10k creation; removing it reaches classic parity, not victory — all list architectures share the same floor (clone + one grouped effect + store target). Create wins are core-signals work. Co-authored-by: Cursor <cursoragent@cursor.com>
… on first partial structural op Flat mode: fills build owners+DOM into parallel arrays (no Rows/chain/map); aligned passes return IDENTICAL on an array walk; clears and no-survivor replaces swap the flat window wholesale; a PARTIAL structural op materializes the chain once (phase-safe: pure bookkeeping over committed state), amortized into the op the chain's 1.5-3.6x wins then repay. Kills the mount-regression blocker: armed jfb-signal run 2.1 / runlots 18.1 = classic parity (was +40% eager), swap 0.5 retained, battery geomean 0.638 clean, all semantic gates green, web 686 green. Co-authored-by: Cursor <cursoragent@cursor.com>
… in by the engaging insert One SlotOps singleton per renderer (web: domOps), threaded through Slot and the row builders. Interleaved A/B on frozen dists: mount 13.6/13.6, tick 5.4/5.4, tick_partial 1.3/1.3 — the indirection is free (monomorphic sites). Groundwork for the module-graph landing: the slot rides For's own import, insert supplies the platform, no registration API, no compiler emission. Co-authored-by: Cursor <cursoragent@cursor.com>
… graph The slot moves to solid-js client (packages/solid/src/client/for-slot.ts) and travels on $for.impl; web's insert engages it with its domOps singleton. Registration API (enableUnifiedFor/setListDriver) deleted; measurement-only ownerless-rows flag dropped. Every keyed <For> in the web corpus now runs the slot: web 696 / solid 585 / signals 1469 / universal 43 / element 10 / html 192 green. Size: signals+frames flat, floor +153 B (seam + ops), For scenarios +2.1-2.2 KB (the deliberate default-on bill), budgets ratcheted. Co-authored-by: Cursor <cursoragent@cursor.com>
…pty-row placeholders, throw-safe builds External audit fixes: - P0: marker tri-state preserved through the seam (undefined = whole parent, null = trailing MULTI child) and every bulk-clear path gated on classic's ownsAllChildren ruling — preceding siblings and streamed foreign nodes survive clear/replace/batch-clear/demote (regression suite covers all four paths plus foreign-node survival). - Empty-rendering rows (null/boolean/empty) hold position with a placeholder text node instead of demoting — sibling DOM state (typed inputs) survives. - Row fns that throw mid-pass dispose the rows built so far (they chain to the persistent slot owner) before the error rides the boundary. - __unifiedForStats increments are IS_DEV-gated (frozen in prod). - Four spike-history changesets consolidated into one describing the shipped behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
…ss shapes and anchors The classic for.spec transition families plus jfb-style moves (31 transitions), run through BOTH implementations: the slot (arity-1 keyed rows) and forced classic (arity-2 rows decline the $for stamp — same semantics through keyed mapArray + reconcileArrays, a live oracle). Each mode covers three row shapes (text / element / static fragment) in three container anchors (whole parent / trailing null marker / bounded element marker), with engagement and zero-demotion asserted for the slot. A differential section renders both modes off one signal through a cumulative no-reset sequence and asserts DOM equality after every step. Co-authored-by: Cursor <cursoragent@cursor.com>
…ows with id parity, reversible mid-fill demote Whole-parent keyed lists now ENGAGE during hydration instead of running classic for life: - Id parity: For peeks the id classic's mapArray owner would spend (sharedConfig.peekNextContextId, installed by enableHydration) and the slot creates its row parent with that explicit id — rows mint identical hydration keys. Proven against real server artifacts (for-then-siblings). - mapArray gains an @internal lazy option: For sets it under hydration so the eager classic pass no longer claims rows first; the owner (id slot) is still created eagerly (#3161 preserved). Classic readers claim on first read with identical ids. - Claims are RECORDED during the hydrating fill (registry delete shadowed); a demote mid-fill hands them back so classic's re-run claims the same nodes — never a stranded claim. - Fill commit is a claim pass: zero DOM writes unless mismatch (leftover server rows removed, key-missed fresh rows inserted in order). - All hydration behavior lives in for-slot-hydration.ts, installed by enableHydration(): CSR bundles shake it (CSR 15.68 -> 15.48 KB; floor 10.89 -> 10.85 after dropping For's direct id-formatter import). Tests: 8 server->client hydration scenarios via the real harness (basic reorder with server-node identity, text rows, mismatch both directions with exact warning counts, demote mid-fill with zero warnings, empty, trailing hole staying classic, nested engagement). web 728 / server 749 / hydrate 173 / solid 585 / signals 1490 / universal 43. Co-authored-by: Cursor <cursoragent@cursor.com>
…hildren engage
A $for accessor reaching insert THROUGH a wrapper (`{props.children}` in a
parent component compiles to insert(el, () => props.children)) now engages
the slot for that hole, whole-parent and bounded alike. The slot is created
inside the hosting effect's compute, so a children change tears it down
(hole-mode cleanup removes its rows; existing classic content is cleaned
via cleanChildren first, keeping insert's multi placeholder invariant). A
post-engage demote can't spawn a second insert into a hole the outer effect
owns — it flips holeClassic and bumps a lazily-created per-hole signal so
the hosting effect re-runs on its classic path. children() introspection
and fragment children stay classic. Hydration through a wrapper engages
too (region = the claimed range; active-hydration guard on the hand-off
clean).
Tests: for.unified.children.spec (6: whole/bounded holes, dynamic children
swap + re-engage, demote-in-hole handoff, children() classic, fragment
classic) + slot-hydrate-through-children harness scenario. web 734 /
server 750 / hydrate 174 / solid 585 / signals 1490 / universal 43. Floor
+~110 B (seam lives in insert), app scenarios +67-147 B, budgets noted.
Co-authored-by: Cursor <cursoragent@cursor.com>
Three of the unified For specs (children, reconcile-parity, siblings) were missing the `@jsxImportSource @solidjs/web` pragma the rest of the suite carries, so test-types failed with TS7026 (no JSX.IntrinsicElements) and the cascading For-typing errors. Tests only; no source change. Co-authored-by: Cursor <cursoragent@cursor.com>
The hydrating client resolves anchored holes (trailing / bounded) to their <!--/--> end-marker NODE via getNextMarker, with the comment-bounded region as insert's initial — so the region is well-defined and a null marker never occurs under hydration. The hooks now engage for Node markers too: fresh rows anchor at the hole's end marker; hydrationRt.slotRegion hands the slot the region minus comment markers (<!--$--> stays, as classic leaves it — reclaimRegion walks back to it). The seam's region hand-off is guarded on an ACTIVE hydration of the parent (post-hydration dynamic changes clean the hole as before). Scenarios: trailing (now engages, sibling survives reorder), bounded (siblings both sides), anchored-hole mismatch (leftover removed inside the hole only). web 734 / server 752 / hydrate 176 / solid 585 / signals 1490 / universal 43. Budgets: floor +22 B (guard), hydrating +29, store +153. Co-authored-by: Cursor <cursoragent@cursor.com>
…ronous hole demote, DEV.unifiedFor Audit round 3: - P1: one module-level recording STACK replaces per-slot registry shadows. The outermost record() installs the shadow once, every active log receives every deletion, an inner commitFill drops only its own log, and an outer restore() hands back everything claimed beneath it — including nested slots' committed claims, which classic's re-engaged nested lists mint again with the same ids. (Per-slot shadows broke both ways: the inner finally tore down the outer's shadow; committed inner claims were in no log.) Scenario: nested + Show-rooted later row → 5 engagements, 1 demote, zero warnings, all spans server nodes. - P2: the hole seam keeps the claimed region as the hosting effect's `current` under hydration, and a demote DURING a hydrating fill re-enters classic synchronously inside the hydration window (the deferred re-run landed after hydrate() flipped the flag and cloned). holeGen is ownedWrite (the bump may fire inside an owned scope). Scenario: through-children + Show-rooted row + server mismatch → rows are server nodes; the leftover survives with the runtime's unclaimed-node report — classic parity (the claim pass never removes leftovers), pinned as such. - __unifiedForStats is no longer a package export: counters ride DEV.unifiedFor (solid-js's dev diagnostics bag, undefined in prod). - Changeset qualifies the tree-shaking claim: the algorithm shakes; ~0.3 KB of engagement seam in insert is retained by every web bundle. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…osting effect's range; dev mismatch report
Audit r3 follow-up (P2 residue): the hydrating demote re-entered classic via
a NESTED insert, which owned a private `current` — the hosting effect's
range stayed frozen at the claimed region (holeClassic, no bump, never
re-ran), and classic insert removes nothing on dispose, so rows classic
appended after the demote survived a later children change ('noned').
The hosting effect's classic inner-effect block is now a shared local
`classic(value, prev)`; the hydrating demote calls it under the hosting
owner, so classic writes THIS insert's `current` and a children change
cleans everything. Scenario: through-children + Show-rooted row → demote,
append, swap children → 'none'.
Design note taken: commitFill emits one dev warning when it repairs a
server/client mismatch (element rows removed/inserted) — the slot is
stricter than classic's claim pass and must not be silent about it.
Text-row churn (fresh text swapping in for server text) is excluded;
adopting server text nodes for primitive rows is the follow-up.
Scenario warning counts updated (mismatch-fewer 1, mismatch-more 2,
trailing-mismatch-fewer 1).
Co-authored-by: Cursor <cursoragent@cursor.com>
… a hydrating demote Audit r3 minor asymmetry: the direct path's lateClassic passed initial=[] for anchored holes, discarding the bounded region the slot had, so a primitive row in a demoting heterogeneous list had no positional text node to adopt. Now region ?? [] — same as the hole seam. Whole-parent still re-derives via claimInitial. (+2-7 B brotli, ratcheted.) Co-authored-by: Cursor <cursoragent@cursor.com>
…d server text Three harness scenarios (whole-parent fewer/more, anchored fewer): the fill removes every region node that isn't ours before inserting fresh text, so server text never survives beside its fresh twin. Exact textContent asserted (a surviving node would add characters). Co-authored-by: Cursor <cursoragent@cursor.com>
…ebase over the #3187 revert The #3187 revert (eager Dynamic creation, #3291) removed insert's insertion-parent tracking; the hole seam and the shared classic() helper drop their withInsertionParent wrappers accordingly. Checking the slot against the revert surfaced a real simplification: the CSR hole demote deferred the classic re-run through a lazily-created signal, which left the hole EMPTY for a microtask (render() then a sync querySelector saw no rows — classic shows them synchronously). The hydration path already re-entered classic synchronously via the shared classic() effect, and nothing about that required hydration: the slot's demote() has removed its rows and disposed its owner, holeClassic steers a later children-change re-run, and the synchronous classic effect writes the shared `current`. One path now for CSR and hydration — no holeGen, no ownedWrite signal, no empty-hole microtask. Dynamic-rooted rows: pinned that they demote cleanly (Dynamic returns a memo, so the row's top level is a function regardless of eager/deferred element creation) with the DOM correct synchronously after render(). Budgets locked in DOWN: floor 11.07 -> 10.98, CSR 15.68 -> 15.61, hydrating 20.60 -> 20.54, store 29.48 -> 29.40. web 760 / server 758 / hydrate 182 / solid 585 / signals 1490 / universal 43. Co-authored-by: Cursor <cursoragent@cursor.com>
…s first fill P0 (audit r4): the slot's first fill runs synchronously inside impl() (a render effect runs compute+effect at creation). When that fill demotes — any row whose top level is a memo/function: Dynamic, Show, Switch, most components — lateClassic() re-enters classic synchronously BEFORE impl returns, i.e. before the hosting effect had pointed `current` at the hand-off range. Classic's first run therefore reconciled against the stale pre-hand-off nodes and its result was then clobbered by the post-assign: rows leaked on the next replace or children change (a b x y) and the multi placeholder survived as an orphan. The decline path shared the gap. `current = keep` now precedes impl(); the post-assign is gone. Classic's first run sees the hand-off range and owns `current` from there. Regression: bounded and whole-parent holes with Dynamic rows — replace, children change both ways, childNodes count (no orphan), holeClassic sticks. web 761 / hydrate 182 / server 758 / solid 585. Fix authored by the audit pass (staged in the worktree), verified here. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…gment runs (browser ruling) Records the 2026-09-06 investigation: the CodSpeed shuffle flag (-10.8%) is jsdom's per-insert anchor-index walk over the slot's ~940 LIS-minimal mid-list inserts (both arms detach the same node count; classic's udomdiff pattern is appends + replaceChild). Fragment-batching runs recovered jsdom to parity but moved every live row twice — Chrome reverse 0.58 -> 0.75, shuffle 0.65 -> 0.78, confirmed both A/B orders — so it was reverted. Ruling: prioritize browsers; the jsdom bench reads behind by construction. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…c rows, anchors, guards, hydration text) Responds to the 2026-09-07 external audit of #3281 (8 P1 / 4 P2). P1-2 (the substantive one): rows whose top level resolves to a function — a component returning <Show>/<Dynamic>/a conditional, a memo, a fragment with accessor leaves — no longer demote. They are DYNAMIC rows: built once (owned, untracked) and resolved by the slot's own compute, tracked — the same `flatten` read classic's insert effect performs for such rows, moved into the slot. No per-row effect, no marker nodes (mount cost: one field), no shape demotion, so user row code never runs twice and a late dynamic row never remounts its siblings. A flip splices only that row's range, reusing positional text nodes with a `.data` write. NotReady from a row's resolution parks the built plan; the retry reuses the rows (mapArray parity — rebuilding would re-fire per-row async and never settle). P1-1: slot owner under For's creation owner (`$for.owner`), where mapArray's internal owner lives; the insert cleanup disposes it explicitly. P1-3: list end anchor is the committed tail's nextSibling while it is still ours (classic contiguity) — appends stay before a foreign trailing node. P1-4: removes are parent-guarded; migrated row nodes are left alone. P1-5: buildParts disposes the row owner when the row fn throws. P1-6: the hydrating fill commit adopts positional server text nodes for primitive rows (node identity survives; zero-write). P1-7 is moot: with no shape demote, nothing can demote MID-fill (duplicates are only detectable on a later partial op; a non-array subject demotes before any row is built) — the claim record/restore stack is deleted. P1-8 disputed: `map.length > 1` is mapArray's own index-arity contract. P2-1: fresh-key duplicates are caught via the existing probe map (-1). P2-4: the slot's node type is opaque (`SlotNode = object`) — core declares no DOM types; the one direct DOM read moved behind `ops.owns`. Tests: for.unified.audit.spec (one per finding, classic as oracle); the former demote-mid-fill hydration scenarios become dynamic-row scenarios (engaged, zero demotes, server nodes); text-row hydration pins text-node identity; text mismatches now report the repair honestly. Size: +~850 B on For-bearing bundles (structural: resolution, parked-plan reuse, anchors, guards), +61 B floor (four domOps methods). Chrome A/B interleaved pre/post on jfb-signal and jfb-deep reorder: parity. Co-authored-by: Cursor <cursoragent@cursor.com>
…web — all modes, no seam Restructures the slot from an opportunistic fast path (engage / decline / demote / late-classic re-entry) into the For implementation for web. The audits' bug class was the seam between two engines; there is no seam now. Engine (for-slot.ts): - Every For mode on the chain: identity keys (default), keyed:false (positional reuse, item accessor + plain index, tail append/remove), keyed:fn (key function, item accessor + index accessor), index accessors by row arity (mapArray's own `length > 1` rule), duplicates (per-pass chained key map — mapArray's newIndicesNext pairing; the persistent slot.map is gone), fallback (an owned empty-state row), array-likes (duck-typed like mapArray). Reused rows write item/index signals exactly where mapArray does (prefix, suffix with dif, middle window). - Empty rows render ZERO nodes (neighbor-anchored) — no placeholder. - Flat mode kept, gated to identity arity-1 rows (measured 2026-09-07: +10% on 10k create/clear with the key map, +4-7% without, parity at 1k; chain-only would regress vs classic at 10k). Modes are implemented once. - Fill paths snapshot items TRACKED before entering the owner wrapper (a store index write must re-run the compute — caught by the nonkeyed store suite). - Fallback row built under the slot owner (context + hydration id chain — caught by a mapArray oracle hydration scenario: the fallback duplicated). - Chain-mode hydrating fills treat claimed nodes as placed; any commit ends the hydrating state. For: stamps `$for` unconditionally (fallback carried). Web insert: engages unconditionally; decline/demote/holeClassic/lateClassic deleted; the hole hand-off keeps no placeholder (the engine anchors on the marker). Hydration hooks: engage never declines (no region → CSR fill). Signals: `signal`/`setSignal`/`accessor` exported @internal for the engine. Tests: reconcile-parity matrix now runs engine / engine-index / mapArray ORACLE (mapArray + insert directly, no <For>); new modes spec with the oracle beside every mode (identity+index, keyed:false, keyed:fn incl. duplicate pairing order, fallback incl. dynamic fallback); demote-contract tests rewritten to the engine contract (duplicates render, strings iterate); hydration scenarios for keyfn / indexed / byindex / fallback plus a classic fallback oracle. web 793, server 779, hydrate 187, solid 585, signals 1503. Size, CUMULATIVE vs next: CSR app +4.10 KB (was +3.58), hydrating +4.56 (was +3.92), floor +236 B (was +308). Seam -0.4 KB, modes/fallback/ duplicates +0.9 KB. Co-authored-by: Cursor <cursoragent@cursor.com>
…ation mismatch, oracle fuzz harness
- Flat mode now covers identity AND key-fn rows, with or without an index
accessor: item/index signals ride parallel arrays (its/ixs) and carry
over at materialize; keys are computed at materialize (a first fill never
needs them); the survivor probe compares KEYS (a key-fn list re-minting
its objects keeps every row — caught by the oracle fuzz). Only
keyed:false is chain-first. Closes the jfb-shallow (keyed={r => r.id})
10k create/clear regression: runlots 17.0/17.1 vs classic 17.9/17.3,
clear 23.9/24.5 vs 24.1/23.7 (was +13%).
- buildParts returns nodes directly (owner/dynamic value through module
slots) and the owned row call uses one shared thunk — no per-row tuple
or closure on a 10k create.
- Hydration mismatch: DETECT, don't recover (ruling 2026-09-07). The fill
commit performs no DOM writes: server text nodes are adopted for
primitive rows, leftover server rows stay, key-missed client rows land
on the next update (classic parity). The runtime already reports
unclaimed elements and key misses; the engine warns once for the one
blind spot, text rows.
- Oracle fuzz harness (for.unified.oracle.spec): 4 modes × 3 seeds × 220
steps off one signal, mapArray + insert as the specification — DOM,
per-key node retention, row-fn invocation counts, cleanup SET per step
(ruling: disposal ORDER among rows removed in one step is not a For
contract; mapArray's own order is an internal artifact).
Suites: web 805, server 779, hydrate 187, solid 585, signals 1503.
Browser (interleaved): jfb-signal creates parity; jfb-shallow creates
parity; dbmon-deep tick engine-faster, mount/tick_partial parity.
Size cumulative vs next: CSR +4.28 KB, hydrating +4.52, floor +229 B.
Co-authored-by: Cursor <cursoragent@cursor.com>
…enderer-built SlotOps insert() engages a `$for` list (direct and through a children hole) with ops built from the renderer's own primitives — no new RendererOptions: a node is any non-array object (insertExpression's own assumption) and text data is remembered for the text nodes the engine creates (textOf/setText). tag() is a no-op (universal reconcile has no slot markers); owns() walks getNextSibling for the last child. Tests: For through the custom renderer — reorders (node identity), every mode + fallback against mapArray through the same renderer, and the children-hole seam (engage, update, teardown on children change, re-engage). universal 46. Co-authored-by: Cursor <cursoragent@cursor.com>
…s array output; mapArray leaves For The engine gains ARRAY output: `unifiedForArray(meta)` is a memo over the same row core (ops === null), committing inline like mapArray (rows created and disposed in the compute) and returning the raw row values — same values, same array identity while structurally unchanged, `[fallback]` when empty. `For` no longer imports mapArray: a plain call of the For accessor (children(), introspection, renderers that don't engage) runs the engine. mapArray stays the public primitive and the oracle. Hydration id parity: the server's mapArray spends TWO id slots at For's position (internal owner, then computed); For now consumes both via sharedConfig.getNextContextId and hands the first to the engine's row owner — the eager lazy-mapArray creation is gone (caught by the #3161 for-then-siblings scenario when I consumed only one). Refactor: engine() returns compute/commit/values/teardown; unifiedForSlot (DOM) wires effect + onCleanup, unifiedForArray wires createMemo. ops and parent are nullable; every node touch is guarded or asserted DOM-only. Row carries `v` (the raw row result). Tests: oracle spec gains an array-output section (4 modes × 60 steps: values, identity stability on non-structural re-reads, invocation counts; fallback → [fallback]). web 810, server 779, hydrate 187, solid 585, universal 46, signals 1503. Size cumulative vs next: CSR +3.62 KB (was +4.28), hydrating +3.86 (was +4.52), floor +223 B — mapArray shakes out of For-bearing bundles. Co-authored-by: Cursor <cursoragent@cursor.com>
…ne's array output; solid-js keeps the node layer The row core moves into @solidjs/signals (list.ts). `mapArray` is now a thin wrapper over its ARRAY output (same API and contract: values, array identity while structurally unchanged, [fallback] when empty, dev strict- read name, `_parentComputed` routing so untracked store reads in row bodies see pending writes). `<For>`'s plain call returns the same. The old mapArray body is deleted from the shipped package and frozen as a TEST REFERENCE (web/test/reference/mapArray.ts, on public API) — the oracle every engine harness now compares against. The RENDERED output drives the engine through a ListNodeLayer — the only code that touches nodes. solid-js (client/for-slot.ts) builds that layer over a renderer's SlotOps (value→nodes, dynamic-row resolve/same/splice, place/detach/anchors/bulk clear/hydration commit) and wires the render effect. Web (domOps) and universal (createRenderer primitives) both consume the same layer — universal's oracle test extended with dynamic rows as the second-renderer proof. When the layer is null (array output) nothing node-related is referenced. Two bugs the reference oracle caught in the move itself, both fixed: - listArray created its computed under the READER (a <For> builds array output lazily on first read) → disposed with the reader's next run. Created under the list's creation owner now. - Routing row owners' _parentComputed through the RENDERED compute gave row-created memos (dynamic rows) a height above the compute → height inversion, double compute per flush on a dynamic flip + list change, rebuilding the pass's fresh rows. Routing is array-output only. Suites: web 810, server 779, hydrate 187, solid 585, universal 47, signals 1503 (its 35 mapArray tests now run against the engine). Chrome (interleaved, pre-move vs moved): reorder and creates identical. Size (brotli, vs next): For-only app +3.81 KB (layer boundary ~+220 B); For + mapArray app +3.87 KB (was +4.38: no second list implementation); mapArray without For +3.69 KB (was +0.85 — the rendered-only control flow shares the engine closure; pluggable flat/LIS/dynamic/placement is the follow-up if that consumer shape matters). Co-authored-by: Cursor <cursoragent@cursor.com>
…nership, lifecycle, one engine, parity) - P1-1 Reentrancy: buildParts returns the row OWNER and writes its result slots (nodes / raw value / dynamic value) only AFTER user code; the node layer's `dyn` likewise. A <For> nested in a row engages synchronously and used to clobber the outer row's owner (pinned: removing an outer row disposes exactly that row + its nested rows). - P1-2 Key-fn parity: identity-first comparison (`row.item === next || key(row) === key(next)`), keys computed LAZILY on the first diff that needs them — no key fn runs during a fill (mapArray's timing); an in-place key mutation keeps its row. Rows carry `item`. - P1-3 The engine dies with the list's CREATION owner (cleanup registered there — not on the list owner, whose cleanups run on bulk dispose(false)): a rendered list whose <For> owner was disposed freezes at its last value. - P1-4 ONE engine per list: `$for.rendered` / `$for.arr`. Rendered first → a plain call reads that engine's tracked array view (version signal bumped per commit); called first → the renderer inserts the array output the classic way (impl returns false). Rows are never built twice. - P1-5 Retained-row reclaim: a chain-wide liveness sweep on structural commits re-places rows whose node user code migrated (classic's reconcile skips only LIVE common nodes). Costs ~20 µs per structural op at 1k rows (removefirst 0.015 → 0.035 ms; still 2-4x ahead of classic) — flagged. - P1-6 Host tagging: `SlotOps.placed` hook; web's host-aware inserts use per-host ops that tag `_$host`. A caller-provided non-hydrating initial range is consumed before engagement (web + universal). - P1-7 Chain-mode hydration (keyed:false, fallback) runs the same positional adoption commit as flat fills; adopted nodes skip placement. Fallback placement skips adopted nodes. - P1-8 Ids: For consumes both server slots and hands them to the engine — row owner (hid) and the array computed (hid2) — no fresh slot burned. - P1-9 Universal: `RendererOptions.isNode` (default: non-array object); text-data tracking guards non-object nodes. - P2 Fallback called with zero arguments; hydration text adoption no longer writes data (server text stands; live edits survive). Tests: audit spec gains one test per finding (nested ownership, key timing + in-place mutation vs the reference, creation-owner freeze, both one-engine orders, migrated-row reclaim vs the reference, host tagging, initial range, fallback arity); hydration scenarios for keyed:false text rows, primitive fallback, children()-introspected For + siblings, differing text. web 819, server 783, hydrate 191, solid 585, universal 47, signals 1503. Size: For app CSR +460 B (cumulative vs next +4.34 KB), floor +44 B. Co-authored-by: Cursor <cursoragent@cursor.com>
… (mapArray's listChurn, both outputs) + dev name on the array computed; rebase drift Ports the two things next added to mapArray after the engine took its place: the dev `name` on the list computed, and `attrHooks.listChurn` (UNSTABLE_LIST_IDENTITY census) — now emitted by the engine for chain plans (rows exited AND entered in one pass) and flat full replaces, for both the array and rendered outputs (slot.node captures the running computation in dev). attribution-list-identity suite: 5/5 against the engine. Co-authored-by: Cursor <cursoragent@cursor.com>
6a91410 to
ccc1e18
Compare
…b JSX source, reference oracle typings) CI's test-types step type-checks the spec files: three specs used the solid-js JSX import source (no DOM IntrinsicElements) and the reference oracle's public-API port needed casts. pnpm test: 33/33 locally. Co-authored-by: Cursor <cursoragent@cursor.com>
Merging this PR will regress 1 benchmark
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | shuffle: 1000 rows (Fisher-Yates) |
99.2 ms | 108.8 ms | -8.78% |
| ⚡ | reverse: 1000 rows |
186 ms | 99.7 ms | +86.69% |
| ⚡ | dynamic-tag mount+clear 1000 rows: compiled |
223.7 ms | 164.8 ms | +35.7% |
| ⚡ | dynamic-tag mount+clear 1000 rows: Dynamic |
255.6 ms | 195.4 ms | +30.81% |
| ⚡ | dynamic-tag mount+clear 1000 rows: dynamic |
247.1 ms | 190.5 ms | +29.73% |
| ⚡ | mount-clear-cycle: 1000 rows |
335.5 ms | 273.4 ms | +22.73% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing unified-for-engine (19b3caa) with next (94fe5b4)
Coverage Report for CI Build 34218356263Coverage decreased (-10.5%) to 61.382%Details
Uncovered Changes
Coverage Regressions3 previously-covered lines in 1 file lost coverage.
Coverage Stats
💛 - Coveralls |
Successor to #3281, restructured per the 2026-09-07 audit findings and the plan discussed with @ryansolid. #3281 stays open as the record/comparison.
What changed in shape
#3281 built the slot as an opportunistic fast path: engage when the row shape looked right, decline or demote to classic otherwise. Every non-algorithm finding across three external audits lived in that seam — ownership, demote hand-offs, hydration claim restore, dynamic rows. This branch removes the seam: the slot is the
Forimplementation for web (and universal). There is no engage/decline/demote, nolateClassic, no hole re-entry.Forstamps$forunconditionally;insertengages unconditionally.That requires the engine to own every
Formode itself, and it does — with mapArray as the specification:keyeddefault (reference identity),keyed={false}(positional reuse, item accessor + plain index, tail append/remove),keyed={fn}(item accessor + index accessor), index accessors by row arity (mapArray's ownlength > 1rule)newIndicesNext), so the second occurrence of a key reuses the second old row — the persistent key map is gone with itfallbackas an owned empty-state row; array-like subjects duck-typed like mapArrayFor's creation owner; contiguous list end past foreign trailing nodes; parent-guarded removes; throw-safe buildsForstill returns a callable that runs mapArray forchildren(), introspection, and renderers that don't engage, so mapArray stays inFor-bearing bundles; it is the public primitive and the spec.Flat mode (parallel-array first fills, chain materializes lazily) was measured rather than assumed: +5–10% on 10k create/clear without it, parity at 1k, so it stays — and it now covers every keyed mode (item/index signals ride the parallel arrays; keys are computed at materialize; survivors are judged by key). Only
keyed={false}is chain-first.Hydration mismatch — detect, don't recover (ruling): the fill commit performs no DOM writes. Server text nodes are adopted for primitive rows; leftover server rows stay; key-missed client rows land on the next update (classic parity). The runtime already reports unclaimed elements and key misses; the engine warns once for the one blind spot (text rows).
Universal:
createRenderer'sinsertengages$forwith ops built from the renderer's own primitives — no newRendererOptions.Verification
for.unified.oracle.spec): 4 modes × 3 seeds × 220 steps off one signal,mapArray+insertdirectly on the other side (no<For>), mixed row shapes (element / zero-node / fragment / dynamic<Show>that flips mid-run), ops incl. shuffles, duplicates, clears, replaces, same-key re-mints. Compared after every step: DOM, per-key node retention, row-fn invocation counts, cleanup set. Ruling recorded in the harness: disposal order among rows removed in one step is not a For contract (mapArray's own order is an internal artifact).for.spec,for.nonkeyed.store.specetc. now run through the engine.Size, cumulative vs
next(brotli)For reference, #3281 was +3.58 / +3.92 KB; the seam removal bought ~0.4 KB back and the modes/fallback/duplicates cost ~0.9 KB. Every ratchet note in
.size-limit.jsnow states the cumulative figure.Performance (Chrome, interleaved A/B on the flagged cells; sequential battery for the rest)
Follow-ups
Fordrop mapArray forchildren()/universal — ~200–300 B, decide after this lands.— Claude via Cursor
Made with Cursor