Skip to content

feat(solid,web): unified For — one slot owns rows and placement, default-on via For's module graph - #3281

Open
ryansolid wants to merge 20 commits into
nextfrom
unified-for-accept
Open

feat(solid,web): unified For — one slot owns rows and placement, default-on via For's module graph#3281
ryansolid wants to merge 20 commits into
nextfrom
unified-for-accept

Conversation

@ryansolid

Copy link
Copy Markdown
Member

What

Keyed <For> gets a unified slot: one persistent structure (intrusive row chain + incremental key→row map) owns both row bookkeeping and DOM placement, replacing the mapArray + reconcileArrays double pass for engaged lists. Pull-based — an ordinary two-phase render effect reads each(), diffs against its own committed chain (prefix/suffix walks, middle partition + LIS), and commits placement. No delivery seam, no channel, no second diff.

Delivery: For's own module graph — zero API, zero compiler

  • For stamps its accessor with $for = { each, row, keyed, impl: unifiedForSlot } — the slot algorithm lives in packages/solid/src/client/for-slot.ts and travels on the descriptor.
  • Web's insert() engages it by handing over its SlotOps singleton (insertBefore/remove/createText/isNode/clear/tag). The slot is platform-free; every node touch rides the ops.
  • Tree-shaking is the eligibility mechanism: apps without For shake the slot entirely. Renamed imports work. Renderers that ignore $for (universal, today) call the accessor and get classic mapArray — the stamp is advisory. Universal adoption = passing its own ops (follow-up; its createRenderer contract already matches).
  • No enableUnifiedFor, no registration, no compiler emission, no new user API. SSR untouched (server For never stamps).

Every keyed For in the test corpus now runs the slot: web 697 / solid 585 / signals 1479 / universal 43 / element 10 / html 192, all green.

Scope and fallbacks

