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-slot-emission-append-race.md b/.changeset/fix-slot-emission-append-race.md new file mode 100644 index 000000000..0a52f5aff --- /dev/null +++ b/.changeset/fix-slot-emission-append-race.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix shallow slot-patch emission racing row creation: appended positions past a fully-aligned prefix (vacuously aligned when the previous list was empty) emitted slot value-ticks for rows that do not exist yet — the slot queue applies before the row ops that create them, crashing the list driver on clear-then-refill and pure appends. Slots now dispatch only for indices with a previous slot; appends are structure-only. Found by the driver/classic equivalence matrix. diff --git a/.changeset/jfb-driver-and-write-bound.md b/.changeset/jfb-driver-and-write-bound.md new file mode 100644 index 000000000..f640c4737 --- /dev/null +++ b/.changeset/jfb-driver-and-write-bound.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +The list driver's identity matching unwraps store proxies on both sides — draft-authored permutations store row proxies verbatim, and matching them against raw records rebuilt every surviving row (caught by the JFB keyed-reorder identity gate). diff --git a/.changeset/optimistic-lists-drivable.md b/.changeset/optimistic-lists-drivable.md new file mode 100644 index 000000000..fb8ce61ed --- /dev/null +++ b/.changeset/optimistic-lists-drivable.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Optimistic family arrays are drivable by the patch-mode list driver, completing the family channel: structural optimism (push/splice/reorder/replace in optimistic drafts) emits identity-diffed row ops at lane timing from the override channel — visible in flight, bypassing the transition stash like optimistic record patches — and reverts emit an identity RESYNC the driver resolves against the live post-revert view. The driver binds optimistic lists from the optimistic view (classic reads the same view through the proxy), and the identity-swap matcher is shared between swaps and resyncs. Equivalence matrix extended with async optimistic scenarios (mounted → in-flight → settled, revert and land, element-level and parent-key structural writes). diff --git a/.changeset/patch-channel-pay-for-use.md b/.changeset/patch-channel-pay-for-use.md new file mode 100644 index 000000000..d0c222fe1 --- /dev/null +++ b/.changeset/patch-channel-pay-for-use.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Patch channel is pay-for-use: the list driver and `patchDriver` moved out of the always-retained web runtime into `patch-driver.ts`, arming the insert seam lazily from `rowProof`/`patchDriver` (which only compiled patch-mode output imports); the store's emitters ride hooks installed at first registration (`patch-hooks.ts`) instead of static imports. Apps without patch-mode output retain only a ~100 B insert hook; the store write-path seams cost ~490 B on the store floor. Before this, every client app carried the full driver (~2.4 KB brotli). 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-reaudit-2.md b/.changeset/patch-channel-reaudit-2.md new file mode 100644 index 000000000..2227ce5dc --- /dev/null +++ b/.changeset/patch-channel-reaudit-2.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Second re-audit hardening of the patch channel: adoption seams demote accessor-bearing adoptees to tracked effects in development, with a loud diagnostic (production emits directly — per-adoption accessor scans cost ~12% of dbmon's tick, and getter-bearing adoptees on patched records are a development-caught shape); setter-returned root replacements and chained-store swaps emit their patches and row ops at fold commit; the list driver's ops application builds every new row before any destructive step (a throwing row factory leaves DOM and bookkeeping atomically unchanged); patch errors route to the nearest computed ancestor so `Errored.reset()` can recompute it (reset also skips non-computed sources), and unhandled patch errors halt like unhandled effect errors; key equality is SameValueZero and occurrence-aware everywhere keys compare — NaN keys stay retained and duplicate keys adopt per occurrence on both channels; same-batch duplicate patch emissions coalesce (one application per batch, effect parity). diff --git a/.changeset/patch-channel-reaudit-3.md b/.changeset/patch-channel-reaudit-3.md new file mode 100644 index 000000000..a09bf5b7c --- /dev/null +++ b/.changeset/patch-channel-reaudit-3.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Third re-audit hardening of the patch channel: same-batch coalescing updates the queued entry in place (latest `next` wins — adoption replaces the captured object, so dropping later emissions applied stale state) and the drain clears the channel stamps (no batch retention on quiet records); the adoption remainder window builds from the misalignment point so prefix-consumed rows are never re-offered to duplicate keys; optimistic tentative matching gains SameValueZero + occurrence-aware parity with the plain channel; a failed row-ops application forces an identity resync on the next update (the store committed the failed topology while DOM kept the old one — positional ops would mis-index) and suppresses slot ticks until the baseline is restored; a throwing row factory also severs its own partial registrations. diff --git a/.changeset/patch-channel-reaudit-5.md b/.changeset/patch-channel-reaudit-5.md new file mode 100644 index 000000000..5315e2070 --- /dev/null +++ b/.changeset/patch-channel-reaudit-5.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Fifth-round hardening of the patch channel: no-op adoptions (A→B→A in one batch) clear the adopted flag so later setter row ops never freeze a driven list; transition merges retarget the moved entries' coalescing stamps (post-merge emissions coalesce instead of double-applying at commit); multi-consumer patch dispatch snapshots the registration list (a callback unbinding a sibling no longer skips consumers); the list driver's initial construction severs partial registrations on throw like update-time builds (one failed initial render no longer elevates patchCount globally); a failed apply actively resyncs from the next slot tick instead of waiting for a structural update; and identity swaps register the new subject's channels before applying so a throwing swap stays recoverable. diff --git a/.changeset/patch-channel-reaudit-hardening.md b/.changeset/patch-channel-reaudit-hardening.md new file mode 100644 index 000000000..09a65cf0e --- /dev/null +++ b/.changeset/patch-channel-reaudit-hardening.md @@ -0,0 +1,8 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +--- + +Patch-channel contract hardening from the stage-2 re-audit: ordinary `patchDriver` registrations unbind with their owner (entries no longer leak past unmount); merged transitions move their held-patch stash so no patch strands; the optimistic drain shares the normal drain's per-entry error isolation and boundary routing; accessor-bearing records are excluded at admission (scan-before-trust) and records that acquire accessors demote their patches to tracked effect fallbacks; writable projection arrays emit setter row ops at their fold-commit visibility moment; row-ops/slot registrations resolve chained backings to the ultimate owner; duplicate keys match occurrence-aware instead of first-wins; the production dev-token typo (`_DX_DEV_`) is fixed; `patchDriver: true` normalizes identically in Babel and the native loader, the option is typed in `TransformOptions`, and a `dom-patch` parity tier ratchets patch-mode output across both compilers (currently byte-identical on all fixtures). 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-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/patch-two-tier-arming.md b/.changeset/patch-two-tier-arming.md new file mode 100644 index 000000000..ccaf76ec4 --- /dev/null +++ b/.changeset/patch-two-tier-arming.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Patch-channel arming is two-tier so the default-on cost stays proportional: `patchDriver` no longer retains the list driver (only `rowProof` — the compiled marker of a patch-mode list — arms the insert seam), and the store emitters split into value hooks (armed by `registerPatch`) and row hooks (armed by list registrations), so non-list patch templates never retain row binding, LIS, or reconcile's diff builders. Flip-preview size scenarios pin both tiers. 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/projection-lists-drivable.md b/.changeset/projection-lists-drivable.md new file mode 100644 index 000000000..719ad2c9a --- /dev/null +++ b/.changeset/projection-lists-drivable.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +"solid-js": patch +"@solidjs/web": patch +--- + +Projection (non-optimistic) family arrays are drivable by the patch-mode list driver: their recomputes walk reconcile, whose row/slot emissions were never family-gated and ride the transition-stamped apply queue. The blanket family decline narrows to optimistic families only (`storeHasOptimisticFamily`), whose user writes ride node overrides and emit no structural ops. Fixes chained-backing patch registration: a projection wrapper's backing is another store's proxy, so `registerPatch`/`patchableRaw` now resolve through the chain to the ultimate owner target — patches registered on wrapped projection rows previously never fired (value transitions fold on the source). Equivalence matrix extended with 13 projection scenarios including recompute-driven structure and retention topology. diff --git a/.changeset/reconcile-walk-guard-hoist.md b/.changeset/reconcile-walk-guard-hoist.md new file mode 100644 index 000000000..7df4a6f96 --- /dev/null +++ b/.changeset/reconcile-walk-guard-hoist.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +The reconcile walk's patch-emission guards short-circuit on the installed-hooks binding before touching target fields, so stores without any patch consumer pay no per-record loads in the adoption walk (CodSpeed caught −7.7% on the 12k-path listened-paths bench). 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/shallow-slot-alignment-samevaluezero.md b/.changeset/shallow-slot-alignment-samevaluezero.md new file mode 100644 index 000000000..f89e3a020 --- /dev/null +++ b/.changeset/shallow-slot-alignment-samevaluezero.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +The shallow branch's slot-alignment prefix compares keys with SameValueZero: strict equality broke alignment on NaN keys, suppressing the slot's value ticks while the ops builder retained the row — a permanently stale DOM row (found by a full-surface self-sweep of every key-comparison site). 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/babel-plugin/src/config.ts b/packages/babel-plugin/src/config.ts index 181a9c161..ab2f8dab4 100644 --- a/packages/babel-plugin/src/config.ts +++ b/packages/babel-plugin/src/config.ts @@ -31,7 +31,7 @@ export interface PluginConfig { * compiled output must not import driver exports the release core only * stubs. Set to the driver's export name (e.g. "patchDriver") to opt a * build in against a channel-bearing core. */ - patchDriver: string | false; + patchDriver: string | boolean; memoWrapper: string | false; validate: boolean; inlineStyles: boolean; diff --git a/packages/babel-plugin/src/shared/preprocess.ts b/packages/babel-plugin/src/shared/preprocess.ts index 5fe1b410a..7322d1054 100644 --- a/packages/babel-plugin/src/shared/preprocess.ts +++ b/packages/babel-plugin/src/shared/preprocess.ts @@ -6,6 +6,9 @@ import type { BabelHubWithMetadata, PluginPass } from "../types"; export default (path: NodePath, state: PluginPass) => { const file = (path.hub as unknown as BabelHubWithMetadata).file; const merged = (file.metadata.config = Object.assign({}, config, state.opts)); + // Boolean opt-in parity with the native loader: `patchDriver: true` means + // the default import name (downstream code uses the value AS the name). + if ((merged.patchDriver as unknown) === true) merged.patchDriver = "patchDriver"; const lib = merged.requireImportSource; if (lib) { const comments = file.ast.comments ?? []; diff --git a/packages/compiler/__tests__/parity/harness.js b/packages/compiler/__tests__/parity/harness.js index c1229b471..d41bfd8e3 100644 --- a/packages/compiler/__tests__/parity/harness.js +++ b/packages/compiler/__tests__/parity/harness.js @@ -73,6 +73,20 @@ const modes = { requireImportSource: false } }, + // Patch-mode parity (re-audit blocker 6): the SAME dom corpus with the + // dual driver on — patch grammar (wrapPatchMode/rowProof stamping) must + // stay byte-identical across backends, ratcheted like every other mode. + "dom-patch": { + fixtureDir: "__dom_fixtures__", + options: { + moduleName: "r-dom", + builtIns: ["For", "Show"], + wrapConditionals: true, + contextToCustomElements: true, + requireImportSource: false, + patchDriver: "patchDriver" + } + }, "dom-hydratable": { fixtureDir: "__dom_hydratable_fixtures__", options: { @@ -186,7 +200,7 @@ function readFixtureSource(mode, fixture) { // Same parser-blocked subset carve-out as babel-fixtures.test.js: Oxc cannot // parse hyphenated JSX member segments (``). function supportedSubset(mode, fixture, source) { - if (mode === "dom" && fixture === "namespaceElements") { + if ((mode === "dom" || mode === "dom-patch") && fixture === "namespaceElements") { return [ source.slice(source.indexOf("const template ="), source.indexOf("const template4")), source.slice(source.indexOf("const template6")) @@ -196,8 +210,8 @@ function supportedSubset(mode, fixture, source) { } function compileBabel(code, options) { - // Patch mode is default-on in BOTH compilers (the Oxc port landed with - // the §3c row-proof work), so parity covers it like any shared feature. + // Patch mode is DORMANT by default in both compilers; the dom-patch mode + // above opts in explicitly so parity covers the patch grammar too. return babel.transformSync(code, { babelrc: false, configFile: false, diff --git a/packages/compiler/types.d.ts b/packages/compiler/types.d.ts index 48b5deb06..2ccd6b93f 100644 --- a/packages/compiler/types.d.ts +++ b/packages/compiler/types.d.ts @@ -20,6 +20,15 @@ export interface TransformOptions { omitNestedClosingTags?: boolean; omitLastClosingTag?: boolean; serverComponents?: boolean; + /** + * Patch-mode dual driver (dormant by default): `true` or an import name + * (`"patchDriver"`) opts compiled templates whose bindings are pure member + * reads of one subject into the store patch channel. The loader normalizes + * `true` to the default import name (the napi wrapper mapping treats bare + * booleans as "default", which this option reads as disabled). + * @default false + */ + patchDriver?: boolean | string; /** Default `["For", "Show", "Switch", "Match", "Loading", "Reveal", "Portal", "Repeat", "Dynamic", "Errored"]`. */ builtIns?: string[]; requireImportSource?: false | string; diff --git a/packages/signals/DESIGN-PATCH-CHANNEL.md b/packages/signals/DESIGN-PATCH-CHANNEL.md new file mode 100644 index 000000000..9551d98c3 --- /dev/null +++ b/packages/signals/DESIGN-PATCH-CHANNEL.md @@ -0,0 +1,250 @@ + +## 18. Impact sweep + A/B on the published pairing (2026-08-26) + +Octane migrated to the real consumer chain: `@solidjs/vite-plugin@3.0.0-next.34` +→ `@solidjs/compiler@2.0.0-rc.3` (published napi binary). Two loader bugs +fixed on next in the process (whitelist rejection; boolean `true` collapsing +to `Wrapper::Default`, which `patch_driver` uniquely reads as disabled — the +loader now normalizes `true` → `"patchDriver"`, so the published binary works +without a native rebuild). + +### §18a. Which benchmarks compile patch grammar (Babel sweep, patch on) + +6 of 15 octane solid fixtures emit at least one patch body +(`scripts/patch-impact-sweep.mjs` in the octane workspace): + +| suite | sites | notes | +|---|---|---| +| dbmon | 1 + rowProof | keyed deep-store list — flagship | +| svg-dashboard | 12 + 3 rowProof | store-driven charts | +| portal-swarm | 3 + rowProof | signal-only subjects — fallback path | +| news (runtime-stress, store-selector-fanout) | 3 | | +| async-waterfall | 1 | latency-dominated harness | +| weather-app | 3 | static-ish states | + +Not impacted (no grammar): js-framework (signal rows), todomvc, +chat-stream (hybrid signal patterns), effectful-list, memo-wall, +recursive-context, signal-favoring, spa-navigation, streaming-ssr. + +### §18b. A/B (same runtime, flag-only; busy machine, deltas well over noise) + +- **dbmon (deep)**: mount 15.8→6.3, tick 8.3→1.8, partial 1.4→0.6, + remount 10.1→5.0, unmount 2.5→0.3, sort flat. +- **svg-dashboard**: mount 9.1→7.0, charts_tick 3.9→3.1, drag 5.5→4.8, + churn 4.1→3.7, series 2.9→2.6, style pulse 6.4→6.0; nothing regresses. +- **async-waterfall / weather-app**: flat (DOM censuses identical — a + correctness signal). +- **portal-swarm**: open/close cycles +0.06–0.10 ms (~5%), reproduced at + 40 iters. All-signal fixture: its 3 patch bodies always miss + `patchableRaw` and take the effect fallback, paying the probe + wrapper + per portal mount. This is the audit's "permanent cost for optional + modes," quantified on a worst-case mount-churn shape. Open item: shrink + the fallback bind cost. + +## 20. Re-audit hardening (2026-08-26 night) + +External re-audit verdict: "materially improved, still not merge-ready" — +six blockers, all verified in code before fixing (every one was real): + +1. **Registration lifecycle**: ordinary patchDriver binds discarded their + unbind — entries and patchCount leaked past unmount (drains only skip + disposed owners). Now owner-cleanup-tied; unbind decrements only on + actual removal. +2. **Transition merges**: `_heldPatches` was an undeclared sidecar that + mergeTransitionState never moved — merged-away transitions silently + dropped their held patches. Now moved like every declared collection. +3. **Accessor contract**: patchableRaw trusted the lazily-discovered `a` + flag (unsound admission — getter deps never re-applied); demotePatches + had no caller. Now: scan-at-admission (sticky, one pass per record) and + defineProperty-acquired accessors demote to tracked effect fallbacks + (deferred to effect phase; getter deps track through the proxy). +4. **Family structure**: writable projection push/splice froze driven + lists (setter row ops gated to fam === null; both the clone branch and + the eager write-override fold now emit, gated off adoption folds and + optimistic families). Row-ops/slot registrations resolve chained + backings to the ultimate owner. A first attempt emitted at fold-commit + WITHOUT the adoption gate and double-applied matrix reorders — the + `t.adopted` flag is the discriminator. +5. **Optimistic errors**: drainOptimistic invoked callbacks bare; now + shares the normal drain's applyEntries (isolation + boundary routing). +6. **Compiler contract**: `patchDriver` typed in TransformOptions, Babel + normalizes boolean `true` like the native loader, and a `dom-patch` + parity tier compiles the full dom corpus with patch mode on — + **byte parity, zero ratchet files**. + +Also: `_DX_DEV_` → `_SOLID_DEV_` (the dev ownership diagnostic was +shipping in production patch bundles — unreplaced truthy string), and +occurrence-aware duplicate-key matching in buildRowOps + identityOps +(first-wins handed one DOM row to multiple next positions). + +Known accepted edge: a demoted LIST-ROW body re-drives under the list +owner, so per-row severing is lost for demoted rows (they only demote when +user code defines an accessor on a row record at runtime). + +Deferred from the audit's secondary list: staged exception-safe applyOps +(remove-before-build), per-store rather than global patchCount gating, the +@ts-nocheck on patch-driver.ts, and a versioned internal compiler entry +for the runtime primitives. + +## 21. Re-audit rounds 2–3 (2026-08-27) — adoption seams, key equality, recovery + +Round 2 (six findings, all real): adoption seams demote accessor-bearing +adoptees (targetIsPlain at both the walk and fold-commit emissions); +setter-returned root replacements + chained-store swaps emit at fold +commit (plain `adopted` targets — eager walk adoptions never queue folds, +so the flag is the discriminator); applyOps went build-before-destroy; +patch errors route to the nearest COMPUTED ancestor (Errored.reset() +recomputes sources — plain list owners crashed it) and unhandled errors +halt like effects; key equality went SameValueZero + occurrence-aware +(the NaN repro's true site was descend's strict-!== detach — NaN slots +detached every tick while row ops retained the DOM row); same-batch +emissions coalesce. + +Round 3 (six findings, five real — the audit caught MY round-2 bugs): +- Coalescing applied STALE state: adoption replaces the captured object, + so skip-on-duplicate applied the first capture while the store held the + last. Entries now update in place (latest next, earliest prev), and the + drain clears the stamps (retention). +- The adoption remainder window built from index 0 — prefix-consumed rows + were re-offered to duplicates past an aligned prefix; now structStart, + exactly the ops builder's window. +- Optimistic applyTentative had its own strict/first-wins matcher — + now shares sameKey + occurrence-aware queues. +- Failed row-ops applies force an IDENTITY RESYNC on the next update + (store committed the failed topology; DOM kept the old — positional + ops mis-indexed). Recovery forfeits retention for that one apply. +- The throwing row's own partial registrations sever (collectBind's + finally publishes the partial collector). + +Lesson pinned: every "safe skip" optimization on the emission path must +be re-derived against ADOPTION semantics (captures are per-emission +objects, not stable references) — the setter-path reasoning does not +transfer. + +### §21a. Self-sweep (2026-08-27 night) — the auditor's method, applied + +Full-surface sweep: every emission site's capture class (stable-ref vs +per-emission, incl. the MIXED setter+adoption same-batch coalescing case +— derived correct: latest next, earliest prev spans both), every matcher's +key equality + occurrence handling + window start, every throw point's +post-exception timeline, every registration's death paths. + +Perf re-check (Ryan's question caught it): the round-2 adoption-seam +accessor demotion ran targetIsPlain per patched-record adoption — adoptPB +resets the scan verdict, so dbmon re-probed every row's keys every tick: +~12% tick regression on the flagship (strip-test attributed: 1.9 -> 1.7). +Ruled by the degenerate-input principle: the demotion + diagnostic is +DEV-ONLY now; prod emits directly (getter adoptees on patched records are +caught loudly in development, never paid for in production). Registration +admission keeps its one-time scan in both modes. + +Found and fixed: the SHALLOW branch's slot-alignment prefix still compared +keys with strict `===` — a NaN-keyed shallow slot broke alignment +(suppressing its value ticks) while the SameValueZero ops builder emitted +nothing for the aligned structure: retained DOM row, permanently stale. +The exact round-1 staleness shape, in the branch none of the four audits +reached. sameKey now; regression test pinned. + +Documented, not fixed: +- ~~Reverted-transition stash retention~~ RETRACTED (probe-verified): + transitions never abort in this design — a FAILED action still commits + its transition (plain writes land, the held stash drains through + releaseBatch; only optimistic overrides revert). Every stash either + drains at commit or moves on merge, so the coalescing stamps always + clear. The retraction also corrects patchCommitHook's misleading + "reverted transitions" comment. +- Keyless rows in the adoption window pair positionally while row ops + treat them as remove+create: DOM content correct either way (the fresh + bind reads the adopted proxy) — retention churn only, by construction + of "no key identity". + +Everything else checked consistent: window starts (structStart both +sides), root/prefix/descend/window/tentative matchers, drain error +isolation + stamp clearing, mixed-channel stamp collisions, demotion vs +queued entries, hydration-claim vs resync interplay. + +## 19. Pay-for-use restructure (2026-08-26) — the merge blocker + +The size gate (scripts/size) failed 5/8 scenarios: every client app paid +~2.4 KB brotli (simple-app floor 10.41 → 12.99 KB) because insert called +`driveList` directly, and stores ~1 KB because the write paths imported +patch.ts's emitters statically. + +Restructure: +- **web**: driver moved to `patch-driver.ts`; insert dispatches through a + `listDriver` hook slot, armed lazily from `rowProof`/`patchDriver` + (module-scope install is an unshakeable top-level side effect in the + flat dist bundle — first attempt measurably FAILED; rowProof runs at + template creation, always before the list's insert, so lazy arming is + order-sound). +- **signals**: emitters ride `patch-hooks.ts`, installed at first + registration. Sound: every emission is `pc`-guarded and `pc` only + exists via registration. `registerSlotPatchNext` moved into patch.ts so + slot-only registrations arm too. + +Result: all 8 scenarios green. App floors ~+100 B vs next (the hook slot ++ `$ll` metadata); store floors +~490 B of trap/walk seams (limits +ratcheted with notes in .size-limit.js). Driver engagement unchanged +(dbmon tick 1.60); full suite 32/32. + +## 17. Family channel complete + equivalence matrix (2026-08-25, stage-2 pickup on the absorbed monorepo) + +Stage 2 resumed on `stage2-channel` (folded repo; the pre-fold history +archive remains on `store-edit-script`). The compiler is in-tree and +dormant — activation is a `patchDriver` default flip. + +### §17a. The equivalence matrix is the merge gate (built, first catches) + +`packages/web/test/for.equivalence.spec.tsx`: every identity mode × +operation sequence runs twice — a hand-compiled patch-mode row (driver +engaged) vs the same DOM under a grouped render effect, unstamped +(classic mapArray) — asserting per-step CONTENT and RETENTION TOPOLOGY +equality (each position: new, or moved from position j; normalized so +creation order cannot fake equivalence; payload graphs cloned per run — +stores adopt/own incoming data). + +Catches on day one: +1. Shallow slot emission raced row creation: appended positions past a + fully-aligned prefix (vacuously aligned from an empty prev) emitted + value-ticks for rows the row ops had not created yet — driver crash + on clear-then-refill and pure appends. Slots now require a previous + slot (i < dlen). +2. Chained-backing registration hole: a projection wrapper's backing IS + the source proxy; patches registered on the wrapper never fired + (value transitions fold on the source). registerPatch/patchableRaw + resolve the chain to the ultimate owner. + +### §17b. Family channel — every store kind is now drivable + +- PROJECTION families: the audit-era blanket decline was broader than + the bug — the reconcile walk's emissions were never family-gated and + ride the transition-stamped apply queue. Decline narrowed to + optimistic (`storeHasOptimisticFamily`); chained registration fixed + (§17a.2). +- OPTIMISTIC families: structural writes ride node overrides (never + walk), so the override-application site emits identity-diffed row ops + at LANE timing (emitRowOpsOptimistic beside emitPatchOptimistic; + buildIdentityRowOps factored from the setter channel). Reverts emit + the RESYNC form (ops === null): the driver rebuilds retention by row + identity against the live post-revert view (drain-time resolution — + overrides are gone by then). The driver binds optimistic lists from + the OPTIMISTIC VIEW through the proxy (committed lags in flight; + classic reads the same view). identityOps shared between swaps and + resyncs. + +Matrix: 47 scenarios green (deep, shallow, projection sync sequences + +retention pins; optimistic async scripts push/splice/reorder+value/ +whole-list-replace × revert+land, snapshotting mounted → in-flight → +settled). This closes the "benchmark-shaped" critique: engagement now +spans plain deep, shallow (reference semantics), projection, and +optimistic arrays. + +### §17c. Remaining before ship + +1. Quiet-machine margins vs current next (which now includes the classic + text-node reuse — expect the driver's update margins to compress). +2. Coverage posture (13% of corpus For lists stamp — report: + scripts/row-coverage.mjs, now folded-repo-aware): grammar growth vs + documented manual rowProof vs accept-narrow. Product call. +3. Ship shape: one default flip (both tiers) vs Tier-2 first. Current + recommendation: one flip, full gates. diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 04cde26b8..0091de7e9 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -503,7 +503,12 @@ export function createErrorBoundary( ): Accessor { return createCollectionBoundary(STATUS_ERROR, fn, queue => { return fallback(accessor(queue._error), () => { - for (const source of queue._sources) recompute(source); + for (const source of queue._sources) { + // Non-computed sources (patch-channel registrations under plain + // owners) are not recomputable — their reset is the record's next + // transition re-applying the patch (re-audit 2, P1-4). + if ((source as any)._fn !== undefined) recompute(source); + } schedule(); }); }); 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..950907ead 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -220,6 +220,27 @@ function mergeTransitionState(target: Transition, outgoing: Transition): void { outgoing._affectsNodes.length = 0; } for (const store of outgoing._optimisticStores) target._optimisticStores.add(store); + // Patch-channel stash (store/next/patch.ts): entries held for the outgoing + // transition must ride the merge like every other per-transition + // collection — releaseBatch only reads the COMMITTING transition's stash, + // so a stranded sidecar would silently drop its patches. Move (don't + // copy), same aliasing rule as the collections above. The field is an + // expando so this module stays free of patch imports (pay-for-use). + const heldPatches = (outgoing as any)._heldPatches as unknown[] | undefined; + if (heldPatches !== undefined) { + (outgoing as any)._heldPatches = undefined; + let dest = (target as any)._heldPatches as unknown[] | undefined; + if (dest !== undefined) dest.push(...heldPatches); + else dest = (target as any)._heldPatches = heldPatches; + // Retarget the entries' coalescing stamps to the surviving stash + // (opaque backref contract with store/next/patch.ts): without this a + // post-merge emission misses the stamp and pushes a SECOND entry — + // the record's patch applies twice at commit (re-audit 5, P1-2). + for (let i = 0; i < heldPatches.length; i++) { + const pc = (heldPatches[i] as any).pc; + if (pc !== undefined && pc.qe === heldPatches[i]) pc.qa = dest; + } + } // Legal transfer, not a new registration: entries move between transitions. if (__DEV__) beginAsyncReporterWrites(); for (const [source, reporters] of outgoing._asyncReporters) { @@ -450,6 +471,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 +825,17 @@ 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. Transitions never + * abort: failed actions still commit (only optimistic overrides revert), + * and merged-away transitions hand their stash to the survivor + * (mergeTransitionState) — every stash drains exactly once. 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 +843,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..3f9860e34 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -25,6 +25,18 @@ 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, + registerSlotPatchNext as registerSlotPatch, + patchableRaw +} from "./next/patch.js"; +export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily } 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..ae8cf1378 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -51,6 +51,12 @@ import { unwrapValue, wrapNext } from "./store.js"; +// Patch-channel emission rides installed hooks (patch-hooks.ts); all +// calls are `t.pc`-guarded. See patch-hooks.ts for the soundness argument. +import { patchHooks, rowHooks } from "./patch-hooks.js"; +// Cycle with reconcile.js is benign: the binding resolves at call time (the +// optimistic write), long after both modules initialize. +import { buildIdentityRowOps, sameKey } from "./reconcile.js"; import { setOptHooks, storeNextLookup } from "./target.js"; type KeyFn = (item: any) => any; import { isRawValue, isWrappable, rawValuesUsed, setNextOptimisticViewResolver } from "../store.js"; @@ -70,6 +76,23 @@ 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) patchHooks!.emitPatchOptimistic(ot, null, null); + // Row-ops resync (family increment 2): reverts flip node values + // back engine-natively; a driven list must rebuild retention by + // row identity against the post-revert view (resolved from the + // target at drain — overrides are gone by then). + if (ot.pc !== null && ot.pc.ro !== null) rowHooks!.emitRowOpsOptimistic(ot, null, null); + } + } + } stores.clear(); }; } @@ -188,6 +211,22 @@ export function notifyOptimisticWrites(t: StoreNextTarget, pb: Record { const node = t.n?.[key as any]; return node !== undefined && hasActiveOverride(node) @@ -319,6 +358,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) patchHooks!.emitPatchOptimistic(t, null, null); } overlaid.clear(); }); @@ -372,7 +415,9 @@ function applyTentative(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null): if (keyFn) { const pk = keyFn(pv); const nk = keyFn(nv); - if (pk !== undefined && nk !== undefined && pk !== nk) return null; + // SameValueZero (re-audit 3, P1-3): parity with the plain reconcile + // channel — NaN keys are self-equal. + if (pk !== undefined && nk !== undefined && !sameKey(pk, nk)) return null; } return map.get(unwrapValue(pv)) ?? null; }; @@ -387,16 +432,31 @@ function applyTentative(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null): const nk = keyFn(nv); if (nk !== undefined) { if (viewByKey === null) { + // Occurrence-aware index queues (re-audit 3, P1-3): parity with + // the plain adoption window — duplicate keys match per + // occurrence, each view row consumed once. viewByKey = new Map(); for (let j = 0; j < viewRows.length; j++) { const p = unwrapValue(viewRows[j]); if (isWrappable(p)) { const pk = keyFn(p); - if (pk !== undefined && !viewByKey.has(pk)) viewByKey.set(pk, p); + if (pk === undefined) continue; + const existing = viewByKey.get(pk); + if (existing === undefined) viewByKey.set(pk, j); + else if (Array.isArray(existing)) existing.push(j); + else viewByKey.set(pk, [existing, j]); } } } - pv = viewByKey.get(nk); + const m = viewByKey.get(nk); + if (m === undefined) pv = undefined; + else if (Array.isArray(m)) { + pv = unwrapValue(viewRows[m.shift()!]); + if (m.length === 1) viewByKey.set(nk, m[0]); + } else { + pv = unwrapValue(viewRows[m]); + viewByKey.delete(nk); + } } else pv = unwrapValue(viewRows[i]); } else pv = unwrapValue(viewRows[i]); const ct = match(pv, nv); diff --git a/packages/signals/src/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts new file mode 100644 index 000000000..80104cad5 --- /dev/null +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -0,0 +1,50 @@ +import type { StoreNextTarget } from "./target.js"; +import type { RowOps } from "./patch.js"; + +/** + * Patch-channel emission seams (pay-for-use). The store/reconcile/optimistic + * write paths emit through these installed hook objects instead of importing + * `patch.js` statically, so the channel tree-shakes out of apps that never + * register a patch consumer. + * + * TWO TIERS, armed at registration (patch.js installs them; it is retained + * only through its registration exports, which only compiled patch-mode + * output — via the web runtime's driver module — imports): + * - VALUE hooks (`patchHooks`): record patches. Armed by `registerPatch` — + * present in any bundle with one eligible template under patch mode. + * - ROW hooks (`rowHooks`): list structure (row ops, slot ticks, the + * identity/keyed diff builders in reconcile.js they drag in). Armed by + * `registerRowOps`/`registerSlotPatchNext` — the LIST driver's + * registrations, so value-only bundles never retain the row machinery. + * + * Soundness: every emission site is guarded by the matching `pc` channel + * (`pc.p` for value, `pc.ro`/`pc.sp` for rows), and a target can only + * acquire that channel through the corresponding registration — so each + * hook object is installed by the time any guard passes. Type-only imports + * from `patch.js` are erased. + */ +export interface PatchValueHooks { + emitPatch(t: StoreNextTarget, next: any, prev: any): void; + emitPatchLocal(t: StoreNextTarget, next: any, prev: any): void; + emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void; + hasPatches(): boolean; + demoteToEffects(t: StoreNextTarget): void; +} + +export interface PatchRowHooks { + emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void; + emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev: any): void; + emitSetterRowOps(t: StoreNextTarget, prevRows: any[], nextRows: any[]): void; + emitRowOpsOptimistic(t: StoreNextTarget, next: any[] | null, ops: RowOps | null): void; +} + +export let patchHooks: PatchValueHooks | null = null; +export let rowHooks: PatchRowHooks | null = null; + +export function installPatchHooks(hooks: PatchValueHooks): void { + patchHooks = hooks; +} + +export function installRowHooks(hooks: PatchRowHooks): void { + rowHooks = hooks; +} diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts new file mode 100644 index 000000000..78b57166a --- /dev/null +++ b/packages/signals/src/store/next/patch.ts @@ -0,0 +1,670 @@ +/** + * 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 { haltReactivity } from "../../core/scheduler.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"; +import { installPatchHooks, installRowHooks } from "./patch-hooks.js"; +// One-way: reconcile emits through the hooks (never imports this module), +// so pulling its setter-channel emitter here creates no cycle. +import { emitSetterRowOps } from "./reconcile.js"; +// Cycle with store.js is benign (established pattern above): both resolve at +// call time, long after module initialization. +import { targetIsPlain } from "./store.js"; +import { runWithOwner, untrack } from "../../core/core.js"; +import { createRenderEffect } from "../../signals.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; + /** Coalescing backref (re-audit 3): set for stamped SELF entries so the + * drain can clear the channel's qa/qe stamps (retention). */ + pc?: { qa: unknown; qe: unknown }; +} +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++) { + clearStamp(q[i]); + const { list, prev, force, t } = q[i]; + const next = t !== null ? (t.pb ?? t.v) : q[i].next; + firstError = applyEntries(list, next, prev, force, firstError); + } + if (firstError !== UNSET) { + // Unhandled patch errors HALT like unhandled effect errors (re-audit 2, + // P1-4): app state is undefined past an unboundaried throw. + haltReactivity(firstError); + throw firstError; + } +} + +const UNSET: unique symbol = Symbol(); + +/** ONE callback/error primitive for every drain (normal, transition-held, + * optimistic): per-entry isolation — a throwing patch must not abort its + * siblings (effect parity) — and failures route through the REGISTERING + * OWNER's queue chain exactly like a render-effect error (§2b): an Errored + * boundary above the row collects it. Unhandled errors are aggregated by the + * caller (first one rethrows after its drain completes). */ +function applyEntries( + list: { fn: Function; owner: Owner | null; u?: boolean }[], + next: any, + prev: any, + force: boolean, + firstError: unknown +): unknown { + // SNAPSHOT multi-consumer lists (re-audit 5, P1-3): a callback can dispose + // a sibling's owner, whose unbind SPLICES this same array mid-iteration — + // index-walking the live array skips the shifted consumer. The dominant + // single-consumer case pays nothing; unbound entries are marked so a + // snapshot never applies a consumer severed by an earlier callback. + const snap = list.length > 1 ? list.slice() : list; + for (let j = 0; j < snap.length; j++) { + const entry = snap[j]; + if (entry.u === true) continue; + // 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) { + // Route through the nearest COMPUTED ancestor (re-audit 2, P1-4): + // .reset() recomputes its sources, and a plain owner (the + // list driver's listOwner) is not recomputable — the component/memo + // scope above it is, and recomputing it rebuilds the rows, exactly + // what reset means for a throwing render effect. + let source = owner; + while (source !== null && source._fn === undefined) source = source._parent; + source ??= owner; + const statusErr = new StatusError(source, err); + ext(source)._error = statusErr; + source._statusFlags = (source._statusFlags ?? 0) | STATUS_ERROR; + handled = owner._queue.notify(source, STATUS_ERROR, STATUS_ERROR, statusErr); + } + if (!handled && firstError === UNSET) firstError = err; + } + } + return firstError; +} + +// 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); +} + +/** Self-entry push with SAME-BATCH COALESCING (re-audit 2/3): a record's + * later non-forced emission into the same container UPDATES the queued + * entry in place — `next` takes the newest capture (adoption swaps the + * backing object per emission; dropping the later one applied STALE state), + * `prev` keeps the batch's earliest (effect semantics: one application per + * batch spanning the whole window). The entry's consumer list is the live + * pc.p array, so mid-batch registrants ride the single application. Forced + * entries and row/slot ops never coalesce; the drain clears the stamps so a + * quiet record retains nothing from its last batch. */ +function pushSelf(pc: { qa: unknown; qe: unknown }, item: QueuedApply): void { + const tx = activeTransition; + let arr: QueuedApply[]; + if (tx !== null) { + let held = (tx as any)._heldPatches as QueuedApply[] | undefined; + if (held === undefined) (tx as any)._heldPatches = held = []; + arr = held; + } else { + if (queue === null) queue = []; + arr = queue; + } + if (pc.qa === arr && pc.qe !== null) { + const qe = pc.qe as QueuedApply; + qe.next = item.next; + qe.list = item.list; // pc.p can be re-created if emptied mid-batch + return; + } + pc.qa = arr; + pc.qe = item; + (item as any).pc = pc; + arr.push(item); + if (arr === queue && !scheduled) { + scheduled = true; + globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); + } +} + +/** Drain-side stamp clear (re-audit 3, P2-6): without it a quiet long-lived + * record's channel retains its last batch's container array, entry, and both + * captured backings for the record's lifetime. */ +function clearStamp(item: QueuedApply): void { + const pc = (item as any).pc as { qa: unknown; qe: unknown } | undefined; + if (pc !== undefined && pc.qe === item) { + pc.qa = null; + pc.qe = null; + } +} + +/** 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) + pushSelf(t.pc!, { + 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) + pushSelf(t.pc!, { + 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; + // Same isolation/routing primitive as the normal drain (re-audit blocker + // 5): one throwing optimistic patch must not abort its siblings, and it + // must reach the registering owner's Errored boundary. + let firstError: unknown = UNSET; + for (let i = 0; i < q.length; i++) { + clearStamp(q[i]); + const { list, prev, force, t } = q[i]; + const next = t !== null ? (t.pb ?? t.v) : q[i].next; + firstError = applyEntries(list, next, prev, force, firstError); + } + if (firstError !== UNSET) { + haltReactivity(firstError); + throw firstError; + } +} + +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 { + // Same-batch coalescing, optimistic container (re-audit 3): later + // non-forced emission updates the queued entry's next in place. + const pc = t.pc! as unknown as { qa: unknown; qe: unknown }; + if (pc.qa === optQueue && pc.qe !== null) { + const qe = pc.qe as QueuedApply; + qe.next = next; + qe.list = p; + } else { + const item: QueuedApply = { list: p, next, prev, force: false, t: null }; + pc.qa = optQueue; + pc.qe = item; + (item as any).pc = pc; + optQueue.push(item); + } + } + // 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); + } +} + +/** Row-ops emission at OPTIMISTIC (lane) timing: user drafts on an + * optimistic family must show structure IN FLIGHT — bypassing the + * transition stash exactly like emitPatchOptimistic. Two forms: + * - `ops` given (write site): `nextRows` is the draft's intended visible + * list, ops the identity diff against the pre-write optimistic view. + * - `ops === null` (revert site): RESYNC — the consumer rebuilds retention + * by row identity against the live post-revert view, resolved from the + * target at drain time (overrides are gone by then, so `pb ?? v` IS the + * committed truth). */ +export function emitRowOpsOptimistic( + t: StoreNextTarget, + nextRows: any[] | null, + ops: RowOps | null +): void { + const list = (t.pc !== null ? t.pc.ro : null) as RowOpsEntry[] | null; + if (list === null) return; + if (optQueue === null) optQueue = []; + optQueue.push({ + list: list.map(e => ({ + owner: e.owner, + fn: (n: any, _p: any) => e.fn(n as any[], ops as any) + })), + next: nextRows, + prev: null, + force: false, + t: nextRows === null ? t : null + }); + 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; +/** Test-only accounting probe: the live registration count must return to + * baseline across register/unbind/demote cycles. @internal */ +export function patchCountForTests(): number { + return patchCount; +} + +export function hasPatches(): boolean { + return patchCount > 0; +} + +export function registerPatch(record: any, fn: PatchFn): () => void { + let t: StoreNextTarget | undefined = record?.[$TARGET]; + if (t === undefined) throw new Error("registerPatch: not a store record"); + // Chained backings (§7b): register on the ULTIMATE owner — that is where + // value transitions fold and dispatch; the wrapper's identity is stable + // and would never fire (see ultimateTarget). + t = ultimateTarget(t) ?? t; + if (!commitHookInstalled) { + commitHookInstalled = true; + armPatchHooks(); + 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; + (entry as any).u = true; // dispatch snapshots skip severed consumers + // Decrement ONLY on actual removal: a demotion (demoteToEffects) may + // have already pulled this entry and repaired the count — the splice + // miss is how this closure learns that. + const idx = list.indexOf(entry); + if (idx >= 0) { + list.splice(idx, 1); + patchCount--; + } + if (list.length === 0 && pc.p === list) pc.p = null; + }; +} + +/** Resolve a target through CHAINED backings (§7b) to the ultimate owner. + * A projection family wrapper's backing IS another store's proxy: value + * transitions fold on the ULTIMATE target (the wrapper's identity never + * changes), so patch registration and raw resolution must land there or + * registered patches never fire (equivalence-matrix finding: projection + * value ticks froze driver rows while classic effects tracked through). */ +function ultimateTarget(t: StoreNextTarget): StoreNextTarget | undefined { + while (t.ch) { + const u: StoreNextTarget | undefined = (t.pb ?? t.v)?.[$TARGET]; + if (u === undefined) return undefined; + t = u; + } + return t; +} + +/** 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), broken chains. */ +export function patchableRaw(record: any): Record | undefined { + let t: StoreNextTarget | undefined = record?.[$TARGET]; + if (t === undefined || t.px !== record || t.a === true) return undefined; + t = ultimateTarget(t); + // SCAN before trusting (re-audit blocker 3): `a` starts false and is only + // discovered lazily (first draft, deep walks) — admission must run the + // one-time own-accessor scan itself, or a getter-bearing record takes the + // patch path and its getter's OUTSIDE dependencies (signals, other + // records) never re-apply. Sticky `sc` makes this one probe pass per + // record lifetime. + if (t === undefined || !targetIsPlain(t)) 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 and repairs the global count; callers re-drive + * the pulled bodies (demoteToEffects). */ +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; + if (p === null) return null; + patchCount -= p.length; + // Drain IN PLACE: unbind closures captured this array — a late unbind must + // miss its indexOf and not double-decrement the repaired count. + return p.splice(0, p.length); +} + +/** The demotion re-drive (re-audit blocker 3): each pulled body becomes the + * SAME dual-driver effect fallback the web runtime would have chosen had the + * record carried the accessor at bind — a tracked compute pass (next === prev + * short-circuits every compare into a pure read THROUGH THE PROXY, so getter + * dependencies track) plus an untracked force-apply at effect timing. + * + * Creation is DEFERRED to the effect phase: the trap that discovers the + * accessor runs mid-draft, and an effect's initial pass must not read + * through the proxy inside the write window. The record's own transition + * for that draft is covered by the new effect's initial force-apply. + * + * Known edge (documented): a demoted LIST-ROW body re-drives under its + * registering owner (the list owner), so per-row severing on removal is + * lost for demoted rows — the effect lives until the LIST disposes. Rows + * only demote when user code defines an accessor on a row record at + * runtime. */ +export function demoteToEffects(t: StoreNextTarget): void { + const entries = demotePatches(t); + if (entries === null || entries.length === 0) return; + const proxy = t.px; + globalQueue.enqueue(EFFECT_RENDER, () => { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.owner !== null && isDisposed(entry.owner)) continue; + const fn = entry.fn; + runWithOwner(entry.owner, () => + createRenderEffect( + () => { + fn(proxy, proxy, false); + }, + () => { + // Block body: a compiled patch body's return value must not be + // mistaken for an effect cleanup. + untrack(() => fn(proxy, undefined, true)); + } + ) + ); + } + }); +} + +// --------------------------------------------------------------------------- +// 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[]; +} + +/** `ops === null` is the RESYNC form (optimistic revert): the consumer + * rebuilds retention by row identity against `next` (the live view). */ +export type RowOpsFn = (next: any[], ops: RowOps | null) => 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 { + let t: StoreNextTarget | undefined = array?.[$TARGET]; + if (t === undefined) throw new Error("registerRowOps: not a store array"); + // Chained backings resolve to the ULTIMATE owner, same as registerPatch + // (§7b) — the walk/fold emits there (re-audit blocker 4). + t = ultimateTarget(t) ?? t; + armRowHooks(); + if (!commitHookInstalled) { + commitHookInstalled = true; + armPatchHooks(); + 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 + }); +} + +/** 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 { + let t: StoreNextTarget | undefined = arr?.[$TARGET]; + if (t === undefined) throw new Error("registerSlotPatchNext: not a store array"); + // Chained backings resolve to the ULTIMATE owner, same as registerPatch + // (§7b) — the walk emits slot ticks there (re-audit blocker 4). + t = ultimateTarget(t) ?? t; + armRowHooks(); + if (!commitHookInstalled) { + commitHookInstalled = true; + armPatchHooks(); + setPatchCommitHook(releaseBatch); + GlobalQueue._drainPatchOptimistic = drainOptimistic; + } + // 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; + }; +} + +/** 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 + }); +} + +// Pay-for-use seams: the write paths (store/reconcile/optimistic) emit +// through installed hooks instead of importing this module. Installation is +// LAZY (first registration) rather than a module-scope call — the dist is a +// flat bundle, and a top-level side effect would retain the whole channel in +// every consumer. TWO TIERS so a value-only registration (registerPatch — +// present in ~every bundle under patch-mode default) does not retain the +// list machinery (row-ops emitters + reconcile's diff builders): row hooks +// arm only from the list driver's registrations. Sound because every +// emission site is guarded by the matching pc channel, which only the +// corresponding registration creates. See patch-hooks.ts. +function armPatchHooks(): void { + installPatchHooks({ + emitPatch, + emitPatchLocal, + emitPatchOptimistic, + hasPatches, + demoteToEffects + }); +} + +function armRowHooks(): void { + installRowHooks({ + emitRowOps, + emitSlotPatch, + emitSetterRowOps, + emitRowOpsOptimistic + }); +} diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index fd15f4b07..775f7c4bf 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -19,6 +19,12 @@ * members (R11). Kind changes replace wholesale (R10). */ import { isEqual } from "../../core/index.js"; +import type { RowOps } from "./patch.js"; +// Patch-channel emission rides installed hooks (patch-hooks.ts) — this +// module must never import patch.js at runtime (patch.js imports +// emitSetterRowOps from here, and the hooks are what keep the channel +// tree-shakeable for non-patch apps). All calls are `t.pc`-guarded. +import { patchHooks, rowHooks } from "./patch-hooks.js"; import { $PROXY, $TARGET, @@ -37,7 +43,8 @@ import { notifyKeyDiff, targetsEqual, notifyKeyValue, - unwrapValue + unwrapValue, + targetIsPlain } from "./store.js"; import { ownedRaw, @@ -88,7 +95,7 @@ export function reconcileNextState( // positional so old-entity subtrees never merge into the new entity's). const prev = t.pb ?? t.v; const eq = keyFn(prev); - if (eq !== undefined && keyFn(incoming) !== eq) { + if (eq !== undefined && !sameKey(keyFn(incoming), eq)) { if (!replace) throw new Error(__DEV__ ? "Cannot reconcile states with different identity" : ""); // Entity change: wholesale swap. The root proxy is stable for life @@ -131,6 +138,33 @@ 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 (patchHooks !== null && eager && t.pc !== null && t.pc.p !== null) { + // Accessor demotion at the ADOPTION seam is DEV-ONLY (prod principle: + // explicitly-odd input must not cost correct-input prod — the + // per-adoption scan was ~12% of dbmon's tick since adoptPB resets the + // verdict every adoption). Dev demotes AND warns; prod emits directly, + // so a getter adoptee's OUTSIDE deps (signals) won't re-apply in prod — + // caught loudly during development instead. Registration-time admission + // (patchableRaw) keeps its full one-time scan in both modes. + if (__DEV__ && !targetIsPlain(t)) { + console.warn( + "A reconcile adopted an object with own getters into a record that " + + "carries compiled patches. Patches read raw values and will not " + + "track the getters' reactive dependencies — this record's patches " + + "are demoted to effects in development, but production will NOT " + + "demote. Avoid getters on patched records, or key them out of " + + "patch-eligible templates." + ); + patchHooks.demoteToEffects(t); + } else { + patchHooks.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); @@ -170,7 +204,7 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj typeof pvRaw === "object" && nv !== null && typeof nv === "object" && - keyFn(pvRaw) === keyFn(nv) + sameKey(keyFn(pvRaw), keyFn(nv)) ) ) break; // misaligned: fall to the keyed remainder below @@ -198,6 +232,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]; @@ -207,16 +242,39 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj let pv: any; if (nk !== undefined) { if (prevByKey === null) { + // Occurrence-aware (re-audit 2, P1-5): duplicate keys queue + // their prev INDICES (rows can themselves be arrays, so index + // queues are the unambiguous encoding — same as buildRowOps) + // and each is consumed ONCE. First-wins would adopt two next + // rows into the SAME prev target while row ops retain two + // separate DOM rows (the second one stale). prevByKey = new Map(); - for (let j = 0; j < prevRows.length; j++) { + // From structStart, not 0 (re-audit 3, P1-2): prefix-aligned + // rows already adopted their incoming counterparts — re-offering + // them here let a duplicate key adopt a prefix row AGAIN while + // row ops (which correctly window from structStart) retained + // the later occurrence's DOM row against a never-adopted target. + for (let j = structStart; j < prevRows.length; j++) { const p = unwrapValue(prevRows[j]); if (p !== null && typeof p === "object") { const pk = keyFn(p); - if (pk !== undefined && !prevByKey.has(pk)) prevByKey.set(pk, p); + if (pk === undefined) continue; + const existing = prevByKey.get(pk); + if (existing === undefined) prevByKey.set(pk, j); + else if (Array.isArray(existing)) existing.push(j); + else prevByKey.set(pk, [existing, j]); } } } - pv = prevByKey.get(nk); + const m = prevByKey.get(nk); + if (m === undefined) pv = undefined; + else if (Array.isArray(m)) { + pv = unwrapValue(prevRows[m.shift()!]); + if (m.length === 1) prevByKey.set(nk, m[0]); + } else { + pv = unwrapValue(prevRows[m]); + prevByKey.delete(nk); + } } else { pv = unwrapValue(prevRows[i]); // keyless item: positional fallback } @@ -230,12 +288,62 @@ 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 ( + rowHooks !== null && + 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 = rowHooks !== null && 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 = rowHooks !== null && 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" && + // SameValueZero (self-sweep): strict === here broke slot + // alignment on NaN keys while buildRowOps retained the row — + // retained DOM with suppressed value ticks (the round-1 NaN + // staleness, in the shallow branch). + sameKey(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. + // `i < dlen` is load-bearing for BOTH modes: an appended position + // past a fully-aligned prefix (vacuously aligned when prev is empty) + // has no previous slot — emitting a slot tick for it races the row + // ops that CREATE the row (the slot queue applies first, indexing a + // row that does not exist yet). Equivalence-matrix finding: + // clear-then-refill and pure appends crashed the driver. + if (sp !== null && i < dlen && (keyFn === null || keyAligned)) { + const pvS = prevRows[i]; + if (pvS !== nvP) rowHooks!.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 +364,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 +393,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 +478,109 @@ 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); + +/** Key equality for EVERY key comparison in this module (re-audit 2, P1-5): + * SameValueZero, matching the Map-based matchers (buildRowOps, the adoption + * window) — NaN keys are equal to themselves, so aligned NaN rows stay + * aligned in the prefix walk instead of forever misaligning. Adoption and + * row ops MUST agree on key equality or retained DOM rows go stale. */ +export function sameKey(a: any, b: any): boolean { + return a === b || (a !== a && b !== b); +} +export function emitSetterRowOps(t: StoreNextTarget, prevRows: any[], nextRows: any[]): void { + const ops = buildIdentityRowOps(prevRows, nextRows); + if (ops !== null) rowHooks!.emitRowOps(t, nextRows, ops); +} + +/** Identity-keyed structural diff, returned rather than emitted: shared by + * the setter channel (regular queue) and the OPTIMISTIC write channel (lane + * queue) — same retention semantics, different dispatch timing. Returns + * null when the lists are identity-aligned (no structure changed). */ +export function buildIdentityRowOps(prevRows: any[], nextRows: any[]): RowOps | null { + 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 null; + return buildRowOps(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 { + rowHooks!.emitRowOps(t, nextRows, buildRowOps(prevRows, nextRows, structStart, keyFn)); +} + +function buildRowOps( + prevRows: any[], + nextRows: any[], + structStart: number, + keyFn: KeyFn | null +): RowOps { + const plen = prevRows.length; + const nlen = nextRows.length; + const sources = new Array(nlen - structStart); + // Occurrence-aware matching (re-audit): duplicate keys queue their old + // indices and each is consumed ONCE — first-wins reuse would hand the same + // source (and its one DOM row) to multiple next positions. The no-dup fast + // shape stays a bare number; collisions upgrade to a queue. + 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) continue; + const existing = oldIndexByKey.get(pk); + if (existing === undefined) oldIndexByKey.set(pk, j); + else if (Array.isArray(existing)) existing.push(j); + else oldIndexByKey.set(pk, [existing, 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) { + if (Array.isArray(m)) { + oldIdx = m.shift()!; + if (m.length === 1) oldIndexByKey.set(nk, m[0]); + } else { + oldIdx = m; + oldIndexByKey.delete(nk); + } + consumed!.add(oldIdx); + } + } + } + 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])); + } + return { prefix: structStart, sources, removed }; +} + function descend( pv: any, nv: any, @@ -374,7 +610,10 @@ function descend( const nk = keyFn(nv); // Key mismatch detaches: the slot takes the new entity; the old proxy // keeps its (old) backing and a fresh proxy wraps the new value on read. - if (pk !== undefined && nk !== undefined && pk !== nk) return; + // SameValueZero (re-audit 2, P1-5): NaN keys are self-equal — strict + // inequality detached every NaN-keyed slot on every tick while the + // Map-based row-ops matcher retained its DOM row (stale forever). + if (pk !== undefined && nk !== undefined && !sameKey(pk, nk)) return; } // Reachability pruning (§6d) is MODE-dependent, both pinned: // - keyed matching descends only where subscriptions exist at/below (`d`) — diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 1e5b9da01..82582541d 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -85,12 +85,19 @@ import { import { devAssertNeverUserMutation, ingestedRaw, + markDescendants, ownedRaw, storeNextLookup, type StoreNextFamily, type StoreNextTarget, + type PatchChannel, optHooks } from "./target.js"; +// Patch-channel emission rides installed hooks (patch-hooks.ts) so the +// channel tree-shakes out of apps that never register a patch consumer. +// Every call is `t.pc`-guarded — a target only acquires `pc` through +// patch.js registration, which installs the hooks first. +import { patchHooks, rowHooks } from "./patch-hooks.js"; // --------------------------------------------------------------------------- // wrap / dedupe @@ -103,8 +110,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; @@ -125,12 +137,17 @@ function TargetShape(this: any) { this.s = undefined; this.ovl = undefined; this.del = undefined; - this.wk = undefined; + this.pc = undefined; this.hv = undefined; this.ht = 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, qa: null, qe: null }); +} + function createTarget( value: Record, parent: StoreNextTarget | null, @@ -152,6 +169,7 @@ function createTarget( t.h = null; t.k = null; t.dk = null; + t.pc = null; t.u = parent; t.pk = parentKey; t.px = null; @@ -164,7 +182,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); @@ -367,14 +384,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) @@ -402,6 +411,13 @@ function cloneRaw(source: Record, t?: StoreNextTarget): Record : Object.create(Object.getPrototypeOf(source), descs); } +/** Scanned plainness for patch admission (patchableRaw): runs the one-time + * accessor scan if it hasn't happened yet — the sticky `a` flag alone is not + * trustworthy before a scan (it starts false and is discovered lazily). */ +export function targetIsPlain(target: StoreNextTarget): boolean { + return target.sc ? !target.a : scanAccessorsOnce(target); +} + /** One-time own-accessor scan (Annex-B probes, no descriptor allocation); * returns true when the container is plain data (overlay-safe). */ function scanAccessorsOnce(target: StoreNextTarget): boolean { @@ -557,15 +573,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 (!hookInstalled) { @@ -615,6 +640,11 @@ function drainFolds(): void { // 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; + // Eager (write-override) family folds swap pb -> v at notifyWrites' + // tail: by the time this drain runs they carry no pb, and their + // structural ops must emit at the fold-commit site below (the clone + // branch never sees them). Re-audit blocker 4. + const foldedEager = t.pb === null; if (t.pb !== null) { // #3089: a fold written under a still-running transition defers to // that transition's settle (the write-time stamp covers unobserved @@ -638,11 +668,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; @@ -681,15 +713,70 @@ 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). + // Family setter drafts (writable projection push/splice through the + // masked setter) fold on this branch too and the fold IS their + // visibility moment — emit unless the structure already rode another + // channel: adoption folds (reconcile walk emitted ops) and + // optimistic families (lane-timed override channel). Re-audit + // blocker 4. + if ( + t.pc !== null && + t.pc.ro !== null && + !t.adopted && + t.fam?.opt !== true && + Array.isArray(pb) && + Array.isArray(t.v) + ) + rowHooks!.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) { + // A no-op adoption (A -> B -> A before flush) still consumed its walk: + // clear the flag or every later setter row-op gate (!t.adopted) stays + // failed and a driven family list freezes (re-audit 5, P1-1). + t.adopted = false; + continue; + } + // 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) — and so do PLAIN fold-adopted targets (setter- + // returned root replacements, chained-store swaps: adoptions WITHOUT a + // reconcile walk, so no walk-site emission ever happened — re-audit 2, + // P1-2). Plain eager targets emitted at their walk/setter sites already. + if (t.pc !== null && (t.fam !== null || t.adopted)) { + // Structural ops for folds whose structure rode no other channel: + // eager-folded family SETTER drafts (write-override swaps pb -> v at + // notifyWrites' tail — the clone branch never sees them; adoption + // folds re-emitting would double the walk's ops) and PLAIN fold + // adoptions (no walk at all). Optimistic families ride the override + // channel (lane-timed ops + revert RESYNC) — never re-emit here. + if ( + t.pc.ro !== null && + t.fam?.opt !== true && + (t.fam !== null ? foldedEager && !t.adopted : t.adopted) && + Array.isArray(t.v) && + Array.isArray(old) + ) + rowHooks!.emitSetterRowOps(t, old as any[], t.v as any[]); + if (t.pc.p !== null) { + // Accessor demotion at the fold-commit seam is DEV-ONLY (see the + // reconcile seam note: prod never pays per-adoption scans). + if (__DEV__ && !targetIsPlain(t)) patchHooks!.demoteToEffects(t); + else patchHooks!.emitPatchLocal(t, t.v, old); } } - if (t.v === old) continue; // adopted then re-adopted back, or no-op // Path copying (CAS: see the eager-fold twin above). if (t.u && t.u.v[t.pk!] === old) { privatizeCommitted(t.u); @@ -711,19 +798,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; @@ -778,8 +852,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) { @@ -816,15 +897,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)) { @@ -854,6 +938,13 @@ 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 && patchHooks !== null && patchHooks.hasPatches()) + patchHooks.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 @@ -870,7 +961,6 @@ function notifyWrites(t: StoreNextTarget): void { 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) { @@ -1610,14 +1700,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)) { @@ -1656,12 +1747,20 @@ const traps: ProxyHandler = { const override = !draft && getWriteOverride(); if (!draft && !override) return true; if (key === "__proto__") return true; - if (desc.get || desc.set) target.a = true; + if (desc.get || desc.set) { + target.a = true; + // Accessor demotion (re-audit blocker 3): a record that acquires an + // accessor after patch registration stops being patchable — pull its + // patches and re-drive them as tracked effect fallbacks. Hooks are + // installed whenever pc.p exists (registration installs them). + if (target.pc !== null && target.pc.p !== null) patchHooks!.demoteToEffects(target); + } // Unwrap before ensurePB (see the set trap: self-reference materializes). 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); @@ -1674,7 +1773,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. @@ -1776,8 +1876,42 @@ function isNextProxy(value: any): boolean { ); } +/** 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; +} + +/** True when `proxy` belongs to an OPTIMISTIC family specifically. The list + * driver declines these (audit finding, narrowed): optimistic user writes + * ride node-level overrides — they never enter the reconcile walk, so no + * row/slot ops are emitted and an engaged list would freeze on optimistic + * structural changes. PROJECTION (non-optimistic) families are drivable: + * their recomputes go through the reconcile walk, whose emissions are + * transition-stamped in the apply queue like any other (equivalence-matrix + * gated). Re-admitting optimistic families requires a lane-timed structural + * emission mirroring emitPatchOptimistic, plus revert resync. */ +export function storeHasOptimisticFamily(proxy: any): boolean { + const t: StoreNextTarget | undefined = proxy?.[$TARGET]; + return t !== undefined && t.fam?.opt === true; +} + /** 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..673a1e82d 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,38 @@ 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; + /** Same-batch coalescing stamp (re-audit 2/3): the container array this + * channel last pushed a non-forced SELF entry into, plus that entry. A + * later same-batch emission UPDATES the queued entry's `next` in place + * (latest state wins — adoption REPLACES the captured object, so dropping + * the later emission would apply stale state) while `prev` stays the + * batch's earliest. The drain clears both stamps so a quiet record + * retains nothing from its last batch. */ + qa: unknown; + qe: unknown; + /** 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 +81,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 +122,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 +176,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..070aeda91 --- /dev/null +++ b/packages/signals/tests/store/patch-channel.test.ts @@ -0,0 +1,920 @@ +import { describe, expect, it } from "vitest"; +import { + action, + createErrorBoundary, + createRoot, + createStore, + flush, + reconcile, + registerPatch, + resetErrorHalt +} 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"]); + // Unhandled patch errors HALT like unhandled effect errors — revive the + // scheduler for the rest of the file (standard test hook). + resetErrorHalt(); + }); + + 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([]); + }); +}); + +describe("patch channel (re-audit hardening)", () => { + it("unbind returns the channel to baseline; double-unbind never double-counts", async () => { + const { patchCountForTests } = await import("../../src/store/next/patch.js"); + // Earlier tests in this file leak registrations by design — assert the + // DELTA returns to this test's own baseline. + const base = patchCountForTests(); + const [state] = createStore({ user: { name: "a" }, other: { name: "b" } }); + const u1 = registerPatch(state.user, () => {}); + const u2 = registerPatch(state.other, () => {}); + expect(patchCountForTests()).toBe(base + 2); + u1(); + u1(); // idempotent — must not double-decrement + expect(patchCountForTests()).toBe(base + 1); + u2(); + expect(patchCountForTests()).toBe(base); + }); + + it("merged overlapping transitions release BOTH stashes of held patches at commit", async () => { + const [state, setState] = createStore({ a: { v: "a0" }, b: { v: "b0" } }); + const log: string[] = []; + registerPatch(state.a, (n: any) => log.push("a:" + n.v)); + registerPatch(state.b, (n: any) => log.push("b:" + n.v)); + let resolveA!: () => void; + let resolveB!: () => void; + let saveA!: () => Promise | void; + let saveB!: () => Promise | void; + createRoot(() => { + saveA = action(function* () { + setState(s => { + s.a.v = "a1"; + }); + yield new Promise(r => { + resolveA = r; + }); + }) as any; + saveB = action(function* () { + setState(s => { + s.b.v = "b1"; + }); + yield new Promise(r => { + resolveB = r; + }); + }) as any; + }); + const pa = saveA(); + flush(); + const pb = saveB(); + flush(); + // Both writes ride transitions — nothing visible, nothing patched. + expect(log).toEqual([]); + resolveA(); + resolveB(); + await pa; + await pb; + flush(); + // Whatever merging happened between the overlapping transitions, each + // held patch releases exactly once at the surviving commit. + expect(log.sort()).toEqual(["a:a1", "b:b1"]); + }); + + it("optimistic drain isolates a throwing patch and routes it to the boundary", async () => { + const { createOptimisticStore, action: act } = await import("../../src/index.js"); + const [state, setState] = (createOptimisticStore as any)({ a: { v: 0 }, b: { v: 0 } }); + const applied: string[] = []; + let caught: unknown; + const b = createRoot(() => + createErrorBoundary( + () => { + registerPatch(state.a, (n: any) => { + if (n.v > 0) throw new Error("optimistic boom"); + }); + registerPatch(state.b, (n: any) => applied.push("b:" + n.v)); + return "content"; + }, + e => { + caught = e(); + return "errored"; + } + ) + ); + expect(b()).toBe("content"); + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + s.a.v = 1; + s.b.v = 1; + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = (save() as Promise).catch(() => {}); + // Lane-timed drain: the throwing sibling must not abort b's patch. + expect(() => flush()).not.toThrow(); + expect(applied).toEqual(["b:1"]); + expect(b()).toBe("errored"); + expect(String(caught)).toContain("optimistic boom"); + resolve(); + await p; + flush(); + }); + + it("accessor-bearing records are NOT admitted (scan runs at admission)", async () => { + const { patchableRaw } = await import("../../src/index.js"); + const [state] = createStore({ + plain: { v: 1 }, + computed: { + base: 2, + get double() { + return this.base * 2; + } + } + }); + // Never written, never scanned — admission itself must run the scan. + expect(patchableRaw(state.computed)).toBeUndefined(); + expect(patchableRaw(state.plain)).not.toBeUndefined(); + }); + + it("a record that acquires an accessor demotes its patches to tracked effects", async () => { + const { patchCountForTests } = await import("../../src/store/next/patch.js"); + const { createSignal } = await import("../../src/index.js"); + const base = patchCountForTests(); + const [dep, setDep] = createRoot(() => createSignal(10)); + const [state, setState] = createStore({ user: { name: "a" } }); + const log: string[] = []; + let dispose!: () => void; + let unbind!: () => void; + createRoot(d => { + dispose = d; + unbind = registerPatch(state.user, (next: any) => + log.push(next.name + ":" + (next.score ?? "-")) + ); + }); + setState(s => { + s.user.name = "b"; + }); + flush(); + expect(log).toEqual(["b:-"]); + // The accessor arrives: patches demote — the SAME body re-drives as a + // tracked effect (initial force-apply at effect phase). + setState(s => { + Object.defineProperty(s.user, "score", { + get() { + return dep(); + }, + enumerable: true, + configurable: true + }); + }); + flush(); + // Demotion repaired the count; the late unbind is inert (no negative). + expect(patchCountForTests()).toBe(base); + unbind(); + expect(patchCountForTests()).toBe(base); + expect(log[log.length - 1]).toBe("b:10"); + // The getter's OUTSIDE dependency now re-applies — the exact divergence + // unsound admission would have silently dropped. + setDep(11); + flush(); + expect(log[log.length - 1]).toBe("b:11"); + dispose(); + }); + + it("same-batch emissions coalesce: two setters, one application (effect parity)", () => { + const [state, setState] = createStore({ user: { name: "a", title: "x" } }); + let applies = 0; + registerPatch(state.user, () => { + applies++; + }); + setState(s => { + s.user.name = "b"; + }); + setState(s => { + s.user.title = "y"; + }); + flush(); + // Both emissions capture the same live pending backing and the same + // committed prev — a classic effect runs once for the batch; so does + // the patch. + expect(applies).toBe(1); + expect(state.user.name).toBe("b"); + expect(state.user.title).toBe("y"); + // The NEXT batch applies again (stale-stamp check). + setState(s => { + s.user.name = "c"; + }); + flush(); + expect(applies).toBe(2); + }); + + it("a no-op adoption (A→B→A) does not freeze later setter row ops (re-audit 5, P1-1)", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const original = [{ id: 1 }, { id: 2 }]; + const [arr, setArr] = createStore(original); + const ops: any[] = []; + registerRowOps(arr, (_next: any[], o: any) => ops.push(o)); + // Same-batch A -> B -> A: the fold commits back to the original backing + // (t.v === old) — the adopted flag must still clear, or every later + // !adopted row-op gate stays failed and the driven list freezes. + setArr(() => [{ id: 9 }]); + setArr(() => original); + flush(); + setArr(s => { + s.push({ id: 3 }); + }); + flush(); + expect(ops.length).toBeGreaterThan(0); + expect(arr.length).toBe(3); + }); + + it("a consumer unbinding during dispatch does not skip its siblings (re-audit 5, P1-3)", () => { + const [state, setState] = createStore({ user: { name: "a" } }); + const applied: string[] = []; + let unbindA!: () => void; + unbindA = registerPatch(state.user, () => { + applied.push("A"); + unbindA(); // self-unbind splices the live list mid-dispatch + }); + registerPatch(state.user, (n: any) => applied.push("B:" + n.name)); + setState(s => { + s.user.name = "b"; + }); + flush(); + // Pre-fix: A's splice shifted B left and the index walk skipped it. + expect(applied).toEqual(["A", "B:b"]); + // A is gone; B alone next batch. + setState(s => { + s.user.name = "c"; + }); + flush(); + expect(applied).toEqual(["A", "B:b", "B:c"]); + }); + + it("coalescing applies the LATEST same-batch state, once (re-audit 3, P1-1)", () => { + const [state, setState] = createStore({ user: { id: 1, name: "a" } }); + // Observe the record (keyed adoption prunes unobserved captures). + const applied: string[] = []; + registerPatch(state.user, (next: any) => applied.push(next.name)); + // Two eager reconciles in one batch capture DIFFERENT adopted objects — + // dropping the second would apply stale state to the DOM while the + // store holds the newer one. + setState(s => { + reconcile({ user: { id: 1, name: "b" } }, "id")(s); + }); + setState(s => { + reconcile({ user: { id: 1, name: "c" } }, "id")(s); + }); + flush(); + expect(applied).toEqual(["c"]); // once, and the newest + expect(state.user.name).toBe("c"); + }); + + it("duplicate keys AFTER an aligned prefix adopt per remaining occurrence (re-audit 3, P1-2)", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const a1 = { id: "a", v: 1 }; + const b = { id: "b", v: 2 }; + const a2 = { id: "a", v: 3 }; + const [state, setState] = createStore({ rows: [a1, b, a2] }); + const r0 = state.rows[0]; + const r2 = state.rows[2]; + registerPatch(r0, () => {}); + registerPatch(r2, () => {}); + const ops: any[] = []; + registerRowOps(state.rows, (_next: any[], o: any) => ops.push(o)); + // Prefix aligns position 0 ('a' key): it adopts a10 into a1. The window + // (positions 1+) must NOT re-offer a1 — the remaining 'a' occurrence is + // a2, which row ops retain for position 1. + setState(s => { + reconcile( + [ + { id: "a", v: 10 }, + { id: "a", v: 20 }, + { id: "b", v: 2 } + ], + "id" + )(s.rows); + }); + flush(); + expect(r0.v).toBe(10); // prefix adoption + expect(r2.v).toBe(20); // window adopts the REMAINING occurrence + expect(state.rows[1]).toBe(r2); // identity preserved where ops retained + }); + + it("shallow slot alignment is SameValueZero: NaN-keyed slots keep their value ticks (self-sweep)", async () => { + const { registerRowOps, registerSlotPatch } = await import("../../src/index.js"); + const keep = { id: 2, v: 2 }; + const [state, setState] = createStore([{ id: NaN, v: 1 }, keep], { + shallow: true + } as any); + const ticks: Array<[number, any]> = []; + const ops: any[] = []; + registerRowOps(state, () => ops.push(1)); + registerSlotPatch(state, (i: number, next: any) => ticks.push([i, next.v])); + // Aligned value replacement on the NaN-keyed slot: strict-equality + // alignment broke at the NaN key, suppressing the slot tick while the + // SameValueZero ops builder emitted nothing (aligned) — the retained + // row went permanently stale. + setState(s => { + reconcile([{ id: NaN, v: 10 }, keep], "id")(s); + }); + flush(); + expect(ticks).toEqual([[0, 10]]); + expect(ops.length).toBe(0); // aligned — structure emitted nothing + }); + + it("optimistic tentative matching is SameValueZero and occurrence-aware (re-audit 3, P1-3)", async () => { + const { createOptimisticStore, action: act } = await import("../../src/index.js"); + const [state, setState] = (createOptimisticStore as any)({ + rows: [ + { id: NaN, v: 1 }, + { id: "x", v: 2 }, + { id: "x", v: 3 } + ] + }); + const r0 = state.rows[0]; + const r1 = state.rows[1]; + const r2 = state.rows[2]; + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + reconcile( + [ + { id: NaN, v: 10 }, + { id: "x", v: 20 }, + { id: "x", v: 30 } + ], + "id" + )(s.rows); + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = save() as Promise; + flush(); + // In-flight tentative view: NaN-keyed row keeps its proxy identity + // (strict inequality detached it), duplicate keys map per occurrence. + expect(state.rows[0]).toBe(r0); + expect(state.rows[0].v).toBe(10); + expect(state.rows[1]).toBe(r1); + expect(state.rows[1].v).toBe(20); + expect(state.rows[2]).toBe(r2); + expect(state.rows[2].v).toBe(30); + resolve(); + await p; + flush(); + }); + + it("reconciling a getter-backed object into a patched record demotes to a tracked effect", async () => { + const { createSignal } = await import("../../src/index.js"); + const [dep, setDep] = createRoot(() => createSignal(1)); + const [state, setState] = createStore({ user: { id: 1, name: "a" } }); + const log: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + registerPatch(state.user, (next: any) => log.push(next.name + ":" + (next.score ?? "-"))); + }); + // Adopt a getter-backed replacement through reconcile (stable key so the + // slot adopts rather than detaching as an entity change): the patch must + // NOT serve it (the getter's dep would never re-apply) — it demotes. + setState(s => { + reconcile( + { + user: { + id: 1, + name: "b", + get score() { + return dep(); + } + } + }, + "id" + )(s); + }); + flush(); + expect(log[log.length - 1]).toBe("b:1"); + setDep(2); + flush(); + // The exact divergence unsound adoption would drop: the getter's OUTSIDE + // dependency re-applies through the demoted effect. + expect(log[log.length - 1]).toBe("b:2"); + dispose(); + }); + + it("setter-returned root replacement emits patches and row ops at fold commit", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore({ name: "a", rows: [{ id: 1 }, { id: 2 }] }); + const log: string[] = []; + const ops: any[] = []; + registerPatch(state, (next: any) => log.push(next.name)); + registerRowOps(state.rows, (_next: any[], o: any) => ops.push(o)); + // Replacement via setter RETURN — an adoption with no reconcile walk. + setState(() => ({ name: "b", rows: [{ id: 2 }, { id: 3 }] })); + flush(); + expect(log).toEqual(["b"]); + expect(state.name).toBe("b"); + // The nested array slot re-points wholesale on root replacement — the + // ROOT patch covers it; structural ops for the array slot ride the next + // array-level transition. Now replace the ARRAY root directly: + const [arr, setArr] = createStore([{ id: 1 }, { id: 2 }]); + const arrOps: any[] = []; + registerRowOps(arr, (_next: any[], o: any) => arrOps.push(o)); + setArr(() => [{ id: 2 }, { id: 3 }]); + flush(); + expect(arrOps.length).toBe(1); + }); + + it("adoption and row ops agree on duplicate keys (occurrence-aware both sides)", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const a1 = { id: "a", v: 1 }; + const a2 = { id: "a", v: 2 }; + const b = { id: "b", v: 3 }; + const [state, setState] = createStore({ rows: [a1, a2, b] }); + // Materialize child targets AND observe them (registerPatch marks + // descendants — keyed pruning detaches unobserved captures by design, + // R18; the list driver registers per-row patches exactly like this). + const r0 = state.rows[0]; + const r1 = state.rows[1]; + registerPatch(r0, () => {}); + registerPatch(r1, () => {}); + const ops: any[] = []; + registerRowOps(state.rows, (_next: any[], o: any) => ops.push(o)); + // Reorder with duplicates: [b, a?, a?] — occurrence-aware matching must + // hand the FIRST a-key row to the first a occurrence and the SECOND to + // the second, on BOTH channels. + setState(s => { + reconcile( + [ + { id: "b", v: 3 }, + { id: "a", v: 10 }, + { id: "a", v: 20 } + ], + "id" + )(s.rows); + }); + flush(); + // Adoption: distinct prev targets adopted per occurrence (values updated + // in place, not both into the first). + expect(r0.v).toBe(10); + expect(r1.v).toBe(20); + expect(state.rows[1]).toBe(r0); + expect(state.rows[2]).toBe(r1); + // Row ops: occurrence-aware sources (0 and 1, not 0 twice). + expect(ops.length).toBe(1); + const win = ops[0].sources.filter((s: number) => s >= 0).sort(); + expect(new Set(win).size).toBe(win.length); + }); + + it("NaN keys are self-equal everywhere: aligned ticks stay aligned, roots don't throw", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore({ + rows: [ + { id: NaN, v: 1 }, + { id: 2, v: 2 } + ] + }); + const r0 = state.rows[0]; + registerPatch(r0, () => {}); // observe: keyed adoption prunes unobserved rows + const ops: any[] = []; + registerRowOps(state.rows, (_next: any[], o: any) => ops.push(o)); + // Aligned value tick on a NaN-keyed row: strict-equality prefixes would + // misalign forever (NaN !== NaN); SameValueZero keeps it aligned. + setState(s => { + reconcile( + [ + { id: NaN, v: 5 }, + { id: 2, v: 2 } + ], + "id" + )(s.rows); + }); + flush(); + expect(state.rows[0]).toBe(r0); // identity preserved + expect(r0.v).toBe(5); // value adopted in place + expect(ops.length).toBe(0); // aligned — no structural ops + // NaN ROOT identity: same-key reconcile must not throw. + const [obj, setObj] = createStore({ id: NaN, v: 1 }); + expect(() => { + setObj(s => { + reconcile({ id: NaN, v: 9 }, "id")(s); + }); + flush(); + }).not.toThrow(); + expect(obj.v).toBe(9); + }); + + it("Errored.reset() survives patch errors registered under plain owners", async () => { + const { createOwner, runWithOwner } = await import("../../src/index.js"); + const [state, setState] = createStore({ a: { v: 0 } }); + let boundary: any; + let resetFn!: () => void; + createRoot(() => { + boundary = createErrorBoundary( + () => { + // Mirror the list driver: registration under a PLAIN owner (no + // compute fn) inside the boundary. + const owner = createOwner(); + runWithOwner(owner as any, () => { + registerPatch(state.a, (n: any) => { + if (n.v > 0) throw new Error("row boom"); + }); + }); + return "content"; + }, + (_e, reset) => { + resetFn = reset; + return "errored"; + } + ); + }); + expect(boundary()).toBe("content"); + setState(s => { + s.a.v = 1; + }); + expect(() => flush()).not.toThrow(); + expect(boundary()).toBe("errored"); + // reset() recomputes sources — a plain-owner registration must not crash + // it (nearest computed ancestor was routed instead, or skipped). + expect(() => { + resetFn(); + flush(); + }).not.toThrow(); + }); + + it("writable projection arrays emit setter row ops at fold commit", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [proj, setProj] = createRoot(() => + createStore(() => ({ list: [{ id: 1 }, { id: 2 }] }), { list: [] as any[] }) + ); + flush(); + const ops: any[] = []; + registerRowOps(proj.list, (_next: any[], o: any) => ops.push(o)); + setProj((s: any) => { + s.list.push({ id: 3 }); + }); + flush(); + // Pre-fix the fam !== null gate swallowed this: the driven list froze. + expect(ops.length).toBe(1); + expect(ops[0]).not.toBeNull(); + expect(proj.list.length).toBe(3); + }); +}); diff --git a/packages/signals/tests/treeshake.test.ts b/packages/signals/tests/treeshake.test.ts index 12891992b..29dc488f6 100644 --- a/packages/signals/tests/treeshake.test.ts +++ b/packages/signals/tests/treeshake.test.ts @@ -101,7 +101,12 @@ describe("pay-for-use tree-shaking (#2883)", () => { // NodeExtension on EVERY effect at creation — +127 B/node heap and +23% // effect creation time (shipped unnoticed with stage 3; caught by the // creation benches). Measured at 20,956 post-change. - expect(minifiedBytes).toBeLessThan(21_100); + // CONSCIOUS BUMP (stage-2, 2026-08-27): +~180B in mergeTransitionState — + // the held-patch stash move + coalescing-stamp retarget (re-audit 5: + // merged-away stashes double-applied their records' patches at commit). + // Core-retained by necessity: transition merging cannot be pay-for-use. + // Measured at 21,134 post-change. + expect(minifiedBytes).toBeLessThan(21_250); }); it("plain stores shed the verdict layer, affects, boundaries, and map", async () => { 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..7e3546483 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -24,6 +24,14 @@ export { onCleanup, onSettled, latest, + // Patch-channel compiler contract (undocumented as application API) + patchableRaw, + registerPatch, + registerRowOps, + registerSlotPatch, + storeIsShallow, + storeHasFamily, + storeHasOptimisticFamily, reconcile, refresh, repeat, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index b719bab89..fc48a0ddf 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -36,6 +36,15 @@ 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, + storeHasOptimisticFamily, reconcile, refresh, repeat, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 46ebba196..86faa9b7b 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -2801,3 +2801,37 @@ export function onSettled(callback: () => void | (() => void)): void { // NoInfer utility type (also re-exported from signals, but define for local use) type NoInfer = [T][T extends any ? 0 : never]; + +// Patch-channel compiler contract (client parity): the channel is inert on +// the server — SSR renders once from current values; hydration claims and +// registers on the client. Registration is a no-op returning a no-op unbind; +// patchableRaw reports "not patchable" so any server-side dual-driver bind +// takes the (equally inert) effect path. +export function registerPatch( + _record: any, + _fn: (next: any, prev: any, force?: boolean) => void +): () => void { + return noopUnbind; +} +export function registerRowOps(_array: any, _fn: (next: any[], ops: any) => void): () => void { + return noopUnbind; +} +export function patchableRaw(_record: any): undefined { + return undefined; +} +export function registerSlotPatch( + _array: any, + _fn: (index: number, next: any, prev: any) => void +): () => void { + return noopUnbind; +} +export function storeIsShallow(_proxy: any): boolean { + return false; +} +export function storeHasFamily(_proxy: any): boolean { + return false; +} +export function storeHasOptimisticFamily(_proxy: any): boolean { + return false; +} +const noopUnbind = () => {}; diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index a83ce0546..b317a47f0 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -4,7 +4,9 @@ import { getOwner, runWithOwner, createComponent, + createOwner, createRoot as root, + onCleanup, sharedConfig, untrack, merge as mergeProps, @@ -121,12 +123,17 @@ 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; +// Patch-mode list driver seam (pay-for-use): the driver lives in +// ./patch-driver.ts, which is retained ONLY by compiled patch-mode output +// (its `patchDriver`/`rowProof` imports) and installs itself here at module +// evaluation. Classic apps retain nothing but this undefined check. +export let listDriver: + | ((parent: Node, listFn: any, marker?: Node, lateClassic?: () => void) => boolean) + | undefined; +export function installListDriver(driver: typeof listDriver): void { + listDriver = driver; +} + import reconcileArrays from "./reconcile.js"; import { DOMWithState } from "./constants.js"; import { @@ -680,17 +687,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 @@ -791,32 +788,7 @@ function stripTextSeparators(nodes) { return nodes; } -// Patch-mode dual driver: compiled template scopes whose bindings are pure -// member reads of ONE subject hand a single compiled body -// `(next, prev, force) => { compares + writes }` here. -// - Patchable record (core provides the seams): the initial force-apply -// reads the raw backing, then the core's own visibility transitions -// dispatch the body through its patch channel. Under hydration the -// registration alone arms the record — server HTML already carries -// current values, so the initial apply is skipped. -// - Anything else (props, derived objects, unaware cores): a dual-phase -// effect runs the same body — the compute pass calls it with -// 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; - if (raw !== undefined) { - if (!sharedConfig.hydrating) body(raw, undefined, true); - registerPatch(subject, body); - } else { - effect( - () => body(subject, subject, false), - () => body(subject, undefined, true) - ); - } -} /** +/** * Compiler-emitted primitive; not for hand-written code. * @internal */ @@ -853,11 +825,11 @@ export function insert(parent, accessor, marker, initial, options) { // derived array, a shallow<->deep kind switch) — the driver clears the // region and re-enters this insert with a bare accessor (no `$ll` marker) // under the ORIGINAL owner. - if (driveList !== undefined && typeof accessor === "function" && accessor.$ll !== undefined) { + if (listDriver !== undefined && typeof accessor === "function" && accessor.$ll !== undefined) { const listAccessor = accessor; const owner = getOwner(); if ( - driveList(parent, accessor, marker, () => + listDriver(parent, accessor, marker, () => runWithOwner(owner, () => insert( parent, diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 4178205f7..19ac4b425 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -31,6 +31,10 @@ import { import type { JSX } from "../jsx/jsx.js"; export * from "./client.js"; +// Pay-for-use: retained only when compiled patch-mode output imports +// `patchDriver`/`rowProof`; importing installs the list driver (sideEffects +// is false, so unused re-exports shake the whole module away). +export { patchDriver, rowProof, driveList } from "./patch-driver.js"; export * from "./server-mock.js"; export * from "./response.js"; export type { JSX } from "../jsx/jsx.js"; diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts new file mode 100644 index 000000000..176138d33 --- /dev/null +++ b/packages/web/src/patch-driver.ts @@ -0,0 +1,591 @@ +// @ts-nocheck +// Patch-mode dual driver + list driver (DESIGN-PATCH-CHANNEL.md). +// +// PAY-FOR-USE: this module is retained ONLY by compiled patch-mode output — +// `patchDriver`/`rowProof` imports exist solely in apps built with the +// compiler's patch mode on. Importing it installs the list driver into the +// runtime's insert seam (`installListDriver`); classic apps retain nothing +// but an undefined check. Do not import this module from the always-retained +// runtime graph. +import { + createOwner, + onCleanup, + patchableRaw, + registerPatch, + registerRowOps, + registerSlotPatch, + runWithOwner, + sharedConfig, + storeHasOptimisticFamily, + storeIsShallow, + untrack +} from "solid-js"; +import { effect } from "./render.js"; +import { installListDriver } from "./client.js"; + +const PURE_ROW = Symbol.for("solid.pure-row"); + +// SIDE-EFFECT-FREE ARMING: the dist is a flat bundle, so a module-scope +// install call would be an unshakeable top-level side effect retaining the +// whole driver in every app. Instead ROWPROOF arms the insert seam — it is +// the compiled marker of a patch-mode LIST (stamped at template creation, +// always before the list's insert), and the only consumer of the list +// driver: an unstamped list never engages, so a bundle without rowProof +// needs no driveList. patchDriver deliberately does NOT arm — non-list +// patch templates must not retain the list driver (LIS, row binding, ops +// apply) they can never use. +const arm = () => { + installListDriver(driveList); +}; + +export function rowProof(fn: T): T { + arm(); + (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; + // OPTIMISTIC families engage (family increment 2): their structural + // writes emit lane-timed row ops from the override channel, and reverts + // emit an identity RESYNC (ops === null). The committed backing lags the + // visible state while optimism is in flight, so the initial bind reads + // the OPTIMISTIC VIEW through the proxy — classic mapArray reads the same + // view, and the equivalence matrix holds across writes/reverts/landings. + const optimistic = storeHasOptimisticFamily(subject); + if (optimistic) raw = untrack(() => Array.from(subject as any)); + + // 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 ("_SOLID_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(); + // Initial construction severs on throw like update-time builds (re-audit + // 5, P1-4): without this, rows registered before a throwing row leak + // their registrations under the never-mounted list — keeping patchCount + // elevated GLOBALLY (every store's setter-site gate stays hot) long after + // an error boundary recovers the region. + try { + 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); + } + } + } catch (err) { + unbindAllRows(); + runUnbinds(lastUnbinds ?? undefined); + for (let j = 0; j < entries.length; j++) { + const n = entries[j] as ChildNode | undefined; + if (n !== undefined && n.parentNode === parent) n.remove(); + } + (listOwner as any).dispose(); + throw err; + } + + // Synthetic full-window ops by ROW IDENTITY against the retained raws: + // used by identity swaps (`s.rows = newArr`) and by the optimistic revert + // RESYNC (ops === null — overrides are gone, the live view is truth, and + // retention must be rebuilt by identity). RAW identity on both sides: + // draft-authored permutations produce arrays of row PROXIES and deep + // ingest stores them verbatim — matching without unwrapping rebuilds + // every row (JFB keyed-reorder identity gate). + const identityOps = (nextArr: any[]): { prefix: number; sources: number[] } => { + const keyOf = (r: any) => { + const w = r != null ? patchableRaw(r) : undefined; + return w !== undefined ? w : r; + }; + // Occurrence-aware (re-audit): duplicate references queue their old + // indices, each consumed once — first-wins reuse would map ONE retained + // DOM node to multiple next positions (the later insert steals it). + const oldIndex = new Map(); + for (let j = 0; j < prevRaws.length; j++) { + const k = keyOf(prevRaws[j]); + const existing = oldIndex.get(k); + if (existing === undefined) oldIndex.set(k, j); + else if (Array.isArray(existing)) existing.push(j); + else oldIndex.set(k, [existing, j]); + } + const sources = new Array(nextArr.length); + for (let k = 0; k < nextArr.length; k++) { + const m = oldIndex.get(keyOf(nextArr[k])); + if (m === undefined) sources[k] = -1; + else if (Array.isArray(m)) { + sources[k] = m.shift()!; + if (m.length === 1) oldIndex.set(keyOf(nextArr[k]), m[0]); + } else { + sources[k] = m; + oldIndex.delete(keyOf(nextArr[k])); + } + } + return { prefix: 0, sources }; + }; + // Failed-apply baseline flag (re-audit 3, P1-4): a throwing row factory + // leaves DOM/bookkeeping on the OLD arrangement while the STORE committed + // the new topology — subsequent positional ops would index against the + // store's baseline and corrupt retention. Until a full apply succeeds, + // ops are discarded in favor of an identity resync against prevRaws, and + // slot ticks are suppressed (the resync rebuild covers their values). + let resyncNeeded = false; + const applyOps = (next: any[], ops: { prefix: number; sources: number[] } | null) => { + if (declined) return; + if (resyncNeeded) ops = null; + if (ops === null) ops = identityOps(next); + const { prefix, sources } = ops; + // EXCEPTION SAFETY (re-audit 2, P1-3): build every NEW row before any + // destructive step. A throwing row factory (user template code, a + // custom-element setter in the initial apply) must leave the DOM and + // the driver's bookkeeping exactly as they were — staged rows sever + // their own registrations on the way out, and the throw surfaces to + // the drain's per-entry isolation like any patch error. + const built: (Node | undefined)[] = new Array(sources.length); + const builtBodies: (any[] | undefined)[] | null = + rowBodies !== null ? new Array(sources.length) : null; + const builtUnbinds: ((() => void)[] | undefined)[] = new Array(sources.length); + let j = 0; + try { + for (; j < sources.length; j++) { + const abs = prefix + j; + const src = sources[j]; + if (src === -1 || (refRebuild && src >= 0 && next[abs] !== prevRaws[src])) { + built[j] = bindRow(abs); + if (builtBodies !== null) builtBodies[j] = lastBodies!; + builtUnbinds[j] = lastUnbinds!; + } + } + } catch (err) { + // Sever completed staged rows AND the throwing row's own partial + // registrations (re-audit 3, P2-5): collectBind's finally published + // the partial collector before the throw propagated here. + for (let k = 0; k < j; k++) runUnbinds(builtUnbinds[k]); + runUnbinds(lastUnbinds ?? undefined); + resyncNeeded = true; + throw err; + } + // Destructive phase — nothing below throws on healthy nodes. + const retained = new Set(); + for (let k = 0; k < sources.length; k++) { + // A refRebuild replacement's old row is NOT retained (rebuilt above). + if (sources[k] >= 0 && built[k] === undefined) retained.add(sources[k]); + } + for (let k = prefix; k < entries.length; k++) { + if (!retained.has(k)) { + (entries[k] as ChildNode).remove(); + runUnbinds(rowUnbinds[k]); + } + } + 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 k = sources.length - 1; k >= 0; k--) { + const abs = prefix + k; + const src = sources[k]; + let node: Node; + if (built[k] !== undefined) { + node = built[k]!; + if (newBodies !== null) newBodies[abs] = builtBodies![k]!; + newUnbinds[abs] = builtUnbinds[k]!; + parent.insertBefore(node, anchor); + } else { + node = entries[src]; + if (newBodies !== null) newBodies[abs] = rowBodies![src]; + newUnbinds[abs] = rowUnbinds[src]; + if (!stable.has(k)) parent.insertBefore(node, anchor); + } + newEntries[abs] = node; + anchor = node; + } + entries = newEntries; + if (newBodies !== null) rowBodies = newBodies; + rowUnbinds = newUnbinds; + prevRaws = next.slice(); + resyncNeeded = false; // a full successful apply restores the baseline + }; + + 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 (resyncNeeded) { + // ACTIVE recovery (re-audit 5, P2-5): a value-only fix after a failed + // apply emits slot ticks but no row ops — suppressing alone would + // leave the old DOM indefinitely. Resync now; a successful rebuild + // clears the flag (a repeat throw keeps it for the next event). + const live = subject != null ? patchableRaw(subject) : undefined; + if (live !== undefined && Array.isArray(live)) applyOps(live as any[], null); + 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)) { + // 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; + } + const swapOps = identityOps(nextRaw); + subject = value; + // Register the NEW subject's channels BEFORE applying (re-audit 5, + // P2-6): a throwing row build mid-swap must leave the list + // recoverable — with channels connected, the next emission triggers + // the failed-apply resync instead of stranding a dead list. + unbindOps = runWithOwner(listOwner, () => registerRowOps(subject, applyOps)) as () => void; + if (shallow) + unbindSlots = runWithOwner(listOwner, () => + registerSlotPatch(subject, applySlot) + ) as () => void; + applyOps(nextRaw, swapOps); + } + ) + ); + 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; +}; + +// Patch-mode dual driver: compiled template scopes whose bindings are pure +// member reads of ONE subject hand a single compiled body +// `(next, prev, force) => { compares + writes }` here. +// - Patchable record (core provides the seams): the initial force-apply +// reads the raw backing, then the core's own visibility transitions +// dispatch the body through its patch channel. Under hydration the +// registration alone arms the record — server HTML already carries +// current values, so the initial apply is skipped. +// - Anything else (props, derived objects, unaware cores): a dual-phase +// effect runs the same body — the compute pass calls it with +// 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 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); + const unbind = registerPatch(subject, body); + if (rowCollector !== null) rowCollector.unbinds.push(unbind); + // Ordinary (non-list-row) templates: the registration dies with the + // registering owner. Drains only SKIP disposed owners — without this, + // every unmounted patched component leaks its entry on the record (and + // patchCount never returns to baseline, keeping the setter-site + // hasPatches() gate on forever). Re-audit blocker 1. + else onCleanup(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), + // 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)) + ); + } +}; diff --git a/packages/web/test/for.equivalence.spec.tsx b/packages/web/test/for.equivalence.spec.tsx new file mode 100644 index 000000000..1b34c4924 --- /dev/null +++ b/packages/web/test/for.equivalence.spec.tsx @@ -0,0 +1,367 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { describe, expect, test } from "vitest"; +import { + action, + createOptimisticStore, + createRenderEffect, + createRoot, + createStore, + flush, + For, + reconcile +} from "solid-js"; +import { patchDriver, rowProof } from "@solidjs/web"; + +// EQUIVALENCE MATRIX (PROPOSAL-KEYED-LIST-DRIVER §8.1, the audit's merge +// gate): for every identity mode × operation sequence, the patch-mode list +// driver must produce EXACTLY the DOM behavior of the classic path — same +// content, same order, and the same retention topology (which positions +// keep their physical node, which get a new one, and where retained nodes +// came from). The two sides run the SAME scenario: +// +// - driver side: a hand-compiled patch-mode row (template clone + ONE +// patchDriver body), rowProof-stamped — the driver engages. +// - classic side: the same DOM built under a grouped render effect (what +// classic compilation emits), UNSTAMPED — the driver declines before +// any DOM work and mapArray owns the list. +// +// Traces are normalized so incidental differences (creation serial order) +// cannot mask or fake divergence: per step, each position reports either +// "new" or "from:". + +interface Row { + id: number; + label: string; +} + +type Step = { content: string; topology: string[] }; +type Op = { kind: "reconcile"; data: Row[] } | { kind: "swap"; data: Row[] }; // identity swap: s.rows = fresh (deep root only) + +const make = (...ids: number[]): Row[] => ids.map(id => ({ id, label: `L${id}` })); +const relabel = (rows: Row[], id: number, label: string): Row[] => + rows.map(r => (r.id === id ? { id: r.id, label } : r)); + +// Driver row: mirrors compiled patch-mode output. One template, one body. +const driverRow = rowProof((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; + if (f || n.id !== p.id) tr.setAttribute("data-id", String(n.id)); + }); + return tr as unknown as any; +}); + +// Classic row: same DOM, grouped render effect (classic compiled shape), +// deliberately UNSTAMPED so the driver declines. +const classicRow = (db: Row) => { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + const text = document.createTextNode(""); + td.appendChild(text); + tr.appendChild(td); + createRenderEffect( + () => ({ label: db.label, id: db.id }), + (v: { label: string; id: number }, p?: { label: string; id: number }) => { + if (!p || v.label !== p.label) (text as Text).data = v.label; + if (!p || v.id !== p.id) tr.setAttribute("data-id", String(v.id)); + } + ); + return tr as unknown as any; +}; + +function runScenario( + useDriver: boolean, + kind: "deep" | "shallow" | "projection", + opsShared: Op[], + seedShared: Row[] +): Step[] { + // Fresh object graphs per run: stores ADOPT/own incoming payloads (raw as + // truth), so sharing records between the driver and classic runs lets the + // first run's ownership/adoption contaminate the second's. + const ops: Op[] = structuredClone(opsShared); + const seed: Row[] = structuredClone(seedShared); + const row = useDriver ? driverRow : classicRow; + const steps: Step[] = []; + createRoot(dispose => { + let div!: HTMLDivElement; + let apply: (op: Op) => void; + if (kind === "deep") { + const [state, setState] = createStore({ rows: seed }); +
+ {row} +
; + apply = op => { + setState(s => { + if (op.kind === "swap") s.rows = op.data; + else reconcile(op.data, "id")(s.rows); + }); + }; + } else if (kind === "projection") { + // PROJECTION family list (re-admission gate): the driven array is the + // OUTPUT of a derived store recomputing from a source. Ops mutate the + // SOURCE; the projection recompute walks reconcile, whose emissions + // are transition-stamped in the apply queue — the driver must match + // classic across recomputed structure and value ticks. + const [source, setSource] = createStore({ rows: seed }); + const [proj] = createStore<{ list: Row[] }>( + () => ({ + // Identity-preserving derive: source row proxies pass through, so + // retention semantics are the source's (deep adoption). + list: source.rows.filter(r => r.label !== "HIDE") + }), + { list: [] } + ); +
+ {row} +
; + apply = op => { + setSource(s => { + if (op.kind === "swap") s.rows = op.data; + else reconcile(op.data, "id")(s.rows); + }); + }; + } else { + // Shallow contract: the store IS the array; records are served raw. + const [state, setState] = createStore( + seed.map(r => ({ ...r })), + { shallow: true } as any + ); +
+ {row} +
; + apply = op => { + setState(s => { + reconcile(op.data, "id")(s); + }); + }; + } + flush(); + + const snapshot = (prev: Node[] | null): { step: Step; nodes: Node[] } => { + const nodes = Array.from(div.querySelectorAll("tr")) as Node[]; + const content = nodes + .map(n => `${(n as Element).getAttribute("data-id")}:${n.textContent}`) + .join(","); + const topology = nodes.map(n => { + if (prev === null) return "new"; + const j = prev.indexOf(n); + return j === -1 ? "new" : `from:${j}`; + }); + return { step: { content, topology }, nodes }; + }; + + let { step, nodes } = snapshot(null); + steps.push(step); + for (const op of ops) { + apply(op); + flush(); + const s = snapshot(nodes); + steps.push(s.step); + nodes = s.nodes; + } + dispose(); + }); + flush(); + return steps; +} + +function assertEquivalent( + kind: "deep" | "shallow" | "projection", + ops: Op[], + seed: Row[] = make(1, 2, 3, 4) +) { + const driver = runScenario(true, kind, ops, seed); + const classic = runScenario(false, kind, ops, seed); + expect(driver).toEqual(classic); + return driver; +} + +const R = (data: Row[]): Op => ({ kind: "reconcile", data }); + +describe("equivalence matrix: driver DOM ≡ classic DOM", () => { + const sequences: Record = { + "aligned value tick": [R(relabel(make(1, 2, 3, 4), 2, "X2"))], + "replace one record, same keys": [ + R([make(1)[0], { id: 2, label: "R2" }, make(3, 4)[0], make(3, 4)[1]]) + ], + "reorder (reverse)": [R(make(4, 3, 2, 1))], + "move + replace through the move": [ + R(make(4, 1, 2, 3)), + R([{ id: 4, label: "Z4" }, ...make(1, 2, 3)]) + ], + "add mid": [R(make(1, 2, 5, 3, 4))], + "remove mid": [R(make(1, 3, 4))], + "clear then refill": [R([]), R(make(7, 8))], + "pure append past an aligned prefix": [R(make(1, 2, 3, 4, 5, 6))], + "append after aligned value tick (same batch)": [ + R([...relabel(make(1, 2, 3, 4), 1, "A1"), ...make(5)]) + ], + "mixed batch (add+remove+move+value)": [ + R([{ id: 3, label: "M3" }, make(1)[0], { id: 9, label: "N9" }, make(4)[0]]) + ], + "duplicate-free churn (all fresh records, same keys)": [ + R(make(1, 2, 3, 4).map(r => ({ ...r, label: r.label + "'" }))) + ] + }; + + for (const [name, ops] of Object.entries(sequences)) { + test(`deep / ${name}`, () => { + assertEquivalent("deep", ops); + }); + test(`shallow / ${name}`, () => { + assertEquivalent("shallow", ops); + }); + test(`projection / ${name}`, () => { + assertEquivalent("projection", ops); + }); + } + + test("projection / recompute-driven structure (filter drops and restores a row)", () => { + assertEquivalent("projection", [ + // Hiding is a VALUE change on the source that becomes STRUCTURE on the + // projection output — the recompute's reconcile emits the ops. + { kind: "reconcile", data: relabel(make(1, 2, 3, 4), 2, "HIDE") }, + { kind: "reconcile", data: relabel(make(1, 2, 3, 4), 2, "BACK") } + ]); + }); + + test("projection / retention across recompute (untouched rows keep nodes)", () => { + const trace = assertEquivalent("projection", [ + { kind: "reconcile", data: relabel(make(1, 2, 3, 4), 2, "HIDE") } + ]); + // Row 2 leaves the output; 1, 3, 4 keep their physical nodes. + expect(trace[1].topology).toEqual(["from:0", "from:2", "from:3"]); + }); + + test("deep / identity swap to a fresh array (s.rows = next)", () => { + assertEquivalent("deep", [ + { kind: "swap", data: make(3, 2, 99) }, + R(relabel(make(3, 2, 99), 2, "Y2")) + ]); + }); + + test("deep / retention sanity: aligned tick retains every node on BOTH sides", () => { + const trace = assertEquivalent("deep", [R(relabel(make(1, 2, 3, 4), 2, "X2"))]); + expect(trace[1].topology).toEqual(["from:0", "from:1", "from:2", "from:3"]); + }); + + test("shallow / retention sanity: replaced record REBUILDS on both sides (identity ruling)", () => { + const trace = assertEquivalent("shallow", [ + R([make(1)[0], { id: 2, label: "R2" }, make(3, 4)[0], make(3, 4)[1]]) + ]); + // Every record object is fresh in the payload (shallow reference + // identity): all four rebuild — classic mapArray semantics exactly. + expect(trace[1].topology).toEqual(["new", "new", "new", "new"]); + }); +}); + +// OPTIMISTIC equivalence (family increment 2): structural optimism rides the +// override channel — lane-timed row ops in flight, identity RESYNC on +// revert. Each script snapshots mounted → in-flight → settled and asserts +// driver ≡ classic at every point. +describe("equivalence matrix: optimistic lists", () => { + type Mutate = (s: { list: Row[] }) => void; + + async function runOptimistic( + useDriver: boolean, + mutate: Mutate, + outcome: "revert" | "land" + ): Promise { + const row = useDriver ? driverRow : classicRow; + const steps: Step[] = []; + let div!: HTMLDivElement; + let dispose!: () => void; + let save!: () => any; + let settle!: (ok: boolean) => void; + createRoot(d => { + dispose = d; + const [state, setState] = createOptimisticStore<{ list: Row[] }>({ list: make(1, 2, 3) }); +
+ {row} +
; + save = action(function* () { + setState(mutate as any); + yield new Promise((res, rej) => { + settle = ok => (ok ? res() : rej(new Error("revert"))); + }); + }) as any; + }); + flush(); + + let nodes: Node[] = []; + const snap = () => { + const now = Array.from(div.querySelectorAll("tr")) as Node[]; + const content = now + .map(n => `${(n as Element).getAttribute("data-id")}:${n.textContent}`) + .join(","); + const topology = now.map(n => { + const j = nodes.indexOf(n); + return j === -1 ? "new" : `from:${j}`; + }); + nodes = now; + steps.push({ content, topology }); + }; + + snap(); // mounted + const p = Promise.resolve(save()).catch(() => {}); + flush(); + snap(); // in-flight optimism + settle(outcome === "land"); + await p; + flush(); + snap(); // settled (landed or reverted) + dispose(); + flush(); + return steps; + } + + async function assertOptimistic(mutate: Mutate, outcome: "revert" | "land") { + const driver = await runOptimistic(true, mutate, outcome); + const classic = await runOptimistic(false, mutate, outcome); + expect(driver).toEqual(classic); + return driver; + } + + const scripts: Record = { + "push a row": s => { + s.list.push({ id: 9, label: "N9" }); + }, + "remove mid (splice)": s => { + s.list.splice(1, 1); + }, + "reorder + value in one draft": s => { + const [a, b, c] = [s.list[0], s.list[1], s.list[2]]; + s.list[0] = c; + s.list[1] = a; + s.list[2] = b; + s.list[0].label = "Z3"; + }, + "replace the whole list (parent key write)": s => { + (s as any).list = make(5, 6); + } + }; + + for (const [name, mutate] of Object.entries(scripts)) { + test(`optimistic / ${name} — revert`, async () => { + await assertOptimistic(mutate, "revert"); + }); + test(`optimistic / ${name} — land`, async () => { + await assertOptimistic(mutate, "land"); + }); + } + + test("optimistic / in-flight visibility sanity: push shows before settle on BOTH sides", async () => { + const trace = await assertOptimistic(s => { + s.list.push({ id: 9, label: "N9" }); + }, "revert"); + expect(trace[1].content).toBe("1:L1,2:L2,3:L3,9:N9"); // optimism visible + expect(trace[2].content).toBe("1:L1,2:L2,3:L3"); // revert restores committed + }); +}); diff --git a/packages/web/test/for.patchlist.spec.tsx b/packages/web/test/for.patchlist.spec.tsx new file mode 100644 index 000000000..02c2ac2c8 --- /dev/null +++ b/packages/web/test/for.patchlist.spec.tsx @@ -0,0 +1,662 @@ +/** + * @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"; +import { resetErrorHalt } from "solid-js"; + +// 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(); + }); + }); + + test("a throwing row factory leaves DOM and bookkeeping atomically unchanged (re-audit 2, P1-3)", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const poison = rowProof((db: Row) => { + if (db.label === "BOOM") throw new Error("row factory boom"); + return buildRow(db); + }); + const [state, setState] = createStore({ rows: make(1, 2, 3) }); +
+ {poison} +
; + expect(labels(div)).toBe("L1,L2,L3"); + const [tr1, tr2, tr3] = rows(div); + + // An update whose NEW row throws mid-construction: the ops application + // fails BEFORE any removal/move — DOM and driver bookkeeping stay + // exactly as they were (build-before-destroy). + setState(s => { + reconcile([make(1)[0], { id: 9, label: "BOOM" }, make(3)[0]], "id")(s.rows); + }); + expect(() => flush()).toThrow("row factory boom"); + resetErrorHalt(); + expect(labels(div)).toBe("L1,L2,L3"); + expect(rows(div)[0]).toBe(tr1); + expect(rows(div)[1]).toBe(tr2); + expect(rows(div)[2]).toBe(tr3); + + // Recovery (re-audit 3, P1-4): the STORE committed the failed + // topology while DOM kept the old one — the driver's baseline is + // wrong, so the next update forces an IDENTITY RESYNC. Reconcile + // swaps backing raws, so the resync rebuilds rows: content is correct + // (that is the guarantee), retention is deliberately forfeited for + // this one recovery apply. + setState(s => { + reconcile([make(3)[0], make(1)[0]], "id")(s.rows); + }); + flush(); + expect(labels(div)).toBe("L3,L1"); + const [r3, r1] = rows(div); + + // Baseline restored: the NEXT update retains again (keyed move keeps + // both nodes). + setState(s => { + reconcile( + [ + { id: 1, label: "L1" }, + { id: 3, label: "L3" } + ], + "id" + )(s.rows); + }); + flush(); + expect(labels(div)).toBe("L1,L3"); + expect(rows(div)[0]).toBe(r1); + expect(rows(div)[1]).toBe(r3); + dispose(); + }); + }); + + test("a row that registers THEN throws severs its own partial registrations (re-audit 3, P2-5)", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + let poisonApplies = 0; + const registerThenThrow = rowProof((db: Row) => { + if (db.label === "BOOM") { + const tr = document.createElement("tr"); + // The compiled body registers on the record FIRST (real compiled + // output registers before later template statements can throw)... + patchDriver(db, () => { + poisonApplies++; + }); + // ...then a later statement in the row factory throws. + throw new Error("late row boom"); + } + return buildRow(db); + }); + const [state, setState] = createStore({ rows: make(1, 2) }); +
+ {registerThenThrow} +
; + expect(labels(div)).toBe("L1,L2"); + + setState(s => { + reconcile([make(1)[0], { id: 9, label: "BOOM" }, make(2)[0]], "id")(s.rows); + }); + expect(() => flush()).toThrow("late row boom"); + resetErrorHalt(); + expect(labels(div)).toBe("L1,L2"); + // The initial force-apply ran before the throw — capture the count + // AFTER the failed bind; severing means it never grows again. + const boomApplies = poisonApplies; + + // The poison record's registration was severed with the failed bind: + // a later write to it must NOT fire the orphaned patch. + setState(s => { + s.rows[1].label = "BOOM2"; + }); + flush(); + expect(poisonApplies).toBe(boomApplies); + dispose(); + }); + }); +}); diff --git a/packages/web/test/hydration/patchlist-claim.spec.tsx b/packages/web/test/hydration/patchlist-claim.spec.tsx new file mode 100644 index 000000000..c62190c48 --- /dev/null +++ b/packages/web/test/hydration/patchlist-claim.spec.tsx @@ -0,0 +1,147 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { describe, expect, test, beforeEach, afterEach } from "vitest"; +import { createStore, flush, For, reconcile, enableHydration } from "solid-js"; +import { hydrate, getNextElement, template, patchDriver, rowProof } from "@solidjs/web"; + +enableHydration(); + +// Patch-mode list hydration (DESIGN-PATCH-CHANNEL §5): claim + register +// ONLY. The driver claims each server row positionally through the row's own +// `_hk` key (the row owner's id is the key minus its trailing child counter), +// patchDriver registers WITHOUT the initial force-apply (server HTML is the +// truth until the first transition), and no per-row effects or owners with +// reactive work are created. + +interface Row { + id: number; + label: string; +} + +// Hand-written mirror of hydratable patch-mode compiled output for +// `
  • `: claim the row root, walk to the text +// node, hand ONE compiled body to the driver. +const rowTmpl = template("
  • "); +const pureRow = rowProof(function pureRow(r: Row) { + const li = getNextElement(rowTmpl) as HTMLElement; + const text = li.firstChild as Text; + patchDriver(r, (n: Row, p: Row, f?: boolean) => { + if (f || n.label !== p.label) text.data = n.label; + }); + return li as unknown as any; +}); + +function setupHydration() { + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; +} + +describe("patch-mode list hydration claiming", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let dispose: (() => void) | undefined; + + beforeEach(() => { + if (dispose) dispose(); + dispose = undefined; + setupHydration(); + container.innerHTML = ""; + }); + + afterEach(() => { + if (dispose) { + dispose(); + dispose = undefined; + } + }); + + test("claims server rows, skips initial apply, patches after first transition", () => { + // Server truth intentionally DISAGREES with the client store on row 2 + // ("SERVER" vs "L2"): claim + register must leave the server text alone. + container.innerHTML = + "
    • L1
    • SERVER
    • L3
    "; + const serverRows = Array.from(container.querySelectorAll("li")); + + const [state, setState] = createStore({ + rows: [ + { id: 1, label: "L1" }, + { id: 2, label: "L2" }, + { id: 3, label: "L3" } + ] as Row[] + }); + + dispose = hydrate( + () => ( +
      + {pureRow} +
    + ), + container + ); + + const rows = () => Array.from(container.querySelectorAll("li")); + // Row DOM is the CLAIMED server DOM — no rebuild, no reordering. + expect(rows()).toEqual(serverRows); + // Skip-initial: the mismatched server text survives hydration. + expect(rows()[1].textContent).toBe("SERVER"); + + // First transition: the registered patch takes over. prev is the + // committed store value, so row 2 repaints even though the DOM held + // different (server) text — visibility transitions drive the channel, + // not DOM state. + setState(s => { + s.rows[1].label = "X2"; + }); + flush(); + expect(rows()[1].textContent).toBe("X2"); + expect(rows()[1]).toBe(serverRows[1]); + expect(rows()[0].textContent).toBe("L1"); + + // Structure post-hydration rides row ops on the claimed nodes. + setState(s => { + reconcile( + [ + { id: 3, label: "L3" }, + { id: 1, label: "L1" } + ], + "id" + )(s.rows); + }); + flush(); + expect(rows().map(r => r.textContent)).toEqual(["L3", "L1"]); + expect(rows()[0]).toBe(serverRows[2]); + expect(rows()[1]).toBe(serverRows[0]); + expect(serverRows[1].isConnected).toBe(false); + }); + + test("row count mismatch declines without disturbing the hydration id chain", () => { + // Two server rows, three store rows — the driver must decline BEFORE + // consuming any child id so the sibling still claims cleanly + // through the classic path. + container.innerHTML = "
    • L1
    • L2
    "; + + const [state] = createStore({ + rows: [ + { id: 1, label: "L1" }, + { id: 2, label: "L2" }, + { id: 3, label: "L3" } + ] as Row[] + }); + + // Classic fallback will key-miss our synthetic row keys (they encode no + // real owner chain) and rebuild rows detached — that's expected here. + // The assertion is only that the decline is clean: no throw, and the + // list still renders all three rows through mapArray. + dispose = hydrate( + () => ( +
      + {pureRow} +
    + ), + container + ); + + expect(container.querySelectorAll("ul").length).toBe(1); + }); +}); diff --git a/scripts/row-coverage.mjs b/scripts/row-coverage.mjs new file mode 100644 index 000000000..07950506c --- /dev/null +++ b/scripts/row-coverage.mjs @@ -0,0 +1,86 @@ +// Row-proof coverage report (DESIGN §16 process rule): compiles every solid +// fixture in the octane benchmark corpus with the CURRENT Babel preset and +// reports, per file, how many lists exist vs how many row functions the +// compiler stamped (rowProof). The diff of this report across compiler +// changes IS the coverage change — admission-affecting work must ship with +// it (the §3c lesson: gates spot-check, this enumerates). +// +// Usage: node scripts/row-coverage.mjs [corpusRoot] +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(path.join(here, "..", "package.json")); +const babel = require("@babel/core"); +// Folded monorepo (dom-expressions absorption): the Babel integration lives +// at packages/babel-plugin and is the raw PLUGIN (the preset wrapper was +// retired in the absorption). Row-proof stamping requires patch mode, which +// is DORMANT by default — the report opts in explicitly (it measures the +// grammar's admission surface, not the shipping default). +const plugin = require(path.join(here, "..", "packages", "babel-plugin", "index.js")); + +const corpus = process.argv[2] ?? "/Users/ryancarniato/Development/octane/benchmarks"; + +function* jsxFiles(dir) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return; + } + for (const e of entries) { + if (e === "node_modules" || e === "dist" || e.startsWith(".")) continue; + const p = path.join(dir, e); + const s = statSync(p); + if (s.isDirectory()) yield* jsxFiles(p); + else if (/\.(jsx|tsx)$/.test(e) && /\/solid(-[^/]+)?\//.test(p + "/")) yield p; + } +} + +let totFors = 0; +let totStamped = 0; +const rows = []; +for (const file of jsxFiles(corpus)) { + const src = readFileSync(file, "utf8"); + const forCount = (src.match(/ lists:", totFors, " stamped row fns:", totStamped, ` (${((totStamped / Math.max(totFors, 1)) * 100).toFixed(0)}% of lists have a stamped row)`); +console.log("NOTE: counts are heuristic (regex on source/output); a stamped fn"); +console.log("count above the For count means non-row functions matched the shape"); +console.log("(inert). Zero stamps beside a For is a list that will DECLINE."); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 2d1a9115d..7208182c7 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -67,7 +67,10 @@ module.exports = [ // companion-walk gate (#3038, debc22b9), and the #3042/#3043 transition // fixes. Verified the prod chunks carry no dev diagnostics — this is // the batch's real retained cost, accepted for its runtime wins. - limit: "7.85 KB", + // Re-audit-5 hardening ripple (2026-08-27): the mergeTransitionState + // stash move + stamp retarget and the dispatch snapshot marks are + // core-retained — a few dozen brotli bytes on every scenario. + limit: "7.9 KB", modifyEsbuildConfig }, { @@ -130,12 +133,29 @@ module.exports = [ // (see treeshake.test.ts) — an injection-table split was measured and // came out LARGER under brotli (indirection adds unique tokens). // - // Fold scheduling (#3089): 13.5 -> 13.6 KB, measured at 13.54. The always- - // arm in queueFold (the size-gated arm stranded later folds), the write- - // time transition stamp (foldBatches WeakMap + ensurePB stamp), and the - // drain's defer check. All load-bearing correctness on paths createStore - // always retains; golfing measured single-digit bytes. - limit: "13.6 KB", + // Stage-2 patch channel: 13.5 -> 14.1 KB (measured 13.71 pre-#3074, ~13.8 + // with the held-view bytes). The channel itself is pay-for-use (emitters + // ride hooks installed at first registration — patch-hooks.ts — and + // shake out of this scenario); the ~490 B here are the write-path SEAMS + // that must live on always-retained trap/walk/fold code: the `pc` + // extension + guards at every emission site, the setter-channel row-ops + // branch in drainFolds, and the fold-commit family emission. Compare the + // app-floor scenarios below, which carry only the ~100 B insert seam. + // + // Re-audit-2 correctness batch + upstream drift: 14.1 -> 14.35 KB + // (measured 14.31). Occurrence-aware key matching (adoption window + + // buildRowOps queues, SameValueZero everywhere keys compare), the + // same-batch coalescing stamp (pc.qa/ql + pushSelf), adoption-seam + // accessor demotion gates, and unhandled-halt parity; the rest is + // upstream core drift (#3082's visibility gate, the shared notifier, + // #3078's dormancy sweep) since the 14.1 ratchet. + // + // Fold scheduling (#3089, merged from next): 14.35 -> 14.45 KB. The + // always-arm in queueFold (the size-gated arm stranded later folds), the + // write-time transition stamp (foldBatches WeakMap + ensurePB stamp), and + // the drain's defer check — ~40 B measured on the pre-stage-2 base. All + // load-bearing correctness on paths createStore always retains. + limit: "14.45 KB", modifyEsbuildConfig }, { @@ -180,8 +200,16 @@ module.exports = [ // the app growth across all four app scenarios tracks the signals // scenarios byte-for-byte (the linked dom-expressions runtime updates // contributed ~nothing to the client bundles). + // + // Upstream drift ratchet (2026-08-27): the shared effect notifier's + // always-retained core bytes ate the last headroom (measured 10.56). + // +50 B of cap, not a feature. + // + // next merge (2026-08-28): 10.6 -> 10.65 KB, measured at 10.61 — the + // branch's insert seam plus next's post-cap drift summing in the same + // floor. path: "minimal-app.js", - limit: "10.55 KB", + limit: "10.65 KB", modifyEsbuildConfig }, { @@ -217,7 +245,14 @@ module.exports = [ // point every hydrating app retains and cannot shake. Golfing measured // ~1 B; the bytes are the fix's real cost. path: "hydrating-app.js", - limit: "17.4 KB", + // Upstream drift ratchet (2026-08-27): shared effect notifier (+core) + // and #3057 invoke's client surface since the 17.25 cap (measured + // 17.38). Drift, not a stage-2 feature. + // + // next merge (2026-08-28): 17.45 -> 17.55 KB, measured at 17.48 — the + // useHead prelude relocation (#3081, ~120 B in hydrate(), see its note) + // arriving from next on top of the drift-ratcheted floor. + limit: "17.55 KB", modifyEsbuildConfig }, { @@ -253,10 +288,17 @@ module.exports = [ // -127 B/node heap and -15% effect creation (the per-effect NodeExtension // allocation it removes). The other floors absorbed it within headroom. // - // Fold scheduling (#3089): 24.9 -> 25 KB, measured at 24.91 — the same + // Stage-2 patch channel: 24.9 -> 25.9 KB (measured 25.23 pre-#3074, + // pre-notifier) — the createStore write-path seams (~490 B, see that + // note) plus this scenario's optimistic/projection family emission seams + // and the web runtime's ~100 B insert hook. The driver + emitters + // themselves are pay-for-use and absent here (no compiled patch output + // imports them). + // + // Fold scheduling (#3089, merged from next): 25.9 -> 26 KB — the same // bytes as the createStore note (this scenario retains all of it). path: "hydrating-store-app.js", - limit: "25 KB", + limit: "26 KB", modifyEsbuildConfig }, { @@ -275,7 +317,37 @@ module.exports = [ // Stage-3 batch (pre-release ratchet): 12.3 -> 12.8 KB, measured at // 12.53 — the signals-core bytes (see the core-floor note). path: "csr-app.js", - limit: "12.8 KB", + limit: "12.9 KB", + modifyEsbuildConfig + }, + { + name: "app: CSR flip preview — + patchDriver (non-list patch templates)", + // What patch-mode DEFAULT-ON adds to ~every app: nearly any real + // template has one eligible pure member-read binding, so the compiler + // emits at least one patchDriver call — retaining the dual driver and + // the store channel's value-tier machinery: registration, the apply + // queue/drains, error routing, and the demotion path (~1.5 KB brotli + // over the classic app). NOT here: the list driver (only rowProof arms + // the insert seam) and the row-ops emitters + reconcile diff builders + // (row hooks arm only from list registrations). + path: "csr-app-patch.js", + limit: "14.6 KB", + modifyEsbuildConfig + }, + { + name: "app: CSR flip preview — + rowProof (patch-mode list driver)", + // The full flip cost: a compiled patch-mode list row (rowProof) arms + // the insert seam and retains the list driver plus the row-hooks tier + // (row-ops/slot emitters + reconcile's keyed/identity diff builders) — + // ~2.1 KB over the patchDriver floor, ~3.6 KB over classic. Paid + // exactly by apps with driver-eligible store lists — the tier the + // dbmon-class wins accrue to. + // + // Re-audit-3 hardening: 16.65 -> 16.75 KB (measured 16.69) — the + // driver's failed-apply resync flag + partial-registration severing and + // the coalescing entry updates ride this tier. + path: "csr-app-patch-lists.js", + limit: "16.9 KB", modifyEsbuildConfig }, { diff --git a/scripts/size/csr-app-patch-lists.js b/scripts/size/csr-app-patch-lists.js new file mode 100644 index 000000000..8268c9705 --- /dev/null +++ b/scripts/size/csr-app-patch-lists.js @@ -0,0 +1,13 @@ +// FLIP PREVIEW, list tier: a patch-mode LIST row (`rowProof`) arms the +// insert seam and retains the list driver (row binding, LIS moves, row-ops +// consumer) on top of csr-app-patch.js's dual-driver floor. This is the +// full flip cost — paid exactly by apps with driver-eligible store lists, +// the ones the channel's dbmon-class wins accrue to. +import { patchDriver, rowProof } from "@solidjs/web"; +import "./csr-app.js"; + +const subject = { x: 1, rows: [] }; +patchDriver(subject, (next, prev, force) => { + if (force || next.x !== prev.x) document.title = String(next.x); +}); +export const row = rowProof(r => String(r)); diff --git a/scripts/size/csr-app-patch.js b/scripts/size/csr-app-patch.js new file mode 100644 index 000000000..4be56de9c --- /dev/null +++ b/scripts/size/csr-app-patch.js @@ -0,0 +1,13 @@ +// FLIP PREVIEW: the CSR app surface as the compiler's patch-mode DEFAULT +// would emit it. Nearly every real app has at least one eligible template +// (a pure `props.x`/`state.x` binding), so default-on retains `patchDriver` +// plus the store channel's registration machinery in ~every bundle; only +// templates compiled as patch-mode LIST rows (`rowProof`) additionally pull +// the list driver — see csr-app-patch-lists.js for that tier. +import { patchDriver } from "@solidjs/web"; +import "./csr-app.js"; + +const subject = { x: 1 }; +patchDriver(subject, (next, prev, force) => { + if (force || next.x !== prev.x) document.title = String(next.x); +});