feat(solid,web): unified For — one slot owns rows and placement, default-on via For's module graph - #3281
feat(solid,web): unified For — one slot owns rows and placement, default-on via For's module graph#3281ryansolid wants to merge 16 commits into
Conversation
🦋 Changeset detectedLatest commit: f587361 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Coverage Report for CI Build 33958201770Coverage decreased (-20.0%) to 51.859%Details
Uncovered Changes
Coverage Regressions1 previously-covered line in 1 file lost coverage.
Coverage Stats
💛 - Coveralls |
|
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. |
Merging this PR will regress 1 benchmark
|
| 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)
1d6c74c to
95ee4ea
Compare
|
Audit response — all findings addressed on the branch (
New coverage for the reconcile surface (this was the audit's meta-point — heavily tested area deserves heavy tests): Suites: web 728 / solid 585 / signals 1490, size budgets green (the P0 guards cost ~120 B in For-bearing scenarios, noted in 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). |
|
Two more landings on the branch ( Hydration (H2) — first-paint SSR lists now engage the slot
Hole seam — lists passed through
|
|
CodSpeed on These three permutation benches render via a wrapper accessor (
Why this is a measurement-model artifact rather than a user regression: CodSpeed simulates against jsdom, where |
|
…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>
8baed80 to
f587361
Compare
|
Audit round 3 — addressed on the branch ( 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 P2 (hole seam + hydration + demote) — fixed, with one ruling. Two problems surfaced: (a) Public surface: 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 Verified-fine items from the audit stand unchanged. CodSpeed shuffle ruling is in the comment above. |
|
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 readseach(), 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
Forstamps its accessor with$for = { each, row, keyed, impl: unifiedForSlot }— the slot algorithm lives inpackages/solid/src/client/for-slot.tsand travels on the descriptor.insert()engages it by handing over itsSlotOpssingleton (insertBefore/remove/createText/isNode/clear/tag). The slot is platform-free; every node touch rides the ops.Forshake 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; itscreateRenderercontract already matches).enableUnifiedFor, no registration, no compiler emission, no new user API. SSR untouched (serverFornever 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 onopt/unified-for-keyed-flat.Measured
Acceptance battery (5 canonical fixtures, semantic gates green, clean provenance) + drift-immune interleaved A/B for every flagged cell:
Size
$for.implcall site + domOps singleton in insert).Open items (follow-ups, not blockers)
insertwiring (pass its ops; until then universal bundles retain the slot as dead weight through For's import — flagged, small).Changesets included (patch, prerelease).
Made with Cursor