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 16 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 16 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: f587361

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 33958201770

Coverage decreased (-20.0%) to 51.859%

Details

  • Coverage decreased (-20.0%) from the base build.
  • Patch coverage: 409 uncovered changes across 4 files (19 of 428 lines covered, 4.44%).
  • 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 55 4 7.27%
packages/solid/src/client/flow.ts 7 0 0.0%
packages/solid/src/client/hydration.ts 5 2 40.0%
Total (5 files) 428 19 4.44%

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: 1424
Covered Lines: 782
Line Coverage: 54.92%
Relevant Branches: 1077
Covered Branches: 515
Branch Coverage: 47.82%
Branches in Coverage %: Yes
Coverage Strength: 10.6 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.5 ms 111.5 ms -10.72%
reverse: 1000 rows 187.9 ms 107 ms +75.56%
mount-clear-cycle: 1000 rows 335.7 ms 276.1 ms +21.56%

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 (f587361) with next (ace227e)

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 and others added 16 commits September 5, 2026 02:19
…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>
@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

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