From c1625d9308a2015a4facfa74f807b9cbc41515ef Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 27 Aug 2026 01:05:32 -0700 Subject: [PATCH 01/27] =?UTF-8?q?stage=202=20replayed=20onto=20the=20absor?= =?UTF-8?q?bed=20monorepo=20=E2=80=94=20patch=20channel=20current=20agains?= =?UTF-8?q?t=20folded=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../audit-driver-family-decline-multi-slot.md | 7 + .../fix-array-target-dictionary-mode.md | 5 + .changeset/fix-probe-cascade.md | 10 + .changeset/jfb-driver-and-write-bound.md | 16 + .changeset/patch-channel-pr-a.md | 19 + .changeset/patch-channel-row-ops.md | 10 + .changeset/patch-driver-web-export.md | 7 + .changeset/patch-fallback-semantics.md | 11 + .changeset/patch-held-on-transition.md | 7 + .changeset/patch-list-hydration-claim.md | 12 + .changeset/patch-list-identity-ruling.md | 6 + .changeset/patch-mode-list-driver.md | 15 + .changeset/per-row-patch-unbinds.md | 5 + .changeset/probe-abort-semantics.md | 10 + .changeset/row-proof-admission.md | 7 + .changeset/setter-row-ops-tentative-lists.md | 12 + .changeset/shallow-compiled-slot-channel.md | 12 + .changeset/web-export-patch-seams.md | 5 + PROPOSAL-KEYED-LIST-DRIVER.md | 224 +++++++ packages/signals/src/core/optimistic.ts | 3 + packages/signals/src/core/scheduler.ts | 14 + packages/signals/src/store/index.ts | 11 + packages/signals/src/store/next/optimistic.ts | 21 + packages/signals/src/store/next/patch.ts | 362 ++++++++++++ packages/signals/src/store/next/reconcile.ts | 130 ++++ packages/signals/src/store/next/store.ts | 277 +++++---- packages/signals/src/store/next/target.ts | 51 +- .../signals/tests/store/patch-channel.test.ts | 366 ++++++++++++ packages/solid/src/client/flow.ts | 36 +- packages/solid/src/index.ts | 7 + packages/solid/src/server/hydration.ts | 11 +- packages/solid/src/server/index.ts | 8 + packages/web/src/client.ts | 469 ++++++++++++++- packages/web/test/for.patchlist.spec.tsx | 558 ++++++++++++++++++ .../web/test/harness/hydration-records.ts | 34 -- .../test/hydration/patchlist-claim.spec.tsx | 147 +++++ .../web/test/server/document-shell.spec.tsx | 5 +- packages/web/test/server/ssr-stream.spec.tsx | 12 +- scripts/row-coverage.mjs | 67 +++ 39 files changed, 2756 insertions(+), 233 deletions(-) create mode 100644 .changeset/audit-driver-family-decline-multi-slot.md create mode 100644 .changeset/fix-array-target-dictionary-mode.md create mode 100644 .changeset/fix-probe-cascade.md create mode 100644 .changeset/jfb-driver-and-write-bound.md create mode 100644 .changeset/patch-channel-pr-a.md create mode 100644 .changeset/patch-channel-row-ops.md create mode 100644 .changeset/patch-driver-web-export.md create mode 100644 .changeset/patch-fallback-semantics.md create mode 100644 .changeset/patch-held-on-transition.md create mode 100644 .changeset/patch-list-hydration-claim.md create mode 100644 .changeset/patch-list-identity-ruling.md create mode 100644 .changeset/patch-mode-list-driver.md create mode 100644 .changeset/per-row-patch-unbinds.md create mode 100644 .changeset/probe-abort-semantics.md create mode 100644 .changeset/row-proof-admission.md create mode 100644 .changeset/setter-row-ops-tentative-lists.md create mode 100644 .changeset/shallow-compiled-slot-channel.md create mode 100644 .changeset/web-export-patch-seams.md create mode 100644 PROPOSAL-KEYED-LIST-DRIVER.md create mode 100644 packages/signals/src/store/next/patch.ts create mode 100644 packages/signals/tests/store/patch-channel.test.ts create mode 100644 packages/web/test/for.patchlist.spec.tsx delete mode 100644 packages/web/test/harness/hydration-records.ts create mode 100644 packages/web/test/hydration/patchlist-claim.spec.tsx create mode 100644 scripts/row-coverage.mjs diff --git a/.changeset/audit-driver-family-decline-multi-slot.md b/.changeset/audit-driver-family-decline-multi-slot.md new file mode 100644 index 000000000..47fc5d0f6 --- /dev/null +++ b/.changeset/audit-driver-family-decline-multi-slot.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +"@solidjs/web": patch +--- + +External-audit fixes on the patch-list driver surface: family (projection/optimistic) arrays now decline the driver — their structural changes emit no row/slot ops and the proxy identity is stable, so an engaged list would freeze on optimistic or projection structure (classic mapArray handles them correctly, including on identity-swap handoff). Shallow slot-patch registration is now multi-consumer — two driven lists over one shallow array previously overwrote each other's channel. Adds `storeHasFamily` (with server stub) and regression tests for both. diff --git a/.changeset/fix-array-target-dictionary-mode.md b/.changeset/fix-array-target-dictionary-mode.md new file mode 100644 index 000000000..710966b8e --- /dev/null +++ b/.changeset/fix-array-target-dictionary-mode.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Group the write-side patch-channel fields (`wk`, `p`, `ro`, `sp`) into one lazily-allocated `pc` extension on store targets and delete the dead prototype binding registry (`b`). Array proxy targets carry their fields as named properties on a real array, and V8 normalizes arrays to dictionary properties as the named count grows — at 24 fields every trap read had become a hash lookup (~15% uibench, tree-heavy scenarios worst). The target is capped at 20 named fields with the shape rule documented; future patch-channel state goes inside `pc`. diff --git a/.changeset/fix-probe-cascade.md b/.changeset/fix-probe-cascade.md new file mode 100644 index 000000000..6fa91866a --- /dev/null +++ b/.changeset/fix-probe-cascade.md @@ -0,0 +1,10 @@ +--- +"@solidjs/web": patch +--- + +Fix list-driver purity probe cascading into nested subtrees: while probing, +reactive work is recorded-and-skipped (effects and function-valued inserts +disqualify the row without building), so a declining probe on a container +row costs one shallow clone instead of recursively constructing and +discarding its subtree (O(N log N) waste on deep trees — 43x on uibench's +depth-10 tree render) diff --git a/.changeset/jfb-driver-and-write-bound.md b/.changeset/jfb-driver-and-write-bound.md new file mode 100644 index 000000000..e1709e779 --- /dev/null +++ b/.changeset/jfb-driver-and-write-bound.md @@ -0,0 +1,16 @@ +--- +"@solidjs/signals": minor +"@solidjs/web": patch +--- + +JFB validation pass: the list driver's identity matching unwraps store +proxies on both sides (draft-authored permutations stored proxies verbatim +and rebuilt every surviving row — caught by the keyed-reorder identity +gate), and setter notification is bounded to WRITTEN keys (`t.wk`) instead +of scanning every subscribed node per write — an id-keyed selection store +with thousands of per-row subscribers pays two node visits per select +instead of a full scan (select_lots 47x → 2x of octane). The bound falls +back to the full scan where it cannot hold: array length writes (implicit +index deletes; index writes record `length` alongside), records with +accessors, and class instances (prototype getters derive from arbitrary +fields). diff --git a/.changeset/patch-channel-pr-a.md b/.changeset/patch-channel-pr-a.md new file mode 100644 index 000000000..ff8b9ec63 --- /dev/null +++ b/.changeset/patch-channel-pr-a.md @@ -0,0 +1,19 @@ +--- +"@solidjs/signals": patch +--- + +Stage 2 (PR-A): the patch channel. Compiled per-record patch consumers +(`registerPatch`, undocumented compiler-contract export) dispatched by store +visibility transitions at all four sites: adoption walk and setter notify +(plain stores, with ancestor bubbling for targeted nested writes), fold +commit (projections — held folds hold their patches), and the override +lifecycle (application emits the visible draft; consumption and engine +reverts force-reapply from the live view). Application timing: per-flush +apply queue at render-effect phase; transition-stamped emissions release +when THEIR batch commits (reverted transactions drop by GC); optimistic +emissions drain at lane-effect timing so in-flight visibility works while +actions stash the regular queues. Unpatched stores pay a null check and the +module tree-shakes out of non-store bundles. Gauntlet: effect-phase timing, +reconcile prev pairing, nested-write bubbling, unbind/multi-consumer, +transition hold, optimistic in-flight + DOM revert, projection refetch, +disposed-owner drop. diff --git a/.changeset/patch-channel-row-ops.md b/.changeset/patch-channel-row-ops.md new file mode 100644 index 000000000..7636570fc --- /dev/null +++ b/.changeset/patch-channel-row-ops.md @@ -0,0 +1,10 @@ +--- +"@solidjs/signals": patch +--- + +Stage 2 (PR-B): row ops. The keyed adoption walk emits structural list ops +(`registerRowOps`: prefix, sources, removed) through the same apply queue as +record patches — aligned value ticks emit nothing; consumers apply minimal +DOM moves via one LIS over data ops instead of re-deriving moves from DOM +node arrays. Measured on dbmon: sort 10.7 → 4.5ms, remount 25.7 → 9.3ms +(octane 4.0/8.5), while ticks stay ahead (3.0/0.9 vs 3.2/1.3). diff --git a/.changeset/patch-driver-web-export.md b/.changeset/patch-driver-web-export.md new file mode 100644 index 000000000..ede0aab59 --- /dev/null +++ b/.changeset/patch-driver-web-export.md @@ -0,0 +1,7 @@ +--- +"@solidjs/web": patch +--- + +Fix patchDriver's runtime references (patchableRaw/registerPatch were in a +re-export block, not module scope) and surface patchDriver from the web entry +for patch-mode compiled templates diff --git a/.changeset/patch-fallback-semantics.md b/.changeset/patch-fallback-semantics.md new file mode 100644 index 000000000..39677979b --- /dev/null +++ b/.changeset/patch-fallback-semantics.md @@ -0,0 +1,11 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Patch-channel semantics completion: a throwing patch now routes through its +registering owner's queue chain to the enclosing error boundary (render- +effect parity; sibling isolation preserved, unhandled errors still rethrow), +and the dual-driver effect fallback splits phases with the same compiled +body — a next===prev read pass tracks in compute, the force apply writes in +the effect phase where transitions and batching expect DOM writes diff --git a/.changeset/patch-held-on-transition.md b/.changeset/patch-held-on-transition.md new file mode 100644 index 000000000..4d5499122 --- /dev/null +++ b/.changeset/patch-held-on-transition.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +--- + +Patch-channel held emissions stash directly on their transition object +instead of a WeakMap — the every-flush commit-hook check becomes one +property read, and reverted transitions drop their stash with the object diff --git a/.changeset/patch-list-hydration-claim.md b/.changeset/patch-list-hydration-claim.md new file mode 100644 index 000000000..0865235d9 --- /dev/null +++ b/.changeset/patch-list-hydration-claim.md @@ -0,0 +1,12 @@ +--- +"@solidjs/web": minor +--- + +Patch-mode list hydration: claim + register only. The list driver claims each +server row positionally through the row's own `_hk` key (a row-scoped +explicit-id owner makes the compiled template's getNextElement resolve it), +and patchDriver skips the initial force-apply while hydrating — server HTML +stays the truth until the first transition. All driver-side `each` reads and +the probe are id-isolated (throwaway/private explicit-id owners), so lazily +minted prop-getter memos can no longer shift the ambient hydration id chain +on either the engage or decline path. diff --git a/.changeset/patch-list-identity-ruling.md b/.changeset/patch-list-identity-ruling.md new file mode 100644 index 000000000..be1d95621 --- /dev/null +++ b/.changeset/patch-list-identity-ruling.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +"@solidjs/web": patch +--- + +Patch-mode lists now implement the identity semantics the view declares instead of the reconcile key's. Deep lists are unaffected (adoption preserves proxy identity, so key ops and reference semantics coincide). Shallow reference-keyed lists rebuild rows whose records were replaced — matching classic `mapArray` exactly, where the driver previously patched them in place (a default-on compiler mode must never change observable DOM identity). `For` forwards its `keyed` prop on the list metadata; explicit `keyed={fn}` lists decline the driver until the accessor-row binding contract lands. diff --git a/.changeset/patch-mode-list-driver.md b/.changeset/patch-mode-list-driver.md new file mode 100644 index 000000000..7ec2d53dc --- /dev/null +++ b/.changeset/patch-mode-list-driver.md @@ -0,0 +1,15 @@ +--- +"@solidjs/signals": minor +"solid-js": minor +"@solidjs/web": minor +--- + +Patch-mode list driver: keyed `` over a store array is offered to the +runtime's row-ops driver (create/bind at op-apply, LIS moves, node removal — +no mapArray, no per-row owners, no DOM-side reconcile). `For` carries `$ll` +metadata on a lazy classic accessor so unaware renderers and declined lists +(non-store subject, impure rows proven by a bind-time owner probe, fallback +or index usage) fall through to today's mapArray path unchanged. Array +identity swaps keep keyed semantics by raw-identity matching. Adds +`ownerIsBlank` (signals) for the purity probe and `driveList` (web, rxcore +seam) for the runtime. diff --git a/.changeset/per-row-patch-unbinds.md b/.changeset/per-row-patch-unbinds.md new file mode 100644 index 000000000..1871af39d --- /dev/null +++ b/.changeset/per-row-patch-unbinds.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Patch-mode lists retain per-row unbind handles: a record the app keeps beyond its row's life no longer holds a live patch registration updating detached DOM — registrations are severed on row removal, contract-leave handoffs, and list disposal. Dev builds also warn when a stamped row's build attaches computations or cleanups to the shared list owner (owned work in handler/attribute value position is unsupported in patch-mode rows). diff --git a/.changeset/probe-abort-semantics.md b/.changeset/probe-abort-semantics.md new file mode 100644 index 000000000..ddc5f41ce --- /dev/null +++ b/.changeset/probe-abort-semantics.md @@ -0,0 +1,10 @@ +--- +"@solidjs/web": patch +--- + +List-driver probe never lets user code observe the speculative build: the +first impurity marker (component creation, effect, memo, function-valued +insert, ref) ABORTS the probe via a sentinel instead of record-and-skip — +no refs handed never-mounted elements, no cleanups for rows that never +existed, no component bodies executed (caught by octane's effectful-list +work-count gate: refs 1001→1000, cleanups 1→0) diff --git a/.changeset/row-proof-admission.md b/.changeset/row-proof-admission.md new file mode 100644 index 000000000..a7184b72a --- /dev/null +++ b/.changeset/row-proof-admission.md @@ -0,0 +1,7 @@ +--- +"@solidjs/web": patch +"solid-js": patch +"@solidjs/signals": patch +--- + +Patch-mode list admission moves entirely to compile time: driveList engages only for row functions carrying the compiler's `rowProof` stamp (exported from @solidjs/web), and the runtime purity probe is deleted — no speculative execution of user row code, no probeMark/probeGate seams, no ownerIsBlank, no tentative empty-list engagement with late decline. Unstamped rows take the classic mapArray path before any DOM work; `lateClassic` remains only for engaged lists whose subject later leaves the contract (identity swap to a derived array, shallow/deep kind switch). diff --git a/.changeset/setter-row-ops-tentative-lists.md b/.changeset/setter-row-ops-tentative-lists.md new file mode 100644 index 000000000..26db94088 --- /dev/null +++ b/.changeset/setter-row-ops-tentative-lists.md @@ -0,0 +1,12 @@ +--- +"@solidjs/signals": minor +"@solidjs/web": minor +--- + +Close two list-driver coverage gaps found by the JFB store scenario: setter- +channel structural mutation (push/splice/index assignment/permutation) now +emits identity-keyed row ops at the fold — a driven list stays DOM-correct +for stores mutated without reconcile — and empty-initial lists engage +TENTATIVELY, deferring the purity probe to the first created row, with a +late decline handing the region to the classic mapArray path through the +runtime's re-entry thunk diff --git a/.changeset/shallow-compiled-slot-channel.md b/.changeset/shallow-compiled-slot-channel.md new file mode 100644 index 000000000..f88781268 --- /dev/null +++ b/.changeset/shallow-compiled-slot-channel.md @@ -0,0 +1,12 @@ +--- +"@solidjs/signals": minor +"@solidjs/web": minor +--- + +Shallow store lists through the compiled driver: slot patches graduate from +prototype to channel semantics (key-aligned value-replaced slots only — +structure rides row ops — queued at effect phase under the registration +owner), and the list driver collects a shallow row's compiled bodies at bind +(rows are raw; nothing to register on) and dispatches them from the array's +slot channel, rebasing indices with structural ops. Adds storeIsShallow; +kind-changing subject swaps (shallow <-> deep) hand off to classic. diff --git a/.changeset/web-export-patch-seams.md b/.changeset/web-export-patch-seams.md new file mode 100644 index 000000000..bf97e7953 --- /dev/null +++ b/.changeset/web-export-patch-seams.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Export the patchableRaw/registerPatch rxcore seams from the web core so the dom-expressions runtime's own patchDriver links. The public patchDriver export is unchanged (the web core's richer driver, with the shallow-row collector branch, still shadows the runtime's). diff --git a/PROPOSAL-KEYED-LIST-DRIVER.md b/PROPOSAL-KEYED-LIST-DRIVER.md new file mode 100644 index 000000000..1981dc0ac --- /dev/null +++ b/PROPOSAL-KEYED-LIST-DRIVER.md @@ -0,0 +1,224 @@ +# PROPOSAL: Keyed list-driver engagement (accessor rows) — for external audit + +Status: PROPOSAL ONLY. Nothing in this document is implemented. It exists to be +audited before any code is written. The author (agent) has made several +attribution and coverage errors in the preceding work (§9 lists them); the +auditor should treat every claim here as unverified until argued from the code. + +Repos/branches involved: +- `solid-edit-script` worktree, branch `store-edit-script` (solid monorepo) +- `dom-expressions-patch` worktree, branch `stage4-ssr` (compilers + runtime) +- Design record: `packages/solid-signals/DESIGN-PATCH-CHANNEL.md` (§16 has the + identity ruling this builds on) + +--- + +## 1. Context: the seam as built (three independent layers) + +1. **Self-declaration (runtime).** `For` is an ordinary component. Inside its + own body it attaches `$ll = { each, row }` to the accessor it returns + (`packages/solid/src/client/flow.ts`). Nothing detects For; it opts in. Any + list primitive may attach the same marker. +2. **Marker check (runtime).** dom-expressions `insert` offers any function + accessor carrying `$ll` to `driveList`; a `false` return falls through to + calling the accessor (classic mapArray path). +3. **Shape proof (compile time, inert).** The compiler stamps + (`rowProof`, `Symbol.for("solid.pure-row")`) any single-param function whose + body is exactly one compiled template with all dynamics landing in one + patch body on the param. Stamping is applied by syntax anywhere, carries no + meaning until a driver consults it, and involves no knowledge of For, + children props, or lists. + +**Identity ruling (landed 2026-08-24, DESIGN §16a):** the driver implements the +identity semantics the view declares, never the reconcile key's. Deep lists +coincide by construction (adoption preserves proxy identity per key). Shallow +reference-keyed lists rebuild replaced records. `keyed={fn}` lists currently +DECLINE the driver, because their rows receive accessors under the classic +contract and the driver binds raw records. + +**Consequence being addressed:** shallow + declared-key lists have no fast +path. Measured on octane's dbmon (same machine, same morning): + +| op | deep+driver (honest) | shallow keyed-classic | shallow old (RETRACTED, unsound) | octane | +|--------------|----------------------|-----------------------|----------------------------------|--------| +| mount | 5.80 | 7.05 | 4.85 | 4.81 | +| tick | 1.56 | 1.83 | 1.40 | 1.42 | +| tick_partial | 0.45 | 0.72 | 0.44 | 0.50 | +| remount | 4.38 | 4.58 | 3.79 | 3.77 | +| sort | 2.04 | 2.23 | 2.06 | 1.54 | +| unmount | 0.53 | 0.32 | 1.60 | 0.42 | + +Target: recover ~the retracted shallow numbers (tick ≈1.40, partial ≈0.44) +under correctly declared semantics. This is an optimization of an +already-winning configuration (deep+driver beats keyed-classic ~7% geomean), +NOT load-bearing for stage 2's case. + +--- + +## 2. Proposal A — seam identity contract (runtime only) + +Replace the currently-landed `keyed` forwarding on `$ll` with a +component-neutral field: + +```ts +$ll = { + each: () => T[], + row: (item) => Node, // raw rows + identity: "reference" | "positional" | ((item: T) => any) +} +``` + +- `For` translates its own prop: `keyed` absent/`true` → `"reference"`, + `false` → `"positional"`, fn → the fn. The translation lives in For's body. +- The driver implements the seam's semantics and has no knowledge of For's + API. Rationale: any keyed-list renderer must define "what makes a row the + same row" — reference, position, or key. The seam carries only this + domain-forced vocabulary, never component vocabulary (boundary rule). + +## 3. Proposal B — accessor-row stamp variant (compile time) + +A second row-proof production, `param().member`: + +- Grammar line, exactly: **a bare, zero-argument call of the row parameter + itself at the head of a member chain** (`db().name`, + `db().queries[0].elapsed` with static/numeric steps). NOT `helper(db).x`, + NOT `db.child()`, NOT `db()(…)`, NOT calls with arguments. +- Produces a DISTINCT stamp variant (e.g. the stamp value `"accessor"` instead + of `true`) recording which shape was proved. +- Emitted body is unchanged machinery: it runs against a RESOLVED subject. + Classic codegen wraps it in an effect that computes `db()` per run (correct + accessor semantics with zero driver involvement); the driver passes the + current record directly. +- **Scope constraint (load-bearing):** the production is admitted ONLY by + `recordPureRow` (row-proof analysis). General patchDriver eligibility for + template dynamics is untouched. Reason: a registered patch binds to a + specific record; only the list driver owns subject lifetime (it re-binds or + rebuilds on every identity transition). `x().member` in general position + would register against a bind-time snapshot and go stale when `x()` starts + returning a different object — the existing subject-stability rule exists + for exactly this. +- Implemented in BOTH compilers (Babel `recordPureRow` in dom/template.ts; + Oxc `record_pure_row` in dom/element.rs) with byte-parity tests. + +## 4. Proposal C — driver accessor binding (runtime) + +For engaged lists with `identity: fn` and accessor-variant rows: + +- Each row binds with a stable per-row closure: `rowFn(() => currentRecord)`. + The closure reads a per-row slot the driver owns. +- Key-retained replacement (same key, new record): update the slot, then + re-apply the row's collected bodies with `(next, prev)` — this is the + EXISTING slot/value channel (`applySlot` in-place branch, currently + unreachable), re-pointed at declared-key lists. No third dispatch path. +- Structural ops (add/remove/move) ride the existing row-ops LIS apply + unchanged. +- Pairing matrix (engagement decision, in order): + 1. row not stamped → decline (classic). + 2. `identity: "reference"` + raw-variant stamp → engage; replacement + REBUILDS (landed behavior). + 3. `identity: fn` + accessor-variant stamp → engage; replacement value-ticks + in place (this proposal). + 4. any other pair (reference+accessor, fn+raw, positional+either) → decline. +- Mismatch soundness (S1 below): classic is always correct for whatever the + author wrote, because the author's row body must match their own `keyed` + declaration for the CLASSIC path to function at all (a raw-reading body + under `keyed={fn}` receives an accessor and is broken with no driver in the + picture; vice versa for `db()` under reference keying). + +## 5. Dev-mode checks + +- Key agreement: when ops from a reconcile walk (keyed by the reconcile + keyFn) apply to an `identity: fn` list, dev mode spot-checks + `identity(next) === identity(prev)` on value-ticked slots and warns on + disagreement (the reconcile key and the view key describing different + identities is an authoring error that would otherwise be silent). +- The existing dev ownership assertion (row builds must attach nothing to the + list owner) applies to accessor rows unchanged. + +## 6. Soundness claims (each falsifiable — auditor: try to break these) + +- **S1 (fallback equivalence):** for every (identity, stamp-variant, authoring) + combination, declining to classic produces the author-intended behavior. + Falsify by exhibiting a row body + keyed declaration that works classically + but breaks when the driver declines. (Decline = literally calling the + accessor; hard to see how it could differ, but that's the point of audit.) +- **S2 (engagement equivalence):** for every engaged combination, driver DOM + behavior ≡ classic DOM behavior for the same op sequence: same nodes + created/removed/moved/retained, same content, same event/ref/handler + timing-observable state. Proposed as an executable test matrix (see §8), not + an argument. KNOWN nuance: bound-data handlers (`onClick={[select, db().id]}`) + evaluate once at build and go stale after key-retained replacement — in BOTH + classic keyed mapArray and the driver (parity, author's semantics). The + matrix must pin this as EQUAL behavior, not fix it. +- **S3 (subject lifetime):** accessor-variant stamps are consumable only by + driveList (the only consumer that re-binds on identity transitions). + Falsify by finding another code path that consults stamps or registers + patches from a `param().member` body. +- **S4 (no component coupling):** compiler stamps by shape anywhere (inert); + seam speaks domain vocabulary; For translates its own API in its own body. + Falsify by finding any point where compiler output depends on the component + named `For`, or where the driver reads For-specific vocabulary. +- **S5 (shape rules):** no new named fields on store targets (the pc-extension + rule, DESIGN §16d); driver-side per-row slots live in driver locals, not on + targets. Falsify by finding a new field on `StoreNextTarget` or >20 named + props on a target in this design. + +## 7. Risks the author already sees (auditor: look for more) + +- **R1:** stale bound-data handlers on key-retained replacement (parity with + classic, but a semantics surprise worth documenting for authors). +- **R2:** the slot-index rebasing under mixed value-ticks + structural moves + (`applySlot` after row-ops in one batch) was tested under the old + key-semantics regime; the matrix must re-cover it under `identity: fn`. +- **R3:** two-compiler byte-parity for the new production (historically a + source of drift; §3c/§3d parity harness covers it but the fixture set must + include accessor rows). +- **R4:** hydration: accessor rows must claim server rows identically + (claim+register, no writes). The claim path binds rows once; the accessor + closure must be initialized before first body application. +- **R5:** transitions/optimistic: value-ticks ride the same apply queue with + transition stamping; nothing new is believed to be needed — verify against + the PR-A gauntlet tests extended to identity-fn lists. +- **R6:** `each`-watch identity swaps (`s.rows = newArr`) on identity-fn + lists: the synthetic full-window op currently matches by RAW identity; for + identity-fn lists it must match by the declared key. Small, easy to miss. + +## 8. Acceptance gates (all must pass before merge) + +1. **Equivalence matrix test** (the trust-restoring artifact): for each + identity mode × op sequence (replace-aligned, replace-through-move, add, + remove, reorder, clear, identity-swap, mixed batch), assert driver-engaged + DOM ≡ classic DOM node-for-node (identity, content, order), by running the + same fixture with the driver enabled and with stamps stripped. +2. Shallow-keyed dbmon through octane's harness: gate PASS + tick ≤ ~1.45, + partial ≤ ~0.50 (recovering the retracted numbers under honest semantics), + with deep+driver numbers unchanged. +3. Compiler parity suite green with accessor-row fixtures added (Babel ≡ Oxc). +4. Full test matrix green (signals/solid/web incl. hydration claim specs). +5. Coverage report artifact: compiled fixture corpus, listing every `For` row + and its status (raw-stamped / accessor-stamped / unstamped+why) — the + audit trail for what each grammar admits, diffable across future changes. +6. No store-target shape changes (probe with `%HasFastProperties` script). + +## 9. Documented errors by the author tonight (audit these areas hardest) + +1. Claimed §3c probe removal was coverage-neutral; it was not (variable-shape + rows lost engagement; uibench). Verified only fixtures whose rows stamp. +2. Claimed the dbmon fixture "keeps keyed + keeps its 1.40/0.44" — false at + the time (keyed rows take accessors, cannot stamp, driver declined). +3. Attributed the js-framework select regression to per-record patch + amplification; actual cause was the wk-bound's plainProto guard vs overlay + prototypes (store layer), found only after compiling the fixture. +4. Measured "parity" from single benchmark runs twice before interleaved A/B + showed regressions (±20% per-suite drift on this machine). +5. Stage-2-era claims (uibench "1.09x inferno", shallow gate pass) retracted; + see DESIGN §16b/§16c for what replaced them. + +## 10. Explicit non-goals + +- No change to general patch eligibility, Tier-2 record patches, or any + non-list template compilation. +- No runtime purity probing in any form. +- No attempt to engage variable-shape rows (uibench `cells()` style): those + remain classic unless an author hand-stamps (`rowProof`, documented promise, + dev-asserted) — which is an authoring decision, not part of this proposal. diff --git a/packages/signals/src/core/optimistic.ts b/packages/signals/src/core/optimistic.ts index e37d179f2..967fdd7dc 100644 --- a/packages/signals/src/core/optimistic.ts +++ b/packages/signals/src/core/optimistic.ts @@ -185,6 +185,9 @@ function runLaneEffects(type: number): void { runQueue(effects, type); } } + // Optimistic patch applications ride the same visibility slot as lane + // effects (in-flight DOM updates); no-op unless patches registered. + if (type === EFFECT_RENDER) GlobalQueue._drainPatchOptimistic?.(); } function cleanupCompletedLanes(completingTransition: Transition | null): void { diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 998e6d63b..3edab416c 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -450,6 +450,10 @@ export class GlobalQueue extends Queue { static _transitionBlocked: ((transition: Transition) => boolean) | null = null; static _cleanupLanes: ((completingTransition: Transition | null) => void) | null = null; static _runLaneEffects: ((type: number) => void) | null = null; + /** Patch-channel optimistic drain (next/patch.ts): optimistic emissions + * apply at lane-effect timing — visible in flight, unlike the regular + * effect queues an action stashes. Injected; null when unused. */ + static _drainPatchOptimistic: (() => void) | null = null; static _gatedRead: | ((el: Signal, owner: OptimisticNode, c: Computed) => boolean) | null = null; @@ -800,6 +804,15 @@ export function setStoreCommitHook(fn: () => void): void { storeCommitHook = fn; } +/** Patch-channel release hook (next/patch.ts): transition-stamped patch + * emissions are released when THEIR batch commits — reverted transitions + * never reach here, so their entries drop by construction. Injected like + * storeCommitHook to stay tree-shakeable. */ +export let patchCommitHook: ((batch: Transition) => void) | null = null; +export function setPatchCommitHook(fn: (batch: Transition) => void): void { + patchCommitHook = fn; +} + function commitPendingNodes() { const pendingNodes = currentBatch._pendingNodes; for (let i = 0; i < pendingNodes.length; i++) { @@ -807,6 +820,7 @@ function commitPendingNodes() { } pendingNodes.length = 0; storeCommitHook?.(); + patchCommitHook?.(currentBatch); } export function finalizePureQueue( diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 7d2f5ff39..734803865 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -25,6 +25,17 @@ import { reconcileNextState } from "./next/reconcile.js"; import { createStoreDerivedNext } from "./next/projection.js"; export { createProjectionNext as createProjection } from "./next/projection.js"; +// Compiler-contract surface (see src/compiler.ts — the sanctioned import is +// the `@solidjs/signals/compiler` subpath; root presence is a single-file +// dev-build artifact and is undocumented). +// Compiler-contract surface (DESIGN-PATCH-CHANNEL.md): what patch-mode +// compiled output links against. Undocumented as an application API. +export { registerPatch, registerRowOps, patchableRaw } from "./next/patch.js"; +export { + registerSlotPatchNext as registerSlotPatch, + storeIsShallow, + storeHasFamily +} from "./next/store.js"; export { createOptimisticStoreNext as createOptimisticStore } from "./next/optimistic.js"; /** Public createStore: plain form `(init, options?)` and derived writable diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 142fd9c8e..a63e6a142 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -51,6 +51,7 @@ import { unwrapValue, wrapNext } from "./store.js"; +import { emitPatchOptimistic } from "./patch.js"; import { setOptHooks, storeNextLookup } from "./target.js"; type KeyFn = (item: any) => any; import { isRawValue, isWrappable, rawValuesUsed, setNextOptimisticViewResolver } from "../store.js"; @@ -70,6 +71,18 @@ function installNextBlockedHalf(): void { // so the hook only empties the batch set. if (!GlobalQueue._clearOptimisticStores) { GlobalQueue._clearOptimisticStores = (stores: Set) => { + // Patch channel (revert site): engine-native reverts flip node values + // back to committed; patched records need a forced DOM re-apply from + // the post-revert view. Emission only — next keeps no layer to clear. + for (const px of stores) { + const t: StoreNextTarget | undefined = px?.[$TARGET]; + const overlaid = t?.fam?.overlaid as Set | undefined; + if (overlaid !== undefined) { + for (const ot of overlaid) { + if (ot.pc !== null && ot.pc.p !== null) emitPatchOptimistic(ot, null, null); + } + } + } stores.clear(); }; } @@ -188,6 +201,10 @@ export function notifyOptimisticWrites(t: StoreNextTarget, pb: Record { const node = t.n?.[key as any]; return node !== undefined && hasActiveOverride(node) @@ -319,6 +336,10 @@ export function consumeOverridesNext(fam: StoreNextFamily): void { insertSubs(t.k, true); schedule(); } + // Patch channel (override-consumption site): visible truth flipped to + // committed for the consumed keys — force a re-apply from the live + // view so the DOM leaves the override state. + if (t.pc !== null && t.pc.p !== null) emitPatchOptimistic(t, null, null); } overlaid.clear(); }); diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts new file mode 100644 index 000000000..389ebea9d --- /dev/null +++ b/packages/signals/src/store/next/patch.ts @@ -0,0 +1,362 @@ +/** + * PR-A: the patch channel (DESIGN-PATCH-CHANNEL.md). + * + * Compiled patch functions — per-record compare-and-write consumers — + * dispatched by the store's visibility transitions instead of render + * effects. This module owns registration, the per-flush apply queue + * (effect-phase timing, §2b), the owned-prev rule (§2c), and dispatch + * bubbling (§4b). Emission calls live at the four visibility-transition + * sites (adoption walk, setter notify, fold commit, override lifecycle) + * and are gated on registration, so unpatched stores pay a null check. + * + * Bubbling contract: a targeted nested write reaches ancestor patches as a + * FORCED re-apply — the third `force` argument makes every compiled compare + * pass, so the ancestor rewrites its bound fields from its current backing + * (idempotent, and prev-free: an ancestor's pre-state is not reconstructible + * after in-place folds). Compiled bodies therefore have the signature + * `(next, prev, force?)`. + * + * Tree-shaking: core never imports this module; stores without patches + * never schedule the queue. + */ +import { EFFECT_RENDER, STATUS_ERROR } from "../../core/constants.js"; +import { ext } from "../../core/core.js"; +import { StatusError } from "../../core/error.js"; +import { getOwner, isDisposed } from "../../core/owner.js"; +import { + activeTransition, + globalQueue, + GlobalQueue, + setPatchCommitHook, + type Transition +} from "../../core/scheduler.js"; +import type { Owner } from "../../core/types.js"; +import { $TARGET } from "../store.js"; +import { markDescendants, ownedRaw, type StoreNextTarget } from "./target.js"; +// Cycle with store.js is benign: pcOf is only called at registration time, +// long after both modules initialize. +import { pcOf } from "./store.js"; + +export type PatchFn = (next: any, prev: any, force?: boolean) => void; + +interface PatchEntry { + fn: PatchFn; + owner: Owner | null; +} + +// Per-flush apply queue. Bubbled (forced) emissions resolve `next` LAZILY at +// drain time from the live target: privatization can clone an ancestor's +// backing between emission and drain, so a captured reference goes stale. +interface QueuedApply { + list: PatchEntry[]; + next: any; + prev: any; + force: boolean; + /** When set, `next` resolves at drain as `t.pb ?? t.v` (bubbles). */ + t: StoreNextTarget | null; +} +let queue: QueuedApply[] | null = null; +let scheduled = false; + +function drainApplyQueue(): void { + // Settle-time fallback for optimistic emissions (a reverting flush may + // have no active lanes left to run the lane-slot drain). + drainOptimistic(); + const q = queue; + queue = null; + scheduled = false; + if (q === null) return; + // Per-entry isolation: one throwing patch must not abort its siblings + // (effect parity — each effect isolates its failure). A throwing patch + // routes through its REGISTERING OWNER's queue chain exactly like a + // render-effect error (§2b): an Errored boundary above the row collects + // it (source = the owner, error read via owner._x?._error). Unhandled errors + // rethrow after the drain so they still surface. + let firstError: unknown = UNSET; + for (let i = 0; i < q.length; i++) { + const { list, prev, force, t } = q[i]; + const next = t !== null ? (t.pb ?? t.v) : q[i].next; + for (let j = 0; j < list.length; j++) { + const entry = list[j]; + // Disposed owners drop their patches (the row unmounted mid-flush). + if (entry.owner !== null && isDisposed(entry.owner)) continue; + try { + entry.fn(next, prev, force); + } catch (err) { + let handled = false; + const owner = entry.owner as any; + if (owner !== null) { + const statusErr = new StatusError(owner, err); + ext(owner)._error = statusErr; + owner._statusFlags = (owner._statusFlags ?? 0) | STATUS_ERROR; + handled = owner._queue.notify(owner, STATUS_ERROR, STATUS_ERROR, statusErr); + } + if (!handled && firstError === UNSET) firstError = err; + } + } + } + if (firstError !== UNSET) throw firstError; +} + +const UNSET: unique symbol = Symbol(); + +// Transition-stamped emissions (§2b, "the walk is not the visibility moment +// inside a transition"): entries stash DIRECTLY on their transition +// (`_heldPatches`) and release into the live queue when THAT batch commits +// (patchCommitHook). Reverted transitions never commit — their stash drops +// with the transition object, no revert bookkeeping. The field (rather than +// a WeakMap) keeps the every-flush commit-hook check to one property read; +// the ambient batch never stashes. +let commitHookInstalled = false; + +function releaseBatch(batch: Transition): void { + const held = (batch as any)._heldPatches as QueuedApply[] | undefined; + if (held === undefined) return; + (batch as any)._heldPatches = undefined; + for (let i = 0; i < held.length; i++) pushLive(held[i]); +} + +function pushLive(item: QueuedApply): void { + if (queue === null) queue = []; + queue.push(item); + if (!scheduled) { + scheduled = true; + globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); + } +} + +function push(item: QueuedApply): void { + const tx = activeTransition; + if (tx !== null) { + let held = (tx as any)._heldPatches as QueuedApply[] | undefined; + if (held === undefined) (tx as any)._heldPatches = held = []; + held.push(item); + return; + } + pushLive(item); +} + +/** Shallow clone for the owned-prev rule (§2c): owned backings fold values + * INTO the same raw at commit, so a queued prev must be snapshotted. */ +function clonePrev(prev: any): any { + return Array.isArray(prev) ? prev.slice() : { ...prev }; +} + +/** + * Emit a record's visibility transition. Callers gate on `hasPatches()` and + * `t.d` cheaply; this function re-checks and walks ancestors (§4b). + */ +export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { + const p = (t.pc !== null ? t.pc.p : null) as PatchEntry[] | null; + if (p !== null) + push({ + list: p, + next, + prev: ownedRaw.has(prev) ? clonePrev(prev) : prev, + force: false, + t: null + }); + // Bubbling: ancestors force-re-apply from their LIVE backing, resolved at + // drain (privatization may clone it between now and then). + let u = t.u; + while (u !== null) { + const up = (u.pc !== null ? u.pc.p : null) as PatchEntry[] | null; + if (up !== null) push({ list: up, next: null, prev: null, force: true, t: u }); + u = u.u; + } +} + +/** Emission for sites that already stand at the record with both sides in + * hand and have already handled ancestors (the adoption walk descends — + * parents were visited first), so no bubbling walk. */ +export function emitPatchLocal(t: StoreNextTarget, next: any, prev: any): void { + const p = (t.pc !== null ? t.pc.p : null) as PatchEntry[] | null; + if (p !== null) + push({ + list: p, + next, + prev: ownedRaw.has(prev) ? clonePrev(prev) : prev, + force: false, + t: null + }); +} + +/** Optimistic-channel emission: overrides are visible THIS flush while the + * transaction is in flight — that is what optimism means. These ride a + * dedicated queue drained at LANE-EFFECT timing (the regular effect queues + * are stashed by an in-flight action), with the regular drain as the + * settle-time fallback. `next === null` = forced re-apply from the live + * target (the revert shape: committed truth back onto the DOM). */ +let optQueue: QueuedApply[] | null = null; + +function drainOptimistic(): void { + const q = optQueue; + optQueue = null; + if (q === null) return; + for (let i = 0; i < q.length; i++) { + const { list, prev, force, t } = q[i]; + const next = t !== null ? (t.pb ?? t.v) : q[i].next; + for (let j = 0; j < list.length; j++) { + const entry = list[j]; + if (entry.owner !== null && isDisposed(entry.owner)) continue; + entry.fn(next, prev, force); + } + } +} + +export function emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void { + const p = (t.pc !== null ? t.pc.p : null) as PatchEntry[] | null; + if (p === null) return; + if (optQueue === null) optQueue = []; + if (next === null) optQueue.push({ list: p, next: null, prev: null, force: true, t }); + else optQueue.push({ list: p, next, prev, force: false, t: null }); + // Backup scheduling: the lane-slot drain covers in-flight application; a + // stashed regular drain guarantees settle-time application when no lane + // survives to the final flush (pure reverts). + if (!scheduled) { + scheduled = true; + globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); + } +} + +/** + * Register a compiled patch on a store record. Multi-consumer (two lists + * can render one record); owner-scoped for disposal. Returns unbind. + */ +// Global registration count: the cheap gate emission sites check before any +// per-record work (unpatched apps pay one number compare per transition). +let patchCount = 0; +export function hasPatches(): boolean { + return patchCount > 0; +} + +export function registerPatch(record: any, fn: PatchFn): () => void { + const t: StoreNextTarget | undefined = record?.[$TARGET]; + if (t === undefined) throw new Error("registerPatch: not a store record"); + if (!commitHookInstalled) { + commitHookInstalled = true; + setPatchCommitHook(releaseBatch); + GlobalQueue._drainPatchOptimistic = drainOptimistic; + } + const entry: PatchEntry = { fn, owner: getOwner() }; + const pc = pcOf(t); + const list = (pc.p ??= []) as PatchEntry[]; + list.push(entry); + patchCount++; + // Bindings are subscriptions for reachability (§6d pruning must descend + // into bound records). + markDescendants(t); + let unbound = false; + return () => { + if (unbound) return; + unbound = true; + patchCount--; + const idx = list.indexOf(entry); + if (idx >= 0) list.splice(idx, 1); + if (list.length === 0 && pc.p === list) pc.p = null; + }; +} + +/** Dual-driver bind probe (compiler runtime contract): when `record` is a + * patchable store record, returns its CURRENT raw backing (the driver's + * initial force-apply reads it directly — no proxy traffic, no tracking); + * returns undefined otherwise (driver falls back to the effect path). + * Not patchable: non-records, non-proxies, accessor-bearing records + * (patches read raw — getters need tracked evaluation). */ +export function patchableRaw(record: any): Record | undefined { + const t: StoreNextTarget | undefined = record?.[$TARGET]; + if (t === undefined || t.px !== record || t.a === true) return undefined; + return t.pb ?? t.v; +} + +/** Accessor demotion (design §5): a record that acquires an accessor after + * registration stops being patchable — reads must go through tracked + * evaluation. Clears patches; the dual-driver bind's effect fallback takes + * over (wired by the compiler's bind closure via onDemote). */ +export function demotePatches(t: StoreNextTarget): PatchEntry[] | null { + if (t.pc === null) return null; + const p = t.pc.p as PatchEntry[] | null; + t.pc.p = null; + return p; +} + +// --------------------------------------------------------------------------- +// Row ops (PR-B): structural list transitions for keyed arrays. + +/** Structural ops for one keyed-array transition. `prefix` rows key-matched + * in place; for each later index i (absolute), `sources[i - prefix]` is the + * OLD index its row retained from, or -1 for a new row. `removed` holds the + * dropped old row values (unbind/teardown handles). Aligned value ticks emit + * NOTHING — ops exist only when structure changed. */ +export interface RowOps { + prefix: number; + sources: number[]; + removed: any[]; +} + +export type RowOpsFn = (next: any[], ops: RowOps) => void; + +interface RowOpsEntry { + fn: RowOpsFn; + owner: Owner | null; +} + +/** Register a structural-ops consumer on a keyed store array (the list + * container's channel — what `For` consumes through the seam). */ +export function registerRowOps(array: any, fn: RowOpsFn): () => void { + const t: StoreNextTarget | undefined = array?.[$TARGET]; + if (t === undefined) throw new Error("registerRowOps: not a store array"); + if (!commitHookInstalled) { + commitHookInstalled = true; + setPatchCommitHook(releaseBatch); + GlobalQueue._drainPatchOptimistic = drainOptimistic; + } + const entry: RowOpsEntry = { fn, owner: getOwner() }; + const pc = pcOf(t); + const list = (pc.ro ??= []) as RowOpsEntry[]; + list.push(entry); + patchCount++; + markDescendants(t); + let unbound = false; + return () => { + if (unbound) return; + unbound = true; + patchCount--; + const idx = list.indexOf(entry); + if (idx >= 0) list.splice(idx, 1); + if (list.length === 0 && pc.ro === list) pc.ro = null; + }; +} + +/** Slot patches (shallow arrays) ride the same apply queue: the walk emits + * per aligned value-replaced slot; application happens at effect phase under + * the registration owner's lifetime. */ +export function emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev: any): void { + const sp = t.pc !== null ? t.pc.sp : null; + if (sp === null) return; + push({ + list: sp.map(e => ({ owner: e.owner, fn: () => e.fn(index, next, prev) })), + next, + prev, + force: false, + t: null + }); +} + +/** Row-ops ride the SAME apply queue/timing as record patches: transition- + * stamped, applied at effect phase, in emission order (structure before the + * new rows' own patches can exist; retained rows' value patches commute). */ +export function emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void { + const list = (t.pc !== null ? t.pc.ro : null) as RowOpsEntry[] | null; + if (list === null) return; + push({ + list: list.map(e => ({ + owner: e.owner, + fn: (n: any, _p: any) => e.fn(n as any[], ops) + })), + next, + prev: null, + force: false, + t: null + }); +} diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index fd15f4b07..5c4ddf436 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -19,6 +19,7 @@ * members (R11). Kind changes replace wholesale (R10). */ import { isEqual } from "../../core/index.js"; +import { emitPatchLocal, emitRowOps, emitSlotPatch } from "./patch.js"; import { $PROXY, $TARGET, @@ -131,6 +132,12 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj const shallow = t.s === true; const old = t.v; adoptPB(t, incoming, eager); + // Patch channel (adoption site): this record transitioned — queue its + // patches with the pre-adopt prev. No bubbling walk: the adoption walk + // visits parents before children, so ancestors emitted already. EAGER + // only — family targets' visibility moment is their fold commit + // (drainFolds emits there; emitting here too would double-fire). + if (eager && t.pc !== null && t.pc.p !== null) emitPatchLocal(t, incoming, old); // Shallow adoption: records are slot values — sticky raw-mark the incoming // set (R41) and never descend; slot notification is the positional diff. if (shallow) markRawIngest(incoming); @@ -198,6 +205,7 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj } } if (t.dk !== null && !dkBumpedA && i < nextRows.length) bumpDeep(t); + const structStart = i; // misalignment point (== nlen on aligned ticks) let prevByKey: Map | null = null; for (; i < nextRows.length; i++) { const nv = nextRows[i]; @@ -230,12 +238,47 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj } } } + // Row ops (PR-B): emit structural ops ONLY when structure changed — + // aligned value ticks pay nothing. Built after the walk so retained + // rows' value patches queue first (adds bind at op-apply). + if (t.pc !== null && t.pc.ro !== null && (structStart < nlen || plen !== nlen)) + buildAndEmitRowOps(t, prevRows, nextRows, structStart, keyFn); } else { const dlen = Math.min(prevRows.length, nextRows.length); const nlen = nextRows.length; let dkBumpedP = false; + const sp = t.pc !== null ? t.pc.sp : null; + // Row ops for shallow/positional lists: track the key-aligned prefix + // (keyed) so aligned value ticks emit nothing; keyless lists emit only + // on length change (append/truncate). Slot-patch consumers need the + // alignment tracking too (aligned = value tick, misaligned = ops). + const ro = t.pc !== null ? t.pc.ro : null; + let keyAligned = keyFn !== null && (ro !== null || sp !== null); + let keyPrefix = 0; for (let i = 0; i < nlen; i++) { const nvP = nextRows[i]; + if (keyAligned && i < dlen) { + const pvK = prevRows[i]; + if ( + pvK !== null && + typeof pvK === "object" && + nvP !== null && + typeof nvP === "object" && + keyFn!(pvK) === keyFn!(nvP) + ) + keyPrefix++; + else keyAligned = false; + } + // Slot-patch dispatch (shallow): a KEY-ALIGNED slot whose value was + // replaced by reference is a value tick — emit through the queue. + // Misaligned/appended slots are STRUCTURE (row ops rebuild or move + // them; new rows initial-apply at bind), so they emit nothing here. + // Keyless positional lists treat same-index replacement as the value + // tick for indices below the common length. + if (sp !== null && (keyFn !== null ? keyAligned : i < dlen)) { + const pvS = i < dlen ? prevRows[i] : undefined; + if (pvS !== nvP) emitSlotPatch(t, i, nvP, pvS); + } if (!shallow && i < dlen && nvP !== null && typeof nvP === "object") descend(unwrapValue(prevRows[i]), nvP, keyFn, fam, proj); if ( @@ -256,6 +299,15 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj } } } + if (ro !== null) { + const plen = prevRows.length; + if (keyFn !== null) { + if (keyPrefix < nlen || plen !== nlen) + buildAndEmitRowOps(t, prevRows, nextRows, keyPrefix, keyFn); + } else if (plen !== nlen) { + buildAndEmitRowOps(t, prevRows, nextRows, dlen, null); + } + } } if (eager) { if (nodes !== null && nodesHit < t.nc) { @@ -276,6 +328,22 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj // slots must not notify, R9). This replaces the notifyFold re-walk that // doubled dbmon's diff cost. for-in covers own enumerable string keys // with no key-array allocation; symbols get a pass only when present. + // PROTOTYPE compiled-patch fast path: a pure-patch record (no nodes, + // no presence/key-set/deep subscribers, no family) adopts and hands the + // (next, prev) pair to its compiled patch — no per-key walk at all. + if ( + t.pc !== null && + t.pc.p !== null && + eager && + t.n === null && + t.h === null && + t.k === null && + t.dk === null && + fam === null + ) { + // Adoption already ran at applyAdopt entry; emission was queued there. + return; + } const nodes = eager ? t.n : null; let nodesHit = 0; let dkBumped = false; @@ -345,6 +413,68 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj const hasOwnP = Object.prototype.hasOwnProperty; +/** Setter-channel row ops (the fold site calls this for array targets with + * ops consumers): structural mutation through the setter — push/splice/index + * assignment/permutation — is a visibility transition for the list container + * just like a reconcile walk, and drivers consuming registerRowOps must see + * it. Setter mutations move the SAME row objects around, so RAW IDENTITY is + * the key. Aligned arrays (value-only folds) emit nothing. */ +const identityKey = (r: any) => unwrapValue(r); +export function emitSetterRowOps(t: StoreNextTarget, prevRows: any[], nextRows: any[]): void { + let p = 0; + const min = prevRows.length < nextRows.length ? prevRows.length : nextRows.length; + while (p < min && unwrapValue(prevRows[p]) === unwrapValue(nextRows[p])) p++; + if (p === prevRows.length && p === nextRows.length) return; + buildAndEmitRowOps(t, prevRows, nextRows, p, identityKey); +} + +/** Shared row-ops builder (keyed deep branch + shallow/positional branch): + * key-matches the misaligned window into { prefix, sources, removed }. + * `keyFn === null` degrades to positional ops (append/truncate only). */ +function buildAndEmitRowOps( + t: StoreNextTarget, + prevRows: any[], + nextRows: any[], + structStart: number, + keyFn: KeyFn | null +): void { + const plen = prevRows.length; + const nlen = nextRows.length; + const sources = new Array(nlen - structStart); + let oldIndexByKey: Map | null = null; + if (keyFn !== null && structStart < plen) { + oldIndexByKey = new Map(); + for (let j = structStart; j < plen; j++) { + const p = unwrapValue(prevRows[j]); + if (p !== null && typeof p === "object") { + const pk = keyFn(p); + if (pk !== undefined && !oldIndexByKey.has(pk)) oldIndexByKey.set(pk, j); + } + } + } + const consumed = oldIndexByKey !== null ? new Set() : null; + for (let k = structStart; k < nlen; k++) { + const nv = nextRows[k]; + let oldIdx = -1; + if (nv !== null && typeof nv === "object" && oldIndexByKey !== null) { + const nk = keyFn!(nv); + if (nk !== undefined) { + const m = oldIndexByKey.get(nk); + if (m !== undefined) { + oldIdx = m; + consumed!.add(m); + } + } + } + sources[k - structStart] = oldIdx; + } + const removed: any[] = []; + for (let j = structStart; j < plen; j++) { + if (consumed === null || !consumed.has(j)) removed.push(unwrapValue(prevRows[j])); + } + emitRowOps(t, nextRows, { prefix: structStart, sources, removed }); +} + function descend( pv: any, nv: any, diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 8ae737f5d..5643fc16b 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -31,23 +31,15 @@ import { import { devGuardStoreSetterWrite, isEqual, - latestReadActive, - prepareComputed, read as readNode, READ_SLOW, readNodeFast, - setLatestReadActive, setSignal, signal, untrack, ext } from "../../core/core.js"; -import { - activeTransition, - currentTransition, - globalQueue, - insertSubs -} from "../../core/scheduler.js"; +import { activeTransition, globalQueue, insertSubs } from "../../core/scheduler.js"; import { getObserver, getOwner } from "../../core/owner.js"; import { GlobalQueue, @@ -84,12 +76,18 @@ import { import { devAssertNeverUserMutation, ingestedRaw, + markDescendants, ownedRaw, storeNextLookup, type StoreNextFamily, type StoreNextTarget, + type PatchChannel, optHooks } from "./target.js"; +import { emitPatch, emitPatchLocal, hasPatches } from "./patch.js"; +// Cycle with reconcile.js is benign: the binding resolves at call time (the +// fold), long after both modules initialize. +import { emitSetterRowOps } from "./reconcile.js"; // --------------------------------------------------------------------------- // wrap / dedupe @@ -102,8 +100,13 @@ import { * headroom for future fields. The prototype is reset to `Object.prototype` * so proxy-forwarded semantics (getPrototypeOf, constructor) are exactly a * plain object's. Array targets keep the bare-`[]` path — they must carry - * the array exotic class for `Array.isArray(proxy)`, and arrays store named - * fields off-object where this cliff does not apply. */ + * the array exotic class for `Array.isArray(proxy)`. + * + * ARRAY SHAPE RULE: arrays normalize their named properties to dictionary + * mode as the count grows (V8 13.x: counts ≡ 0 mod 3 from 18 up), so the + * target's named field count is capped at 20 — write-side patch-channel + * state lives inside the single `pc` extension (see target.ts), never as + * new named fields here. */ function TargetShape(this: any) { this.v = undefined; this.ch = undefined; @@ -124,12 +127,15 @@ function TargetShape(this: any) { this.s = undefined; this.ovl = undefined; this.del = undefined; - this.wk = undefined; - this.hv = undefined; - this.ht = undefined; + this.pc = undefined; } TargetShape.prototype = Object.prototype; +/** Lazily allocate the patch-channel extension (one literal shape). */ +export function pcOf(t: StoreNextTarget): PatchChannel { + return t.pc ?? (t.pc = { sp: null, p: null, ro: null, wk: null }); +} + function createTarget( value: Record, parent: StoreNextTarget | null, @@ -151,6 +157,7 @@ function createTarget( t.h = null; t.k = null; t.dk = null; + t.pc = null; t.u = parent; t.pk = parentKey; t.px = null; @@ -163,9 +170,6 @@ function createTarget( t.s = false; t.ovl = false; t.del = null; - t.wk = null; - t.hv = null; - t.ht = null; t.px = new Proxy(t, traps); // Legacy interop: shared machinery (affects walks, wrap dedupe) reads the // proxy off looked-up targets as a field. @@ -366,14 +370,6 @@ export function bumpDeep(t: StoreNextTarget): void { if (t.dk !== null) setSignal(t.dk, 1 as any); } -function markDescendants(target: StoreNextTarget): void { - let t: StoreNextTarget | null = target; - while (t && !t.d) { - t.d = true; - t = t.u; - } -} - // --------------------------------------------------------------------------- // pending backing + fold (the single mutation point) @@ -489,28 +485,6 @@ function ensurePB(target: StoreNextTarget): Record { return pb; } -/** Sentinel holder for `t.ht`: a latest()-pull staged this adoption outside - * any transition — the hold lasts until the fold commit (drainFolds). */ -const PLAIN_HOLD: unique symbol = Symbol("plainHold"); - -/** True while a latest() read is pulling the projection computed up to date - * (see the get trap): adoptions landing during the pull are speculative - * against the un-flushed batch and stage a held view. (Not injectable — the - * derived createStore overload retains projection machinery in every store - * bundle, see treeshake.test.ts.) */ -let latestPullActive = false; - -/** Resolve the held committed view (#3074): answers the masked old backing - * while the hold is live, and lazily clears a hold whose transition has - * committed (transitions merge — resolve through currentTransition, same as - * foldHeld's node stamps). */ -function heldMaskView(t: StoreNextTarget): Record | null { - const ht = t.ht; - if (ht === null) return null; - if (ht !== PLAIN_HOLD && currentTransition(ht)?._done === true) return (t.ht = t.hv = null); - return t.hv; -} - /** * Adoption (2026-08-16c): the incoming object becomes the committed backing * IMMEDIATELY — reconcile is eagerly visible to every reader (shipped @@ -530,20 +504,6 @@ export function adoptPB( if (!eager) { queueFold(target); // records the pre-batch old before we swap target.adopted = true; - // #3074/#3075: a projection recompute deriving from uncommitted inputs - // swaps the backing SPECULATIVELY — committed-visibility readers must - // keep the pre-hold view until the hold resolves (a source held by a - // live transition, or a latest()-pull ahead of the flush). Post-await - // landings (write-override) stay immediately visible — landed truth — - // and clear any hold; optimistic families ride the lane machinery. - if (target.fam?.opt !== true) { - if (getWriteOverride()) { - target.ht = target.hv = null; - } else if (activeTransition !== null || latestPullActive) { - if (heldMaskView(target) === null) target.hv = target.v; - target.ht = activeTransition ?? PLAIN_HOLD; - } - } } target.pb = null; // Overlay and accessor-scan state describe the OUTGOING backing — a @@ -555,15 +515,24 @@ export function adoptPB( // draft rescans once (#3044 audit follow-up). target.ovl = false; target.del = null; - target.wk = null; // adoption supersedes any staged trap writes target.sc = false; target.a = false; + if (target.pc !== null) target.pc.wk = null; // adoption supersedes staged trap writes target.v = incoming; target.ch = (incoming as any)[$TARGET] !== undefined; (target.fam?.map ?? storeNextLookup).set(incoming, target); if (__TEST__ && ingestedRaw && !ownedRaw.has(incoming)) ingestedRaw.add(incoming); } +/** Sentinel for `t.wk`: the written-keys bound is unusable this batch (an + * array length write implicitly deleted indices) — consumers full-scan. */ +const WK_ALL: Set = new Set(); + +const plainProto = (o: object): boolean => { + const p = Object.getPrototypeOf(o); + return p === Object.prototype || p === Array.prototype || p === null; +}; + function queueFold(target: StoreNextTarget): void { if (foldOlds.has(target)) return; if (foldOlds.size === 0) { @@ -596,10 +565,6 @@ function drainFolds(): void { const entries = [...foldOlds]; foldOlds.clear(); for (const [t, old] of entries) { - // A latest()-pull staging holds only until the fold commit: this flush - // is committing the batch the pull ran ahead of. Transition holds stay — - // they clear when their transition is done (heldMaskView). - if (t.ht === PLAIN_HOLD) t.ht = t.hv = null; if (t.pb !== null) { // Setter path: nodes were setSignal'd at setter exit (write-time // notification — transitions/holds ride core machinery). Commit the @@ -612,11 +577,13 @@ function drainFolds(): void { // Only written keys can hold (their nodes took the setSignal); the // wk bound keeps this O(written) — see notifyWrites. Same fallback // rules as the notify (WK_ALL / accessors / non-plain prototypes). - const wkh = t.wk; + const wkh = t.pc !== null ? t.pc.wk : null; const keys: Iterable = wkh === null || wkh === WK_ALL || t.a === true || + // Overlay pbs chain to the COMMITTED object (#3044) — plainness is + // the committed container's prototype, not the overlay's. !plainProto(t.ovl ? (t.v as object) : pb) ? Reflect.ownKeys(nodes) : wkh; @@ -655,15 +622,34 @@ function drainFolds(): void { (t.fam?.map ?? storeNextLookup).delete(pb); t.pb = null; t.ovl = false; - t.wk = null; // written-keys window closes with the fold commit + if (t.pc !== null) t.pc.wk = null; // written-keys window closes with the fold commit } else { + // Setter-channel structural ops: a fold that changes an array's shape + // (push/splice/permutation through the setter — the reconcile walk + // never queues here) is a structural visibility transition for any + // registered list driver. Identity-keyed; aligned folds emit nothing. + // Family targets defer to their own adoption emission (fam reconcile). + // Arrays always fold on this clone branch (overlay is non-array only). + if ( + t.pc !== null && + t.pc.ro !== null && + t.fam === null && + Array.isArray(pb) && + Array.isArray(t.v) + ) + emitSetterRowOps(t, t.v as any[], pb as any[]); t.v = pb; t.ch = false; // pb is always a plain clone t.pb = null; - t.wk = null; // written-keys window closes with the fold commit + if (t.pc !== null) t.pc.wk = null; // written-keys window closes with the fold commit } } if (t.v === old) continue; // adopted then re-adopted back, or no-op + // Patch channel (fold-commit site): family targets emit HERE — the fold + // IS their visibility moment (held folds re-queued above emit when they + // actually commit). Plain eager targets emitted at their walk/setter + // sites already. + if (t.fam !== null && t.pc !== null && t.pc.p !== null) emitPatchLocal(t, t.v, old); // Path copying (CAS: see the eager-fold twin above). if (t.u && t.u.v[t.pk!] === old) { privatizeCommitted(t.u); @@ -685,19 +671,6 @@ function drainFolds(): void { * "pending home = the node when a node exists"). Unobserved keys stay in the * pending backing and fold directly at commit. */ -/** Sentinel for `t.wk`: the written-keys bound is unusable this batch (an - * array length write implicitly deleted indices) — consumers full-scan. */ -const WK_ALL: Set = new Set(); - -/** Plain-prototype check for the written-keys bound: prototype getters on - * class instances can derive from ANY field, so only plain-data containers - * may bound the notify to written keys. Overlay pbs chain to the COMMITTED - * object (#3044), so overlay plainness is judged on the committed proto. */ -const plainProto = (o: object): boolean => { - const p = Object.getPrototypeOf(o); - return p === Object.prototype || p === Array.prototype || p === null; -}; - function notifyWrites(t: StoreNextTarget): void { let pb = t.pb; if (pb === null) return; @@ -752,8 +725,15 @@ function notifyWrites(t: StoreNextTarget): void { // not a full scan). Falls back to the full node scan when the bound can't // hold: no trap granularity (wk null), an array length write (WK_ALL — // implicit index deletes), accessors on the record (t.a — a getter node's - // value can change when ANY key is written), or a non-plain prototype. - const wk0 = t.wk; + // value can change when ANY key is written), or a non-plain prototype + // (class instances: prototype getters derive from arbitrary fields). + const wk0 = t.pc !== null ? t.pc.wk : null; + // Overlay pbs chain to the COMMITTED object (#3044): a prototype-overlay + // draft is plain data on its own layer, but its getPrototypeOf is the + // committed container — judge plainness by the COMMITTED prototype or the + // bound never engages for overlay writes (every plain-object setter batch + // would full-scan: the exact selection-map workload wk exists for; jf + // `select` regressed 2x on this). const writtenKeys = wk0 === WK_ALL || t.a === true || !plainProto(t.ovl ? (t.v as object) : pb) ? null : wk0; if (nodes !== null) { @@ -790,15 +770,18 @@ function notifyWrites(t: StoreNextTarget): void { } const has = t.h; if (has !== null) { - for (const key of Reflect.ownKeys(has)) - setSignal(has[key as any], key in pb && !(t.del !== null && t.del.has(key))); + const keys: Iterable = writtenKeys ?? Reflect.ownKeys(has); + for (const key of keys) { + const node = has[key as any]; + if (node !== undefined) setSignal(node, key in pb && !(t.del !== null && t.del.has(key))); + } } // Deep-witness (dk): setter writes must notify a deep() subscriber even on - // keys with no node. O(pb keys) equality only when a witness exists. + // keys with no node. O(written/pb keys) equality only when a witness exists. if (t.dk !== null) { if (t.del !== null && t.del.size !== 0) bumpDeep(t); else - for (const key of Reflect.ownKeys(pb)) { + for (const key of writtenKeys ?? Reflect.ownKeys(pb)) { const nv = pb[key as any]; const ov = old[key as any]; if (nv !== null && typeof nv === "object" ? !targetsEqual(ov, nv) : !isEqual(ov, nv)) { @@ -828,6 +811,12 @@ function notifyWrites(t: StoreNextTarget): void { } if (changed) setSignal(t.k, v => v + 1); } + // Patch channel (setter site): a committed write transitions this record — + // queue its patches and bubble to ancestors (targeted nested writes must + // reach the row patch, §4b). One number compare when no patches exist. + // Family targets skip this site: their visibility moment is the FOLD + // commit (drainFolds emits), not the recompute/draft write. + if (t.fam === null && hasPatches()) emitPatch(t, pb, old); // Projection backing folds split by channel (two pinned contracts): // - sync-derive drafts (recompute body): NEVER eager — a downstream async // hold can form LATER in the same flush and the leaf must stay at stale @@ -839,12 +828,8 @@ function notifyWrites(t: StoreNextTarget): void { // downstream consumer's own async still holds the effect-level reveal // (spec-async "verdicts never inherit consumers' in-flight state"). if (t.fam !== null && t.pb !== null && getWriteOverride()) { - // Landed truth (post-await write-override): immediately visible to every - // reader — any staged held view is superseded. - if (t.ht !== null) t.ht = t.hv = null; const oldBacking = t.v; t.pb = null; - t.wk = null; // written-keys window closes with the eager fold t.v = pb; t.ch = false; if (t.u && t.u.v[t.pk!] === oldBacking) { @@ -1115,20 +1100,6 @@ function foldHeld(target: StoreNextTarget): boolean { } function readSource(target: StoreNextTarget): Record { - // Held view first (#3074): an adoption staged under a live hold serves the - // pre-hold committed backing to committed-visibility readers. Speculative - // readers — drafts, write-override, owner-context computeds recomputing - // inside the transaction, and latest() reads — see the adopted backing. - if ( - target.ht !== null && - !latestReadActive && - !inDraft(target) && - !getWriteOverride() && - !inOwnerContext() - ) { - const hv = heldMaskView(target); - if (hv !== null) return hv; - } // Signal-parity visibility (core read(): owner-context reads serve // _pendingValue, context-free reads serve committed — effects recompute // BEFORE commitPendingNodes in the flush, so the pending view must be @@ -1193,11 +1164,9 @@ export function hasActiveOverride(node: Signal): boolean { * FORCE sentinels never surface (they only bump subscribers of accessor * keys, which are served by the trap, not the node). */ function nodeValue(node: Signal, backing: any): any { - // latest() sees the in-flight parked value like an owner-context reader - // does (#3075) — signal/memo parity for store-node-backed keys. const v = hasActiveOverride(node) ? unwrapOverride(node._x?._overrideValue) - : node._pendingValue !== NOT_PENDING && (latestReadActive || inOwnerContext()) + : node._pendingValue !== NOT_PENDING && inOwnerContext() ? node._pendingValue : backing; return v === (FORCE as any) ? backing : v; @@ -1297,28 +1266,6 @@ function firewallGate(target: StoreNextTarget): void { if (fw != null && fw._statusFlags & (STATUS_UNINITIALIZED | STATUS_ERROR)) readNode(fw); } -/** latest() pull (#3075): bring the projection computed up to date so the - * read serves the IN-FLIGHT derivation — signal/memo parity, where core - * read() routes latest() through a companion that recomputes speculatively. - * The latest flag is suspended for the recompute (the derive's own reads - * are normal reads), and latestPullActive marks any adoption it commits as - * staged (see adoptPB) — the speculative swap must not leak to - * committed-visibility readers before the flush. */ -function pullProjectionForLatest(target: StoreNextTarget): void { - const fw = target.fam!.node; - if (fw == null) return; - const prevLatest = latestReadActive; - setLatestReadActive(false); - const prevPull = latestPullActive; - latestPullActive = true; - try { - prepareComputed(fw as any, true); - } finally { - latestPullActive = prevPull; - setLatestReadActive(prevLatest); - } -} - const traps: ProxyHandler = { get(target, key, receiver) { // One typeof gates every brand-symbol compare off the hot string path @@ -1346,11 +1293,6 @@ const traps: ProxyHandler = { } if (pendingCheckActive) witnessAffectsMark(target as any, key); if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target); - // latest() pull (#3075): store traps never reach core read() without an - // observer, so bring the projection computed up to date here — signal/ - // memo parity for latest() reads through a projection. - if (target.fam !== null && latestReadActive && !inDraft(target) && !getWriteOverride()) - pullProjectionForLatest(target); const src = readSource(target); // Overlay delete (#3044): a prototype overlay cannot shadow a delete, so // deleted keys are tracked aside and read as absent in the pending view. @@ -1584,14 +1526,15 @@ const traps: ProxyHandler = { // Array length writes implicitly delete indices — the written-keys bound // can't see them, so poison to the full scan for this batch. Index // writes implicitly GROW length, so arrays always record it alongside. + const pcs = pcOf(target); if (Array.isArray(pb)) { - if (key === "length") target.wk = WK_ALL; - else if (target.wk !== WK_ALL) { - const wk = (target.wk ??= new Set()); + if (key === "length") pcs.wk = WK_ALL; + else if (pcs.wk !== WK_ALL) { + const wk = (pcs.wk ??= new Set()); wk.add(key); wk.add("length"); } - } else if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key); + } else if (pcs.wk !== WK_ALL) (pcs.wk ??= new Set()).add(key); // Own data keys literally named "prototype"/"constructor" land as data — // defineProperty sidesteps a proto-chain setter named the same. if (UNSAFE_KEYS.has(key)) { @@ -1635,7 +1578,8 @@ const traps: ProxyHandler = { if ("value" in desc) desc = { ...desc, value: unwrapValue(desc.value) }; const pb = ensurePB(target); pendingNotify.add(target); - if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key); + const pcd = pcOf(target); + if (pcd.wk !== WK_ALL) (pcd.wk ??= new Set()).add(key); Object.defineProperty(pb, key, desc); if (target.del !== null) target.del.delete(key); if (override) notifyWrites(target); @@ -1648,7 +1592,8 @@ const traps: ProxyHandler = { if (!draft && !override) return true; const pb = ensurePB(target); pendingNotify.add(target); - if (target.wk !== WK_ALL) (target.wk ??= new Set()).add(key); + const pcx = pcOf(target); + if (pcx.wk !== WK_ALL) (pcx.wk ??= new Set()).add(key); delete pb[key as any]; // A prototype overlay cannot shadow a delete of a committed key — // record it aside (#3044); reads/has/ownKeys/commit consult the set. @@ -1750,8 +1695,54 @@ function isNextProxy(value: any): boolean { ); } +/** Slot patch for shallow arrays: the reconcile walk emits (index, next, + * prev) for KEY-ALIGNED value-replaced slots (structure rides row ops), and + * the emission queues through the patch apply queue — effect-phase timing, + * transition stamping, disposed-owner drop — like every other channel. */ +export function registerSlotPatchNext( + arr: any, + fn: (index: number, next: any, prev: any) => void +): () => void { + const t: StoreNextTarget | undefined = arr?.[$TARGET]; + if (t === undefined) throw new Error("registerSlotPatchNext: not a store array"); + // Multi-consumer (external audit): one shallow array can drive several + // lists — registrations are a list, unbinds splice their own entry. + const pc = pcOf(t); + const entry = { fn, owner: getOwner() }; + (pc.sp ??= []).push(entry); + markDescendants(t); + let unbound = false; + return () => { + if (unbound || pc.sp === null) return; + unbound = true; + const idx = pc.sp.indexOf(entry); + if (idx >= 0) pc.sp.splice(idx, 1); + if (pc.sp.length === 0) pc.sp = null; + }; +} + +/** True when `proxy` is a SHALLOW store (children served verbatim, slots + * replaced by reference — #2932). The list driver uses this to choose the + * slot-patch channel (collected row bodies) over per-record registration. */ +export function storeIsShallow(proxy: any): boolean { + const t: StoreNextTarget | undefined = proxy?.[$TARGET]; + return t !== undefined && t.s === true; +} + +/** True when `proxy` belongs to a projection/optimistic FAMILY. The list + * driver must DECLINE family arrays (external audit finding): family + * structural changes never emit row/slot ops (the setter channel is + * fam-gated; optimistic writes ride node overrides), and the proxy identity + * is stable so the each-watch cannot catch the change either — an engaged + * list would freeze on optimistic/projection structural updates. Record- + * level family patches are unaffected (they have their own emission). */ +export function storeHasFamily(proxy: any): boolean { + const t: StoreNextTarget | undefined = proxy?.[$TARGET]; + return t !== undefined && t.fam !== null; +} + /** Tracking deep snapshot (`deep()` for next targets): subscribes to the - * key-set and every property node at every reachable level, then returns the + * key-set and deep-witness node at every reachable level, then returns the * plain view. Shared references and cycles handled via the visited set. */ export function deepNext(value: T): T { const t0: StoreNextTarget | undefined = (value as any)?.[$TARGET]; diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 5fb41b598..565d4aefd 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -12,7 +12,7 @@ * entry per read-through object; zero layer slots; nodes, has-nodes, and the * key-set node are lazy, materialized only by subscription. */ -import type { Computed, Signal } from "../../core/types.js"; +import type { Computed, Owner, Signal } from "../../core/types.js"; /** Projection family (§7b): children wrap into the family's own map (writes * land in the projection, never the source family), and every node created @@ -34,6 +34,29 @@ export interface StoreNextFamily { shallow?: boolean; } +/** Write-side patch-channel state (stage 2), grouped off the target's named + * fields — see the shape rule on `StoreNextTarget.pc`. One literal shape, + * allocated by `pcOf` on first use. */ +export interface PatchChannel { + /** Slot-patch hooks for shallow arrays — the reconcile walk emits + * (i, next, prev) for key-aligned value-replaced slots through the patch + * apply queue (records are raw, no per-record targets exist). + * MULTI-CONSUMER (external audit): one array can drive several lists. */ + sp: { fn: (index: number, next: any, prev: any) => void; owner: Owner | null }[] | null; + /** Patch-channel consumers (next/patch.ts): per-record compiled patch + * entries, multi-consumer. null when unpatched (the common case). */ + p: object[] | null; + /** Row-ops consumers (next/patch.ts, PR-B): structural list ops — + * (nextRows, { prefix, sources, removed }) at apply timing. */ + ro: object[] | null; + /** Keys written through the traps since the last fold commit. Bounds the + * setter notify/hold-check to O(written) instead of O(subscribed nodes) — + * a record with thousands of per-key subscriptions (selection maps) would + * otherwise pay a full node scan on every write. null = no trap writes + * this batch (bulk paths fall back to the full scan). */ + wk: Set | null; +} + export interface StoreNextTarget { /** Committed backing: source object (shared) or owned clone. */ v: Record; @@ -49,6 +72,15 @@ export interface StoreNextTarget { h: Record> | null; /** Lazy key-set node: membership/iteration/$TRACK subscriptions (§6). */ k: Signal | null; + /** Patch-channel extension (lazily allocated on first use): groups the + * write-side stage-2 fields so they never widen the TARGET's own named + * field count. LOAD-BEARING SHAPE RULE: array proxy targets carry their + * fields as named properties on a real array, and V8 normalizes an array + * to dictionary properties as the named count grows (empirically at + * counts ≡ 0 mod 3 from 18 up on V8 13.x) — every trap field read then + * becomes a hash lookup (~15% uibench, tree suites worst). New + * patch-channel state MUST go inside this object, not on the target. */ + pc: PatchChannel | null; /** Lazy deep-witness node: `deep()` subscribes ONE node per record instead * of one per path; write paths bump it only when it exists. Separate from * `k` so $TRACK/mapArray never rerun on leaf value changes (R9). */ @@ -81,13 +113,6 @@ export interface StoreNextTarget { /** Keys deleted in the overlay window (a prototype overlay cannot shadow * a delete); null when none. */ del: Set | null; - /** Keys written through the traps since the last fold commit. Bounds the - * setter notify/hold-check to O(written) instead of O(subscribed nodes) — - * a record with thousands of per-key subscriptions (selection maps) would - * otherwise pay a full node scan on every write. null = no trap writes - * this batch (bulk paths fall back to the full scan); WK_ALL sentinel = - * bound unusable this batch (array length write implies index deletes). */ - wk: Set | null; /** Projection family, null for plain stores (§7b). */ fam: StoreNextFamily | null; /** Shallow store root (values served raw). */ @@ -142,3 +167,13 @@ export let optHooks: OptStoreHooks | null = null; export function setOptHooks(h: OptStoreHooks): void { optHooks = h; } + +/** Sticky descendants flag walk (§6d): reconcile's keyed pruning descends + * only where subscriptions exist at/below. Nodes AND patches count. */ +export function markDescendants(target: StoreNextTarget): void { + let t: StoreNextTarget | null = target; + while (t && !t.d) { + t.d = true; + t = t.u; + } +} diff --git a/packages/signals/tests/store/patch-channel.test.ts b/packages/signals/tests/store/patch-channel.test.ts new file mode 100644 index 000000000..1c04fc588 --- /dev/null +++ b/packages/signals/tests/store/patch-channel.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from "vitest"; +import { + action, + createErrorBoundary, + createRoot, + createStore, + flush, + reconcile, + registerPatch +} from "../../src/index.js"; + +describe("patch channel (PR-A)", () => { + it("setter write applies the patch at flush, not at write time", () => { + const [state, setState] = createStore({ user: { name: "a", title: "x" } }); + const log: string[] = []; + registerPatch(state.user, (next: any, prev: any, force?: boolean) => { + log.push((force ? "F:" : "") + prev?.name + "->" + next.name); + }); + setState(s => { + s.user.name = "b"; + }); + // Effect-phase timing: nothing applied inside the batch window. + expect(log).toEqual([]); + flush(); + expect(log.length).toBe(1); + expect(log[0].endsWith("->b")).toBe(true); + }); + + it("reconcile applies the patch with (incoming, pre-adopt prev)", () => { + const [state, setState] = createStore({ rows: [{ id: 1, count: 5 }] }); + const log: string[] = []; + registerPatch(state.rows[0], (next: any, prev: any) => { + log.push(prev.count + "->" + next.count); + }); + setState(s => { + reconcile([{ id: 1, count: 9 }], "id")(s.rows); + }); + flush(); + expect(log).toEqual(["5->9"]); + }); + + it("targeted nested write bubbles to the ancestor patch as a forced re-apply", () => { + const [state, setState] = createStore({ + rows: [{ id: 1, count: 1, queries: [{ elapsed: "1" }] }] + }); + const log: Array<[boolean | undefined, string]> = []; + registerPatch(state.rows[0], (next: any, prev: any, force?: boolean) => { + log.push([force, next.queries[0].elapsed]); + }); + // Touch the nested record through the draft so it has its own target, + // then write it directly — the row patch must still hear about it. + setState(s => { + s.rows[0].queries[0].elapsed = "2"; + }); + flush(); + expect(log.length).toBe(1); + expect(log[0][0]).toBe(true); // forced (ancestor bubble) + expect(log[0][1]).toBe("2"); + }); + + it("unbind stops dispatch; multi-consumer keeps the other", () => { + const [state, setState] = createStore({ user: { name: "a" } }); + const a: string[] = []; + const b: string[] = []; + const unbindA = registerPatch(state.user, (n: any) => a.push(n.name)); + registerPatch(state.user, (n: any) => b.push(n.name)); + unbindA(); + setState(s => { + s.user.name = "z"; + }); + flush(); + expect(a).toEqual([]); + expect(b).toEqual(["z"]); + }); + + it("transition-held write does NOT apply until the transition commits", async () => { + const [state, setState] = createStore({ user: { name: "a" } }); + const log: string[] = []; + registerPatch(state.user, (next: any) => log.push(next.name)); + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = action(function* () { + setState(s => { + s.user.name = "held"; + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = save(); + flush(); + // The write rides the action's transition: not visible, not patched. + expect(log).toEqual([]); + resolve(); + await p; + flush(); + // Transition committed: the patch applies with the landed value. + expect(log).toEqual(["held"]); + }); + + it("optimistic write patches the view in flight, revert force-reapplies committed", async () => { + const { createOptimisticStore, action: act } = await import("../../src/index.js"); + const [state, setState] = (createOptimisticStore as any)({ user: { name: "saved" } }); + const log: Array<[string, boolean | undefined]> = []; + registerPatch(state.user, (next: any, _prev: any, force?: boolean) => + log.push([next.name, force]) + ); + let reject!: (e: any) => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + s.user.name = "optimistic"; + }); + yield new Promise((_, rej) => { + reject = rej; + }); + }) as any; + }); + const p = (save() as Promise).catch(() => {}); + flush(); + // Override applied THIS flush — in-flight visibility. + expect(log.length).toBe(1); + expect(log[0][0]).toBe("optimistic"); + reject(new Error("fail")); + await p; + flush(); + // Revert: forced re-apply lands with committed truth visible. + const last = log[log.length - 1]; + expect(last[1]).toBe(true); + expect(state.user.name).toBe("saved"); + }); + + it("async projection refetch patches at landing, never mid-flight", async () => { + const { refresh } = await import("../../src/index.js"); + let resolve!: (v: any) => void; + let state: any; + createRoot(() => { + [state] = createStore(async () => { + const user = await new Promise(r => { + resolve = r; + }); + return { user }; + }, {} as any); + }); + flush(); + resolve({ name: "first" }); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(state.user.name).toBe("first"); + + const log: string[] = []; + registerPatch(state.user, (next: any) => log.push(next.name)); + + refresh(state); + flush(); + // Mid-refetch: no patch fired, DOM state untouched. + expect(log).toEqual([]); + resolve({ name: "second" }); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(state.user.name).toBe("second"); + expect(log).toEqual(["second"]); + }); + + it("row ops: aligned ticks emit nothing; reorder/insert/remove emit exact ops", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore({ + rows: [ + { id: "a", v: 1 }, + { id: "b", v: 2 }, + { id: "c", v: 3 } + ] + }); + const ops: any[] = []; + registerRowOps(state.rows, (next: any[], o: any) => + ops.push({ + prefix: o.prefix, + sources: o.sources, + removed: o.removed.map((r: any) => r.id), + ids: next.map(r => r.id) + }) + ); + // Aligned value tick: same keys, same order — NO structural emission. + setState(s => { + reconcile( + [ + { id: "a", v: 9 }, + { id: "b", v: 9 }, + { id: "c", v: 9 } + ], + "id" + )(s.rows); + }); + flush(); + expect(ops).toEqual([]); + // Reorder + insert + remove: c moves front, b removed, d added. + setState(s => { + reconcile( + [ + { id: "c", v: 3 }, + { id: "d", v: 4 }, + { id: "a", v: 1 } + ], + "id" + )(s.rows); + }); + flush(); + expect(ops.length).toBe(1); + const o = ops[0]; + expect(o.prefix).toBe(0); + // c came from old index 2, d is new, a came from old index 0. + expect(o.sources).toEqual([2, -1, 0]); + expect(o.removed).toEqual(["b"]); + expect(o.ids).toEqual(["c", "d", "a"]); + }); + + it("shallow keyed arrays emit row ops; aligned value ticks emit nothing", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore( + [ + { id: "a", v: 1 }, + { id: "b", v: 2 } + ] as any, + { shallow: true } as any + ); + const ops: any[] = []; + registerRowOps(state, (_next: any[], o: any) => + ops.push({ prefix: o.prefix, sources: o.sources, removed: o.removed.map((r: any) => r.id) }) + ); + // Aligned value tick: fresh records, same keys/order — slots replace but + // NO structural emission. + setState((s: any) => { + reconcile( + [ + { id: "a", v: 9 }, + { id: "b", v: 9 } + ], + "id" + )(s); + }); + flush(); + expect(ops).toEqual([]); + // Reorder + remove + add. + setState((s: any) => { + reconcile( + [ + { id: "b", v: 2 }, + { id: "c", v: 3 } + ], + "id" + )(s); + }); + flush(); + expect(ops.length).toBe(1); + expect(ops[0]).toEqual({ prefix: 0, sources: [1, -1], removed: ["a"] }); + }); + + it("a throwing patch does not abort sibling patches (first error rethrows)", () => { + const [state, setState] = createStore({ a: { v: 1 }, b: { v: 1 } }); + const applied: string[] = []; + registerPatch(state.a, () => { + throw new Error("boom"); + }); + registerPatch(state.b, (next: any) => applied.push("b:" + next.v)); + setState(s => { + s.a.v = 2; + s.b.v = 2; + }); + expect(() => flush()).toThrow("boom"); + expect(applied).toEqual(["b:2"]); + }); + + it("setter-channel structural mutation emits identity-keyed row ops", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const a = { id: "a", v: 1 }; + const b = { id: "b", v: 2 }; + const c = { id: "c", v: 3 }; + const [state, setState] = createStore({ rows: [a, b, c] }); + const ops: any[] = []; + registerRowOps(state.rows, (_next: any[], o: any) => + ops.push({ prefix: o.prefix, sources: o.sources, removed: o.removed.map((r: any) => r.id) }) + ); + + // Value-only fold: array shape unchanged — no structural emission. + setState(s => { + s.rows[0].v = 10; + }); + flush(); + expect(ops).toEqual([]); + + // splice removal: same row objects, one gone. + setState(s => { + s.rows.splice(1, 1); + }); + flush(); + expect(ops).toEqual([{ prefix: 1, sources: [2], removed: ["b"] }]); + + // push: pure append past the aligned prefix. + setState(s => { + s.rows.push({ id: "d", v: 4 }); + }); + flush(); + expect(ops[1]).toEqual({ prefix: 2, sources: [-1], removed: [] }); + + // permutation: same objects reversed — moves only, no removals. + setState(s => { + s.rows.reverse(); + }); + flush(); + expect(ops[2]).toEqual({ prefix: 0, sources: [2, 1, 0], removed: [] }); + }); + + it("a throwing patch routes to the enclosing error boundary like a render-effect error", () => { + const [state, setState] = createStore({ a: { v: 1 }, b: { v: 1 } }); + const applied: string[] = []; + let caught: unknown; + const b = createRoot(() => + createErrorBoundary( + () => { + // Registered under the boundary's owner: a throw during drain + // must route up this owner's queue chain, not crash the flush. + registerPatch(state.a, (n: any) => { + if (n.v > 1) throw new Error("row boom"); + }); + registerPatch(state.b, (n: any) => applied.push("b:" + n.v)); + return "content"; + }, + e => { + caught = e(); + return "errored"; + } + ) + ); + expect(b()).toBe("content"); + setState(s => { + s.a.v = 2; + s.b.v = 2; + }); + expect(() => flush()).not.toThrow(); + // Sibling isolation still holds under routing. + expect(applied).toEqual(["b:2"]); + expect(b()).toBe("errored"); + expect(String(caught)).toContain("row boom"); + }); + + it("disposed owner drops its patches mid-flight", () => { + const [state, setState] = createStore({ user: { name: "a" } }); + const log: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + registerPatch(state.user, (n: any) => log.push(n.name)); + }); + setState(s => { + s.user.name = "b"; + }); + dispose(); + flush(); + expect(log).toEqual([]); + }); +}); diff --git a/packages/solid/src/client/flow.ts b/packages/solid/src/client/flow.ts index 4c9baa9e1..e357302d0 100644 --- a/packages/solid/src/client/flow.ts +++ b/packages/solid/src/client/flow.ts @@ -1,5 +1,13 @@ import { children, IS_DEV } from "../client/core.js"; -import { createMemo, untrack, mapArray, repeat, createRevealOrder } from "@solidjs/signals"; +import { + createMemo, + untrack, + mapArray, + repeat, + createRevealOrder, + getOwner, + runWithOwner +} from "@solidjs/signals"; import { createErrorBoundary, createLoadingBoundary } from "./hydration.js"; import type { Accessor, RevealOrder } from "@solidjs/signals"; export type { RevealOrder }; @@ -81,11 +89,27 @@ export function For(props: { ? { keyed: props.keyed, fallback: () => props.fallback } : { keyed: props.keyed }; if (IS_DEV) options.name = ""; - return mapArray( - () => props.each, - props.children as any, - options as any - ) as unknown as SolidElement; + // Patch-mode list seam (DESIGN-PATCH-CHANNEL §3b): the returned accessor + // carries `$ll` metadata so a row-ops-aware renderer can drive a keyed + // store array structurally (registerRowOps → moves/creates/removals), + // bypassing mapArray entirely. Renderers that don't recognize the marker — + // and any list the driver declines (non-store each, impure rows, fallback/ + // index usage) — simply call the accessor and get the classic mapArray + // path, created lazily under the component's owner on first read. + const owner = getOwner(); + let mapped: (() => any) | undefined; + const list = () => { + if (mapped === undefined) + mapped = runWithOwner(owner, () => + mapArray(() => props.each, props.children as any, options as any) + ) as () => any; + return mapped(); + }; + if (props.keyed !== false && !("fallback" in props) && props.children.length < 2) + // `keyed` rides along so the driver implements the DECLARED identity + // semantics (reference vs key fn) — see driveList's identity ruling. + (list as any).$ll = { each: () => props.each, row: props.children, keyed: props.keyed }; + return list as unknown as SolidElement; } /** diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 41893d941..4d1a51788 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -24,6 +24,13 @@ export { onCleanup, onSettled, latest, + // Patch-channel compiler contract (undocumented as application API) + patchableRaw, + registerPatch, + registerRowOps, + registerSlotPatch, + storeIsShallow, + storeHasFamily, reconcile, refresh, repeat, diff --git a/packages/solid/src/server/hydration.ts b/packages/solid/src/server/hydration.ts index edd400f06..2a5683657 100644 --- a/packages/solid/src/server/hydration.ts +++ b/packages/solid/src/server/hydration.ts @@ -69,19 +69,14 @@ function ssrLoadingBoundary( ): () => unknown { const ctx = currentCtx; const parent = getOwner(); - // One context lookup pass: both reads resolve off the parent's (shared, - // immutable-at-this-point) context map — no owner switch needed, and - // getContext takes the owner explicitly. - const parentHandler = parent && getContext(ErrorContext, parent); - const revealGroup = parent && getContext(RevealGroupContext, parent); + const parentHandler = parent && runWithOwner(parent, () => getContext(ErrorContext)); + const revealGroup = parent && runWithOwner(parent, () => getContext(RevealGroupContext)); const o = createOwner(); // Boundaries sever reveal-group coordination for their subtree (matching the // client): only direct Loading children of a Reveal join its group. A nested // Loading is covered by its own fallback inside the (possibly held) slot and // activates independently instead of delaying the ancestor group (#2871). - // setContext clones the context map, so only pay it when there IS a group - // in scope to sever — without one, children read null regardless. - if (revealGroup) setContext(RevealGroupContext, null, o); + setContext(RevealGroupContext, null, o); const id = o.id!; (o as any).id = id + "00"; // fake depth to match client's createLoadingBoundary nesting diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index b719bab89..d21be0681 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -36,6 +36,14 @@ export { onCleanup, onSettled, latest, + // Patch-channel compiler contract (parity with the client entry; the + // channel is inert on the server — SSR renders once, hydration claims) + patchableRaw, + registerPatch, + registerRowOps, + registerSlotPatch, + storeIsShallow, + storeHasFamily, reconcile, refresh, repeat, diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index 38fdae2bc..0dc5ef25a 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -4,8 +4,16 @@ import { getOwner, runWithOwner, createComponent, + createOwner, createRoot as root, + onCleanup, + patchableRaw, + registerPatch, + registerRowOps, + registerSlotPatch, sharedConfig, + storeHasFamily, + storeIsShallow, untrack, merge as mergeProps, flatten, @@ -121,12 +129,418 @@ export const waitAsset = (promise: Promise): void => { gate(); }; -// Optional patch-channel seams (DESIGN §16): dormant (default-off). Cores -// that don't provide them degrade gracefully — list accessors run classic -// mapArray, compiled bodies run the dual-phase effect. -const driveList = undefined; -const patchableRaw = undefined; -const registerPatch = undefined; +const PURE_ROW = Symbol.for("solid.pure-row"); +export function rowProof(fn: T): T { + (fn as any)[PURE_ROW] = true; + return fn; +} + +// Patch-mode dual driver (DESIGN-PATCH-CHANNEL.md, PR-C): compiled template +// scopes whose bindings are pure member reads of one subject hand ONE +// compiled body `(next, prev, force) => { compares + writes }` here. +// - Patchable store record: initial force-apply reads the RAW backing (no +// proxy traffic, no tracking), then the store's own visibility transitions +// dispatch the body through the patch channel (effect-phase timing, lanes, +// transition holds — all channel semantics). +// - Anything else (props, signals-derived objects, accessor records): a +// render effect force-applies the same body; reads through the subject +// track normally, force short-circuits every compare so `prev` is never +// dereferenced. Same semantics, different dispatcher. +// Row-bind collector, active while the list driver binds a row. +// - `unbinds`: every patch registration made during the bind (deep rows — +// the stamped template's one patchDriver on the row record). The driver +// retains them per row so a REMOVED row's registration is severed even +// when user code externally retains the record — otherwise the patch +// keeps firing against detached DOM for the record's lifetime, where +// classic per-row effects die with the row (audit lifecycle hole). +// - `bodies`: shallow store rows are RAW (no record target to register on), +// so compiled bodies whose subject IS the row are collected and +// dispatched by the driver from the array's slot-patch channel. +let rowCollector: { row: any; bodies: any[]; unbinds: (() => void)[] } | null = null; + +// Longest-increasing-subsequence over row-ops sources: positions whose rows +// are already in relative order (they stay put; everything else moves). +// Standard patience-sort with predecessor links; -1 sources (new rows) are +// not part of the sequence. +const lisPositions = (sources: number[]) => { + const n = sources.length; + const tails: number[] = []; + const tailsIdx: number[] = []; + const prev = new Array(n).fill(-1); + for (let j = 0; j < n; j++) { + const v = sources[j]; + if (v === -1) continue; + let lo = 0, + hi = tails.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (tails[mid] < v) lo = mid + 1; + else hi = mid; + } + if (lo > 0) prev[j] = tailsIdx[lo - 1]; + tails[lo] = v; + tailsIdx[lo] = j; + } + const stable = new Set(); + let k = tailsIdx.length ? tailsIdx[tails.length - 1] : -1; + while (k >= 0) { + stable.add(k); + k = prev[k]; + } + return stable; +}; + +// Patch-mode list driver (DESIGN-PATCH-CHANNEL §3b): drives a keyed store +// array structurally through registerRowOps — create/bind at op-apply, LIS +// moves, node removal — bypassing mapArray and the second (DOM-side) diff. +// Called by the runtime's insert when a `` accessor carries `$ll` +// metadata; returns false to decline (unproven row function, non-store +// subject, hydration mismatch), in which case insert falls through to the +// classic mapArray path by simply calling the accessor. +// +// Row purity is proven at COMPILE time (§3c): the driver engages only for +// row functions carrying the compiler's `rowProof` stamp — one compiled +// template, no reactive or owned work, patches only on the row parameter. +// Rows therefore need no per-row owners: value updates ride each record's +// registered patch, structure rides the array's row-ops channel, and a +// removed row's registrations die with its record. There is no speculative +// build and no runtime probe; `lateClassic` only serves ENGAGED lists whose +// subject later leaves the contract (identity swap to a derived array, a +// shallow<->deep kind switch). +export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic?: () => void) => { + const meta = listFn.$ll; + // Compile-time admission: unstamped row functions never engage. + if (meta.row?.[PURE_ROW] !== true) return false; + // `keyed={fn}` rows receive ACCESSORS (the classic contract) — the driver + // binds rows with raw records, so engaging would hand user code the wrong + // shape. Decline until the accessor-row binding + compiler grammar for + // `param().member` bodies lands (identity-ruling follow-up); these rows + // cannot currently stamp anyway, this is a defensive contract pin. + if (typeof meta.keyed === "function") return false; + // The decision read is id-ISOLATED: evaluating `each` can mint compiler + // memos lazily inside the prop getter (wrapConditionals), and minting them + // on the ambient chain here would consume a child id the classic path + // expects to consume later — shifting every subsequent hydration key on + // decline. A throwaway explicit-id owner absorbs (and disposal discards) + // anything the read creates. + const evalOwner = createOwner({ id: "&each" }); + let subject: any = runWithOwner(evalOwner, () => untrack(meta.each)); + (evalOwner as any).dispose(); + let raw = subject != null ? patchableRaw(subject) : undefined; + if (raw === undefined || !Array.isArray(raw)) return false; + // Family arrays (projection/optimistic) DECLINE (external audit): their + // structural changes never emit row/slot ops and the proxy identity is + // stable, so an engaged list would freeze on optimistic/projection + // structure. Classic mapArray handles them correctly. + if (storeHasFamily(subject)) return false; + + // Hydration precheck (claim + register only — §5): rows are the region's + // server-rendered elements, claimed positionally through each element's + // own `_hk` key. V1 supports the whole-parent region (no marker) and + // requires an exact row count and a clean key on every row; anything else + // declines to classic hydration. Keys end in the row scope's FIRST child + // id ("0" — pure rows consume no ids before the root claim), so the row + // owner's id is the key minus that suffix. + const hydrating = !!sharedConfig.hydrating; + // Empty-initial lists have nothing to claim — classic hydration owns them. + if (hydrating && raw.length === 0) return false; + let domRows: Element[] | undefined; + let rowIds: string[] | undefined; + if (hydrating) { + if (marker !== undefined) return false; + domRows = Array.from((parent as Element).children); + if (domRows.length !== raw.length) return false; + rowIds = new Array(raw.length); + for (let i = 0; i < domRows.length; i++) { + const key = domRows[i].getAttribute("_hk"); + if (key === null || !key.endsWith("0") || key.length < 2) return false; + rowIds[i] = key.slice(0, -1); + } + } + + const rowFn = meta.row; + const endAnchor = marker ?? null; + + // Shallow store lists: rows are RAW, so compiled bodies are COLLECTED at + // bind (patchDriver's rowCollector branch) and dispatched from the array's + // slot-patch channel; `lastBodies` carries each bind's collection to its + // bookkeeping site. + const shallow = storeIsShallow(subject); + let lastBodies: any[] | null = null; + let lastUnbinds: (() => void)[] | null = null; + const collectBind = (abs: number, build: () => Node): Node => { + const prevC = rowCollector; + rowCollector = { row: shallow ? subject[abs] : undefined, bodies: [], unbinds: [] }; + try { + return build(); + } finally { + lastBodies = rowCollector.bodies; + lastUnbinds = rowCollector.unbinds; + rowCollector = prevC; + } + }; + + // Engaged. The list owner consumes exactly one child id, mirroring the + // owner mapArray would have created — subsequent siblings' hydration ids + // stay aligned on both the engage and (pre-owner) decline paths. + const listOwner = createOwner(); + let declined = false; + const bindRow = (abs: number, claimId?: string): Node => { + if ("_DX_DEV_") { + // Ownership assertion: a stamped row must attach NOTHING to the list + // owner — the compiler proved the template, but handler/attribute + // VALUE expressions are arbitrary user code, and owned work created + // there (a handler factory calling onCleanup/createEffect) would + // outlive the row. Snapshot the owner's slots around the real build. + const o = listOwner as any; + const prevChild = o._firstChild; + const prevDisposal = o._disposal; + const node = collectBind(abs, () => + runWithOwner(listOwner, () => + claimId !== undefined + ? (runWithOwner(createOwner({ id: claimId }) as any, () => + untrack(() => rowFn(subject[abs])) + ) as Node) + : (untrack(() => rowFn(subject[abs])) as Node) + ) + ) as Node; + if (o._firstChild !== prevChild || o._disposal !== prevDisposal) { + console.warn( + "A patch-mode list row created reactive computations or cleanups " + + "during build (likely a handler/attribute value expression calling " + + "createEffect/onCleanup). This work attaches to the LIST, not the " + + "row, and will not dispose when the row is removed. Move owned " + + "work into effects/refs (which opt the row out of patch mode)." + ); + } + return node; + } + return collectBind(abs, () => + runWithOwner(listOwner, () => + claimId !== undefined + ? (runWithOwner(createOwner({ id: claimId }) as any, () => + untrack(() => rowFn(subject[abs])) + ) as Node) + : (untrack(() => rowFn(subject[abs])) as Node) + ) + ) as Node; + }; + + let entries: Node[] = new Array(raw.length); + let rowBodies: any[][] | null = shallow ? new Array(raw.length) : null; + // Per-row patch unbind handles (deep rows register on their record): run + // on row removal, contract-leave, and list disposal, so a record the app + // retains beyond the row cannot keep patching detached DOM. + let rowUnbinds: (() => void)[][] = new Array(raw.length); + const runUnbinds = (list: (() => void)[] | undefined) => { + if (list !== undefined) for (let u = 0; u < list.length; u++) list[u](); + }; + const unbindAllRows = () => { + for (let j = 0; j < rowUnbinds.length; j++) runUnbinds(rowUnbinds[j]); + rowUnbinds = []; + }; + let prevRaws: any[] = raw.slice(); + if (hydrating) { + // Claim pass: each bind claims its server row through the row-scoped id + // (getNextElement resolves the `_hk` registry entry); patchDriver skips + // the initial apply. + for (let i = 0; i < raw.length; i++) { + entries[i] = bindRow(i, rowIds![i]); + if (rowBodies !== null) rowBodies[i] = lastBodies!; + rowUnbinds[i] = lastUnbinds!; + } + } else { + for (let i = 0; i < raw.length; i++) { + const node = bindRow(i); + entries[i] = node; + if (rowBodies !== null) rowBodies[i] = lastBodies!; + rowUnbinds[i] = lastUnbinds!; + parent.insertBefore(node, endAnchor); + } + } + + const applyOps = (next: any[], ops: { prefix: number; sources: number[] }) => { + if (declined) return; + const { prefix, sources } = ops; + const retained = new Set(); + for (let j = 0; j < sources.length; j++) if (sources[j] >= 0) retained.add(sources[j]); + for (let j = prefix; j < entries.length; j++) { + if (!retained.has(j)) { + (entries[j] as ChildNode).remove(); + runUnbinds(rowUnbinds[j]); + } + } + const newEntries: Node[] = new Array(prefix + sources.length); + const newBodies: any[][] | null = + rowBodies !== null ? new Array(prefix + sources.length) : null; + const newUnbinds: (() => void)[][] = new Array(prefix + sources.length); + for (let i = 0; i < prefix; i++) { + newEntries[i] = entries[i]; + if (newBodies !== null) newBodies[i] = rowBodies![i]; + newUnbinds[i] = rowUnbinds[i]; + } + const stable = lisPositions(sources); + let anchor: Node | null = endAnchor; + for (let j = sources.length - 1; j >= 0; j--) { + const abs = prefix + j; + const src = sources[j]; + let node: Node; + if (src === -1) { + node = bindRow(abs); + if (newBodies !== null) newBodies[abs] = lastBodies!; + newUnbinds[abs] = lastUnbinds!; + parent.insertBefore(node, anchor); + } else if (refRebuild && next[abs] !== prevRaws[src]) { + // Reference-keyed shallow lists: a retained (key-matched, moved) + // slot whose record was replaced REBUILDS — reference semantics. + runUnbinds(rowUnbinds[src]); + (entries[src] as ChildNode).remove(); + node = bindRow(abs); + if (newBodies !== null) newBodies[abs] = lastBodies!; + newUnbinds[abs] = lastUnbinds!; + parent.insertBefore(node, anchor); + } else { + node = entries[src]; + if (newBodies !== null) newBodies[abs] = rowBodies![src]; + newUnbinds[abs] = rowUnbinds[src]; + if (!stable.has(j)) parent.insertBefore(node, anchor); + } + newEntries[abs] = node; + anchor = node; + } + entries = newEntries; + if (newBodies !== null) rowBodies = newBodies; + rowUnbinds = newUnbinds; + prevRaws = next.slice(); + }; + + let unbindOps = runWithOwner(listOwner, () => registerRowOps(subject, applyOps)) as () => void; + + // IDENTITY SEMANTICS RULING: the driver implements whatever identity the + // VIEW declared, never the reconcile key's (that would make patch mode a + // semantic change, not an optimization — the compiler is default-on). + // - deep lists: adoption preserves proxy identity per key, so key ops + // and reference semantics coincide by construction — nothing to do. + // - shallow + reference-keyed (`keyed` absent/true): the records ARE the + // identity. A key-aligned slot whose record was REPLACED must rebuild + // its row, exactly as classic mapArray does. + // - shallow + `keyed={fn}`: replacement under a matching key is a value + // tick — patch the row in place (the declared semantics). + const refRebuild = shallow && typeof meta.keyed !== "function"; + const rebuildSlot = (i: number): void => { + runUnbinds(rowUnbinds[i]); + const old = entries[i] as ChildNode; + const node = bindRow(i); + rowBodies![i] = lastBodies!; + rowUnbinds[i] = lastUnbinds!; + parent.insertBefore(node, old); + old.remove(); + entries[i] = node; + }; + // Shallow value channel: a key-aligned slot replaced by reference. Under + // declared-key semantics this is a value tick — run the row's collected + // bodies against (next, prev) and adopt the new raw as that slot's + // identity. Under reference semantics it is a REPLACE — rebuild the row. + // Structure never lands here (the walk emits misaligned slots as row ops + // only). + const applySlot = (i: number, next: any, prev: any) => { + if (declined) return; + if (refRebuild) { + rebuildSlot(i); + prevRaws[i] = next; + return; + } + const bodies = rowBodies![i]; + if (bodies !== undefined) { + for (let b = 0; b < bodies.length; b++) bodies[b](next, prev, false); + } + prevRaws[i] = next; + }; + let unbindSlots = shallow + ? (runWithOwner(listOwner, () => registerSlotPatch(subject, applySlot)) as () => void) + : null; + + // Identity swaps (`s.rows = newArr` without reconcile) keep mapArray's + // keyed semantics: rows matched by RAW IDENTITY retain their DOM; the rest + // bind/remove through the same LIS apply, as a synthetic full-window op. + // Created under the list owner: every tracked `each` read can mint getter + // memos, and the list owner's id counter is private (id-chain neutral). + runWithOwner(listOwner, () => + effect( + () => meta.each(), + (value: any) => { + if (declined || value === subject) return; + const nextRaw = value != null ? patchableRaw(value) : undefined; + unbindOps(); + unbindSlots?.(); + // A swap that changes the store KIND (shallow <-> deep) leaves this + // engagement's channel wiring invalid — treat it like leaving the + // contract and hand off to classic. + if (nextRaw !== undefined && Array.isArray(nextRaw) && storeIsShallow(value) !== shallow) { + for (let j = 0; j < entries.length; j++) (entries[j] as ChildNode).remove(); + entries = []; + prevRaws = []; + unbindAllRows(); + subject = value; + declined = true; + (listOwner as any).dispose(); + lateClassic?.(); + return; + } + if (nextRaw === undefined || !Array.isArray(nextRaw) || storeHasFamily(value)) { + // Subject left the driver's contract (e.g. `each` switched from + // the store array to a DERIVED array — a filtered view). Clear the + // region and hand the list to the classic path, which renders the + // current subject and owns it from here on. + for (let j = 0; j < entries.length; j++) (entries[j] as ChildNode).remove(); + entries = []; + prevRaws = []; + unbindAllRows(); + subject = value; + declined = true; + (listOwner as any).dispose(); + lateClassic?.(); + return; + } + // RAW identity on both sides: permutations authored inside drafts + // (`s.rows = [...permuted draft reads]`) produce arrays of row + // PROXIES, and deep ingest stores them verbatim — matching them + // against the previous raws without unwrapping rebuilds every row + // (caught by the JFB keyed-reorder identity gate). + const keyOf = (r: any) => { + const w = r != null ? patchableRaw(r) : undefined; + return w !== undefined ? w : r; + }; + const oldIndex = new Map(); + for (let j = 0; j < prevRaws.length; j++) { + const k = keyOf(prevRaws[j]); + if (!oldIndex.has(k)) oldIndex.set(k, j); + } + const sources = new Array(nextRaw.length); + for (let k = 0; k < nextRaw.length; k++) sources[k] = oldIndex.get(keyOf(nextRaw[k])) ?? -1; + subject = value; + applyOps(nextRaw, { prefix: 0, sources }); + unbindOps = runWithOwner(listOwner, () => registerRowOps(subject, applyOps)) as () => void; + if (shallow) + unbindSlots = runWithOwner(listOwner, () => + registerSlotPatch(subject, applySlot) + ) as () => void; + } + ) + ); + onCleanup(() => { + unbindOps(); + unbindSlots?.(); + // Sever every row's patch registration, not just the channels: the + // channel skips disposed-owner entries but never removes them, so a + // record the app retains past the list would otherwise carry dead + // entries for its lifetime. + unbindAllRows(); + (listOwner as any).dispose(); + }); + return true; +}; + import reconcileArrays from "./reconcile.js"; import { DOMWithState } from "./constants.js"; import { @@ -680,17 +1094,7 @@ export function ref(fn, element) { runWithOwner(null, () => applyRef(resolved, element)); } -// Compile-time row proof (DESIGN-PATCH-CHANNEL §3c): the compiler wraps row -// functions it PROVED pure — single compiled template, no reactive or owned -// work, patches only on the row parameter — and the patch-mode list driver -// engages only for stamped rows. `Symbol.for` so the stamp survives -// duplicated module instances (compiled app code and the driver's core may -// resolve different copies of this runtime). -const PURE_ROW = Symbol.for("solid.pure-row"); -export function rowProof(fn) { - fn[PURE_ROW] = true; - return fn; -} /** Compiler-emitted primitive; not for hand-written code. @internal */ +/** Compiler-emitted primitive; not for hand-written code. @internal */ export function scope any>(fn: T): T; // Compiler tag for holes that can allocate hydration ids: the outer insert @@ -804,19 +1208,38 @@ function stripTextSeparators(nodes) { // next === prev so every compare fails and it becomes a pure tracked // read; the commit pass force-applies, keeping DOM writes in the effect // phase where transitions and batching expect them. -export function patchDriver(subject, body) { - const raw = - patchableRaw !== undefined && registerPatch !== undefined ? patchableRaw(subject) : undefined; +export const patchDriver = (subject, body) => { + const raw = patchableRaw(subject); if (raw !== undefined) { + // Hydration is claim + register ONLY (DESIGN-PATCH-CHANNEL §5): the + // server HTML already carries current values, so the initial force-apply + // is skipped — no writes, no graph edges. The registration alone arms + // the record for post-hydration transitions. if (!sharedConfig.hydrating) body(raw, undefined, true); - registerPatch(subject, body); + const unbind = registerPatch(subject, body); + if (rowCollector !== null) rowCollector.unbinds.push(unbind); + } else if (rowCollector !== null && subject === rowCollector.row) { + rowCollector.bodies.push(body); + if (!sharedConfig.hydrating) body(subject, undefined, true); } else { + // Effect fallback with correct WRITE TIMING: the compute pass calls the + // body with next === prev, so every compare fails and it becomes a pure + // TRACKED READ of each binding expression (eligible expressions are pure + // member chains — double evaluation is free of side effects); the commit + // pass force-applies, putting DOM writes in the effect phase where + // transitions and batching expect them — same split as classic compiled + // effects, same single compiled body. effect( () => body(subject, subject, false), - () => body(subject, undefined, true) + // untrack: the commit pass re-evaluates binding expressions by design + // (force short-circuits compares, not reads) — without it, dev-mode + // strict-read flags every re-read as an untracked effect-callback read + // (false positive: the compute pass tracked the same expressions). + () => untrack(() => body(subject, undefined, true)) ); } -} /** +}; +/** * Compiler-emitted primitive; not for hand-written code. * @internal */ diff --git a/packages/web/test/for.patchlist.spec.tsx b/packages/web/test/for.patchlist.spec.tsx new file mode 100644 index 000000000..7fb45d07d --- /dev/null +++ b/packages/web/test/for.patchlist.spec.tsx @@ -0,0 +1,558 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { describe, expect, test } from "vitest"; +import { + createEffect, + createOptimisticStore, + createRoot, + createSignal, + createStore, + flush, + For, + getObserver, + getOwner, + reconcile +} from "solid-js"; +import { patchDriver, rowProof } from "@solidjs/web"; + +// Patch-mode list driver (DESIGN-PATCH-CHANNEL §3b/§3c): when a keyed +// `` over a store array carries a row function the COMPILER proved pure +// (wrapped with `rowProof` — one template, no computations/cleanups, patches +// only on the row param), the runtime drives the list through the store's +// row-ops channel — no mapArray, no per-row owners, no DOM-side reconcile. +// Admission is the stamp alone: there is no runtime purity probe, and +// unstamped rows decline to classic before any DOM work. These rows are +// hand-written exactly as patch-mode compilation emits them (template clone +// + one patchDriver body, rowProof-wrapped). + +interface Row { + id: number; + label: string; +} + +// Mirrors compiled patch-mode output for ``. +const buildRow = (db: Row) => { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + const text = document.createTextNode(""); + td.appendChild(text); + tr.appendChild(td); + patchDriver(db, (n: Row, p: Row, f?: boolean) => { + if (f || n.label !== p.label) { + (text as Text).data = n.label; + tr.setAttribute("data-id", String(n.id)); + } + }); + return tr as unknown as any; +}; +const pureRow = rowProof(buildRow); + +const rows = (div: HTMLElement) => Array.from(div.querySelectorAll("tr")); +const labels = (div: HTMLElement) => + rows(div) + .map(tr => tr.textContent) + .join(","); +const make = (...ids: number[]): Row[] => ids.map(id => ({ id, label: `L${id}` })); + +describe("patch-mode list driver", () => { + test("value ticks patch retained rows; structure moves/creates/removes nodes", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const owners: unknown[] = []; + const [state, setState] = createStore({ rows: make(1, 2, 3) }); + const spiedRow = rowProof((db: Row) => { + owners.push(getOwner()); + return buildRow(db); + }); +
+ {spiedRow} +
; + expect(labels(div)).toBe("L1,L2,L3"); + // Engagement proof: the driver binds EVERY row under ONE shared list + // owner (no per-row owners, no probe owner for row 0); mapArray would + // mint one per row. + expect(owners.length).toBe(3); + expect(owners[0]).toBe(owners[1]); + expect(owners[1]).toBe(owners[2]); + const [tr1, tr2, tr3] = rows(div); + + // Value tick: same structure, one label — the row's patch fires, the + // node is retained, siblings untouched. + setState(s => { + reconcile( + make(1, 2, 3).map(r => (r.id === 2 ? { ...r, label: "X" } : r)), + "id" + )(s.rows); + }); + flush(); + expect(labels(div)).toBe("L1,X,L3"); + expect(rows(div)[1]).toBe(tr2); + + // Move: keyed survivors keep their DOM nodes. + setState(s => { + reconcile([make(3)[0], make(1)[0], { id: 2, label: "X" }], "id")(s.rows); + }); + flush(); + expect(labels(div)).toBe("L3,L1,X"); + expect(rows(div)[0]).toBe(tr3); + expect(rows(div)[1]).toBe(tr1); + expect(rows(div)[2]).toBe(tr2); + + // Remove + add in one transition. + setState(s => { + reconcile(make(3, 4), "id")(s.rows); + }); + flush(); + expect(labels(div)).toBe("L3,L4"); + expect(rows(div)[0]).toBe(tr3); + expect(tr1.isConnected).toBe(false); + expect(tr2.isConnected).toBe(false); + + dispose(); + }); + }); + + test("setter-driven structure (push/splice/permutation) keeps the driven list in sync", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const [state, setState] = createStore({ rows: make(1, 2, 3) }); +
+ {pureRow} +
; + const [tr1, tr2, tr3] = rows(div); + + // splice removal — surviving nodes retained. + setState(s => { + s.rows.splice(1, 1); + }); + flush(); + expect(labels(div)).toBe("L1,L3"); + expect(rows(div)[0]).toBe(tr1); + expect(rows(div)[1]).toBe(tr3); + expect(tr2.isConnected).toBe(false); + + // push — appended row binds at op-apply. + setState(s => { + s.rows.push({ id: 4, label: "L4" }); + }); + flush(); + expect(labels(div)).toBe("L1,L3,L4"); + expect(rows(div)[0]).toBe(tr1); + + // in-place permutation — same records, nodes move. + setState(s => { + s.rows.reverse(); + }); + flush(); + expect(labels(div)).toBe("L4,L3,L1"); + expect(rows(div)[1]).toBe(tr3); + expect(rows(div)[2]).toBe(tr1); + + // unshift — prepend binds new, retains the rest. + setState(s => { + s.rows.unshift({ id: 5, label: "L5" }); + }); + flush(); + expect(labels(div)).toBe("L5,L4,L3,L1"); + expect(rows(div)[3]).toBe(tr1); + + // Permutation authored FROM DRAFT PROXIES (`s.rows = [...permuted + // reads]`) — deep ingest stores the proxies verbatim; identity + // matching must unwrap or every row rebuilds (JFB reorder gate). + const before = rows(div); + setState(s => { + s.rows = [s.rows[2], s.rows[3], s.rows[0], s.rows[1]]; + }); + flush(); + expect(labels(div)).toBe("L3,L1,L5,L4"); + expect(rows(div)[0]).toBe(before[2]); + expect(rows(div)[1]).toBe(before[3]); + expect(rows(div)[2]).toBe(before[0]); + expect(rows(div)[3]).toBe(before[1]); + + dispose(); + }); + }); + + test("array identity swap retains rows matched by raw identity", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const shared = make(1, 2, 3); + const [state, setState] = createStore({ rows: shared }); +
+ {pureRow} +
; + const [tr1, , tr3] = rows(div); + + // New array object, two raw rows carried over — mapArray's keyed + // (identity) semantics: carried rows keep their DOM. + setState(s => { + s.rows = [shared[2], { id: 9, label: "L9" }, shared[0]]; + }); + flush(); + expect(labels(div)).toBe("L3,L9,L1"); + expect(rows(div)[0]).toBe(tr3); + expect(rows(div)[2]).toBe(tr1); + + // The re-registered channel still drives the new array. + setState(s => { + reconcile([{ id: 9, label: "N9" }], "id")(s.rows); + }); + flush(); + expect(labels(div)).toBe("N9"); + + dispose(); + }); + }); + + test("each switching to a DERIVED array hands the region to classic (filtered-view pattern)", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const [state, setState] = createStore({ rows: make(1, 2, 3), filter: false }); + const visible = () => (state.filter ? state.rows.filter(r => r.id !== 2) : state.rows); +
+ {pureRow} +
; + expect(labels(div)).toBe("L1,L2,L3"); + + // Filter ON: `each` becomes a plain derived array — the driver hands + // off and the classic path renders the filtered view. + setState(s => { + s.filter = true; + }); + flush(); + expect(labels(div)).toBe("L1,L3"); + + // The classic path owns the list from here: filter OFF re-renders all. + setState(s => { + s.filter = false; + }); + flush(); + expect(labels(div)).toBe("L1,L2,L3"); + + // Value updates still flow (classic fine-grained rows via patches on + // records or effects — either way the DOM must track). + setState(s => { + s.rows[0].label = "Z1"; + }); + flush(); + expect(labels(div)).toBe("Z1,L2,L3"); + + dispose(); + }); + }); + + test("unstamped (impure) rows decline the driver and keep classic semantics", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + let effectRuns = 0; + const [state, setState] = createStore({ rows: make(1, 2) }); + // No rowProof stamp: the compiler never proves a row that creates + // computations, so the driver declines up front and mapArray owns the + // list — per-row owners and all. + const impureRow = (db: Row) => { + const tr = buildRow(db); + createEffect( + () => db.label, + () => { + effectRuns++; + } + ); + return tr; + }; +
+ {impureRow} +
; + flush(); + expect(labels(div)).toBe("L1,L2"); + const runsAfterMount = effectRuns; + + setState(s => { + reconcile([make(2)[0], { id: 1, label: "Y" }], "id")(s.rows); + }); + flush(); + expect(labels(div)).toBe("L2,Y"); + // The per-row effect survives and re-fires — proof rows kept owners + // (the classic path), not the ownerless patch-list path. + expect(effectRuns).toBeGreaterThan(runsAfterMount); + + dispose(); + }); + }); + + test("empty initial list engages directly (stamped rows need no first-row proof)", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const owners: unknown[] = []; + const [state, setState] = createStore({ rows: [] as Row[] }); + const spiedRow = rowProof((db: Row) => { + owners.push(getOwner()); + return buildRow(db); + }); +
+ {spiedRow} +
; + expect(rows(div).length).toBe(0); + // First arrival through the setter channel: rows bind ownerlessly + // under the shared list owner — engagement was decided at insert. + setState(s => { + s.rows.push(...make(1, 2, 3)); + }); + flush(); + expect(labels(div)).toBe("L1,L2,L3"); + expect(owners.length).toBe(3); + expect(owners[0]).toBe(owners[1]); + expect(owners[1]).toBe(owners[2]); + // Still driven: reconcile structure + value patch both apply. + const [tr1] = rows(div); + setState(s => { + reconcile([make(3)[0], { id: 1, label: "Y1" }], "id")(s.rows); + }); + flush(); + expect(labels(div)).toBe("L3,Y1"); + expect(rows(div)[1]).toBe(tr1); + dispose(); + }); + }); + + test("empty initial list with unstamped rows takes classic from the start", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + let effectRuns = 0; + const [state, setState] = createStore({ rows: [] as Row[] }); + const impureRow = (db: Row) => { + const tr = buildRow(db); + createEffect( + () => db.label, + () => { + effectRuns++; + } + ); + return tr; + }; +
+ {impureRow} +
; + expect(rows(div).length).toBe(0); + setState(s => { + s.rows.push(...make(1, 2)); + }); + flush(); + // No stamp, no engagement — classic owns the region from insert and + // renders arrivals with per-row owners (the effect lives and fires). + expect(labels(div)).toBe("L1,L2"); + setState(s => { + s.rows[0].label = "Z1"; + }); + flush(); + expect(labels(div)).toBe("Z1,L2"); + expect(effectRuns).toBeGreaterThan(0); + dispose(); + }); + }); + + test("effect fallback keeps DOM writes in the effect phase (reads tracked, writes untracked)", () => { + // Non-patchable subject (props-shaped: getters over a signal) takes the + // dual-driver effect fallback. The compiled body's writes must land in + // the EFFECT phase (observer null — transitions/batching timing), while + // the read pass still tracks the signal so changes re-apply. + const [sig, setSig] = createSignal("a"); + const subject = { + get label() { + return sig(); + } + }; + const writes: Array<{ v: string; observed: boolean }> = []; + createRoot(() => { + patchDriver(subject, (n: any, p: any, f?: boolean) => { + const v = n.label; + if (f || v !== p.label) writes.push({ v, observed: getObserver() !== null }); + }); + }); + flush(); + expect(writes).toEqual([{ v: "a", observed: false }]); + setSig("b"); + flush(); + expect(writes).toEqual([ + { v: "a", observed: false }, + { v: "b", observed: false } + ]); + }); + + test("shallow store list: reference semantics — replaced records rebuild, moved references retain", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + // Shallow contract: children are served RAW, so the store IS the array. + // IDENTITY RULING: with no `keyed` declared, the view's identity is the + // RECORD REFERENCE — the driver must match classic mapArray exactly: a + // key-aligned slot replaced by a fresh record REBUILDS its row (the + // reconcile key is a store-write detail the view never declared). + const r1 = make(1)[0]; + const r2 = make(2)[0]; + const r3 = make(3)[0]; + const [shRows, setState] = createStore([r1, r2, r3], { shallow: true } as any); +
+ {pureRow} +
; + expect(labels(div)).toBe("L1,L2,L3"); + const [tr1, tr2, tr3] = rows(div); + + // Aligned tick: same keys, row 2 replaced BY REFERENCE — reference + // semantics rebuild that row; same-reference slots keep their nodes. + setState(s => { + reconcile([r1, { id: 2, label: "X2" }, r3], "id")(s); + }); + flush(); + expect(labels(div)).toBe("L1,X2,L3"); + expect(rows(div)[0]).toBe(tr1); + expect(rows(div)[1]).not.toBe(tr2); + expect(tr2.isConnected).toBe(false); + expect(rows(div)[2]).toBe(tr3); + + // Structure: reorder + remove + add — SAME references move with their + // nodes; the new record binds fresh. + const r4 = { id: 4, label: "L4" }; + setState(s => { + reconcile([r3, r4, r1], "id")(s); + }); + flush(); + expect(labels(div)).toBe("L3,L4,L1"); + expect(rows(div)[0]).toBe(tr3); + expect(rows(div)[2]).toBe(tr1); + + // Retained-slot replacement THROUGH a structural op: key 3 keeps its + // position but the record is fresh — rebuilt, not patched in place. + const tr3b = rows(div)[0]; + setState(s => { + reconcile([{ id: 3, label: "Z3" }, r4, r1], "id")(s); + }); + flush(); + expect(labels(div)).toBe("Z3,L4,L1"); + expect(rows(div)[0]).not.toBe(tr3b); + expect(rows(div)[2]).toBe(tr1); + + dispose(); + }); + }); + + test("removing a row severs its patch registration even when the record is retained", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const [state, setState] = createStore({ rows: make(1, 2, 3) }); +
+ {pureRow} +
; + // App code retains the record beyond the row's life (selection state, + // caches). Classic per-row effects die with the row; the patch tier + // must sever the registration at removal or the patch keeps firing + // against detached DOM for the record's lifetime. + const retained = state.rows[1]; + const tr2 = rows(div)[1]; + // Sanity: while in the list, writes through the retained ref patch. + setState(() => { + retained.label = "LIVE"; + }); + flush(); + expect(tr2.textContent).toBe("LIVE"); + setState(s => { + reconcile(make(1, 3), "id")(s.rows); + }); + flush(); + expect(tr2.isConnected).toBe(false); + expect(tr2.textContent).toBe("LIVE"); + setState(() => { + retained.label = "GHOST"; + }); + flush(); + expect(retained.label).toBe("GHOST"); + expect(tr2.textContent).toBe("LIVE"); + dispose(); + }); + }); + + test("list disposal stops patch dispatch", () => { + let div!: HTMLDivElement; + const [state, setState] = createStore({ rows: make(1) }); + const dispose = createRoot(d => { +
+ {pureRow} +
; + return d; + }); + expect(labels(div)).toBe("L1"); + const tr = rows(div)[0]; + dispose(); + setState(s => { + s.rows[0].label = "dead"; + }); + flush(); + expect(tr.textContent).toBe("L1"); + }); +}); + +// External-audit regression coverage (2026-08-24): two findings against the +// landed driver surface, independent of the (rejected-for-now) keyed proposal. +describe("patch-mode list driver — audit regressions", () => { + test("two driven lists over ONE shallow array both receive slot ticks and structure", () => { + createRoot(dispose => { + let divA!: HTMLDivElement; + let divB!: HTMLDivElement; + const [shRows, setState] = createStore(make(1, 2, 3), { shallow: true } as any); + <> +
+ {pureRow} +
+
+ {pureRow} +
+ ; + expect(labels(divA)).toBe("L1,L2,L3"); + expect(labels(divB)).toBe("L1,L2,L3"); + + // Slot tick (same keys, one record replaced): BOTH lists must update — + // slot registration was single-consumer before the audit fix (the + // second registration silently overwrote the first). + setState(s => { + reconcile([make(1)[0], { id: 2, label: "X2" }, make(3)[0]], "id")(s); + }); + flush(); + expect(labels(divA)).toBe("L1,X2,L3"); + expect(labels(divB)).toBe("L1,X2,L3"); + + // Structure: both lists apply row ops. + setState(s => { + reconcile([make(3)[0], make(1)[0]], "id")(s); + }); + flush(); + expect(labels(divA)).toBe("L3,L1"); + expect(labels(divB)).toBe("L3,L1"); + + // Unbind one list (dispose order: A's region cleared manually is not + // trivial here, so assert via full dispose at the end instead — the + // splice-unbind path is exercised by the removal in applyOps above). + dispose(); + }); + }); + + test("optimistic family arrays DECLINE the driver — structural optimism renders classically", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const [rows] = createOptimisticStore<{ list: { id: number; label: string }[] }>({ + list: [ + { id: 1, label: "L1" }, + { id: 2, label: "L2" } + ] + }); +
+ {pureRow} +
; + // Family structural changes emit no row/slot ops and the proxy identity + // is stable — an ENGAGED list would freeze. Declined lists render and + // update through classic mapArray, which tracks the array read. + expect(labels(div)).toBe("L1,L2"); + dispose(); + }); + }); +}); diff --git a/packages/web/test/harness/hydration-records.ts b/packages/web/test/harness/hydration-records.ts deleted file mode 100644 index d352d7947..000000000 --- a/packages/web/test/harness/hydration-records.ts +++ /dev/null @@ -1,34 +0,0 @@ -import vm from "node:vm"; - -/** - * Executes a rendered payload's