Skip to content

feat: unified For is the keyed-For engine — all modes, no seam, mapArray as the oracle - #3308

Open
ryansolid wants to merge 33 commits into
nextfrom
unified-for-engine
Open

feat: unified For is the keyed-For engine — all modes, no seam, mapArray as the oracle#3308
ryansolid wants to merge 33 commits into
nextfrom
unified-for-engine

Conversation

@ryansolid

Copy link
Copy Markdown
Member

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 For implementation for web (and universal). There is no engage/decline/demote, no lateClassic, no hole re-entry. For stamps $for unconditionally; insert engages unconditionally.

That requires the engine to own every For mode itself, and it does — with mapArray as the specification:

  • keyed default (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 own length > 1 rule)
  • duplicates are legal: mapArray's chained pairing (newIndicesNext), so the second occurrence of a key reuses the second old row — the persistent key map is gone with it
  • fallback as an owned empty-state row; array-like subjects duck-typed like mapArray
  • empty rows render zero nodes (neighbor-anchored) — no placeholder
  • rows live under For's creation owner; contiguous list end past foreign trailing nodes; parent-guarded removes; throw-safe builds
  • dynamic rows (function top level) resolved tracked by the engine's compute — classic's list-effect model — with NotReady-parked plan reuse

For still returns a callable that runs mapArray for children(), introspection, and renderers that don't engage, so mapArray stays in For-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's insert engages $for with ops built from the renderer's own primitives — no new RendererOptions.