Engages: identity-keyed array Fors (keyed !== false, no fallback prop, row arity < 2). Declines to classic (pre-engage) or late-demotes (post-engage): key functions, hydration claiming, duplicate keys, function-top-level rows, empty-rendering rows, non-array subjects. Flat mode keeps mounts at mapArray economics: first fills build parallel arrays (no Rows/chain/map); the structure materializes lazily on the first partial structural op. Keyed-fn mode was built and measured (+1,290 B for an idiom we don't believe in) and deliberately excluded — banked on opt/unified-for-keyed-flat.

Measured

Acceptance battery (5 canonical fixtures, semantic gates green, clean provenance) + drift-immune interleaved A/B for every flagged cell:

  • jfb-signal: structural ops 1.2–7x faster (displace 0.40–0.44, removefirst 0.13–0.20, rotate ~0.63, insertmid 0.60–0.75, shuffle 0.80–0.86); creates at parity (1k 0.94–1.00, 10k 0.94–1.06 across pairs); clear parity. Geomean 0.63.
  • jfb store fixtures: replace 0.77–0.94, swap 0.75–0.87, shuffle 0.61–0.64; creates parity.
  • uibench: total 0.73 (tree 0.68, table 0.78, worst-cases 0.82; nested-For render 0.45).
  • dbmon: no regression — battery flags reproduced only in sequential legs; interleaved A/B dead even on mount/tick/tick_partial/remount (e.g. mount 16.10/16.10).
  • Ops indirection + module-graph wiring: interleaved dead even (mount 13.60/13.60 and 14.10/14.20; tick 5.40/5.40 and 5.70/5.60) — the monomorphic singleton inlines to nothing.

Size

  • Signals scenarios + frames: flat.
  • Simple-app floor (no For): +153 B — the engagement seam only ($for.impl call site + domOps singleton in insert).
  • For-bearing app scenarios: +2.1–2.2 KB — the deliberate default-on bill (slot retained via For's import). Budgets ratcheted with notes.

Open items (follow-ups, not blockers)

  • Universal insert wiring (pass its ops; until then universal bundles retain the slot as dead weight through For's import — flagged, small).
  • Hydration claiming (H2) — hydrating inserts decline to classic today; post-hydration mounts engage.
  • No runtime kill-switch: demotion covers contract exits, but there's no app-level "force classic" toggle. Say the word if we want one before release.

Changesets included (patch, prerelease).

Made with Cursor

@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0c262a4

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/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal 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

@coveralls

coveralls commented Sep 4, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34016871526

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage decreased (-20.4%) to 51.427%

Details

  • Coverage decreased (-20.4%) from the base build.
  • Patch coverage: 419 uncovered changes across 4 files (19 of 438 lines covered, 4.34%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
packages/solid/src/client/for-slot.ts 360 12 3.33%
packages/solid/src/client/for-slot-hydration.ts 65 4 6.15%
packages/solid/src/client/flow.ts 7 0 0.0%
packages/solid/src/client/hydration.ts 5 2 40.0%
Total (5 files) 438 19 4.34%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
packages/solid/src/client/flow.ts 1 49.71%

Coverage Stats

Coverage Status
Relevant Lines: 1434
Covered Lines: 782
Line Coverage: 54.53%
Relevant Branches: 1088
Covered Branches: 515
Branch Coverage: 47.33%
Branches in Coverage %: Yes
Coverage Strength: 10.53 hits per line

💛 - Coveralls

@ryansolid
ryansolid marked this pull request as draft September 4, 2026 20:45
@ryansolid
ryansolid marked this pull request as ready for review September 4, 2026 20:47
@ryansolid

Copy link
Copy Markdown
Member Author

Hydration claiming (H2) ruling: NOT a merge blocker — a For present during hydration declines pre-engage and runs classic mapArray for its lifetime (today's exact path and perf, zero mismatch risk); post-hydration Fors engage normally. The gap is benefit coverage on first-paint lists in SSR apps, not correctness. H2 lands as the immediate follow-up and silently upgrades hydrated Fors when it does.

@codspeed-hq

codspeed-hq Bot commented Sep 4, 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

⚡ 2 improved benchmarks
❌ 1 regressed benchmark
✅ 133 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.6 ms 111.7 ms -10.81%
reverse: 1000 rows 187.6 ms 107.1 ms +75.13%
mount-clear-cycle: 1000 rows 337.5 ms 277.1 ms +21.78%

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-accept (0c262a4) with next (d2b50e9)

Open in CodSpeed

@ryansolid

Copy link
Copy Markdown
Member Author

Audit response — all findings addressed on the branch (f864dede + 95ee4ea2):

  • P0 (marker collapse): the marker now passes through the seam untouched — undefined = whole parent, null = trailing MULTI child, Node = bounded hole. Every bulk path (flat clear, no-survivor replace, chain batch-clear, removeFlatDom/demote) is additionally gated on classic's ownsAllChildren ruling (our first/last nodes must be the parent's first/last children), so streamed foreign nodes survive even true whole-parent clears. Regression suite covers all four paths, the trailing-sibling shape, and foreign-node survival — with engagement asserted so the tests can't pass vacuously (the audit's own render(() => <For/>) shape turns out to run classic; the compiled sole-child shape is the one that engages).
  • Late-demotion state loss: empty-rendering rows (null/boolean/"" /empty fragment) now hold position with a placeholder text node instead of demoting — the typed-input repro is pinned green. Function-top-level rows still demote (dynamic content is real future work); duplicates still demote (documented limitation, classic owns them).
  • Throw mid-pass: row fns that throw now dispose the rows built so far (they chain to the persistent slot owner) before the error rides the boundary — both buildFlat and the mid-window build.
  • __unifiedForStats: increments are IS_DEV-gated; frozen in prod bundles.
  • Changesets: the four spike-history changesets are consolidated into one describing shipped behavior.
  • Rebased onto next tip (including today's store sweep and the [P1] 2.0.0-rc.6: Moving an array row and editing it in one setter overwrites another row #3282[P1] 2.0.0-rc.6: A nested derived-store write disconnects ancestor property observers #3284 ratchets).

New coverage for the reconcile surface (this was the audit's meta-point — heavily tested area deserves heavy tests): for.unified.reconcile-parity.spec.tsx runs a 31-transition matrix (the classic for.spec families + jfb moves) through both the slot and a live classic oracle (arity-2 rows decline the stamp, same semantics through mapArray + reconcileArrays), across three row shapes × three container anchors, asserting zero demotions — ~1,700 DOM assertions — plus a differential section rendering both modes off one signal through a cumulative no-reset sequence with DOM equality asserted at every step.

Suites: web 728 / solid 585 / signals 1490, size budgets green (the P0 guards cost ~120 B in For-bearing scenarios, noted in .size-limit.js).

Not addressed here, per the audit's own framing: the coverage/size trade (fragment-with-expression and component rows demote today — the slot is deliberately conservative) and hydration claiming (ruled a fast-follow, not a blocker, in the thread above).

@ryansolid

Copy link
Copy Markdown
Member Author

Two more landings on the branch (dedf642d, fe954591) — both were coverage gaps that would have left most real apps on classic:

Hydration (H2) — first-paint SSR lists now engage the slot

  • Id parity: For peeks the id classic's mapArray owner would spend (via an enableHydration()-installed sharedConfig.peekNextContextId) and the slot's row parent is created with that explicit id — rows mint identical hydration keys. Proven against real server artifacts; for-then-siblings (the <For> followed by siblings desyncs hydration in 2.0.0-rc.4 #3161 regression scenario) still passes.
  • mapArray lazy (@internal): under hydration For defers the classic first pass to first read so the slot claims first; the owner (id slot) is still created eagerly, so <For> followed by siblings desyncs hydration in 2.0.0-rc.4 #3161's sibling-id fix is intact. Classic readers claim on first read with identical ids — ordering of user code now matches CSR.
  • Reversible claims: the hydrating fill records the registry keys its templates consume; a demote mid-fill hands them back and classic's re-run (same ids) claims the same server nodes. The "never strand a claim" invariant is satisfied by making claims reversible rather than by predicting row shape — no probe, no double-run.
  • Fill commit is a claim pass: zero DOM writes unless server/client mismatch (leftover rows removed, key-missed fresh rows inserted).
  • Tree-shakeable: everything above lives in for-slot-hydration.ts, installed by enableHydration(); CSR bundles carry only null-guarded hook calls (CSR 15.68 → 15.48 KB after the split; floor dropped too once For's direct id-formatter import went behind the hook).
  • Scope (v1): whole-parent lists. Anchored holes stay classic under hydration.
  • 9 server→client 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, and a For passed through a wrapper.

Hole seam — lists passed through props.children engage

<Table><For/></Table> compiles the parent's hole to insert(el, () => props.children), which previously hid the stamp → classic. The outer insert's compute now engages the slot when the resolved value is the $for accessor. The slot is created inside that compute, so a children change tears it down (hole-mode cleanup removes rows; prior classic content is cleaned via cleanChildren first, preserving the multi-placeholder invariant). A post-engage demote flips a per-hole flag and bumps a lazily-created signal so the hosting effect re-runs on its classic path — no second insert fighting for the hole. children() introspection and fragment children stay classic by design. Works under hydration too.

Perf: interleaved on frozen dists (pre-hydration vs current): mount 14.10/13.10 vs 14.00/12.60, tick 5.20/4.90 vs 5.20/4.80 (32 pairs), tick_partial identical — the seam's per-insert null check is invisible.

Suites: web 734 / server 750 / hydrate 174 / solid 585 / signals 1490 / universal 43. Size budgets ratcheted with notes (floor +~110 B for the seam living in insert; app scenarios +67–147 B; hydrating scenarios carry the hooks module, CSR shakes it).

Ready for the next audit round — the invariants I'd want checked hardest: reversible claims under hydration, and the hole handoff/demote under dynamic children.

@ryansolid

Copy link
Copy Markdown
Member Author

CodSpeed on 8baed803 — 2 improved, 1 regressed, 133 untouched. Context for the flag:

These three permutation benches render via a wrapper accessor (insert(container, () => <For/>, null)), so until the hole seam landed they ran classic and CodSpeed saw no change. They are now the first true slot-vs-classic instruction-count comparison on permutation shapes:

  • reverse: 1000 rows +75%, mount-clear-cycle: 1000 rows +21%.
  • shuffle: 1000 rows (Fisher-Yates) −10.8% — the adversarial case: a full random permutation has LIS ≈ 2√n (≈63 of 1000), so the slot's O(n log n) LIS pass buys almost nothing while classic's udomdiff skips that work.

Why this is a measurement-model artifact rather than a user regression: CodSpeed simulates against jsdom, where insertBefore is cheap JavaScript — the LIS's JS cost is visible and its DOM-move savings are not. In a real browser DOM moves dominate: on this exact op the slot wins (jfb-signal shuffle 0.80–0.86, uibench adversarial worst-cases 0.71–0.82, drift-cancelled A/B). Recovering the instruction count would mean skipping LIS on full-window permutations, which adds DOM moves and makes the browser number worse — the wrong trade. Recommending acknowledgement on the dashboard; a bounded micro-pass on the full-window bookkeeping (Map/stamp overhead, no change to move counts) is a reasonable follow-up if we want the flag gone.

@ryansolid

Copy link
Copy Markdown
Member Author

Third-pass audit (8baed803) — scope since last audit: H2 hydration claiming, the {props.children} hole seam, anchored-hole hydration.

P1 — nested lists strand claims on a mid-fill demote (the "never a stranded claim" invariant fails)

Reproduced with a temporary harness scenario (not committed): outer keyed list whose rows each contain a nested <For>, where a LATER outer row is <Show>-rooted (heterogeneous rows — cond ? <Special/> : <li/> is a very common shape). Outer engages, row x (and its nested slot) claim, row y returns a function → outer demotes mid-fill.

Result: text correct, but 4 hydration key-miss warnings, only 1 of 3 server <span> rows survive as server nodes — the rest are recreated detached elements (the runtime's "subtree will not appear hydrated" path). The single-level slot-hydrate-demote-mid-fill scenario passes; the failure is nesting-specific.

Two causes in for-slot-hydration.ts:

  1. record() shadows registry.delete as an own property and its finally does delete reg.delete. A nested slot's record() (row build → inner insert → inner engage → inner fill, all synchronous inside the outer build) installs its own shadow and then restores the prototype method, silently ending the outer log. Every key the outer's remaining rows consume after the first nested list is unlogged → not handed back on restore().
  2. Even with (1) fixed, a nested slot that COMMITTED (commitFill drops its hydLog) has made its claims permanent — they are in no log. When the outer demotes and disposes it, classic's re-run re-engages the nested lists, which key-miss on the already-consumed ids.

Fix direction: a single module-level recording stack rather than per-slot shadows — the outermost record() installs the shadow once, every active log on the stack receives each deletion, nested commitFill only clears its OWN entry, and outer restore() hands back everything claimed beneath it (including nested slots' committed claims). Then add the nested+demote scenario to for-slot-scenarios.tsx (engaged: 4, demoted: 1, warnings: 0, identity on span).

P2 — hole seam + hydration + demote: classic re-enters with current = []

client.ts hole seam: under active hydration keep = [], so after a mid-fill demote the hosting effect's classic path runs reclaimRegion([], …) (returns []first is undefined) and insertExpression(parent, inner, [], marker). Claimed rows are already children so they reposition rather than duplicate, but on a server/client mismatch the leftover server rows are invisible to classic and survive. The direct-insert lateClassic passes initial = undefined for whole-parent holes so claimInitial re-derives the range — the hole path has no equivalent. Not covered by slot-hydrate-through-children (no demote). Worth a scenario (through-children + Show-rooted row + isServer mismatch) even if the ruling is "accept".

Size — the floor now pays

.size-limit.js: floor (no For) 10.73 → 11.03 KB (+0.30), CSR app 12.97 → 15.62 (+2.65), hydrating app 17.61 → 20.57 (+2.96), store app 26.43 → 29.45 (+3.02). The floor cost is the engagement seam living in insert's effect (domOps singleton, $for guards, holeGen/holeClassic, slotRegion, contains) — every bundle carries it, For or not. The notes document it honestly, but the changeset's "apps without For tree-shake it entirely" should be qualified: the algorithm shakes; ~300 B of seam does not. Hydration added ~0.8 KB on top of the previously audited ~2.1 KB.

Verified fine

  • mapArray lazy option: computed(fn, LAZY_OPTIONS)REACTIVE_LAZY → recompute on first read via prepareComputed; the internal owner still spends the id slot at creation (<For> followed by siblings desyncs hydration in 2.0.0-rc.4 #3161 fix preserved). Internal-only, documented @internal.
  • Id parity: peekNextContextId (installed by enableHydration(), CSR-shaken) peeks before the eager create() consumes; createOwner({ id }) accepts the explicit id; keyed:true rows mint one owner per row in order on both paths. slot-hydrate-nested confirms parity for the happy path.
  • demote() runs in the effect phase, so the hole seam's holeGen[1](…) write is not an owned-scope write; holeGen is created lazily only for holes that see a $for.
  • Hole-mode cleanup removes rows (flat or chained) — a children change tears down cleanly; marker passes through untouched (P0 wipe fix intact).
  • engage() declines marker === null under hydration; commitFill inserts back-to-front anchored on slot.end (whole-parent: append; anchored: <!--/-->), removes only region leftovers still in parent.
  • __unifiedForStats exported from solid-js (client + server stub) — a new public export, dev-only counters. Flagging per your rule; @internal in the doc comment but it is on the package's public surface.

Status

CI green except CodSpeed (shuffle −10.8% is the jsdom-instruction-count artifact explained above; reverse +75%, mount-clear +21%). Branch is CONFLICTING with next on packages/web/src/client.ts and scripts/size/.size-limit.js after #3183 — needs a rebase before merge regardless.

Recommendation: fix P1 (it turns a common row shape into degraded hydration with warnings), decide P2, rebase, then merge.

Claude via Cursor

@ryansolid

Copy link
Copy Markdown
Member Author

Audit round 3 — addressed on the branch (e5a87f26, rebased onto next at f587361c; the rebase over #3183 is done).

P1 (nested claims stranded on mid-fill demote) — fixed. Both causes were as diagnosed. Per-slot registry shadows are replaced by one module-level recording stack in for-slot-hydration.ts: 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 lists' already-committed claims, which classic's re-engaged nested lists mint again with the same ids. Scenario slot-hydrate-nested-demote (outer list, nested lists per row, Show-rooted later row): 5 engagements, 1 demote, zero warnings, every <span> is its server node. (5 rather than 4: the Show-rooted row's <li> template runs its own hole insert before the outer sees the function and demotes — all five claim cleanly on the re-run.)

P2 (hole seam + hydration + demote) — fixed, with one ruling. Two problems surfaced: (a) keep = [] — the hosting effect now keeps the claimed region as current; (b) more importantly, the deferred holeGen re-run was landing after hydrate() flipped the flag and cloning instead of claiming. A demote inside a hydrating fill now re-enters classic synchronously within the hydration window (insert(parent, () => listFn(), marker, current, options) under the hosting owner — the invoking wrapper cannot re-engage, which is also why the direct seam never loops; verified empirically). holeGen is ownedWrite (the bump may fire inside an owned scope). Scenario slot-hydrate-through-demote-mismatch: rows are server nodes, 1 demote. Ruling pinned: the leftover server row survives with the runtime's honest "1 unclaimed server-rendered node" report — that is classic's own claim-pass behavior on the identical mismatch (it never removes server leftovers), so this is classic parity, not a slot defect. The hosting effect's real-range current means a later children change cleans it.

Public surface: __unifiedForStats is no longer a package export — the counters ride DEV.unifiedFor (solid-js's existing dev diagnostics bag; undefined in prod). Changeset now says the slot algorithm tree-shakes while ~0.3 KB of engagement seam in insert is retained by every web bundle, and hydration adds ~0.8 KB to hydrating bundles only.

Verification on the rebased tip: web 760 / server 754 / hydrate 178 / solid 585 / signals 1490 / universal 43; size budgets green (+17–32 B from #3183 drift + round 3, noted in .size-limit.js).

Verified-fine items from the audit stand unchanged. CodSpeed shuffle ruling is in the comment above.

@ryansolid

Copy link
Copy Markdown
Member Author

Fourth-pass audit — round-3 fixes (e5a87f26, f587361c, rebased over #3183; local, not yet pushed at time of review).

P1 (nested claim recording) — fixed, verified

The module-level recording stack is correct: the outermost record() installs the shadow once, every active log receives every deletion, logs.pop() is LIFO-safe because nesting is synchronous, an inner commitFill nulls only its own hydLog (the array object stays on the stack), and an outer restore() hands back nested committed claims. Re-set of an already-restored key (inner demote → inner classic re-claim → outer demote) is an idempotent Map.set. slot-hydrate-nested-demote pins it.

P2 follow-up — the synchronous hydration demote leaves list residue on a later children swap (reproduced)

client.ts hole seam, hydrating branch of the demote thunk: runWithOwner(holeOwner, () => insert(parent, () => listFn(), marker, current, options)). That nested insert() has its own current closure. The hosting effect's current stays frozen at the claimed region, and holeClassic = true with no holeGen bump means the hosting compute never re-runs to re-take ownership. Classic insert never removes its nodes on dispose — it relies on the enclosing effect's current — so any row the nested insert adds after hydration is invisible to the hosting effect.

Probe (temporary harness scenario, reverted): <ListShell>{cond() ? <For each={items()}>{heterogeneous rows}</For> : <p>none</p>}</ListShell>, hydrate (engage → demote mid-fill → sync classic), then setItems([...,"d"]); flush(); setCond(false); flush()noned — the <p> lands before a stranded <li>d</li>. The same scenario with homogeneous rows (no demote; current = region stale but ownership-guarded) gives none, and the CSR demote path (holeGen re-run) is covered by for.unified.children.spec and is fine. So this is specific to the sync re-entry.

Fix: don't spawn a nested insert(). Re-enter with the hosting effect's own inner-effect shape so it writes the shared current — factor the existing classic block into a local classic(value) closure (effect(() => (hydrationRt && (current = reclaimRegion(current, parent, marker)), withInsertionParent(parent, () => normalize(value, current, multi))), inner => { current = insertExpression(parent, inner, current, marker); host && tagHost(current, host); }, …)) and call runWithOwner(holeOwner, () => classic(() => listFn())) from the hydrating demote. Same owner and lifetime as the nested insert, but current stays honest. Then pin: through-children + Show-rooted row, post-hydration append, flush, children swap → none.

Design note — mismatch handling is now asymmetric, and the direct path is silent

slot-hydrate-mismatch-fewer / slot-hydrate-trailing-mismatch-fewer expect the leftover server row REMOVED with warnings: 0; slot-hydrate-through-demote-mismatch expects it KEPT with the runtime's 1 unclaimed-node report and is pinned as "classic parity". If classic's claim pass leaves leftovers and reports them, then the slot's commitFill is stricter than classic (good: no residue) but quieter (it repairs a server/client mismatch without a dev report). Hydration mismatches are bugs the developer should hear about; suggest commitFill warn in dev when it removes leftovers or inserts key-missed rows — one message, parity with the runtime's unclaimed-node report — rather than silently normalizing. Not blocking; a ruling.

Verified fine

  • __unifiedForStats off the package surface; rides DEV.unifiedFor (dev only). Note Object.assign(_DEV!, …) mutates @solidjs/signals' DEV singleton, so signals' DEV also grows unifiedFor — harmless, just cross-package.
  • holeGen is ownedWrite — the hydrating demote bump path no longer exists, but the CSR bump can still fire inside an owned scope; correct.
  • Stale current = region after a successful hydrating engage: reconcileArrays (isLive / after = marker || null), cleanChildren (parentNode === parent), and ownsAllChildren (first/last identity) are all ownership-guarded — a later children change reconciles correctly (probe: none, no residue).
  • Size ratchet: floor 11.07 (+0.04 incl. feat(web): support responsive image preloads #3183 drift), CSR 15.66, hydrating 20.59 — within the previously discussed envelope.

Recommendation: fix the sync re-entry current leak (P2) before merge; the mismatch-report question can follow.

Claude via Cursor

@ryansolid

Copy link
Copy Markdown
Member Author

Round-3 follow-up addressed (91b0f946, pushed — the earlier round-3 commits were pushed at f587361c shortly after the audit ran).

P2 residue leak — fixed as suggested. 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 instead of spawning a nested insert, so classic writes this insert's current and a later children change cleans everything classic appended. Scenario slot-hydrate-through-demote-residue pins the exact repro (hydrate → demote → append d → swap children) → none.

Design note — taken. commitFill now emits one dev warning when it repairs a server/client mismatch ("Hydration mismatch in <For>: N unclaimed server row node(s) removed, M client row node(s) inserted — DOM repaired, server and client should render the same list"). The count covers element rows only: primitive rows currently re-create their text node (fresh text swaps in for the server's — correct DOM, not a mismatch), and adopting server text nodes for primitive rows is the follow-up that makes that path zero-write. Scenario warning counts updated accordingly (mismatch-fewer 1, mismatch-more 2 = key-miss + repair, trailing-mismatch-fewer 1).

Verification: web 760 / server 755 / hydrate 179 / solid 585 / signals 1490 / universal 43; budgets green (+9/+12 B).

@ryansolid

Copy link
Copy Markdown
Member Author

Fifth-pass audit — 91b0f946 (round-4 follow-up; local, not yet pushed).

P2 residue — fixed, verified

The hosting effect's classic inner-effect block is now a shared classic(value, prev) closure that writes THIS insert's current; the hydrating demote calls runWithOwner(holeOwner, () => classic(listFn, undefined)). normalize unwraps listFn through flatten (no $for check on that path, so no re-engage), prev = undefined matches a first fill's options, and the normal path is byte-for-byte the previous block. slot-hydrate-through-demote-residue pins append → flush → children swap → none. Re-ran the hydrate (28 files / 179), unified-For (6 / 56) and server (78 / 755) suites on the branch: green.

Mismatch report — taken, one gap noted

commitFill now warns once in dev when it removes or inserts ELEMENT row nodes; scenario counts updated (mismatch-fewer 1, mismatch-more 2 = runtime key-miss + repair, trailing-mismatch-fewer 1). The text-row exclusion is honest about why (primitive rows currently re-create their text node on every hydrating fill, so counting them would warn on the happy path) — but it also means a genuine text-row mismatch is silent, and the happy path for {item => item} lists is a DOM write pass, not the zero-write claim the module header promises. Fine as a documented follow-up (adopt server text nodes for primitive rows, then drop the exclusion); flagging so it doesn't get lost.

Minor — direct-path lateClassic under anchored-hole hydration

insert's direct seam re-enters classic with initial = marker !== undefined ? [] : undefined. For whole-parent holes undefined lets claimInitial re-derive the range; for ANCHORED holes [] throws the bounded region away, so classic's claim pass reconciles against nothing — element rows recover through the registry, but a primitive row in a demoting heterogeneous list has no positional text node to adopt (normalize's "raw primitive in an array during a claim pass" bail). Passing the slot's region back (region ?? []) would give classic the same range the slot had. Not covered by a scenario (trailing/bounded × demote); low frequency, but it is the one remaining asymmetry between the two demote paths.

Status

Remote is still at 8baed803; the rebased branch (e5a87f26, f587361c, 91b0f946) hasn't run CI. Push, let CI + CodSpeed run (expect the shuffle flag to persist — the jsdom instruction-count artifact already explained), then merge. No blocking findings from this pass.

Claude via Cursor

@ryansolid

Copy link
Copy Markdown
Member Author

Remote state correction + the minor asymmetry, closed. The remote was not at 8baed803f587361c was force-pushed right after round 3 ran, and 91b0f946 fast-forwarded on top (git ls-remote confirms 91b0f946 at the time of the r3 follow-up audit; the audit's fetch was stale). Now at af30bda1.

Minor asymmetry — taken (af30bda1): the direct seam's lateClassic now passes region ?? [] for anchored holes, so a hydrating demote hands classic the bounded region the slot had — a primitive row in a demoting heterogeneous list adopts its positional text node, same as the hole seam. Whole-parent still re-derives via claimInitial. +2–7 B, ratcheted.

Follow-up recorded (not in this PR): adopt server text nodes for primitive rows in the hydrating fill, then drop the element-only exclusion from the mismatch report — closes both the silent text-row mismatch and the text-row write pass.

CI is running on af30bda1. Shuffle flag persists under the standing ruling; everything else green locally (web 760 / server 755 / hydrate 179).

ryansolid and others added 13 commits September 5, 2026 23:28
…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>
ryansolid and others added 7 commits September 5, 2026 23:29
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>
@ryansolid

Copy link
Copy Markdown
Member Author

Rebased over the #3187 revert (d2b50e91) — and it simplified the hole seam (0c262a4b, force-pushed).

  • The revert removed insert's insertion-parent tracking; the hole seam and the shared classic() helper drop their withInsertionParent wrappers. No slot behavior depended on it.
  • Checked ourselves against the revert's semantics. Eager creation at component time + refs firing at creation is exactly the slot's row model (rows build in the compute, refs fire then — same as mapArray), so the revert brings classic back in line with the slot rather than the other way round. Dynamic-rooted rows still demote — Dynamic returns a memo, so the row's top level is a function regardless of eager/deferred element creation — pinned as such, with the DOM asserted synchronously after render().
  • That last assertion caught a real gap: the CSR hole demote deferred the classic re-run through the lazy holeGen signal, leaving the hole empty for a microtask (a sync querySelector after render() 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 — so the deferred path is gone entirely. One path for CSR and hydration: no holeGen, no ownedWrite signal, no empty-hole microtask.
  • Budgets locked in DOWN: floor 11.07 → 10.98, CSR 15.68 → 15.61, hydrating 20.60 → 20.54, store 29.48 → 29.40 (revert + signal removal).

web 760 / server 758 / hydrate 182 / solid 585 / signals 1490 / universal 43. CI running on 0c262a4b.

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