Verification

  • Oracle harness (for.unified.oracle.spec): 4 modes × 3 seeds × 220 steps off one signal, mapArray + insert directly 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).
  • Parity matrix runs engine / engine-index / mapArray-oracle across 3 anchors × 3 row shapes × 31 transitions.
  • Modes spec with the oracle beside every mode; the pre-existing classic for.spec, for.nonkeyed.store.spec etc. now run through the engine.
  • Hydration: 187 scenarios incl. key-fn / indexed / by-index / fallback fills and a classic-fallback oracle.
  • Bugs the oracle caught during this work (all fixed, all pinned): fill paths reading items untracked (store index writes didn't re-run), fallback row built under the wrong owner (duplicated under hydration), key-fn survivor probe by identity (re-minted objects rebuilt rows), and two wrong identity assumptions in my own tests.
  • Suites: web 805, server 779, hydrate 187, solid 585, signals 1503, universal 46.

Size, cumulative vs next (brotli)

scenario next this branch delta
app floor (no For) 10,733 10,962 +229 B
CSR app with For 12,900 17,182 +4.28 KB
hydrating app 17,485 22,003 +4.52 KB

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.js now states the cumulative figure.

Performance (Chrome, interleaved A/B on the flagged cells; sequential battery for the rest)

Follow-ups

  • One-engine array output (teach the engine to answer a plain call with the current row values) would let For drop mapArray for children()/universal — ~200–300 B, decide after this lands.

Claude via Cursor

Made with Cursor

@changeset-bot

changeset-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 19b3caa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
solid-js Patch
@solidjs/web Patch
@solidjs/signals Patch
@solidjs/universal Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/diagnostics Patch

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

@ryansolid

Copy link
Copy Markdown
Member Author

Update — one list implementation (f70d5ce3).

The row core now lives in @solidjs/signals (list.ts) and mapArray is its array output — same API and contract (values, array identity while structurally unchanged, [fallback] when empty, dev strict-read name, _parentComputed routing for row bodies). A plain call of a <For> accessor returns the same. The old mapArray body is deleted from the shipped package and kept only as a test reference (web/test/reference/mapArray.ts, on public API); every oracle harness now compares the engine against it. signals' own 35 mapArray tests (NotReady exception safety, store non-keyed, latest/repeated writes) run against the engine and passed unchanged.

The rendered output goes through a node layer — the only code that touches nodes — which solid-js builds over a renderer's SlotOps. Web (domOps) and @solidjs/universal (createRenderer primitives) both consume the same layer; universal's oracle test now includes dynamic rows as the second-renderer proof.

Two bugs the reference oracle caught in the move itself, both fixed: the array memo was created under its reader (a <For> builds array output lazily on first read) and died with the reader's next run; and routing row owners' _parentComputed through the rendered compute gave dynamic rows' memos a height above the compute — a height inversion that double-ran the compute on a flip + list change and rebuilt the pass's fresh rows. Routing is array-output only now.

Size (brotli, minimal bundle, vs next For-only 12,019 B):

consumer next engine before move now
For only 12,019 +3.59 KB +3.81 KB
For + mapArray 12,026 +4.38 KB +3.87 KB
mapArray, no For +850 B +850 B +3.69 KB

For-only pays ~220 B for the layer boundary; For + mapArray drops ~510 B (no second implementation). mapArray-without-For (custom renderers without For, data-only mapArray) pays more because the rendered-only control flow shares the engine closure — making flat/LIS/dynamic/placement pluggable is the follow-up if that consumer shape matters.

Perf: Chrome, interleaved pre-move vs moved on jfb-signal — reorder mins and creates identical. Suites: web 810, server 779, hydrate 187, solid 585, universal 47, signals 1503.

Claude via Cursor

ryansolid added a commit that referenced this pull request Sep 8, 2026
…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>
@ryansolid

Copy link
Copy Markdown
Member Author

Response to the 2026-09-08 audit (9 P1 / 4 P2) — fixed in 6a914102, one test per finding.

P1-1 module-global scratch — real, the most serious one. bpOwner was written before the row fn ran; a <For> nested in a row engages synchronously and clobbered it, so the outer row recorded the nested row's owner. buildParts now returns the owner and writes its result slots only after user code (LIFO-safe); the node layer's dyn slot likewise. Pinned: removing an outer row disposes exactly that row and its nested rows.
P1-2 key-fn semantics — identity-first comparison (row.item === next || key(row) === key(next)), keys computed lazily by the first diff that needs them, so no key fn runs during a fill and an in-place key mutation keeps its row. Pinned against the reference (0 key calls on fill; mutated id keeps the node).
P1-3 creation-owner disposal — the engine registers its death on the creation owner (not the list owner, whose cleanups fire on every bulk dispose(false)): a rendered list whose <For> owner was disposed freezes at its last value, like mapArray.
P1-4 two engines — one engine per list. Rendered first → a plain call reads that engine's tracked array view (version signal bumped per commit). Called first → impl returns false and the renderer inserts the array output the classic way. Rows are built once in both orders (pinned both).
P1-5 migrated retained rows — chain-wide liveness sweep on structural commits, matching classic's live-only prefix/suffix skip. Pinned against the reference. Cost flagged below.
P1-6 host tagging / initial rangeSlotOps.placed hook; web's host-aware inserts use per-host ops tagging _$host; a caller-provided non-hydrating initial range is consumed before engagement (web + universal).
P1-7 chain-mode hydrationkeyed={false} and fallback fills run the same positional adoption commit as flat fills. Pinned: keyed={false} text rows and a primitive fallback hydrate without duplication.
P1-8 extra idFor consumes the server's two slots and hands both to the engine: row owner (hid) and the array computed (hid2). Pinned: a children()-introspected For followed by siblings keeps their ids.
P1-9 universal node inferenceRendererOptions.isNode (default: non-array object, which is what universal's insertExpression already assumed); text-data tracking guards non-object nodes.
P2 — fallback called with zero arguments (pinned on mapArray); hydration adoption no longer writes text data (server text stands, live edits survive — pinned with a differing-text scenario); falsy-fallback retention recorded as a ruling.

Perf trade-off to rule on (P1-5): the reclaim sweep is one parentNode read per row per structural commit — ~20 µs at 1k rows: removefirst 0.015→0.035 ms, rotate 0.066→0.076, displace 0.07→0.09. Still 2–4× ahead of classic on every op, but it spends a third of the small-op win on a rare user action. Options: keep (exact parity), or restrict reclaim to the changed window (a migrated prefix row would come back only when it enters a window). Parity is in for now.

On the architectural recommendation (share the kernel, keep a light mapArray adapter): the +43% standalone mapArray figure is the mapArray-without-For case. Measured on this branch: For-only apps +3.81 KB vs next, For + mapArray apps +3.87 KB (was +4.38 with two implementations), mapArray-only +3.69 KB (was +0.85). Making flat mode / LIS / dynamic rows / placement pluggable so the array path shakes them is the follow-up if that consumer shape matters; it's additive.

Suites: web 819, server 783, hydrate 191, solid 585, universal 47, signals 1503. Cumulative size vs next: CSR +4.34 KB, hydrating +4.51 KB, floor +276 B.

Claude via Cursor

ryansolid and others added 26 commits September 8, 2026 03:45
…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>
ryansolid and others added 6 commits September 8, 2026 03:46
…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>
…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>
@codspeed-hq

codspeed-hq Bot commented Sep 8, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 5 improved benchmarks
❌ 1 regressed benchmark
✅ 136 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

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)

Open in CodSpeed

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 34218356263

Coverage decreased (-10.5%) to 61.382%

Details

  • Coverage decreased (-10.5%) from the base build.
  • Patch coverage: 159 uncovered changes across 4 files (14 of 173 lines covered, 8.09%).
  • 3 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
packages/solid/src/client/for-slot.ts 116 9 7.76%
packages/solid/src/client/for-slot-hydration.ts 41 2 4.88%
packages/solid/src/client/flow.ts 10 0 0.0%
packages/solid/src/client/hydration.ts 5 2 40.0%
Total (5 files) 173 14 8.09%

Coverage Regressions

3 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
packages/solid/src/client/flow.ts 3 51.83%

Coverage Stats

Coverage Status
Relevant Lines: 1166
Covered Lines: 781
Line Coverage: 66.98%
Relevant Branches: 947
Covered Branches: 516
Branch Coverage: 54.49%
Branches in Coverage %: Yes
Coverage Strength: 12.98 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants