diff --git a/.changeset/store-deep-witness-node.md b/.changeset/store-deep-witness-node.md new file mode 100644 index 000000000..ab29b05b8 --- /dev/null +++ b/.changeset/store-deep-witness-node.md @@ -0,0 +1,14 @@ +--- +"@solidjs/signals": patch +--- + +Store: `deep()` subscribes one witness node per record instead of one node +per path. The rewrite's first `deep()` created and read a property node for +every reachable key on every effect re-run (~40% regression on the +single-deep()-effect benchmark vs the legacy per-record $TRACK). Targets now +carry a lazy deep-witness node: `deep()` reads the key-set node plus the +witness per record and walks targets directly (no per-child proxy +round-trips); write channels bump the witness only when it exists (one null +check otherwise). Declared affects() scopes mark the witness like any +property node, so isPending() probes over deep() reads keep working. +Benchmark restored to parity with the legacy implementation. diff --git a/.changeset/store-next-settled-visibility.md b/.changeset/store-next-settled-visibility.md new file mode 100644 index 000000000..670e6d7fe --- /dev/null +++ b/.changeset/store-next-settled-visibility.md @@ -0,0 +1,8 @@ +--- +"@solidjs/signals": patch +--- + +Store: pending-value visibility in the rewrite mirrors core's #3006 rule — +CHILDREN_FORBIDDEN execution scopes (createTrackedEffect / onSettled +callbacks) read committed values, so a store write inside onSettled parks +and an immediate read returns the settled value, matching signals. diff --git a/.changeset/store-rewrite-creation-perf.md b/.changeset/store-rewrite-creation-perf.md new file mode 100644 index 000000000..b84081215 --- /dev/null +++ b/.changeset/store-rewrite-creation-perf.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite performance: eliminate the eager per-object accessor scan +(replaced by allocation-free per-node probes computed at node creation, with +own-gated single probes on fold paths), and construct proxy targets with +direct field assignment on a shared hidden-class chain. uibench drops from +36.6ms (legacy) to 27.5ms; dbmon tick reaches legacy parity. diff --git a/.changeset/store-rewrite-delete-legacy.md b/.changeset/store-rewrite-delete-legacy.md new file mode 100644 index 000000000..d1ba2739a --- /dev/null +++ b/.changeset/store-rewrite-delete-legacy.md @@ -0,0 +1,12 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: legacy implementation deleted. The store module now has a +single implementation — the rewrite's single-home storage model (raw as +truth, CoW pending backings, lazy per-property nodes, adoption-channel +reconcile) serves every public form: plain, shallow, derived, projection, +and optimistic stores. `store.ts` is reduced to shared machinery (symbols, +raw-marking, wrappability, affects scopes), the transitional dispatchers are +gone, and the package is ~0.9kb gzip smaller than before the rewrite while +carrying the same contract. diff --git a/.changeset/store-rewrite-derived-form.md b/.changeset/store-rewrite-derived-form.md new file mode 100644 index 000000000..89eb29c93 --- /dev/null +++ b/.changeset/store-rewrite-derived-form.md @@ -0,0 +1,10 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: the derived writable `createStore(fn, seed)` form serves from +the rewrite (projection internals + a recompute-masking setter). The §6c +status gate now covers errored derives (memo parity) and only guards raw +fallthrough — tracked reads link through firewall-backed nodes in core +read(), so landings wake async-memo readers exactly like legacy. Has- and +key-set nodes carry the firewall link too. diff --git a/.changeset/store-rewrite-eager-adoption.md b/.changeset/store-rewrite-eager-adoption.md new file mode 100644 index 000000000..fe9efb183 --- /dev/null +++ b/.changeset/store-rewrite-eager-adoption.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: plain-store reconcile notifies inline after the adoption +descent instead of a queue/drain round trip; projection folds stay deferred +for downstream-hold correctness. diff --git a/.changeset/store-rewrite-fused-adoption.md b/.changeset/store-rewrite-fused-adoption.md new file mode 100644 index 000000000..98f963983 --- /dev/null +++ b/.changeset/store-rewrite-fused-adoption.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite performance: the adoption walk notifies inline (fused single +pass — descend first so identity-preserved slots stay silent, then per-key +node notification), replacing the separate fold re-walk. Deleted keys ride a +counted fast-out; presence/membership stay as a shared tail. Fold writes pass +values directly instead of allocating a closure per changed key. diff --git a/.changeset/store-rewrite-node-wrap-cache.md b/.changeset/store-rewrite-node-wrap-cache.md new file mode 100644 index 000000000..570eaf270 --- /dev/null +++ b/.changeset/store-rewrite-node-wrap-cache.md @@ -0,0 +1,11 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: per-node wrap cache on the read path. Raw-as-truth stores raw +values in nodes, so every tracked object read paid a WeakMap lookup to +re-wrap the child — measured as a ~10% dbmon tick regression vs the legacy +implementation, whose nodes stored pre-wrapped values. Nodes now cache the +last served proxy and the raw it wrapped; one pointer compare replaces the +WeakMap hit and the wrappability check. A replaced child fails the compare +and re-wraps, so no invalidation hooks are needed. diff --git a/.changeset/store-rewrite-optimistic-completion.md b/.changeset/store-rewrite-optimistic-completion.md new file mode 100644 index 000000000..0477a21f7 --- /dev/null +++ b/.changeset/store-rewrite-optimistic-completion.md @@ -0,0 +1,11 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: optimistic surface completion. Strict-read refetch-window +escalation, marks witnessing through chained store views, snapshot composition +across chained optimistic targets, presence-based structural classification for +landing consumption, hold-correct projection backing folds (never eager; +context-free freshness via pending-backing visibility), root-exempt store +setter guard with ownedWrite store nodes, and a transitionBlocked store-half +that only holds transactions carrying live overrides. diff --git a/.changeset/store-rewrite-optimistic.md b/.changeset/store-rewrite-optimistic.md new file mode 100644 index 000000000..286fc3d31 --- /dev/null +++ b/.changeset/store-rewrite-optimistic.md @@ -0,0 +1,15 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: optimistic stores ride core lanes natively. `createOptimisticStore` +(plain and derived, non-shallow) now serves from the rewrite: per-property nodes +are armed core signals, so optimistic writes, per-transaction ownership, +entanglement, reverts, and refetch-holds are all engine-inherited — the +store-side override layer, backup snapshots, and owner maps are gone. Structural +optimism (adds/deletes/length) rides armed presence nodes and consumes on +authoritative landings; value overrides persist with their owning transaction. +Reconcile diffs against the lane view so optimistic rows recycle their proxies +when landed data carries the same key. snapshot/deep compose the optimistic +view. Also fixes legacy `createWriteTraps` clobbering `projectionWriteActive` +(hard reset instead of save/restore). diff --git a/.changeset/store-rewrite-parity.md b/.changeset/store-rewrite-parity.md new file mode 100644 index 000000000..c1cdf8bc8 --- /dev/null +++ b/.changeset/store-rewrite-parity.md @@ -0,0 +1,12 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: full plain-store parity. The rewrite now passes the entire +suite (91/91 files) serving plain deep stores and reconcile package-wide: +transition holds (write-time notification via per-key nodes, per-leaf +isPending), affects() coverage over rewrite targets, legacy interop via +structural field aliasing (v/n/h/d/s + $PROXY), markRaw/shallow interop, +dev diagnostics (registerGraph, onStoreNodeUpdate, strictRead warnings), and +owned-subtree snapshot copies (non-enumerable symbols excluded, cycle +identity preserved — fixes FINDING-3's cycle duplication). diff --git a/.changeset/store-rewrite-phase-one.md b/.changeset/store-rewrite-phase-one.md new file mode 100644 index 000000000..e4ff0cf67 --- /dev/null +++ b/.changeset/store-rewrite-phase-one.md @@ -0,0 +1,16 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite phase 1 (in progress, branch-scoped): plain deep stores and +reconcile now serve from the new single-home storage model +(`src/store/next/`) — owned-raw backing with copy-on-write privatization +(user source objects are never mutated), lazy per-property nodes as real core +signals (transition holds, isPending, and lanes ride core machinery +natively), and reconcile as the adoption channel with the ownership-guarded +identity skip. Fixes the unsound nested reconcile same-reference skip +(FINDING-1: a re-sent reference after a flushed setter write now restores the +incoming values). Derived, shallow, and optimistic store forms still route to +the legacy implementation via transitional dispatchers. Design contract and +findings log: `packages/solid-signals/INTERNALS-STORE-STATE.md`, +`packages/solid-signals/rules-mining/`. diff --git a/.changeset/store-rewrite-projection-infra.md b/.changeset/store-rewrite-projection-infra.md new file mode 100644 index 000000000..4ee1619d3 --- /dev/null +++ b/.changeset/store-rewrite-projection-infra.md @@ -0,0 +1,17 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: projection infrastructure. Family wrapping (per-projection +child registries with firewall-carrying nodes), the storeSetterNext +primitive, write-override interop for post-await draft writes, the §6c +status gate (uninitialized async derives are unobservable through every +trap), replace-mode reconcile (projection roots merge entity changes in +place, displaced raws unregister), and a next-native createProjection +(bring-up: passes basics/selection; async and chained suites gate via the +next config while the default build keeps routing projections to legacy). +Also two correctness fixes benefiting all stores: the write-notification +diff no longer uses a lagging old-side (a recompute before the prior fold +commits could swallow changes), and node equality is logical-slot aware +(privatization/adoption raw-identity swaps no longer produce phantom +notifications). diff --git a/.changeset/store-rewrite-projections.md b/.changeset/store-rewrite-projections.md new file mode 100644 index 000000000..81284ccc2 --- /dev/null +++ b/.changeset/store-rewrite-projections.md @@ -0,0 +1,11 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: next-native projections are now the default. `createProjection` +(sync, async, generator, and chained forms) runs on the single-home storage +model — firewall status gating links tracked readers for NotReady wake-up, +write scope extends through draft reads (cross-store draft writes preserved), +and fold-time parent-slot fixes are compare-and-swap so draft array splices +cannot resurrect removed rows. Unkeyed nested objects in async yields now +merge in place (identity preserved) instead of accidentally replacing. diff --git a/.changeset/store-rewrite-shallow-forms.md b/.changeset/store-rewrite-shallow-forms.md new file mode 100644 index 000000000..a7322c258 --- /dev/null +++ b/.changeset/store-rewrite-shallow-forms.md @@ -0,0 +1,10 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: shallow derived and optimistic forms serve from the rewrite +(fam.shallow → root slot semantics). The shallow serve rule is now exact +legacy parity (#2932): raw-marked data serves verbatim, store-proxy slot +values get boundary wrappers in the shallow store's own family so downstream +writes never land upstream. Every public store form now runs on the new +implementation. diff --git a/.changeset/store-rewrite-shallow-port.md b/.changeset/store-rewrite-shallow-port.md new file mode 100644 index 000000000..2186fc1b5 --- /dev/null +++ b/.changeset/store-rewrite-shallow-port.md @@ -0,0 +1,10 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: shallow stores serve from the rewrite. Slot values are stored +verbatim (proxies pass through by reference, #2932) and served raw; ingest is +sticky raw-marked at creation, set-trap, and reconcile adoption (the +never-both-wrapped-and-raw invariant); reconcile on shallow targets is the +slot-granular positional diff with no descent. In-process A/B holds legacy +shallow's dbmon performance at parity. diff --git a/.changeset/store-rewrite-suite-green.md b/.changeset/store-rewrite-suite-green.md new file mode 100644 index 000000000..0c4ef522f --- /dev/null +++ b/.changeset/store-rewrite-suite-green.md @@ -0,0 +1,10 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: full suite green. Optimistic array length is a view of the +composed membership (tear-free iteration by construction), landing consumption +folds committed values into nodes directly (no stranded wakes behind parked +transactions), and `$TRACK` on chained store views reads through to the inner +store's key-set node so keyed iteration observes structural changes at the +source (#2864). diff --git a/.changeset/store-rewrite-tentative-reconcile.md b/.changeset/store-rewrite-tentative-reconcile.md new file mode 100644 index 000000000..90f760bf4 --- /dev/null +++ b/.changeset/store-rewrite-tentative-reconcile.md @@ -0,0 +1,11 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: tentative reconcile channel and affects integration. A +user-context reconcile on an optimistic store now parks as engine overrides +(values, membership, length) instead of committed adoption — key-matched rows +keep proxy identity via descent, and everything reverts with its transaction. +Fixes FINDING-2 (a key added by an in-window reconcile now reverts at settle). +The affects/marks system covers next-store targets, including optimistic rows +in motion at declaration time. diff --git a/.changeset/store-rewrite-walk-validation-hoist.md b/.changeset/store-rewrite-walk-validation-hoist.md new file mode 100644 index 000000000..75231cbb5 --- /dev/null +++ b/.changeset/store-rewrite-walk-validation-hoist.md @@ -0,0 +1,13 @@ +--- +"@solidjs/signals": patch +--- + +Store rewrite: reconcile walk validation hoisted to one authority. `descend` +resolves the tracked target first — a lookup hit implies the previous value +was wrappable and never raw-marked (only wrappables acquire targets), so +per-pair `isWrappable`/`isRawValue` checks on the old side are gone; the new +side still validates fully (frozen/platform/markRaw'd values stay leaves). +The keyed array walk's alignment checks are routing heuristics, not +semantics, so they use bare typeof gates and defer validation to `descend`. +Closes the remaining dbmon tick gap to statistical parity with the legacy +implementation (best-case ticks now beat legacy's). diff --git a/.changeset/store-treeshakeable-optimism.md b/.changeset/store-treeshakeable-optimism.md new file mode 100644 index 000000000..040741a5f --- /dev/null +++ b/.changeset/store-treeshakeable-optimism.md @@ -0,0 +1,13 @@ +--- +"@solidjs/signals": patch +--- + +Store: the optimistic write channel is tree-shakeable. The optimistic-only +machinery (engine-write diffing, landing consumption, view composition, the +tentative reconcile channel) moved out of the plain store/reconcile modules +into the optimistic module behind an injection table installed by +`createOptimisticStore` — every call site is `fam?.opt`-gated, so the table +is always populated before it can be reached and plain paths pay nothing. +Apps that never use optimistic stores now shake ~0.5kb gzip of store code; +the whole-package (bundlephobia-style) size drops ~0.5kb gzip as well from +the accompanying dead-export sweep. diff --git a/documentation/performance-experiments.md b/documentation/performance-experiments.md index 4b9b44285..bbed56142 100644 --- a/documentation/performance-experiments.md +++ b/documentation/performance-experiments.md @@ -6723,3 +6723,133 @@ Read: - The extra branch/counter/marker state in the hot loop appears to cost more than the saved generic `formatChildId` work. - Reverted the source probe. The safe runtime baseline remains Investigation 21 + 22, without this id-cache change. + +## Store Rewrite Lane (2026-08-18): baselines at full functionality + +The store rewrite (`src/store/next/`, branch `store-rewrite`; contract in +`packages/solid-signals/INTERNALS-STORE-STATE.md`) reached full-suite green +plus one optimization pass today. Baselines below; per-scenario variance +matters more than totals. + +### Tier-1 (in-repo, `pnpm vitest bench --run tests/store/...`) + +Legacy = main checkout, next = worktree, same machine back-to-back. The +dbmon SHALLOW row runs identical legacy code in both checkouts — it is a +built-in noise control (measured 1.14x session skew for this pair; treat +sub-15% Tier-1 deltas accordingly). + +| bench (mean) | legacy | next | ratio | +|---|---|---|---| +| reconcile read-once tree, 10 of ~12k paths subscribed | 0.87ms | 0.51ms | 0.59x | +| tree reverse 1111 keyed | 4.55 | 4.19 | 0.92x | +| tree shuffle 1111 keyed | 4.47 | 4.74 | 1.06x (rme ±9–11%) | +| dbmon full tick deep | 121.4 | 133.4 | 1.10x | +| dbmon partial tick deep | 126.2 | 137.7 | 1.09x | +| dbmon shallow (control) | 66.2 | 75.2 | 1.14x | + +### Tier-2 (browser) + +- UIBench (matched morning session, 10-iter): legacy 36.6ms total → next + 27.5ms. Per-test: ZERO regressions >1.1x&0.05ms; wins concentrate in + keyed structure (moves 0.62x, sort/filter 0.71x) and creation + (tree/render 0.68x). vs ivi (21.4): we now beat ivi on table/filter; + remaining gaps are tree/render (creation) and reorders (re-derivation + stack — phase-2 signature). +- dbmon browser (same-session 30-iter, all columns together): sort 0.92x + (faster than legacy), mount 1.03x, remount 1.07x, unmount noise — but + tick 1.21x and tick_partial ~1.2x SLOWER. + +### The open regression (the current target) + +Both tiers agree in direction: dense value-diff reconcile (dbmon full and +partial tick) is slower than legacy — Tier-1 shows ~1.10x raw (inside the +1.14x control band, so partially environmental, but Tier-2's same-session +1.21x confirms a real gap). Everything else is parity or faster. The +requirement is parity (no regression) before phase 2. Suspects after the +fused-walk pass: per-target adoption bookkeeping (registration WeakMap.set, +ownership WeakSet.has), per-key call overhead in the fused walk +(notifyKeyDiff + setSignal per changed key) vs legacy's monolithic +applyStateFast. Iteration tool: `reconcile-dbmon.bench.ts` (sub-10s loop, +shallow row as control). + +### Inline per-key pass + in-process A/B (2026-08-18, later) + +Changes: per-key notify body inlined into the fused adoption walk (legacy's +own lesson — extracted helper cost ~7% on CodSpeed); reference-identity +early-continue per key with the FINDING-1 ownership guard (`ov === nv` skips +only unowned backings; accessor-flagged nodes never skip); both-side fetches +done once. Suite green. + +New tool: `tests/store/reconcile-dbmon-ab.bench.ts` — the same workload +against next (dispatcher) and legacy (direct import) interleaved in ONE +process. No session/thermal skew; this supersedes cross-checkout Tier-1 +comparisons for the transition period. Delete with the legacy modules. + +Results (time:4000, both variants same process): + +| bench | next | legacy | read | +|---|---|---|---| +| full tick mean | 187.0 | 190.7 | parity (0.98x) | +| full tick min | 146.9 | 133.4 | 1.10x, GC-riddled (rme ±20%) | +| partial mean | 148.9 | 134.4 | 1.11x (rme ±6–12%) | +| partial min | 124.2 | 116.4 | 1.07x | + +Verdict: full-tick parity reached in-process; partial holds a residual +~7–10% (borderline vs rme but consistently legacy-favored). Remaining +partial suspects: per-child adoption bookkeeping on value-identical rows +(registration WeakMap.set + adopted-flag bookkeeping per query array/row +even when every key ===-continues). Browser re-validation owed on a cool +machine with the current build. + +### A/B corrected for __TEST__ machinery (benchmark mode) + +Discovery: default vitest runs define `__TEST__: true`, which makes next pay +its invariant oracles (ingestedRaw WeakSet add PER ADOPTION, no-mutation +assertions on privatize/fold paths) that legacy has no equivalent of — the +earlier A/B numbers overstated next's cost. `--mode benchmark` strips them +(vite.config.ts already provided the mode). + +Two benchmark-mode runs, same command minutes apart (means): + +| bench | run 1 | run 2 | +|---|---|---| +| full tick next/legacy | 129.5/160.8 = **0.81x** | 139.8/132.0 = 1.06x | +| partial next/legacy | 140.4/131.2 = 1.07x | 155.5/136.8 = 1.14x | +| partial MINS | 115.5/114.8 = 1.01x | 128.7/117.5 = 1.10x | + +Verdict: full-tick oscillates AROUND parity (0.81–1.06x) — no measurable +regression remains, and no stable win either; partial reads 1.0–1.14x. +Cross-run variance (bench order, GC epochs, end-of-day thermals) now +exceeds the effect size even in-process. STOP MEASURING HERE. Definitive +read = cool machine, first runs of the day, both A/B runs repeated 3x, +report min-of-means per side. Measurement rule going forward: any +next-vs-legacy claim must come from `--mode benchmark` (the __TEST__ +asymmetry biases default-mode numbers against next). + +### Read-path flattening + positional-prefix keyed walk (2026-08-18, midday) + +Two more single-loop changes (no forks): +1. Trap read path: one `typeof key` gates all brand-symbol compares off the + hot string path; the common serve case (existing plain node, unchained, + tracked) is inlined in the trap — readNodeFast, no serveDataKey frame, no + FORCE compare (only accessor keys hold the sentinel), primitives bail + before isWrappable. +2. Keyed arrays: positional-prefix fast path (legacy keyedMatch-walk + parity) — aligned rows descend in place with inline identity skip + (FINDING-1 guard); prevByKey is built only for the misaligned remainder, + never on aligned ticks (was: 1000-entry Map per tick). + +Browser (octane harness, alternating rounds, current build): +- FULL tick: 1.02/1.08/1.15x pre-prefix → 1.10/1.04x post — parity band. +- PARTIAL tick: was ~1.24x IN BOTH SWEEP ORDERS (the one order-robust + regression) → 1.03x / 0.97x post-prefix — CLOSED. +- Sweep-order finding: run.mjs's fixed order (solid before solid-next) + biases the second column; reversed-order run flipped full tick from + 1.17x to 1.045x. Alternating per-round loops are the trustworthy method; + medians from single fixed-order sweeps are not. +- Suite + next-gate green throughout. + +Status vs the no-regression bar: full and partial tick both in the +alternating-measurement parity band (0.97–1.15x swings, centered ~1.05); +sort/mount/unmount at parity or faster. Cool-machine confirmation still +recommended for the record. diff --git a/packages/solid-signals/INTERNALS-STORE-STATE.md b/packages/solid-signals/INTERNALS-STORE-STATE.md new file mode 100644 index 000000000..c133da3ae --- /dev/null +++ b/packages/solid-signals/INTERNALS-STORE-STATE.md @@ -0,0 +1,989 @@ +# Store Storage Model Rewrite — Semantic Rules & Working Notes + +Companion to `INTERNALS-ASYNC-STATE.md`, same method: pin the semantic contract +as rules first, wire the checkable ones as `__TEST__` assertions, then let the +implementation land against them. Nothing below is final until it survives the +existing store/projection/optimistic suites plus rule-derived tests. + +Motivating evidence (2026-08-16 baselines, octane dbmon four-way + uibench +profile): store mechanism cost is 3.6x its actual diff cost (wrap/init 97ms + +traps 63ms vs applyState 44ms across the uibench suite); deep-store dbmon tick +is 4x the identical engine in shallow shape (10.3ms vs 2.5ms); teardown cost is +graph size, not walk speed (1.1ms vs 0.3ms with right-sized graph). Harnesses: +`~/Development/octane-dbmon-local` (vs octane/vapor), `~/Development/solid-uibench` +(vs ivi). Targets: octane + ivi (VDOM adversaries), vapor (sibling reference). + +Note on what these workloads measure: they are reconcile-only and never +*populate* override layers, yet they pay layer **presence** on every +operation — `getOverlayLayer` per read, layer slots in every target +allocation, override-gated path selection in reconcile, READ_SLOW gating on +the node fast path. The measured tax is empty-layer tax; the layer deletion +targets exactly it. Conversely they validate nothing about the replacement: +node-lane performance under *populated* layers needs its own harness scenario +(optimistic write storm) — correctness rides the optimism/transition suites. + +## 1. Storage model (the single-home rule) + +Proposed homes for a property value, replacing the current four: + +- **Owned raw graph** — the committed truth, always. **Never the user's + objects**: dropping source-object mutation was an intentional 1.x→2.0 change + (user complaints; Svelte precedent; Vue still mutates targets and is + considered broken here). Ownership is copy-on-write: on ingest the store + shares user objects structurally (zero copies, reads pass through); the + first write to an object shallow-clones it into store ownership and lands + there; every later write to it is direct write-through (single home, + Vue-like cost without source mutation). Steady state: fully privatized, + zero clones per update. `snapshot`'s documented contract ("original identity + for subtrees not modified relative to the source") is exactly the ownership + boundary: unwritten subtrees return source identity, privatized ones return + the owned objects. Owned raw mutates at exactly one moment: flush commit + (writes batch like signals — see §3 urgent write; privatization may defer + to commit). +- **Privatization mechanics (path copying)** — a shared parent cannot be + mutated to point at an owned child, so privatizing an object privatizes its + ancestor chain first; the root is privatized eagerly at `createStore` (one + clone) so chains terminate. Each object privatizes at most once: lifetime + clone count = number of objects ever written (worst single write = O(depth) + shallow clones; steady state = zero; traversal of owned raw needs no lookup + table — it is a real object graph). Privatization is **not a reactive + event**: no notification, no proxy identity change, no subscriber runs; only + the internal backing pointer moves (R1 extension: ownership state is as + unobservable as node existence). The backing pointer is not new machinery: + the shipped 2.0 proxy already wraps the internal target — not the raw — with + `STORE_VALUE` as the pointer to backing (`$TARGET` is a free trap answer; + proxy identity binds to the target, never to raw). Privatization and + adoption are the same primitive with different sources: repoint + `STORE_VALUE` behind a stable proxy. Ownership tracking, first cut: a WeakSet of + store-owned raws — one structure serving both production checks and the + `__TEST__` no-mutation oracle. Symbol tags on raw are ruled out permanently: + stamping a symbol *is* a mutation of the passed-in source (visible via + `getOwnPropertySymbols`, rejected by frozen objects — the 1.x `$PROXY` stamp + was part of the original complaint class). Cheaper carriers (`target._owned` + field — every consulting path resolves the target anyway) are recorded as + optimization candidates, adopted only if post-phase-1 profiling shows + ownership lookups on hot paths (historical expectation: field/symbol reads + beat WeakSet identity-hash lookups; verify before spending complexity). +- **Node (lazily materialized, per tracked-or-written property)** — a real core + signal. Carries: subscriptions, and pending lane values (transition + + optimistic) via the same core machinery signals already use. No store-side + override maps, no backup snapshots, no separate transactional subsystem. +- **Key-set node (per object, lazy)** — carries `ownKeys`/`has`/`length` + overlay during pending structural edits, and key-set subscriptions + (`trackSelf` successor). + +A property with no node has its value in raw and nowhere else. A property with +a node still has its committed value in raw; the node adds subscription + any +in-flight lane values on top. + +## 2. Read paths + +Every read goes node-first when a node exists, raw otherwise: + +| path | committed | during transition lane | during optimistic lane | +|---|---|---|---| +| tracked proxy read | raw (subscribes) | lane value if lane-visible, else raw | optimistic value | +| untracked proxy read | raw | same-lane rules as tracked | optimistic value | +| `snapshot` (non-tracking) | raw identity, zero copy | CoW: copy lane-touched subtrees only — **confirm (O1)** | same as transition | +| `deep` (tracking snapshot) | raw identity + deep subscribe | same CoW as `snapshot` | same | +| internal `$RAW`/`$TARGET` | raw | raw (committed) | raw (committed) | +| `ownKeys` / `has` / iteration | raw keys | key-set node overlay | key-set node overlay | + +Rule R1 (load-bearing): **node existence must be unobservable.** For any +program, behavior is identical whether or not a node happens to be +materialized. Laziness is an optimization, never a semantic. + +Rule R2 (ruling heuristic — Ryan, 2026-08-17): **signal parity by default.** +A store behavior should be what a signal would do in the same situation +(batching, lane collapse, equality cuts, transition visibility all resolved +this way). Divergence is legitimate only where granularity forces it — a +store is a family of nodes plus a membership dimension signals don't have +(key-sets, structural edits, keyed identity, per-key ownership, store-wide +status enforcement). Every divergence must be identified as such and +documented; "the store does its own thing here" is never the default. + +Corollary R2a (Ryan, 2026-08-17): **take no responsibility for mutation +outside reactivity.** Input to a store is immutable by convention, same as +signals; the store defends reactivity's own contract, never the user's +discipline. Spending mechanism to detect or survive external mutation is +where perf is lost. (Applied: reconcile's diff baseline is the *current +view* — signal parity; the identity skip is sound when keyed on the current +backing, because same-reference input plus the immutability convention proves +there is nothing to diff.) + +## 3. Write paths (all must stay equivalent) + +- **Urgent write** — batches exactly like a core signal write (RUL-1, + verified against `core.ts` read/write paths 2026-08-16): parks in the + pending home — the node's `_pendingValue` when a node exists, a transient + per-target pending record otherwise (no subscriptions; folded into backing + and discarded at flush commit). Read rule mirrors signals: context-free + reads see committed backing until flush; reads under an owner context see + pending; setter drafts and `snapshot` read pending explicitly. Reconcile + parks as one pending backing-swap per target (not per-property values). + Privatization/path-copying may defer to flush commit. +- **Transition write** — materializes the node (writes always materialize); + value parks in the lane slot; raw untouched until the transition commits. +- **Optimistic write** — same as transition write in the optimistic lane; + rollback = discard lane value; raw was never touched (this replaces + override+backup entirely). +- **Reconcile / projection merge** — the **immutable-diff adoption channel** + (per Ryan: "we let reconcile do essentially immutable diffs"). Reconcile + never merge-writes into backing objects: it adopts `next` as the + authoritative backing at every proxied level (internal pointer swap — no + clones, no user-object mutation), value-notifies only where nodes exist, and + skips whole subtrees by reference equality. Adoption **resets ownership to + shared**; setter privatizations since the last reconcile fold into the diff + and their owned clones are discarded. Identity skip rule: + `incoming === backing && !owned(backing)` — sound because both sides are + user-supplied immutable objects; an owned backing is definitionally + setter-diverged and must diff. (Current code encodes the same guard + structurally: `applyStateFast` is only selected when no overrides exist, + `applyStateShallow` skips only on `next === previous && !override`, and + "reconcile makes next the authoritative base" is already the documented + contract.) Logical identity for consumers is proxy identity, which is + stable across adoption; raw identity intentionally moves to the incoming + graph, which is what makes post-reconcile `snapshot` free. +- Commit hook: lane settle folds the winning lane value into raw, then the + node returns to passthrough (no pending state retained). + +## 4. Identity rules + +- Proxy identity per logical node is stable for the store's lifetime; the + backing raw may change identity via exactly two mechanisms (setter + privatization → owned clone; reconcile adoption → incoming object), both + unobservable through the proxy. +- Reconcile/projection preserve *logical* identity for key-matched branches + (same proxy, keyed flows compare proxy identity); reference-equal unowned + branches additionally keep raw identity (the skip path). +- New objects introduced under a pending lane are wrapped on read like any + other; on rollback they simply become unreachable. + +## 5. Laziness invariants (candidates for `__TEST__` assertions) + +- **(high)** No write path ever mutates a user-provided (non-owned) object. + First cut: the ownership WeakSet serves both the production check and the + `__TEST__` oracle — assert every raw mutation's target is in the owned set. +- **(high)** No *permanent* node is created by: proxy creation, untracked + reads, urgent writes with no observers, or reconcile writes to unobserved + properties. (Observer-less writes may hold a transient per-target pending + record until flush commit — carries no subscriptions, discarded at fold.) +- **(high)** A node is created by: first tracked read, first has/keys tracking, + any transition/optimistic write, projection write to an observed property. +- **(high)** After every flush with no active lanes: for every materialized + node, node's committed view === raw value (single-home coherence). +- **(medium)** Disposal of a store tears down only materialized nodes + (teardown cost ∝ tracked surface, not data size). +- **(medium)** `snapshot` on settled state (no active lanes touching the + subtree) returns raw identity — allocates nothing, materializes no nodes. + +## 5b. Creation budget (phase-1 fitness) + +Creation tax decomposed, with the phase-1 budget per unit: + +- **Per store**: zero extras — no projection internals, no firewall unless the + store *is* a projection (today every `createStore` pays + `createProjectionInternal`). +- **Per adopted-but-unread object**: zero allocations. Adoption links by + reference; nothing wraps until read. Creation is O(rendered), not O(data). +- **Per read-through object**: one minimal target (backing pointer + lazy + slots + flags — no layer slots, no `initStoreFields` ceremony), one proxy, + one `storeLookup` entry. Slimming, not deferral — fully-rendered workloads + (uibench/dbmon mount) can only win here, since they read everything. +- **Per tracked binding**: one bare core signal + one graph link. This is the + fine-grained floor, accepted; the shallow column (2.5ms vs deep 10.3ms, + same engine) shows ~75% of today's deep cost is mechanism above that floor. + If post-phase-1 mount gaps vs ivi/octane are floor-dominated, that is the + trigger for the phase-2 edit-script channel — not more store surgery. + +## 5c. Comparison method (shipped vs rewrite) + +Side-by-side at three levels; the worktree split makes both dists coexist: + +- **Perf**: harnesses gain a `solid-next` fixture (identical app code, deps + pointed at the worktree build) so every sweep reports shipped vs rewrite in + one table beside the references (shallow floor, vapor, octane; ivi for + uibench). Requires moving the dbmon workspace's root pnpm overrides to + per-fixture `file:` deps (root overrides are global — they'd force one + checkout for all fixtures). Re-baseline shipped columns whenever the + harness itself changes; never compare against stale numbers. + **A/A noise floor (measured 2026-08-17)**: with byte-identical bundles in + both columns, single sweeps diverge up to ~30% on tick (session-order / + thermal variance, direction unstable across runs). Treat sub-30% + single-sweep deltas as noise; real claims need ABBA-interleaved sweeps or + effect sizes beyond the floor. Baseline: `octane-dbmon-local/baselines/ + 2026-08-17-shipped-rebaseline.txt`. +- **Correctness**: old suites run unmodified against the rewrite (hard gate). + Rule-derived `__TEST__` assertions run against *both* implementations — + shipped first, to record which rules it already violates (findings-log + baseline); each violation gets a deliberate ruling (bug being fixed vs rule + written wrong) before the rewrite is built to satisfy it. +- **Size**: a co-equal goal, not a trailing metric — this effort started as a + size audit (store ~tripled 1.9→2.0; the growth is the same machinery the + perf work deletes: override/optimistic layers + merge logic, store-side + transaction bookkeeping, projection internals in plain `createStore`). The + esbuild+terser store-subpath measurement runs on **every increment** beside + the perf columns; size regressions need the same justification as perf + regressions. What the rewrite adds back (privatization helper + path walk, + ownership WeakSet, key-set node) must stay small; O3 (`applyStateFast` + delete-or-keep, large module for 33ms) is a size ruling as much as a perf + one. **Shipped baseline (2026-08-17, `scripts/store-size.mjs`)**: full + 24.0kb gz / 76.8kb min; core-only (no store) 8.4kb gz; store attribution + (full − core) **15.5kb gz / 50.0kb min**. `createStore + reconcile` alone + is 14.5kb gz — and the optimistic entry adds only ~0.6kb more, confirming + plain stores already pay the projection/optimistic machinery. + **Transitional checkpoint (2026-08-18, full plain-store parity)**: full + 26.4kb gz / store attribution 18.0kb gz — the build carries BOTH + implementations + dispatcher glue; the rewrite's own modules (plain stores, + reconcile, snapshot/deep, interop) are the ~2.5kb gz delta over shipped. + Perf same checkpoint (15-iter, single sweep, ~30% A/A floor): tick 15.2 vs + 12.1 legacy, mount/remount slightly behind, sort equal — correctness work + (node-authoritative writes, transitions) spent earlier optimization gains; + the profile loop resumes post-functionality. + Target: NOT 1.9 scale — optimism and projections are new capability + 1.9 never had (and 1.9 carried `createMutable`, since deleted). The target + is 1.9's mechanism cost + the *honest* cost of the new features built on + core lanes — what gets deleted is duplication (override layers re-creating + lane machinery, projection internals taxed on every plain store), not + capability. + +## 6. Structural edits — the key-set node (resolves O2, RUL-8) + +One lazy **key-set node** per wrapped object; the granularity divergence that +gives stores the membership dimension signals lack (R2). Design: + +- **Subscriptions**: `ownKeys` / iteration / `$TRACK` subscribe to the + key-set node. `in`/`has` stays per-key (presence-sensitive subscription on + the property node) — R13's contract. +- **Lane-scoped membership edits**: a transaction's adds and deletes park as + overlay entries on the key-set node — add: key → present; delete: key → + tombstone sentinel — each stamped with its owning transaction (per- + transaction granularity; FINDING-2 is the motivating bug: shipped reverts + optimistic deletes but leaks optimistic adds). Commit folds the winning + edits into backing (real add/delete on owned raw); rollback discards + exactly the rolling transaction's edits. Backing keys never mutate mid-lane. +- **Length is a view, not a node**: for arrays, `length` derives from the + key-set node's state (committed keys + visible lane overlay). One node + holds membership and length, index nodes hold values — tear-free iteration + by construction (opt R26): a consumer reading `length` then indices within + one computation resolves both against the same overlay state. +- **Visibility**: the key-set overlay obeys the same lane-visibility rules as + value nodes (§2 read table) — mid-lane iteration in the writing lane sees + the overlay; other lanes and committed readers see backing keys. +- **Resize notification matrix** (recon-snap R12) is the acceptance suite: + shrink notifies removed tracked indices with `undefined` and flips `in`; + growth notifies appearing indices; trailing removal notifies `$TRACK`; + membership sync is key-based, never length-range-based (R13's `"1e3"`). + +## 6b. Lane-aware adoption (RUL-5) + +Adoption inside a lane is lane-scoped end to end: + +- The backing swap parks as a **lane backing** on the target (pending swap + per lane), committed only when the lane's transaction settles to top. + Rollback discards the lane backing, the lane's key-set edits, and the + lane's node values together — restoring prior backing AND prior ownership + state (the passing half of `adoption-lane-rollback.test.ts` pins this). +- **Diff baselines during in-lane reconcile** (opt R28/R29, both required): + *previous-arrangement* reads — prev length, key matching, including rows + that exist only optimistically — consult the **lane view**; *entity + identity* probes (root key-mismatch checks against incoming data) read + **committed base**. A raw-only diff is unsound against optimistic rows + (the #2864/#2899-adjacent regression class). +- Divergence note (R2): signals carry one lane value per node; a store's + lane view composes per-node lane values + key-set lane edits + the lane + backing. The composition rule is this section; everything else is §2. + +## 6c. Store-wide status gating (RUL-7) + +Derived-store status (UNINITIALIZED / ERRORED) is **one field on the root +target**, checked before backing fallthrough in *every* trap — get, has, +ownKeys, descriptors, spread paths. Uninitialized → NotReady (prod) or the +`[PENDING_ASYNC_UNTRACKED_READ]` dev escalation per the strict-read matrix +(opt R30–R34, including the isPending-probe prod-path rule); errored → the +derive's error for all readers, tracked or not. R2 hybrid: parity in meaning +(a signal's status), divergent in enforcement surface (a signal throws from +one read path; a store must gate its whole trap table or the seed leaks +through enumeration). Plain stores carry no status — one undefined-check +branch. + +## 6d. Diff reachability (RUL-11) + +Port of the `STORE_DESC` mechanism: materializing a node (or key-set node) +on a target sets a sticky descendants flag up the parent-target chain. +Adoption descends into a changed child pair only where a target with the +flag (or nodes) exists below — never-subscribed subtrees are pruned wholesale +(recon-snap R17), while a subscriber several untracked levels down keeps its +ancestors walkable (recon-snap R16). Reference-equal unowned pairs skip +before reachability is even consulted (§3). The flag is monotone (sticky) +exactly as shipped — clearing it buys nothing measurable and risks pruning a +live path. + +## 7. Projections & optimism layering + +- Projection = computed store: recompute merges output into its raw via §3 + rules; its writes ride whatever lane the recompute runs in, so projections + remain optimism-compatible by construction (createOptimistic's store form + keeps building on projection internals). +- Lane collision on the same property defers to core lane semantics — no + store-specific collision rules. Deep writes to distinct properties in + different lanes are independent by construction (per-property nodes). + +## 7b. Chained backing (cross-store) — spec + +The third backing variant (RUL-6 spec work; shipped contract #2941/#2864). +A target's backing pointer may aim at **another store's proxy** instead of a +raw object. Mechanics: + +- **Read-through**: reads on the outer target resolve through the inner proxy, + firing the inner store's traps in the outer reader's tracking context — + so subscriptions land on the *inner* nodes naturally. This is the entire + "live chaining" mechanism: no re-derive, no notification forwarding, no + bridge machinery. Updates flow because consumers are literally subscribed + to the source (proj R17, R18, R20). +- **Structural chaining** (#2864, core R21): outer `ownKeys`/`$TRACK` reads + likewise read through to the inner key-set node — chaining is the same + read-through rule, not a special case. +- **Lane masking = shadow + dynamic dependencies** (core R36 zero-churn): a + lane value on an outer property node shadows read-through. Subscribers + re-run once on the hold write, rebuild dependencies against the lane value + (no longer reaching inner nodes), and therefore receive *zero* + notifications from mid-hold inner changes. The reveal re-runs them and + re-subscribes through the chain. No suppression mechanism exists — the + mask falls out of standard fine-grained dependency rebuilding. +- **Severing** (proj R19): a derive switching its return is an adoption- + channel backing swap; subscribers re-read, re-subscribe to the new backing, + and the old chain drops via dependency rebuild. The displaced backing's + `storeLookup` registration is superseded at swap (proj R10 — proxy keeps + its backing; the *raw→proxy* entry moves). +- **Snapshot through a chain** unwraps backing pointers to the base raw. + Snapshot identity rule (resolves proj R22 + recon-snap R24/R25 jointly): + **source identity for unowned subtrees; cached copy for owned subtrees** + (copy created on first snapshot after the last write, reused until the + next write). Never-written stores stay zero-copy; owned subtrees are by + definition "modified relative to source," so the documented CoW contract + already mandates copies for exactly them — and copying is what makes the + chained-projection snapshot detached from future in-place owned-raw writes. +- **Cross-store ownership**: an owned object belongs to exactly one store + family; the identity-skip guard (`incoming === backing && !owned(backing)`) + consults the owning family. Raw→proxy dedupe is keyed by *current* backing + registration — privatization and adoption move the registration, resolving + core R2's divergent-backing question: dedupe follows the registration, not + stale raw identity. + +## 8. Assumptions / open questions + +- **O1**: `snapshot` during pending lanes — today it reads the current view + through the proxy (optimistic values included) with CoW identity + preservation. Proposal: keep that observable behavior; raw-as-truth makes it + free when settled and sparse (lane-touched subtrees only) when pending. + Confirm no consumer depends on snapshot meaning *committed* state + (persistence use cases might want a committed variant — decide if that's a + new API or out of scope). +- **O2 (RESOLVED 2026-08-17)**: key-set node spec in §6 — per-transaction + membership overlay, tombstone deletes, length as derived view. +- **O3**: does `applyStateFast` survive phase 1? Re-measure after lazy + creation; expectation: delete (33ms measured benefit pre-laziness, large + module weight). +- **O4 (reframed 2026-08-18, Ryan)**: shallow exists ONLY for performance — + "if I could retire it I would." Shallow is therefore NOT a port target: it + routes to the legacy implementation via the dispatcher indefinitely, and + the shallow column in the dbmon harness is the **retirement bar** — if + deep-next's tick closes on it, shallow gets deleted (API, implementation, + tests, size share) instead of ported. Interop (legacy shallow nested in + next deep stores, sticky raw-marking, cross-implementation dedupe) is done + and is the full extent of shallow investment. +- **O5**: symbol keys, class instances, frozen objects — enumerate current + suite coverage, port as rules. +- **O6**: node committed-value storage — if nodes are literal core signals, + does the signal `_value` slot mirror the committed value (re-creating a + second home + the §5 coherence obligation), or do store nodes read through + to raw for committed state, using the slot only for lane values? + Single-home purism says read-through: the coherence invariant then holds by + construction instead of by assertion. Decide whether core signal internals + permit a read-through variant without forking the hot read path. +- **O8**: un-noded child reads. With lazy nodes, an untracked read of a + wrappable child re-enters `wrap()` and pays a `storeLookup` WeakMap hit + every time (tracked reads cache in the node). 2.0 already accepted this + trade when it dropped 1.x's `$PROXY` source stamp (verified: `wrap()` + resolves raws via `storeLookup.get`; `$TARGET`/`$PROXY` are trap answers + only; nothing is ever stamped). If post-phase-1 profiles show this path, + the candidate is a per-target child-wrapper slot (node-lite: cached wrapper + without subscription) — never a return to source stamping. +- **O7 (RESOLVED 2026-08-16)**: reconcile identity fast-path under CoW + ownership. Resolved by the two-channel model (§3): reconcile is the + immutable-diff adoption channel; skip iff + `incoming === backing && !owned(backing)`. Owned backing means + setter-diverged since last reconcile → must diff; adoption resets ownership + so the fast-path is restored every reconcile. Verified against current code: + the override-gated selection of `applyStateFast` / the + `next === previous && !override` guard in `applyStateShallow` are the same + rule expressed structurally. Port as `__TEST__` rule: a reconcile that + re-sends the prior reference after an intervening setter write must still + restore the incoming values (no unsound skip). **Update 2026-08-17: rule + test written (`reconcile-resend-identity.test.ts`) and run against shipped — + the nested path FAILS (FINDING-1, rules-mining/FINDINGS.md): committed node + divergence is invisible to `applyStateFast`'s identity return, confirming + the suspicion recorded here. Root and derived paths pass. The ownership + guard fixes it by construction.** + +## 8b. Suite-mined rules (2026-08-16) — index & rulings needed + +Five parallel mining passes over the 23 store-related suites (~17k lines) +extracted **217 deduplicated observable rules**. Full catalogs with per-rule +evidence live in `rules-mining/`: `core-store.md` (58), `reconcile-snapshot.md` +(40), `projections.md` (36), `optimistic-store.md` (46), `optimistic-lanes.md` +(37). Most rules are compatible with (or actively validated by) this design; +below are only the cross-cutting conflicts requiring a ruling before +implementation, deduplicated across reports. + +### Confirmations (design validated by the suites) + +- **O1 RESOLVED**: `snapshot`/`deep` = current view, lane values included + (opt-store R5, recon-snap R26). A committed-only snapshot would break #2850. +- **O7's trigger test already exists** (core R32: setter-staged write + + reconcile lands the reconciled value); the re-send variant still needs adding. +- Per-property lane independence (#2899 disjoint keys) is the strongest + validation of nodes-as-lane-carriers (opt-store R19). +- Shallow reconcile's reference-skip (core R40) is the adoption channel's + skip rule already shipping in shallow form. +- Target indirection is pinned by the proxy-invariant suite (core R51) — + proxying raw directly is permanently off the table. + +### Rulings needed (cross-cutting, deduplicated) + +- **RUL-1 — RESOLVED (2026-08-16, verified per "mirror signals if verified" — + adopted; flag to reopen).** Core + signals already implement exactly the pinned matrix: writes park in + `_pendingValue`; context-free reads see `_value` until flush; owner-context + reads see pending (`core.ts` read fast paths); flush folds. Store adopts + the same rule: node → `_pendingValue`; no node → transient per-target + pending record; reconcile → one pending backing swap per target; drafts and + `snapshot` read pending explicitly; optimistic lane values are the + visible-immediately exception (per lane semantics). §1/§3/§5 updated. +- **RUL-2 — RULED (Ryan, 2026-08-17): no landing matrix. Two orthogonal + rules, both pre-existing.** (1) **Lane collapse governs visibility** — pure + signal semantics: a completing action folds its committed state into its + parent transition (`_pendingValue` holds, INV-5/INV-7 machinery); entangled + actions share a merged parent, so a member's landing is invisible until the + last member completes. (2) **Visible landed truth always replaces + optimism**, propagation gated only by the signal equality cut. All three + pinned behaviors derive with no store-specific logic: rapid-toggle "stays + false" = rule 1 (the landing never became visible — not a preserve rule); + bare-refresh recycle = rule 2 (no pending transition owns the re-ask → + collapses to top → visible → adopts, identity recycled); #2719 clear = + rule 2 (disjoint keys → never entangled → independent transition completes + → visible → replaces the foreign optimistic row). **No shipped test + expectations change.** The store rewrite implements zero landing logic; + it inherits collapse from core lanes and adds only "visible landing + replaces lane values, equality-gated." +- **RUL-3 — RESOLVED (verified 2026-08-16).** Ownership already lives + per-node in core (`_overrideOwner` in `core/types.ts`, `lanes.ts`, + `core/optimistic.ts`); the store-side `STORE_OPTIMISTIC_OWNERS` map exists + only because store properties aren't real signals today. Nodes-as-core- + signals deletes the duplicate with no semantic work. Remainder: the key-set + node must carry the same per-transaction ownership for structural edits — + folded into RUL-8/O2. +- **RUL-4 — RESOLVED (2026-08-17, signal parity — verified empirically).** + New test `optimistic-signal-refetch-hold.test.ts` proves the signal form + already holds bare writes through an in-flight refetch and flashes with + settled truth (2/2 passing) — #2951 was the store failing to reproduce + signal behavior, not a distinct semantic. One rule: *an ephemeral + optimistic lane collapses into the transition owning the node's in-flight + question; no in-flight question → completes at flush (flash).* R2 + granularity bridge, documented: a store property's "question" lives on the + derive's firewall computed, not the written node — the ephemeral lane must + consult it (settle = actions empty AND async reporters empty, per opt R14). + Still open from this item: the un-actioned double-notify shape + (`[v, opt, v]` in one flush, lanes R5) — contract or artifact (folded into + RUL-12). +- **RUL-5 — SPEC'D (2026-08-17): §6b.** Lane backing + lane-view/committed- + base diff baselines + joint rollback. Test evidence: + `adoption-lane-rollback.test.ts` (values/length/captures revert on shipped; + key-addition leak is FINDING-2). +- **RUL-6 — reclassified: SPEC WORK, not a ruling.** Live chaining (#2941 — + derive returns a store, updates flow with no re-derive) is shipped, tested + contract; nothing to decide unless the contract changes. Owed: a §7b spec + for the third backing variant (target's backing pointer aimed at another + store's proxy, subscription bridging) plus its interaction rules — active + lane hold on the wrapper masks the chain (#2864, core R36 vs R21 + precedence); chained-projection snapshot detaches (proj R22); cross-store + ownership/dedupe (recon-snap R25, core R2); displaced-raw unregistration + (proj R10). +- **RUL-7 — SPEC'D (2026-08-17): §6c.** One status field on the root target, + checked in every trap before backing fallthrough; strict-read matrix + preserved. +- **RUL-8 — SPEC'D (2026-08-17): §6 rewrite, resolves O2.** Per-transaction + membership overlay with tombstones; length as a derived view of the key-set + node (tear-free by construction); FINDING-2 is the motivating shipped bug. +- **RUL-9 — RESOLVED (2026-08-17, parity by construction).** Every piece of + mid-flight-correction evidence (lanes R13) is signal-form — it is already + core lane behavior, not a store transition to enumerate. Nodes-as-core- + signals inherit it; §3 references lane-value transitions as core-provided + (commit / rollback / correction) rather than defining them store-side. +- **RUL-10 — The equality trio.** One precise rule needed spanning: no-op + writes must not entangle lanes (opt R38); equal-value action writes must + still register ownership and dirty downstream (lanes R17); same-value + manual writes on derived stores must mask the recompute for the tick (core + R31) despite equality-checked core signals. +- **RUL-11 — SPEC'D (2026-08-17): §6d.** Sticky descendants flag ported from + `STORE_DESC`; reference-skip precedes reachability; monotone by design. +- **RUL-12 — Smaller rulings, each with a proposed default** (proceeding on + the proposals unless overruled; the four marked ⚑ genuinely change or add + observable behavior and warrant Ryan's eyes): + - *Multi-parent (DAG) privatization* (recon-snap R37) — proposal: keep + today's per-object registration-resolved traversal in snapshot/diff + (already `value[$TARGET] || lookupTarget(value)`), so shared children + resolve to their owned backing through any parent; no new mechanism. + - *Snapshot-capture for node-less writes* (recon-snap R31) — proposal: + capture-window writes materialize the node and use the signal + `_snapshotValue` path (pure parity; R1 keeps it unobservable). + - *Sticky cross-store raw-marking* (core R41) — **RULED KEEP + (2026-08-17)**: stickiness is one half of a single invariant — *an object + is never both deep-wrapped and raw*. Shallow-first order: the record + stays a leaf everywhere (no second truth can form). Deep-first order: the + R44 dev-throw refuses the shallow ingest. Dropping stickiness would allow + a deep store to privatize its view of a record while the shallow store + keeps serving the stale source by identity — one entity, two truths. + Reconcile mechanics would survive; entity coherence would not. + - *Dev-throw on deep-tracked ingest* (core R44) — keep as the deep-first + half of the never-both-wrapped-and-raw invariant; best-effort dev + *diagnostic*, documented as materialization-dependent (R1 governs + semantics, not dev-error coverage). + - *Unkeyed nested-object replace-vs-merge in async yields* (proj pin 1) — + **RULED MERGE (2026-08-17)**: consistent with reconcile/positional + semantics; the yield-path replace was an accident. Rewrite the two async + identity assertions at port time. + - *Throwing-reconcile atomicity* (recon-snap pin 6) — proposal: key + mismatch is a root precondition checked before any mutation → failed + reconcile is atomic by construction; assert it. + - *Snapshot identity mid-overlay* (opt pin 2) — proposal: fresh copy per + call during pending windows (matches the pinned `not.toBe`), cached copy + when settled (§7b rule). + - *Writes into frozen subtrees under CoW* — **RULED ALLOW (2026-08-17)**: + the clone is unfrozen and the frozen source is never mutated (freezing + protects *their* object, which we honor). New capability, documented. + - *Platform-object draft mutations* (core R47) — already deliberate; + encode as an exemption in the `__TEST__` no-mutation oracle. + - *Host-object detection* (core pin 8) — proposal: structural tag check + (NC's mechanism) is the rule; the global-`Node`-mock test retires. + - *`markRaw` API status* (core pin 2) — **RULED KEEP INTERNAL + (2026-08-17)**: no demonstrated public need (nesting-shallow-in-deep is + handled by stickiness without API; platform objects are auto-raw); was + previously proposed public and rejected. Revisit only on post-rewrite + evidence of demand. + - *Un-actioned double-notify `[v, opt, v]`* (lanes R5, from RUL-4) — + resolved by R2 parity: it is pinned signal behavior; keep. +- **RUL-13 — RESOLVED (verified 2026-08-16)**: `optimistic-lane-transaction- + ownership` passes against shipped (2/2) — the "FAILS today" comment is + stale. R15/R16 per-node ownership + entanglement is shipped behavior and + therefore hard contract for RUL-3. Delete the stale comment when porting. + +## 9. Decision log + +- **2026-08-16**: Nodes are real core signals; transactions/optimism ride core + lanes (no store-side transactional subsystem). Raw is committed truth; raw + mutates only at commit. Laziness is unobservable (R1). Edit-script channel + (reconcile → mapArray → insert) deferred to phase 2, gated on post-phase-1 + re-baseline; dom-expressions untouched in phase 1. Fitness: dbmon deep column + converging toward shallow column; uibench tree/render toward ivi. +- **2026-08-16b**: Source-object mutation stays prohibited (intentional 1.x→2.0 + decision, user complaints, Svelte precedent). Single-home is achieved via + CoW ownership (owned raw graph, privatize-on-first-write), not by writing to + user objects. External mutation of a still-shared source object remains + visible-without-notification — now a defined ownership boundary rather than + an accident; document as such. +- **2026-08-16c**: Two-channel write model. Setters = CoW channel (privatize, + path-copy, mutate owned). Reconcile = adoption channel (immutable diff, swap + backing to `next`, reset ownership, no clones) — confirmed as the intended + semantic ("essentially immutable diffs"). Bridge: identity skip only on + unowned backing. This supersedes the earlier §3 draft where reconcile + merge-wrote into owned raw (that variant would have privatized everything + and killed the reference fast-path, making partial ticks O(data)). +- **2026-08-16d**: Mechanism purity first. Resolution is **proxy-target + indirection**, the shipped 2.0 pattern: the Proxy wraps the internal target + (whose `STORE_VALUE` points at backing raw), `$TARGET` is a trap answer, + child wrappers are reached structurally through parent node state — nothing + is ever stamped on user objects (1.x stamped `$PROXY` via `defineProperty`; + 2.0 dropped it). The raw→target direction always goes through the + `storeLookup` WeakMap: `wrap()` dedupe, reconcile resolving + externally-handed raws, adoption registering `next` for re-send resolution, + store families (see O8 for the un-noded repeated-read consequence). Ownership = WeakSet in the first cut; + field/symbol carriers are optimization candidates gated on post-phase-1 + profiles. Rationale: symbol stamping mutates the source (the complaint class + behind 2026-08-16b), and carrier micro-optimization before the architecture + is measured is complexity spent blind. +- **2026-08-18a**: Projection port COMPLETE — next-native `createProjection` + is the default build; all projection suites green on both configs (58/58 + gate, 91/91 default files). Three mechanisms closed the last nine failures: + (1) the §6c firewall gate must *link* tracked readers when throwing NotReady + (settle wakes them; the dependency drops on the post-settle re-run via + dynamic deps, so proj R12 isolation holds) — one line cleared the whole + isPending/transition cluster; (2) write scope is per-store, extended through + draft reads (legacy `Writing` semantics ported: reading another store's + proxy through a draft admits it for writes; reads of *dependency* stores + inside a derive track normally — chained projections require this); (3) the + parent-slot fix on fold is compare-and-swap (only replace a slot still + holding the folded-away backing) — stale wrap-time indices after draft + splices otherwise resurrect removed rows; registration-based resolution + (the DAG rule) covers the slots CAS declines. Also executed the RUL-12 + unkeyed-merge ruling: the two async yield-identity assertions rewritten to + merge semantics (identity preserved) with ruling citations. +- **2026-08-18p**: SIZE PASS — TREESHAKEABLE OPTIMISM. notifyOptimisticWrites, + consumeOverridesNext, optimisticView (from next/store.ts) and + applyTentative (from next/reconcile.ts) moved into next/optimistic.ts + behind an injection table (target.ts optHooks) installed inside + createOptimisticStore's once-guard — NOT at module scope, so the module + stays side-effect-free and shakes. Soundness: every call site is + fam?.opt-gated and optimistic families are only mintable by + createOptimisticStore, so the non-null assertions hold by construction. + The affects view resolver registration moved into the same install. Also: + dead-export sweep (isNextProxy internalized, createProjectionNextInternal + and lookupTarget unexported). MEASURED: plain store entry 13,710 → 13,229 + gzip (−481); whole-package bundlephobia-style vs main 23,367 → 22,845 + gzip (−522). Perf guards: interleaved dbmon — tick next-WINS 41/16 + (−0.4ms), sort next-wins, partial noise; optimistic write-storm 0.94x + (next faster). Suite 1257 green, full build green. +- **2026-08-18o**: TICK PARITY REACHED (final phase-1 perf state). The + "one more pass" hoisted walk validation into descend as the single + authority: lookup-first (a family-map hit implies the old side was + wrappable and never raw-marked — only wrappables acquire targets), + new-side-only isWrappable/isRawValue, and the prefix/remainder alignment + checks reduced to typeof gates (they are routing heuristics — both routes + notify identically). isWrappable left next's hot profile entirely. + Measured (interleaved, n=100 ×3): tick +0.0/+0.3/+0.4ms (statistical + parity at the live-desktop measurement floor), tickPartial parity, sort + −0.1/−0.2ms (next wins every run, ~70/30 rounds). Block sweep: tick + MEDIAN NEXT-FASTER (12.3 vs 12.8; mins 10.4 vs 11.5 — next's best tick + beats legacy's best by 1.1ms), mount/unmount par, remount +1.0ms within + its noise band. Under CDP profiling next is −9%/tick overall. Phase-1 + perf ledger vs shipped: uibench creation −25%, dbmon ticks parity + (next-favored bests), sort faster, partial parity. +- **2026-08-18n**: PROD TICK REGRESSION FOUND AND MOSTLY CLOSED. A + thermal-fair interleaved A/B (both fixtures open, samples alternating + per round — sign test immune to drift; octane-dbmon-local/interleave.mjs) + exposed what block-ordered runs and the dev-mode in-process bench had + masked: dbmon tick was +1.4ms (~10%, 58/60 rounds) vs shipped. Profile + attribution: next's read path cost ~800ms/400 ticks vs legacy's ~470 — + raw-as-truth serves raw from nodes, so every tracked object read paid + wrapNext's WeakMap lookup + isWrappable, where LEGACY NODES STORED + PRE-WRAPPED VALUES. Fix: per-node wrap cache (node.px/pxv) — one pointer + compare serves the cached proxy; a replaced child fails the compare and + re-wraps (no invalidation, at most one stale proxy pinned until next + read). Result: tick +0.3–0.5ms (~3%), tickPartial ~parity, sort −0.2ms + (next wins 68/23). NEGATIVE RESULT recorded: removing notifyKeyValue's + pre-compare as "redundant with node equals" regressed partial ticks + badly (93/5) — setSignal parks pending + registers with the batch BEFORE + commit-time equality (RUL-1), so identity-preserved adopted containers + (every row's fresh queries array) must be gated out before setSignal. + Reverted with the rationale inlined. Residual ~3% tick attribution: + isWrappable call volume in the adopt walk (+78ms/400 ticks) + thin + spread; the phase-2 edit-script channel replaces this walk for keyed + arrays, so further micro-tuning here may be moot. +- **2026-08-18m**: LEGACY DELETED. The remaining prerequisites landed in two + commits: fam.shallow wired shallow derived/optimistic forms to next's t.s + slot semantics — with the serve rule corrected to exact #2932 parity + (raw-marked data serves verbatim; store-proxy slot values get boundary + wrappers in the shallow family so downstream writes never land upstream) — + and createWriteTraps moved into next/projection. Then the wholesale + deletion: reconcile.ts / projection.ts / optimistic.ts removed, store.ts + gutted from ~1600 to ~570 lines of shared machinery (symbols, raw-marking, + isWrappable, write-override flag, affects scopes with a next-only walk, + StoreNode as a structural view of StoreNextTarget), utils.ts to merge/omit + only, dispatchers and test shims removed — store/index.ts exports next + directly. Orphans swept: storeLookup, symbolKeyedRecords (+ its skipped + legacy-pinned test), registerTransientStoreNode, mergedOverlay. Suite + 1253 green, full monorepo build + tests green. SIZE vs main (gzip): full + 24,162 → 23,259 (−903, −3.7%); store attribution 15,572 → 14,803 (−769). + The rewrite carries the full 2.0 store contract (optimism, projections, + affects, shallow) at slightly under the legacy size with the dbmon/uibench + wins banked; the deeper size cut and the shallow-retirement question both + belong to phase 2 (edit-script channel). +- **2026-08-18k**: O4 RE-RULED (Ryan) + shallow PORTED. Stage 1 validated + that deep cannot reach shallow-class tick performance (that is the + phase-2 edit-script question — the retirement decision may reopen there), + so shallow STAYS as an API for stage 1 — which resolves the deletion + blocker by porting it: plain shallow stores now serve from next. The port + is small because shallow's speed is a property of the DATA SHAPE (raw + children, trap-free leaf reads, slot-granular diff), not of legacy's + implementation: `t.s` targets serve children verbatim (proxies pass by + reference, #2932), ingest sticky raw-marks at creation/set/adoption + (shared R41 invariant, markRawOne skips proxies), reconcile on shallow + forces the positional slot diff with descends gated off. Suite green + (1253, including the #2932 suite); in-process A/B (benchmark mode): + next-shallow 15.88ms vs legacy-shallow 15.48ms mean — parity (p75 favors + next; mean carries one GC outlier). REMAINING for wholesale legacy + deletion: shallow derived/optimistic forms (fam.shallow wiring), + createWriteTraps move into next, then delete legacy modules + dispatch. +- **2026-08-18j**: The FUSE landed — adoption is a single pass. applyAdopt's + object and array branches notify each key's node inline (descend FIRST per + key: targetsEqual needs the child's re-registration before the parent-slot + compare, or identity-preserved slots would spuriously notify — R9); + deleted-key nodes ride a counted fast-out (`t.nc` live node count, zero + cost when nothing was deleted); presence/membership are a shared tail + (notifyFoldTail); notifyKeyDiff is the shared per-key compare, now writing + values directly (no closure per changed key). notifyFold survives for the + DEFERRED channels only (projection folds via drainFolds). Suite + next + gate green. Measurement caveat: afternoon thermals now swamp single + rounds (legacy itself drifted 9.6→13.0 across the day); the honest + reading across interleaved rounds is next/legacy ≈ 1.2–1.3x on dbmon + full-tick (from ~1.35 pre-fuse), with the remaining delta split between + the per-key accessor probes (which legacy pays inside applyStateFast too, + but cheaper) and adoption bookkeeping (registration WeakMap set + identity + WeakSet has per target). VERDICT INPUTS for the shallow ruling (O4): deep + dbmon will not reach shallow's 3.1ms in phase 1 — that class needs the + phase-2 edit-script channel; deep-next holds a 25% uibench win and near- + legacy dbmon with 3.5x less store code. A cool-machine ABBA sweep should + finalize the numbers before the ruling. +- **2026-08-18i**: Derived createStore form ported (the last non-shallow API + shape on legacy) — dispatch now routes `createStore(fn, seed)` to + projection internals + a recompute-masking setter (core R31). Two §6c + refinements fell out, both toward LESS mechanism: the gate covers ERRORED + derives (memo parity — untracked reads throw the derive's error), and the + gate is now ONLY the raw-fallthrough guard — tracked reads go through + firewall-backed nodes where core read() links-then-throws (the node link + is what wakes async-memo readers on landings; a pre-linking gate variant + broke 22 Loading-boundary tests and was discarded). Has-/key-set nodes + now carry the firewall arg like value nodes. Channel-split eager folds: + sync-derive drafts defer (downstream holds form later in the flush; + "pends only the written leaf"), post-await write-override landings commit + immediately ("verdicts never inherit consumers' in-flight state") — both + spec-async contracts hold simultaneously. Suite green (1253). + DELETION STATUS: every non-shallow call path now serves from next; legacy + wholesale deletion is gated on the O4 shallow ruling (dbmon deep 13.4 vs + shallow 3.1 — bar not met), so the fuse optimization precedes the size + realization. Interop imports (legacyReconcile, createWriteTraps, legacy + optimistic hooks) are the remaining static pulls. +- **2026-08-18h**: INITIAL SIZE AUDIT (post-functionality) — the size thesis + confirmed. Method: next-only entries bypassing dispatchers, plus a FLOOR + variant with legacy modules stubbed (honest approximations kept for + machinery that survives deletion, e.g. isWrappable; no-ops for what + deletion removes). Numbers (gzip): + - dual build today: full 29.0kb, store attribution 20.5kb (carrying both + implementations + dispatchers; shipped baseline was 24.0 / 15.5). + - next-only as-is (no dispatchers): 19.5kb — interop imports drag nearly + all of legacy in (legacyReconcile, createWriteTraps, legacy optimistic + hooks, legacy store.ts). The gap to the floor IS the deletion worklist. + - **next-only FLOOR, all features (plain+derived stores, reconcile, + snapshot/deep, projections, optimistic): 12.8kb full ⇒ ~4.4kb store + attribution — 3.5x smaller than shipped's 15.5kb.** Plain store + + reconcile floor: ~2.6kb attribution. + - Honest adjustments to the floor: createWriteTraps must move into next + (~0.3kb), affects wiring survives (treeshaken when unused), and the + public API glue (setter overloads, derived-form dispatch) adds a little + — realistic post-deletion attribution ≈ 5-6kb gz, i.e. roughly the 1.9 + store's size WITH projections and optimistic stores, which 1.9 never + had. The original audit goal (store ~tripled 1.9→2.0) is answered: the + rewrite un-triples it while keeping the new capabilities. + - Deletion prerequisites surfaced by the audit: port the DERIVED + createStore form (fn+seed — routes to legacy today; small, reuses + createProjectionNextInternal), move createWriteTraps into next, then + delete legacy store/reconcile/projection/optimistic modules + dispatch. + Shallow (O4) stays routed to legacy pending the retirement ruling. +- **2026-08-18g**: dbmon decomposition — the remaining gap is ONE structural + item. Interleaved ABBA (200-tick rounds): next/legacy ratio stable at + ~1.3x (13.3–15.3 vs 9.6–11.7; both columns drift with thermals, the ratio + doesn't). Legacy profile: applyStateFast 874ms/300t — diff + notify FUSED + in one pass. Next: applyAdopt 854ms (parity with legacy's walk!) + a + SECOND full diff in notifyFold 670ms — the adoption channel re-walks node + keys re-fetching old/new values the descent just visited. Read-path cuts + landed (cached `ch` chained flag — no per-read symbol probe on backings; + single node lookup threaded into serveDataKey; interned-string pollution + guard; fam-first gate ordering) — worth only ~3%; get-trap delta vs + legacy (498 vs 261ms) is mostly the second diff's re-reads attributed + into the trap. NEXT STEP (scoped, single item): fuse fold notification + into the adoption walk — during applyAdopt's key iteration, notify the + key's node inline (value compare + setSignal) and reduce notifyFold to + deletions, has-nodes, and key-set handling. Expected: removes ~0.6ms+ of + the ~1.2ms/tick structural overhead vs the fused legacy walk. uibench + standings unchanged (27.5 vs legacy 36.6 — keyed/structural ops carry it). +- **2026-08-18f**: First optimization pass — the creation-tax thesis + CONFIRMED. uibench (10-iter, same-session sequence): legacy 36.6 → next + 33.8 (functionality landed) → 32.6 (scanAccessors deleted) → 31.1 + (createTarget direct construction) → **27.5 after the full pass — 25% + faster than legacy**; family breakdown shows keyed structural ops at + 0.67–0.85x of legacy and the remaining ivi gap (21.4) concentrated in + tree/render (creation) + removeAll (teardown). dbmon tick: 14.35 → + **11.50ms/tick — legacy parity** (shallow 3.1 still the phase-2 bar). + Mechanisms: (1) the eager `scanAccessors` descriptor enumeration per + object (115.7ms in the uibench profile — the single largest store cost) + is DELETED — accessor-ness is a per-node flag probed allocation-free at + node creation (`__lookupGetter__`/`__lookupSetter__`, own-gated); + accessor keys serve via `Reflect.get` with the PROXY receiver (also more + correct for R20 nested tracking); fold paths use the cached flag plus ONE + own-gated getter probe on the incoming side (merge/adoption-installed + getters — pinned by three suite tests; prototype getters deliberately + keep the invoke-compare path — their fold tracking depends on it, pinned + by three other tests). (2) createTarget: direct field assignment in fixed + order (shared hidden-class transition chain) instead of Object.assign + literal copy. Suite green throughout (1253). Next profile targets: + applyAdopt self-time (813ms/300 ticks: per-target ownKeys allocations, + double WeakMap registration, prevByKey maps), then the ivi creation gap. +- **2026-08-18e**: Perf checkpoint at full functionality (single sweeps, + subject to the ~30% A/A floor — ABBA discipline required before any + claims): dbmon tick — next 14.2–16.5, legacy 11.5–12.4, shallow 3.1, + vapor 3.7 (30-iter medians across two sweeps; both columns drifted + together between sweeps, classic session-order variance). Profile (300 + ticks): adoption channel ≈ 46% of tick (applyAdopt + notifyFold + adoption + mechanics ~5.2ms/tick self), read path (get/serveDataKey/read) ~2.2ms, + dom-expressions effects ~2.5ms (floor shared with legacy). First + structural change landed: plain-store adoption notifies INLINE after the + descent (no foldOlds queue/drain round trip; projections keep deferred + folds for hold semantics) — semantics-neutral (suite green), but the + profile shows the cost is the diff work + per-target fixed overhead + (~600 targets/tick: key-array allocations in applyAdopt/notifyFold, + double WeakMap registration per adoption, WeakSet ownership checks), not + the queue trip. Next profiling cycle candidates: for-in over null-proto + node maps (kills ~1200 key-array allocations/tick), single-registration + adoption, and the phase-2 edit-script question if the floor holds. + Also: worktree `pnpm build` green again (type fixes: overload placement + in legacy optimistic.ts, computed shape in next/optimistic.ts, + ownKeys cast). +- **2026-08-18d**: FULL SUITE GREEN — 91/91 files, 1253 passed, zero + unhandled errors; next-gate store sweep 362 passed. The "zombie cascade" + decomposed into three real bugs, all fixed: (1) §6's length-as-view rule + implemented for optimistic arrays — length reads serve the composed view + (backing ± overrides) with the node used only for tracking, making torn + iteration (length on the stale-value rail, indices on the pending rail) + impossible by construction; (2) landing consumption now performs the FULL + legacy node reset (fold committed into `_value`, clear pending) instead of + relying on a transaction's commit — a parked transaction's stashed queues + stranded the wake otherwise; (3) $TRACK on chained backings reads through + to the inner store's key-set node (§7b structural chaining, #2864/core + R21) — mapArray over a wrapper view now observes the source's structural + notifications. Optimistic increment COMPLETE. Remaining queue: legacy + deletion + dispatcher removal (the size payoff), benchmark sweeps, final + size vet. +- **2026-08-18c**: Optimistic increment COMPLETE except one zombie. + Full default suite: every genuine failure fixed; the only remaining red is + a 4-test in-file cascade in createOptimisticStore.test.ts (all 4 pass in + isolation) caused by ONE unhandled rejection: the mapArray fixture test + ends with an undisposed root + a tail `refresh()` whose fetch never + resolves; a later flush runs its stashed/zombie mapArray recompute, which + reads an undefined row (`comment.text` TypeError via async.ts notifyStatus + → StatusError) and poisons subsequent flushes. Mitigation attempted (not + sufficient): the next-shape transitionBlocked half now only blocks while + the family holds LIVE overrides (a fully-consumed transaction settles even + with its firewall eternally pending — also the correct #2951 semantics). + The residual tear is in the stashed lane/zombie queue interplay — FIRST + ITEM next cycle: reproduce the zombie recompute standalone (undisposed + root + never-resolving refresh + later flush) and pin where mapArray sees + length/index disagree. + Fixes landed this stretch beyond 2026-08-18b: strict-read refetch-window + escalation ported (firewall-pending untracked reads throw + PENDING_ASYNC_UNTRACKED_READ); witnessAffectsMark resolves chained + backings (marks witness through wrapper views); affects declaration walk + composes the optimistic view and covers pending backings; store nodes are + CONFIG_OWNED_WRITE (the setter carries the owned-scope guard; the guard + itself now exempts roots — legacy parity); snapshot composes optimistic + views across chained targets (multi-level, innermost outward); landing + consumption classifies structural keys by PRESENCE OVERRIDES (pre-adoption + truth), fixing post-landing retention of optimistic adds; projection + backing folds are NEVER eager (a downstream hold can form later in the + same flush) — freshness for context-free readers comes from readSource + serving a fam target's pending backing unless `foldHeld` (any node parked + under a live transition; the lazy-recompute read case has no transition + stamp and stays fresh). +- **2026-08-18b**: Optimistic increment (in progress, most of the surface + green). Architecture validated: armed nodes (`_overrideValue` slot) ride the + core engine wholesale — zero store-side layer/backup machinery. Mechanisms + landed: optimistic write channel (visible-view diff at setter exit, draft + discarded, committed raw untouched — revert target by construction); + membership overlay derived from armed has-nodes (ownKeys/has/descriptors); + pb seeding + draft reads from the optimistic view (compose, not clobber — + #2951); firewall-transition entanglement on bare writes (legacy parity, + #2951 hold) + next-shape transitionBlocked store-half; landing consumption + split — structural optimism consumes on landings (legacy layer parity, + #2719), value overrides on keys present in landed data stay with their + owning transaction (rapid-toggle); §6b lane-view diff baseline in reconcile + (optimistic rows recycle their proxies against key-matched landings); + chained-gate pierce for active overrides (§7b shadow rule); + snapshot/deep compose the optimistic view (O1) with fam threading. Two core + fixes surfaced: legacy `createWriteTraps` hard-reset `projectionWriteActive` + to false per trap-op (now save/restore — it clobbered any enclosing + authoritative scope); untracked node-first reads now serve the BACKING for + committed state (O6 ruled read-through in practice: node `_value` lagged the + eagerly-committed backing when a lazy derive recomputed on the very read + that forced it). Main optimistic suite 64/68 — the 4 remaining are pure + cascades from one cross-test zombie: the mapArray fixture test leaves an + undisposed root + a tail refresh whose fetch never resolves; during LATER + tests its zombie mapArray re-runs against stuck-lane state, reads an + undefined row, and the unhandled rejection (async.ts syncError) poisons + subsequent flushes (rendered arrays stay empty). Also outstanding: ~14 + failures in affects/marks, question-scoped-pending, strict-read, + uninitialized-visibility, store-in-store, captured-proxies, adoption-lane + rollback — next cycle's queue. +- **2026-08-17f**: Granularity specs written — §6 (key-set node, resolves O2: + per-transaction membership overlay + tombstones + length-as-view), §6b + (lane-aware adoption: lane backing, dual diff baselines, joint rollback), + §6c (store-wide status gating on the root target), §6d (sticky reachability + flag ported). RUL-5/7/8/11 all closed as spec'd. Also affirmed (Ryan's + morning note): reference-equality diff pruning is kept wholesale — the + ownership guard completes the skip's proof, it does not reduce skip + frequency (adoption resets ownership, so reconcile-driven workloads never + pay it). +- **2026-08-17e**: Reconcile diff baseline ruled (Ryan): the **current view**, + signal parity — accepted with the explicit concern that we not take + responsibility for unnecessary things (external mutation is the user's, + immutable-input convention; R2a pinned). FINDING-1 confirmed a bug the + rewrite fixes by construction; scope narrowed by a passing control — fresh + references already restore on shipped, only the same-reference early-return + is unsound (it keys "current" on raw, stale since 2.0 stopped mutating + sources). Fresh-object control added to the rule test (3 pass, 1 expected + fail). +- **2026-08-17d**: RUL-12 rulings (Ryan): unkeyed async-yield objects MERGE + (yield-path replace was accidental); frozen-subtree writes ALLOWED (clone + unfrozen, source honored); `markRaw` stays INTERNAL (no demonstrated public + need; previously proposed and rejected). Sticky raw-marking KEPT with the + reversal of my drop proposal: stickiness + the R44 dev-throw are two halves + of one invariant — an object is never both deep-wrapped and raw; dropping + either allows one entity to hold two silently-diverging truths (deep + privatization vs stale shallow identity). Reconcile mechanics survive that + state; entity coherence does not. +- **2026-08-17c**: RUL-4 resolved by new empirical evidence + (`optimistic-signal-refetch-hold.test.ts`, 2/2): signal form already rides + in-flight refetches; #2951 was a parity failure, not a semantic. RUL-9 + resolved: mid-flight correction is core lane behavior (all evidence is + signal-form); store inherits. §7b written: chained backing = read-through + (subscriptions land on inner nodes; masking = lane shadow + dynamic + dependency rebuild; severing = backing swap); snapshot identity rule: + source identity for unowned subtrees, cached copy for owned (resolves proj + R22 + recon-snap R24/R25 jointly). RUL-12 populated with proposed defaults, + four flagged for Ryan (⚑). +- **2026-08-17b**: Meta-rule pinned as R2 (Ryan): signal parity by default; + divergence only where granularity forces it, documented per case. Remaining + queue classified: RUL-4/RUL-9 expected pure parity; RUL-5/RUL-8/RUL-11 + genuine granularity divergences (membership dimension); RUL-7 hybrid + (parity in meaning, divergent in enforcement surface). +- **2026-08-17a**: RUL-2 ruled by Ryan: landed truth always replaces optimism + (equality cut gates propagation) — AND visibility of a landing is + lane-scoped: an entangled member's completion collapses its lane into the + parent transition, held until the parent completes ("if the parent isn't + complete no one sees the completion of that optimism anyway — look at + signals"). The apparent three-way landing matrix derives entirely from + these two pre-existing rules; no store-specific landing mechanism exists in + the rewrite, and no shipped test expectations change. (Corrects an earlier + same-day entry that misapplied the ruling at global visibility and wrongly + re-ruled the rapid-toggle preserve leg.) +- **2026-08-16f**: RUL-1 adopted after verification (Ryan's instruction: + mirror signals *if verified* — verified against `core.ts` read/write paths: + context-free reads → committed; owner-context reads → pending; + drafts/`snapshot` read pending explicitly; transient per-target pending + record for node-less writes; adoption = one pending backing swap; + privatization may defer to commit). RUL-3 closed as a withdrawn flag, not a + ruling: verification showed ownership is already per-node in core; store + duplicate deletes; key-set remainder folded into RUL-8. RUL-6 reclassified + as spec work (live chaining is shipped contract). RUL-2 candidate principle + recorded — NOT ruled; pending verification against the three landing + suites, then Ryan's yes/no. +- **2026-08-16e**: Implementation method — rewrite the state model, preserve + the addressing model. Core (target shape, traps, write paths, ownership, + node lifecycle) is written fresh from this doc: nothing exists until a rule + requires it. Diff mechanics (reconcile key-matching, array reconciliation) + are *ported*, not re-derived — their contract is unchanged and + edge-case-hardened. The old suites + rule-derived `__TEST__` assertions are + the contract both halves answer to. Convergence with already-correct parts + of the shipped code (indirection, adoption) is the expected outcome of a + rewrite, not a copy-edit; the difference is what's absent (layers, override + merge machinery, store-side transactions, projection internals in plain + stores). diff --git a/packages/solid-signals/rules-mining/FINDINGS.md b/packages/solid-signals/rules-mining/FINDINGS.md new file mode 100644 index 000000000..403c30015 --- /dev/null +++ b/packages/solid-signals/rules-mining/FINDINGS.md @@ -0,0 +1,92 @@ +# Findings log — rule assertions vs the shipped store + +Method (INTERNALS-STORE-STATE.md §5c): rule-derived tests run against the +shipped implementation first; every violation gets a deliberate ruling — bug +the rewrite fixes, or rule written wrong. Entries are numbered and referenced +from test comments. + +## FINDING-1 — nested reconcile identity skip is unsound against diverged nodes + +- **Date**: 2026-08-17. **Test**: `tests/store/reconcile-resend-identity.test.ts` + ("re-sending the original array after a flushed row write restores row + values", marked `it.fails` on shipped). +- **Behavior**: after a *flushed* setter write (`d.rows[0].v = 50` — value + committed to the row's node; raw untouched since 2.0 never mutates + sources), re-sending the original array reference via + `reconcile(rows, "id")(d.rows)` leaves the store at `50`. Expected `1` — + "reconcile makes `next` the authoritative base." +- **Root cause**: `applyStateFast` early-returns on `next === previous` + (store/reconcile.ts, the swap-path identity check). The guard is only + structural for *staged* overrides (they route to the slow path); a + committed node divergence is invisible to it, so the identity skip proves + nothing and the diff never runs. +- **Scope**: nested path only. The root re-send (`setS(reconcile(data, key))`) + and the derived-store recompute re-returning the same reference both + restore correctly (2/3 tests pass on shipped). +- **Real-world shape**: any cache that returns the same object graph on + refetch (SWR-style hit) fails to revert locally-written values on + reconcile. +- **Scope refinement (2026-08-17)**: a FRESH reference carrying the original + values DOES restore on shipped (control test passes) — the per-key node + writes already compare against the current view. The unsoundness is + narrowly the `next === previous` same-reference early-return, not the diff + baseline. +- **Ruling (Ryan, 2026-08-17)**: reconcile's diff baseline is the **current + view** (signal parity — signals compare writes against current value, not + original). FINDING-1 stays a bug the rewrite fixes by construction: after a + setter write privatizes the backing, the re-sent source fails + `incoming === backing` and the diff runs. Guiding principle recorded with + the ruling: the store takes no responsibility for mutation outside + reactivity (immutable-input convention, same as signals) — defending + against user indiscipline is where perf is lost. The identity skip itself + is sound *when keyed on the current backing*; shipped's bug is keying it + on raw, which stopped being "current" when 2.0 stopped mutating sources. + +## FINDING-2 — key ADDED by in-window reconcile survives optimistic settle + +- **Date**: 2026-08-17. **Test**: `tests/store/adoption-lane-rollback.test.ts` + ("a key added by an in-window reconcile reverts at settle", `test.fails`). +- **Behavior**: inside an action window, `reconcile({rows, tag: "tentative"})` + on an optimistic store shows tentatively (correct); at settle, row values + and array length revert (correct) but the added object key leaks — + `s.tag === "tentative"` and `"tag" in s === true` persist after settle. +- **Contrast**: optimistic *deletes* revert correctly (pinned, + `optimistic-undefined-override` test 5); additions do not. +- **Assessment**: the key-set rollback gap RUL-8 predicted, present even + single-transaction. The rewrite's key-set node carries per-transaction + membership edits; rollback discards them by construction. +- **Ruling**: bug the rewrite fixes. Shipped hotfix at Ryan's discretion. + +## FINDING-3 — snapshot breaks cycle identity on a written cyclic object + +- **Date**: 2026-08-17. **Test**: + `tests/store/shared-child-multiparent.test.ts` ("snapshot preserves cycle + identity on a written cyclic object", `it.fails`). +- **Behavior**: `node.self = node`, wrap, write `d.root.name`; then + `snapshot(s.root).self` is a *second copy* (internally cyclic: + `self: [Circular]`) rather than the snapshot root itself — the copy routine + duplicated one logical object. +- **Contrast**: symbol-key cycles on untouched objects preserve identity + (pinned, recon-snap R29 "cycles through symbol keys preserved"); the + written string-key self-cycle misses the seen-map. +- **Assessment**: violates R29's shared-references-stay-shared contract. The + rewrite's copy routine must register the copy in the seen-map *before* + descending into children. +- **Ruling**: bug the rewrite fixes. Low real-world frequency (cyclic store + data is rare); shipped hotfix likely not worth it. +- **FIXED 2026-08-18**: the rewrite's snapshot walk registers owned copies in + the seen-map BEFORE descending; test flipped to plain `it`. + +## Positive controls (shipped agrees with the contract) + +- Shared child via two parents: write through path A visible through path B + on reads, subscription, and snapshot; snapshot keeps the child shared + (one copy); source untouched (`shared-child-multiparent.test.ts`, passing). + Validates the RUL-12 registration-resolution proposal. +- In-window reconcile tentative visibility + settle revert of values, length, + and captured-proxy views (`adoption-lane-rollback.test.ts`, passing). +- Fresh-reference reconcile restores flushed setter writes (FINDING-1's + control): shipped is view-parity everywhere except the same-reference + early-return. +- Signal-form refetch-hold parity (`optimistic-signal-refetch-hold.test.ts`, + 2/2): RUL-4's evidence. diff --git a/packages/solid-signals/rules-mining/core-store.md b/packages/solid-signals/rules-mining/core-store.md new file mode 100644 index 000000000..883820c48 --- /dev/null +++ b/packages/solid-signals/rules-mining/core-store.md @@ -0,0 +1,218 @@ +# Mined rules: core store suites + +Files: **CS** = `tests/store/createStore.test.ts`, **SP** = `tests/store/storePath.test.ts`, **SIS** = `tests/store/store-in-store-tracking.test.ts`, **SH** = `tests/store/shallow.test.ts`, **SPC** = `tests/shallow-store-proxy-children.test.ts`, **RE** = `tests/store/recursive-effects.test.ts`, **NC** = `tests/store/native-collections.test.ts`, **MA** = `tests/maparray-store-nonkeyed.test.ts`. + +## A. Value residency & identity + +**R1. Wrappable values are wrapped: reading a plain object/array child never returns the raw source (`state.data !== data`).** +- CS "State wrapping > Setting plain object", "Setting plain array". No conflict. + +**R2. Raw→proxy resolution is global and deduplicating: wrapping the same raw through two different stores yields the same proxy (`outer.list === inner`).** +- SH "shallow store nested in a deep store reconciles through the parent". +- **CONFLICT (mild):** under CoW, once a store privatizes a shared raw, the raw held by the other store diverges — dedupe key (raw identity) and logical node no longer coincide. Ruling needed on what dedupe means once backings diverge. + +**R3. A store proxy ingested into another store (deep or shallow) is re-wrapped in the ingesting store's own proxy family — never identity-passed, never raw-marked.** +- SPC "each level serves its own proxies…", "set-trap ingest passes through without raw-marking", "seed ingest…". + +**R4. Write isolation across a store chain: writing through the last store in a derived chain is visible only there; upstream stores and base objects untouched (shallow or deep middle).** +- SPC "write to the last store stays in the last store (shallow middle)", "parity with the shallow:false control". + +**R5. Upstream writes propagate downstream through the chain without re-running structural machinery.** +- SPC "each level serves its own proxies; upstream writes stay fresh through the chain". + +**R6. No store write path ever mutates a user-provided source object.** Aligned with 2026-08-16b. +- SH "setter replacement never mutates the base rows", "optimistic shallow store…"; SPC. + +**R7. Circular references wrap without infinite recursion; cycle consistent through proxy (`state.b.a === state.a`).** +- CS "State recursion > there is no infinite loop". + +**R8. `snapshot` returns fully unwrapped values (no proxy anywhere, `$TARGET` undefined), incl. frozen objects/arrays; reflects committed written values incl. writes over inherited prototype props.** +- CS "Unwrapping Edge Cases" (×3), "writing over an inherited property…". + +**R9. Proxy identity per logical slot is stable across writes and reconciles** (mapArray keyed flows reuse rows across refetch/reconcile). +- SIS "read through derived optimistic store + mapArray", "mapArray directly over the base store"; SH. Aligned with §4. + +## B. Tracking granularity + +**R10. Per-property tracking; same-value writes (direct or functional path setter returning prev) do not re-trigger.** +- CS "Track a state change"; SP "Functional setter no-op when returning same value". + +**R11. Per-path tracking: reading `state.user.firstName` subscribes to that leaf; reading the reference `store[0]` does not subscribe to `store[0].i`.** +- CS "Track a nested state change", "arrays > supports arrays". + +**R12. Reading an absent key subscribes to that key: other-key changes don't trigger; defining it later (assignment or defineProperty) does.** +- CS "Not Tracking Top level key addition/removal", "supports Object.defineProperty inside a setter". + +**R13. `in` tracks presence, not value: undefined-write doesn't retrigger; delete does; adding absent key does. `in`/`has` never invokes source getters.** +- CS "objects > has properties", "In Operator > wrapped nested class" (access === 0). + +**R14. `Object.keys` / `for…in` subscribe to key-set membership (root and nested) — distinct from property nodes.** Aligned: key-set node. +- CS "Tracking iteration Object key addition/removal", "Tracking Top level iteration…". + +**R15. Array structural tracking is uniform across idioms: indexed length loop, `for…of`, mapArray ($TRACK) all re-run exactly once per flush on add/update/removal.** +- CS "Tracking Top-Level Array iteration". + +**R16. `length` independently trackable; index write extending the array notifies length subscribers.** +- CS "Array length > Setting plain object", "direct array index extension updates length immediately". + +**R17. Truncating via `length = N` notifies tracked index reads of removed slots (re-run, observe undefined) and clears has/index/keys for removed indices.** +- CS "Array truncation notifies tracked index reads (#2768)", "Truncating array length clears stale indices…", "Track array item on removal". + +**R18. `snapshot` is non-tracking.** Aligned with read table. +- CS "Doesn't trigger object on addition/removal", "arrays > supports arrays". + +**R19. `untrack` scopes only the wrapped read; property access on the escaped value afterwards tracks normally.** +- RE "respects untracked". + +**R20. Source getters (own, prototype, merge-installed) execute with the proxy as receiver, so their internal reads track — incl. through projections.** +- CS "prototype getters track instance field updates", "…through projection stores", "State Getters"; NC. + +**R21. Structural subscriptions through a wrapper view (store-in-store) chain to the wrapped source: $TRACK/mapArray, ownKeys, snapshot/trackSelf through an outer derived store re-run when the inner store reconciles/changes shape.** +- SIS all of "#2864…". +- **CONFLICT (design work):** key-set node is per-object; wrapper views give one logical object two node records (view + source). $TRACK/key-set chaining across wrapper views must be a first-class rule or #2864 regresses. + +**R22. Slots holding non-wrappable values (markRaw, Map/Date, function) track by reference: reassignment notifies; internal mutation doesn't.** +- SH "raw values are tracked by reference at their slot"; NC; CS "Track function change". + +## C. Write semantics + +**R23. The proxy is immutable from outside the setter: direct assignment and delete are silently ignored (no change, no notify, no TypeError — traps report success while discarding).** +- CS "State immutability > Setting a property", "Deleting a property", "objects > is immutable from the outside". + +**R24. Writes batch like signals: inside the setter draft, reads are read-your-writes (values, length, `in` sync); outside the setter, ALL reads — value, `in`, length — return pre-write state until flush(). Holds for adds, deletes, array extension.** +- CS "Simple Key Value", "Test Array", "Test Array Nested", "direct array index extension…" (×2), "In Operator > batches like signals on cold writes", "State Getters"; SP nearly every test; "storePath.DELETE". +- **CONFLICT (the big one):** §3 "urgent write — commit now: write raw" + read table routing untracked committed reads to raw ⇒ untracked read right after setState would see the new value. Dozens of assertions demand the old value until flush. Either "urgent" means applied-at-flush (synchronously within the flush), or pre-flush writes park in a pending home untracked reads bypass. + +**R25. Writes to properties with ZERO observers still batch (no effects anywhere; pre-write value visible between setState and flush).** +- CS "Simple Key Value"; SP non-reactive tests. +- **CONFLICT:** laziness invariant says no node from observer-less urgent writes AND raw written immediately — but the pending value must live somewhere reads don't serve. Ruling needed before the `__TEST__` assertion is wired. + +**R26. Setting a key to undefined is not deletion: key stays present (`in` true, no key-set notify); only delete / storePath.DELETE removes.** +- CS "objects > has properties". + +**R27. The setter may return a replacement value that swaps the root wholesale; symbol keys on the replacement preserved.** +- CS "Tracking Top-Level Array iteration", "Returned object replacement keeps symbol keys"; SH "canonical filter-removal idiom…". + +**R28. `storePath` addressing: string keys, numeric indices, index arrays, predicate filters ((value, index)), ranges, trailing functional setters address and update intended paths + trigger per-path subscribers; nested plain-object arg MERGES (unlisted keys preserved), non-wrappables and arrays REPLACE; root object arg merges at root; storePath.DELETE deletes with full batching semantics.** +- SP "Triggers reactive updates", "Deeply nested reactive updates", "storePath.DELETE" + batching assertions. + +**R29. Merge/replacement preserve accessor descriptors and keep getters LIVE (re-evaluated per read, reactive reads track), for pre-existing and new keys.** +- SP "Root-level merge preserves getter descriptors", "Preserves getter descriptors when replacing an existing key"; CS "supports Object.defineProperty inside a setter". +- **CONFLICT (mechanics):** CoW's first-write shallow clone must copy descriptors (Object.defineProperties-style), not values, or installed getters collapse to snapshots; merge writes must install descriptors onto owned raw. + +**R30. Prototype pollution fully guarded: `__proto__` assignment inert; reading `constructor` on the draft returns undefined; storePath refuses `__proto__`/`constructor`/`prototype` segments; skips unsafe own keys during merges while applying safe siblings; own keys literally named prototype/constructor land as data.** +- CS "ignores prototype pollution keys in draft setters"; SP "storePath prototype pollution guard". + +**R31. Derived-store manual writes win over the recompute for the tick: manual setStore beats a queued recompute in the same flush; a SAME-VALUE manual write still holds against the recompute for that tick; next source change reclaims.** +- CS "derived store manual writes" (#2692 ×2). +- **CONFLICT (framing + mechanics):** "keeps the override for the tick" — override layers deleted. Manual-write-precedence-until-next-recompute incl. same-value writes must be reproduced by node/lane precedence; equality-checked signal write would no-op yet the mask must hold. + +**R32. A setter-staged replacement followed by reconcile lands the reconciled value — staged writes fold into the diff.** Aligned: O7's resolution (a test already exists). +- SH "setter write followed by reconcile lands the reconciled value". + +**R33. Action/async lane semantics on store properties: a write held by an action makes isPending true for that property (per-property, not whole-store) while showing the committed value; applies on settle.** Aligned: the node-lane model's purpose. +- CS "isPending sees a derived store property update held by an action", "…held by async work". + +**R34. Optimistic writes visible immediately at write time (before flush), never touch base raw; ambient (non-action) optimistic writes auto-revert at flush end.** +- SH "optimistic shallow store: replacement stages, base rows untouched, children raw". +- **CONFLICT (asymmetry to define):** ordinary writes invisible pre-flush (R24) but optimistic writes visible pre-flush. Read-path table needs a "pre-flush" column. + +**R35. Mid-refetch optimistic overlays are consumed when data lands — identical via direct reads, mapArray, wrapper views, Object.keys, snapshot.** +- SIS 5 tests. + +**R36. An active optimistic hold on a wrapper view masks inner-store changes for the view's subscribers: mid-hold inner refresh landing causes ZERO re-runs of the view's structural subscribers; the reveal re-runs them with the mid-hold data.** +- SIS "an active override on the wrapper view holds…". +- **CONFLICT:** a lane value on the wrapper's property node must actively SUPPRESS the chained structural notification from the inner store (which R21 says normally propagates). Precedence rule between R21 chaining and lane masking not yet in the doc. + +**R37. Setting store state from effect callbacks and promise resolutions works, applying next flush.** +- CS "Setting state from signal", "Select Promise". + +## D. Shallow store contract + +**R38. Shallow stores: root keys reactive (per-key nodes, membership, length), values served raw by identity at every depth, arrays and objects.** +- SH "root keys are reactive, values are raw", "shallow OBJECT store…", "length changes propagate". + +**R39. Shallow setter-scope reads serve raws; in-place mutation of a served raw is reactively inert — records replaced, never edited.** +- SH "setter reads serve raws…", "canonical filter-removal idiom…", "shallow projection…". + +**R40. Shallow reconcile is positional: per-index effects only where the reference changed; reference-identical rows skip entirely; length propagates; `key` option moot.** Aligned: unowned-reference skip rule in shallow form. +- SH 4 tests. + +**R41. A plain record replaced into a shallow store is STICKY raw-marked: presents raw in this store AND in any deep store that later ingests it.** +- SH "record replacement through the setter works and marks raw"; SPC. +- **CONFLICT (ruling needed):** global sticky marking caused #2932 for proxies. Cross-store stickiness for plain records is an implementation choice pinned as semantics — decide deliberately (O4). + +**R42. markRaw values never wrap through ANY store (deep included); leaves for reconcile (reference replacement, no recursion).** +- SH 2 tests. + +**R43. Store proxies are exempt from shallow raw treatment: shallow store ingesting another store's proxy passes it through unmarked and serves a live wrapped view (upstream visible, downstream isolated), seed or set-trap.** +- SPC "#2932…" + derived chain tests. + +**R44. Ingesting an already-deep-tracked raw into a shallow store throws in dev.** +- SH "ingesting a deep-tracked value into a shallow store throws in dev". +- **CONFLICT (violates R1-unobservability):** the throw fires only because a prior READ lazily registered the child; whether createStore throws depends on materialization timing. Make the check materialization-independent or drop it. + +**R45. A shallow store nested in a deep store participates in the parent's reconcile (raw replacement, per-index notify).** +- SH. + +**R46. Shallow projections work end-to-end (derive re-runs, output reconciles at boundary, rows stay raw).** +- SH. + +## E. Edge cases + +**R47. Platform objects (Map, Set, Date, Node instances, subclasses) are structurally non-wrappable: served raw by identity; internal-slot methods work on read and draft paths; draft mutations land on the raw collection (visible, un-notified); only the holding slot tracks.** +- NC "#2952" describe; CS "does not wrap Node instances". +- Note: draft collection mutations ARE raw mutation of a user object — a deliberate carve-out from R6 the ownership WeakSet oracle must exempt. + +**R48. User class instances (custom prototypes) DO wrap: prototype getters track; methods on the draft receive the proxy as `this` (reactive writes).** +- CS "wrapped nested class", "not wrapped nested class" (historical name); NC ×2. + +**R49. Null-prototype objects wrap and track; function-valued props callable through the proxy.** +- NC; CS "#2771". + +**R50. Frozen sources fully supported (read/snapshot; getters returning frozen don't throw).** +- CS "Unwrapping Edge Cases", "supports getters that return frozen objects". +- Note: no test writes INTO a frozen subtree — under CoW that becomes possible (clone unfreezes); open behavioral question. + +**R51. Proxy-invariant compliance via target indirection: keys/spread/descriptor reads never throw regardless of source rigidity; source-non-configurable prop readable, writable through the store, reported `configurable: true`; descriptors agree with reads after flush; non-enumerable stays non-enumerable; accessor descriptors preserve get/set identity; write over inherited prop yields own data descriptor.** +- CS "Proxy invariant correctness" (8 tests). Pins target-indirection architecture (kept). + +**R52. Symbol-keyed properties first-class: read/write/descriptors/preserved through root replacement + storePath root merge; on arrays symbol writes are metadata (never affect length).** +- CS "#2769" (4 tests) + descriptor test. + +**R53. Array key hygiene: non-index string keys never affect length; `s[len] = undefined` grows length AND creates a present key.** +- CS 2 tests. + +**R54. Array natives work through the proxy on read (filter/reduce/map/iterate) and draft (push/pop/shift) paths.** + +**R55. Functions stored as values served raw, replaceable, slot-tracked.** + +## F. Recursive effects / re-entrancy + +**R56. Multiple setter calls before one flush coalesce: even a deep-reading (structural clone) effect re-runs exactly once per flush.** +- RE 3 tests (called === 2 after ≥2 writes). + +**R57. Effect ordering: parent effects before child effects created inside them, incl. shared deps through memos.** +- RE "runs parent effects before child effects". + +**R58. Mid-flush read coherence: untracked store reads inside internal machinery running WITHIN a flush (mapArray keyed:false under a Root owner) must observe the value being written in that flush, not stale committed.** +- MA "#2687", "updates when same-length primitive array items are replaced". +- **CONFLICT (needs precision):** flip side of R24 — before flush reads see old values, during flush reads under any owner context see in-flight values. Read table needs a "mid-flush, un-noded, untracked" row; current fix threads owner context (`_parentComputed`). Combined with R24/R25 this defines when the pending→committed swap becomes readable. + +## Tests pinning internals — need a ruling + +1. **`$TARGET` as public-ish probe** (CS Unwrapping; SPC) — is-proxy oracle hard-codes the symbol's trap semantics; survives if $TARGET stays. +2. **`markRaw` internal import** (SH, "internal for now") — decide if markRaw is API before porting as semantic rules. +3. **Sticky cross-store raw-marking (R41 second assertion)** — global mutable dispatch state, same class that caused #2932. Contract or accident? +4. **Dev-throw on deep-tracked ingest (R44)** — trigger is lazy-wrap timing; violates R1. Re-specify or drop. +5. **Override-vocabulary tests, portable behavior:** CS "same-value setStore… keeps the override for the tick" (R31); SIS "active override on the wrapper view holds…" (R36); SPC + SH comments naming override layers/STORE_SHALLOW/applyStateChild. Port assertions, rewrite framing. +6. **Core-internal fields in comments** (MA #2687: `_value`/`_pendingValue`/`_parentComputed`) — restate R58 as a read-visibility contract. +7. **Target-indirection pinning** (CS non-configurable test) — forbids ever proxying raw directly. Compatible with kept architecture. +8. **Host-object detection via global `Node` mock** (CS) vs NC's structural tag checks — reconcile into one detection rule. +9. **Suite weakness (absence):** most of storePath asserts only pre-flush batching, not landed addressing results — rule-derived tests should close that gap. + +## The three conflicts that matter most + +1. **R24/R25 vs "urgent write commits now"** — decides where pending values live and whether observer-less writes materialize nodes; decides the laziness invariant's exact wording. +2. **R36 (lane masking suppresses chained structural notifications) vs R21 (wrapper views chain structural tracking)** — interaction unspecified in the doc. +3. **R31 same-value manual-write precedence on derived stores** — equality-checked core write would no-op; the tick-long mask must hold anyway. diff --git a/packages/solid-signals/rules-mining/optimistic-lanes.md b/packages/solid-signals/rules-mining/optimistic-lanes.md new file mode 100644 index 000000000..5e7a123d6 --- /dev/null +++ b/packages/solid-signals/rules-mining/optimistic-lanes.md @@ -0,0 +1,151 @@ +# Mined rules: optimistic lanes (createOptimistic, undefined-override, lane-transaction-ownership) + +Source suites: `tests/createOptimistic.test.ts` (CO), `tests/optimistic-undefined-override.test.ts` (UO), `tests/optimistic-lane-transaction-ownership.test.ts` (LTO). + +Scope note: CO contains **no store-form tests** — it is entirely the signal/computed form. Store-form coverage in this set exists only in UO (tests 3–5) and LTO (repro 1). Nested paths, deep writes, and per-property-vs-whole-store optimism beyond those have **no coverage in this set** — a gap the rewrite's rule-derived tests must fill. + +## A. createOptimistic contract (signal & computed form) + +**R1.** `createOptimistic(value | fn)` returns `[accessor, setter]`; the accessor returns the initial or computed value; the setter accepts a value or an updater function. +- Evidence: CO — "should store and return value on read", "should update signal via update function and revert on flush" + +**R2.** An optimistic write is synchronously visible to direct reads before any flush — inside the action body, outside it, and outside any reactive context. +- Evidence: CO — "should update signal via setter and revert on flush", "reading outside reactive context…", "rapid user actions: multiple selections before first resolves" + +**R3.** The setter's updater receives the current *visible* (optimistic-if-overridden) value, never the committed value; a plain setter on the underlying source during a transition composes on the transition's *pending* value. The two compose independently. +- Evidence: CO — "should provide current optimistic value in update callback", "should combine pending value with optimistic write when transition completes" + +**R4.** Multiple optimistic writes before settle compose sequentially (each updater sees the prior override; last write wins). +- Evidence: CO — "should allow multiple optimistic updates before flush" + +**R5.** An optimistic write **outside any action** reverts at the next flush; subscribers observe the optimistic value and then the reverted value within that single flush (effect log `[1, 2, 1]` after one `flush()`). +- Evidence: CO — "should update signal via setter and revert on flush", "independent optimistic writes create separate lanes", "optimistic effect runs before regular effect on same node" +- **CONFLICT:** requires core lanes to support an ephemeral, auto-settling lane for un-actioned writes, with two subscriber runs inside one flush pass. + +**R6.** An optimistic write inside an `action` holds for the entire action window and reverts when the action's transition completes; each intermediate write during a multi-yield action is observable in order (`[0,1,2,0]`). +- Evidence: CO — "should show optimistic value during async transition and revert when complete", "should show each optimistic update during transition" + +**R7.** Computed-form `createOptimistic(fn)` with no overrides is a transparent passthrough of its (possibly async) source: promise resolutions, re-fired promises, and async-iterable yields all propagate; overrides still revert when the source is async. +- Evidence: CO — "identity pass-through with async source (no override)" describe, "should still revert overrides when source is async" + +**R8.** Reset-on-settle targets the source's **newly computed value at settle time**, not the pre-write value: a wrong optimistic guess is auto-corrected to the real result; a correct guess settles silently (see R28). +- Evidence: CO — "optimistic value does not match computed result", "first async resolves first…", "action pattern with mismatch…", "two full cycles with mismatch correction on second cycle" + +**R9.** Regular signals written in the same action are held (transition semantics) while optimistic writes display immediately; downstream memos and chained optimistic computeds see optimistic values and revert with them. +- Evidence: CO — "should hold regular signal value during transition while showing optimistic", "should chain optimistic signals correctly", "should propagate optimistic changes through memo chain", "nested optimistic computeds propagate through single lane" + +**R10.** `refresh()` of an optimistic accessor inside an action clears the override when the refetch settles; calling `refresh()` while the upstream source is still pending must not throw. +- Evidence: CO — "refreshing an optimistic async accessor clears the override when it settles", "refreshing an optimistic accessor does not throw upstream pending reads (#2694)" + +**R11.** Verdict channels: an optimistic override **is the value** on every channel — plain read and `latest()` both return it (including literal `undefined`); the override itself is **verdict-inert** — it never makes its own slot pending, and it cannot silence pending when the source's *question* changed and is in flight (`isPending` reads true through the override). +- Evidence: UO — "1b: verdict channels see the undefined override (latest/isPending)"; CO — "optimistic value matches computed result", "optimistic value does not match computed result", "isPending tracks optimistic node state alongside value effects", "action pattern: setOptimistic -> yield api -> refresh" + +**R12.** A bare `refresh()` is a quiet re-ask — never pending; a **declared** reload (`affects(x)` + `refresh(x)` inside an action) pends the slot for the whole reload window, even when the sole consumer is a reactive `isPending`. +- Evidence: CO — "refresh() of an async optimistic accessor is a quiet re-ask — not pending (#2799…)", "a declared reload (affects + refresh) fires isPending when it is the only consumer (#2806…)" + +**R13.** During the pending window, a source recompute that reveals a value **different** from the current override corrects the override in place (before the action settles), triggering downstream refetch; a recompute matching the override leaves everything untouched and silent. +- Evidence: CO — "shared async config resolves first: lanes stay separate despite shared dependency", "rapid action: correction should not be blocked…" +- **CONFLICT:** the design's write model is binary (rollback = discard; commit = fold). Correction is a third behavior: source-driven mid-flight replacement of the lane value with cascade invalidation. Needs a defined path in §3. + +## B. Lane / transaction ownership + +**R14.** Independent optimistic writes to unrelated signals form independent lanes: notifications scoped to each signal's own subscribers; each action's overrides revert when *that* action settles, regardless of other in-flight actions. +- Evidence: CO — "independent optimistic writes create separate lanes", "should show both optimistic updates immediately when two independent actions are triggered rapidly" + +**R15.** A shared subscriber reading multiple optimistic sources merges lanes **for scheduling only**; it must not transfer transaction ownership of overrides. Disjoint-key work settles with its owning action even when lanes merged through the shared effect. +- Evidence: LTO — "repro 1: #2899 test-3 shape with B's writes swapped", "repro 2: three actions on plain createOptimistic signals" +- **CONFLICT (critical):** §7 defers collision to core lane semantics. Today this uses node-level owner stamps (`_overrideOwner`) + store-side `STORE_OPTIMISTIC_OWNERS`. Core lanes must natively carry per-node transaction ownership or §7's deference is insufficient. + +**R16.** Same-key writes from multiple actions **entangle** those actions: the override (and transitively every override of the entangled actions) reverts only when the **last** entangled action settles. +- Evidence: LTO — "repro 1"; CO — "holds same-value optimistic writes until all overlapping actions settle" +- **CONFLICT:** per-property lane values must support multi-action refcounting/entanglement per key, including transitive spread across all keys those actions wrote. + +**R17.** An equal-value write still registers ownership and still performs lane bookkeeping: a second action writing the same value keeps the override alive after the first settles; an override write whose value equals the (corrected) current value must still dirty downstream to invalidate stale in-flight async. +- Evidence: CO — "holds same-value optimistic writes until all overlapping actions settle", "rapid action: unchanged override value should still dirty downstream to invalidate stale _inFlight" +- **CONFLICT:** the core write path must not equality-short-circuit lane registration or downstream invalidation. + +**R18.** All optimistic writes in one action share one transaction and revert together atomically; lanes/transactions clean up fully between cycles — the Nth cycle behaves exactly like the first, including after rapid lane reuse. +- Evidence: CO — "should revert multiple optimistic signals together…", "concurrent optimistic writes in same action share a lane", "multiple sequential cycles", "two full cycles - lanes clean up properly…" (×3), "rapid action: correction should not be blocked when lane is reused across actions" + +**R19.** A shared **upstream** async resolving must not merge distinct downstream optimistic lanes — independent paths keep updating independently; genuine merge happens only at convergence points (a memo reading both), where the merged node waits for all inputs. +- Evidence: CO — "shared async config resolves first…", "latest() allows independent progressive display for parallel optimistic paths" + +**R20.** A later action's override wins over an earlier action's background settle: when action 1's refresh resolves *under* action 2's live override, the visible value is unchanged, downstream must not recompute, and pending must not flicker. +- Evidence: CO — "second action while first still in flight…", "should NOT double-flicker isPending on rapid actions when background resolves" + +## C. Undefined / absent-value semantics + +**R21.** An optimistic write of literal `undefined` is a full-fledged override: visible on plain read and `latest()`, verdict-inert on `isPending`, and it reverts at settle exactly like any other value. +- Evidence: UO — "1: optimistic undefined is visible during the action window", "1b: verdict channels see the undefined override" +- **CONFLICT (critical, by design intent):** #2898 was `undefined` colliding with the no-override sentinel. Lane slots must use a sentinel distinct from `undefined` (NOT_PENDING-style brand); every surface exposing the lane value must unwrap it. + +**R22.** A follow-up optimistic write after an `undefined` override still rides the optimistic path and reverts at settle — `undefined` in the slot must never erase the node's optimistic identity or route later writes to permanent commit. +- Evidence: UO — "2: follow-up write reverts at settle (no permanent commit)" + +**R23.** Store form distinguishes "override to undefined" from "delete": optimistic set-to-undefined reads `undefined` with the key still present; optimistic `delete` reads `undefined` **and** `"key" in store === false`; at settle both restore the committed value and key presence. +- Evidence: UO — "4: optimistic store set-to-undefined is visible then reverts", "5: optimistic store delete is visible then reverts" +- **CONFLICT (critical):** a per-property lane *value* cannot express key absence — deletion must live in the key-set node overlay (§6/O2). The `has` trap must consult the key-set lane overlay; rollback must restore property view and key membership atomically. + +## D. Settle / replay + +**R24.** A transition completes only when **all** reachable asyncs (upstream source and downstream lane asyncs) resolve; held source values must never leak to subscribers before completion, even when the upstream resolved first with a value matching the override. +- Evidence: CO — "transition holds when upstream resolves first…", "only first async resolves, second stays pending" + +**R25.** Lane readiness gating: subscribers reached *through a downstream async memo* fire with optimistic values only once that async resolves; direct reads show the override immediately. The lane may flush **before** the upstream source resolves. +- Evidence: CO — "first async resolves first, optimistic value matches computed result", "second async resolves first…", "multiple user actions before any async resolves", "action pattern: setOptimistic -> yield api -> refresh" + +**R26.** At settle, the commit of held transition writes and the revert of optimistic overrides are delivered **atomically**: one subscriber run observing both, never a torn intermediate. +- Evidence: CO — "should hold regular signal value during transition while showing optimistic", "should revert multiple optimistic signals together when transition completes" + +**R27.** Rapid successive user writes replay correctly: the latest override wins; earlier lane flushes deliver the values current at their readiness time; final settled state reflects the last action's confirmed result. +- Evidence: CO — "rapid user actions: multiple selections before first resolves", "multiple user actions before any async resolves" + +## E. Notification granularity + +**R28.** No-op settles are silent: if the optimistic write equals the current value, neither the write nor the revert notifies; if the settle-time computed value equals the override, no extra notification fires. +- Evidence: CO — "should not trigger effect if optimistic value matches original", "first async resolves first, optimistic value matches computed result", "two full cycles…" +- **CONFLICT:** lane-fold/discard on the node must equality-check against raw before notifying. + +**R29.** Pre-flush writes coalesce: subscribers see only the latest override per flush (`[0, 2, 0]`, never intermediate `1`). +- Evidence: CO — "lane reuses existing lane for same signal" + +**R30.** Render-tier and user-tier effects must observe **identical value sequences** at every flush, including the mid-transition moment where an action finished but async reporters are still in flight. +- Evidence: CO — "plain optimistic stays true through refresh-of-unrelated-async (issue #2685)" + +**R31.** Optimistic lane notifications run even while an unrelated transition is stashed/pending; pending async in one lane never blocks another lane's write/revert notifications. +- Evidence: CO — "lane effects run even when transition is stashed", "cross-lane reads return committed value during optimistic context" + +**R32.** `isPending` granularity: each async path's pending slot clears when its **own** async resolves; merged downstream nodes stay pending — emitting **no intermediate half-state values** — until all inputs resolve; multiple `isPending` consumers must agree at every flush. +- Evidence: CO — "isPending holds until merged lane completes…", "3-optimistic-node checkout…", "checkout: combined style effect…", "multiple isPending effects track independently" + +**R33.** No pending flicker when the visible value is unchanged: background refresh phases with an unchanged visible override must not re-pend downstream; a genuinely new in-flight question must fire `isPending` true even on the Nth rapid action. +- Evidence: CO — "no double pending flicker during refresh phase", "should NOT double-flicker isPending…", "isPending effect fires on second rapid action" + +**R34.** `latest()` readers opt into progressive per-path display while plain readers of merged memos wait for full resolution. +- Evidence: CO — "latest() allows independent progressive display…", "two full cycles - lanes clean up properly between country changes" + +## F. Store-form specifics + +**R35.** `createOptimisticStore` returns `[proxy, setter]`; draft-style mutations inside an action are optimistic: immediately visible through the proxy, wholly reverted at settle. +- Evidence: UO — tests 3–5; LTO — "repro 1" + +**R36.** Array structural edits (e.g. filter-removal) are visible during the window through `length`, index reads, and iteration, and fully revert at settle. +- Evidence: UO — "3: optimistic store filter-removal reverts at settle" +- **CONFLICT:** rollback must atomically discard all touched index lane values, the length lane value, and the key-set overlay — with mid-lane iteration consistency (O2). + +**R37.** Store optimism is per-key: different keys written by different actions settle independently — same ownership rules as signals (R15/R16) at store-key granularity. +- Evidence: LTO — "repro 1" +- **CONFLICT:** current code stamps `STORE_OPTIMISTIC_OWNERS` in the store layer (deleted); per-key ownership must come from core lane values on nodes. + +## Tests pinning current internals — need a ruling + +1. **LTO "repro 2" carries a live `// FAILS today: x reverts to 1 while C is in flight` comment** (plain `it`, not `it.fails`) — either fixed with stale comment, or the suite is red. Ruling: is R15/R16's signal-form variant shipped behavior or aspirational spec? +2. **UO file header** pins the fix mechanism (`_overrideValue` doubling as brand, `OVERRIDE_UNDEFINED`, NO_SNAPSHOT). Assertions behavioral, survive; header narrative is not contract. +3. **LTO file header** pins `_overrideOwner`, `resolveTransition` preference, `STORE_OPTIMISTIC_OWNERS`. Behavior (R15–R17) is contract; the two-structure mechanism is what the rewrite deletes. +4. **CO "rapid action" Fix 1/Fix 2 tests** encode old-scheduler choreography (`_laneVersion`/`_overrideVersion`, `insertSubs`/`valueChanged` gates). Behaviors (R13, R17) are contract; exact resolve ordering may need re-derivation. +5. **CO "optimistic effect runs before regular effect on same node"** — title claims an ordering never asserted; only pins R5's double-run-in-one-flush. Ruling: is that double notification contract or artifact? +6. **CO stash-mechanism comments** (#2685, `_actions`/`_asyncReporters`) — assertions stand (R30, R31), narration doesn't. +7. **Exact effect-sequence arrays throughout** pin notification counts. Most encode genuine no-flicker contracts (R28–R30); ruling needed on which counts are contract vs incidental. +8. **isPending decree history** — tests cite two partially-superseding rulings (mask 2026-07-07c vs question-scoped 2026-07-13); the "action pattern" test still asserts mask behavior. Confirm the mask-vs-question boundary before porting. + +**Biggest tensions, ranked:** (1) per-node transaction ownership + same-key entanglement must move into core lane semantics (R15–R17, R37); (2) optimistic delete/absence needs O2's key-set/tombstone answer (R23/R36); (3) mid-flight correction (R13) is a third lane-value transition missing from §3; (4) `undefined`-safe lane sentinels (R21) and equality-gate exemptions (R17, R28) are small but load-bearing. diff --git a/packages/solid-signals/rules-mining/optimistic-store.md b/packages/solid-signals/rules-mining/optimistic-store.md new file mode 100644 index 000000000..4dae0b59a --- /dev/null +++ b/packages/solid-signals/rules-mining/optimistic-store.md @@ -0,0 +1,156 @@ +# Mined rules: optimistic store suites + +Source suites: `tests/store/createOptimisticStore.test.ts`, `tests/optimistic-store-refetch-hold.test.ts`, `tests/optimistic-store-layer-scope.test.ts`, `tests/strict-read-pending-store.test.ts`. + +## A. Visibility + +**R1 — Synchronous universal visibility.** An optimistic write is visible to every reader immediately at write time, before any flush: tracked, untracked, inside the action body, outside any reactive context, and subsequent setter drafts. +- createOptimisticStore — "should update store via setter and revert on flush", "should show optimistic value when read outside reactive context"; refetch-hold draft assertion. + +**R2 — Drafts compose on the live optimistic view.** Each setter draft reads through all prior optimistic state (same tick, across ticks, across separate actions/refetches). +- "should allow multiple optimistic updates before flush", "should accumulate rapid successive array pushes"; refetch-hold "#2951: consecutive bare writes stack…". +- CONFLICT: the draft read path must resolve lane view, not raw, including structural array state (length/index nodes + key-set node jointly coherent for the next draft). + +**R3 — Per-change notification.** One notification per distinct optimistic value change; sequences like `[0, 1, 2, 0]` are contract. +- "should show each optimistic update during transition", "should track property changes through effects". + +**R4 — Equality cut.** An optimistic write equal to current committed value: no notification on write or settle. +- "should not trigger effect if optimistic value matches original". + +**R5 — Snapshot/deep read the optimistic view (resolves O1).** snapshot()/deep() agree with every other reader: overlays, nested writes, optimistic deletes (key absent), array mutations; after settle show committed; deep() re-runs on write and revert. +- entire "snapshot and deep see optimistic writes (#2850)" block. +- Note: confirms O1 — snapshot = current view; a committed-only meaning would break these. + +**R6 — Snapshot allocates fresh objects while an overlay is live** (not identity-stable across calls); settled returns raw identity. +- "snapshot shows the overlay during a transition…" (`during !== snapshot(state)`). +- CONFLICT (mild): pins allocation behavior; sparse CoW satisfies it, but internals-adjacent (see H2). + +**R7 — Propagation through derived graphs** (memo chains, mapArray) like committed values. + +**R8 — `latest()` returns the optimistic value** during a pending refetch window. + +**R9 — Cross-lane atomic flip.** Regular store written in the same action holds old value while optimistic store shows overlay; at settle both land in ONE notification pass (mixed intermediates never observed). +- "should hold regular store value during transition while showing optimistic". + +## B. Rollback / settle + +**R10 — Settle reverts to base with one notification** (`[0,1,0]`). + +**R11 — Deep-state restoration.** Revert restores complete pre-overlay state at every depth: nested writes, wholesale replacement, array length/indices/order, deletions (value + key membership). + +**R12 — Revert target is the CURRENT derived base, not a stale snapshot** (dependency changed mid-overlay → revert to recomputed value). +- "should derive from source signal and revert optimistic writes"; "optimistic write reverts to computed value after async completes". +- Note: why backup snapshots were already wrong; "discard lane, read through to raw" satisfies it IF the projection recompute has adopted into raw by settle time. + +**R13 — Base data is not overlay data.** Async-fetched/derived data commits to base and persists; only setter-originated optimistic state discards. + +**R14 — No-flicker across the settle/refresh seam.** From action-body return until refresh fetch lands, subscribers never observe the previously-committed value of an overridden property. +- "should not flicker through previously-committed value on second toggle…". +- CONFLICT: lane settle condition must be actions empty AND async reporters empty, jointly — pending async spawned by the transaction keeps the lane alive. + +**R15 — Unaffected subscribers do not rerun on another action's settle.** + +**R16 — Cycles are independent** (no residue between sequential write/settle cycles). + +**R17 — Optimistic writes never pend.** A plain optimistic store is never pending; an optimistic write alone never makes isPending true on any read (shallow, deep(), root or nested proxy, value or length, same- or separate-render probes). + +## C. Layer scoping + +**R18 — Overlay lifetime is transaction-bound, per key** (never a timer, never a mere flush boundary — under an action). + +**R19 — Disjoint-key concurrent actions revert independently** (incl. different rows, deletes) (#2899 ×3). +- Note: per-property nodes give this by construction — strongest validation of the new model. + +**R20 — Same-key writes entangle whole transactions:** latest write displays; NOTHING in the merged transaction settles until the last member completes — including keys written by only one of them. +- "#2899: same-key writes entangle…", "should handle 3 rapid toggles…", "rapid same-tick toggles…". +- CONFLICT (high): §7 defers collision to core lanes; this demands transaction-level merge propagation. If core lanes merge per-property only, `s.b` reverts at B's settle and the test breaks. Ruling: is core lane merge transaction-granular? + +**R21 — Optimistic delete is per-transaction scoped** (a concurrent action's settle must not resurrect another action's delete). +- CONFLICT: key-set node is one per object — #2899's flat-record problem reappears at the key-set node; its overlay must carry per-transaction granularity for key adds/removes (O2 unspecified). + +**R22 — Ambient (transaction-less) writes flash:** visible until end of flush, then revert — without touching in-flight actions' keys. +- CONFLICT: needs an "ambient lane" with flush-end lifetime; combined with R42, ambient-lane lifetime is conditional. + +**R23 — Actions scope globally (a transaction, not a store handle):** writes made under action A belong to A regardless of which store; separate stores under separate actions settle independently. + +**R24 — Re-override of a still-overridden key notifies and wins;** the earlier action's completion never resurfaces its value. + +## D. Structural + +**R25 — Array mutation overlays:** push, splice, whole-array replacement, top-level array stores — length, index reads, holes, spread/iteration, .map all coherent mid-pending and restore exactly on revert. + +**R26 — Length reactively consistent with contents;** a consumer reading length then indices in one computation never observes a torn state. +- CONFLICT (mild): length on key-set node + elements on index nodes → tearing is the natural failure mode; §6's acceptance criteria. + +**R27 — Key enumeration and `has` are lane-reactive** (Object.keys / `in` reflect optimistic adds/deletes, notify, revert). + +**R28 — Proxy identity survives truth adoption of optimistic rows:** server data key-matching an optimistically pushed row recycles the proxy (identity preserved) and adopts server values. Single and multiple pushes. +- CONFLICT (high): an optimistic-only row does not exist in raw — key-matching and prev-length require the adoption channel to consult the LANE VIEW (the pinned regression: "reconcile was blind to STORE_OPTIMISTIC_OVERRIDE, prevLength was 0"). Raw-only diff is unsound here. + +**R29 — Entity-swap key probes read committed base, not overlay** (an optimistic `s.id = 99` must not confuse the swap); `key: null` → positional identity. +- Note: paired with R28: identity/key probes of incoming-vs-existing entity use committed base; length/row-matching of the previous arrangement must see the overlay. Both needed, explicitly. + +## E. Strict-read / pending-read + +**R30 — Seed invisibility.** Derived store's seed is a draft, never observable: before first resolution every read — get, `in`, keys, spread — throws NotReadyError untracked. Applies to createStore(fn, seed) and createOptimisticStore(fn, seed). +- CONFLICT (high): with seed-as-initial-raw + raw fallthrough reads, uninitialized state must gate EVERY trap before the §2 raw fallthrough or the seed leaks. + +**R31 — Dev strictRead scopes escalate:** uninitialized read in a component body throws the `[PENDING_ASYNC_UNTRACKED_READ]` dev error (exact tag is contract), precedence over plain NotReadyError. + +**R32 — Post-init untracked reads flow committed values,** including during a later refetch window. + +**R33 — Refetch window keeps the dev safeguard** (committed value untracked; component-body read still dev-throws). + +**R34 — isPending probes take the prod path in both builds:** dev safeguard must not fire inside a probe; uninitialized + surrounding context ⇒ NotReadyError propagates out of isPending identically dev/prod; fully untracked with no context, isPending never throws. + +**R35 — Plain stores unaffected** (read normally in every context incl. component bodies). + +## F. Refetch / pending-verdict + +**R36 — Dependency-driven refetch pends the leaf and holds the committed view** until the fetch lands. + +**R37 — Optimistic writes are verdict-inert:** a mid-refetch write displays but neither clears nor causes pending; the honest mixed state {value: 999, pending: true} is observable. (Re-ruled 2026-07-13, superseding the A20 mask.) + +**R38 — No-op setters are fully inert:** trap-firing no-ops (s => s, s => ({...s}), same-value write, delete of absent prop) mid-refetch display nothing, don't silence pending, don't entangle with the surrounding transaction. +- CONFLICT (mild): "writes always materialize the node" is fine (unobservable), but LANE ENTANGLEMENT must be gated on actual value change — incl. recognizing a returned shallow copy of identical values as a no-op. Equality-cut before any lane linkage forms. + +**R39 — Landing truth wins over the override:** fetch resolves → server/computed value displays, override consumed, pending clears — even if written mid-flight. + +**R40 — Bare refresh is a quiet re-ask; affects + refresh is a declared reload.** refresh(store) alone never pends reads; affects(store) + refresh pends them, clearing when data lands. Sync-back refresh inside an action is quiet. + +**R41 — Streaming continuations are not pending windows.** A generator-based derive (or wrapped createProjection) that yielded once reads settled while awaiting its next chunk, incl. with an override displayed. + +**R42 — Bare writes ride an in-flight refetch (#2951).** A transaction-less optimistic write while the store's own truth is in flight does NOT revert at flush end; holds until truth lands. Order-independent within the tick; also for later-tick writes during the same refetch. Optimistic state clears when truth lands or its transaction settles — never on a timer. +- CONFLICT (high): the rule that killed the old firewall/layer split. Must define which lane a bare write joins — attach to / held open by the store's in-flight recompute transition. R22 flash + R42 ride are ONE rule conditioned on in-flight truth; two mechanisms recreates #2951. + +**R43 — Refresh-in-action landings preserve still-pending overlays** (same key ⇒ merged transaction: landing does not consume the pending action's optimistic value). + +**R44 — Bare-refresh landings consume key-matched overlay content** (optimistic "Optimistic" → server "Saved"); the action's later settle does not revert it. + +**R45 — Separate-transition landings clear foreign optimistic rows (#2719):** a different source transition resolving fresh data clears optimistic rows of a still-pending unrelated action immediately; later settle does not resurrect. Returned-value and draft-mutating derive forms. +- CONFLICT (high, jointly with R43/R44): three different answers to "what does landed truth do to a pending action's optimistic state": preserve (refresh inside entangled action), adopt-and-consume per matched row (bare refresh), clear entirely (separate source transition). Needs one lane principle (plausibly: whether the landing occurs inside the overriding lane's transaction or supersedes it). The tightest constraint set in the suite. + +**R46 — Refetch persistence across multi-action windows:** overlay survives arbitrary interleaved refresh landings while any overlapping action is pending. + +## G. Consolidated CONFLICT index + +1. **R20** — transaction-level same-key entanglement vs §7 "defer to core": per-property or per-transaction merge? +2. **R42/R22** — ambient lane lifetime is conditional (flush-end vs ride-the-refetch): one rule or #2951 recurs. +3. **R28** — reconcile must be lane-aware for key-matching/prev-length; pair with R29's committed-side identity probes. +4. **R43/R44/R45** — the landing matrix needs a single principled discriminator. +5. **R21** — key-set node needs per-transaction granularity or #2899 recurs structurally. +6. **R30** — seed-in-raw: uninitialized firewall gates every trap before raw fallthrough. +7. **R14** — lane settle condition = no live actions AND no live async reporters. +8. **R38** — equality-cut before lane linkage ("writes always materialize" must not imply "no-op writes entangle"). +9. **R26** — length/index/key-set coherence mid-lane. + +## H. Tests pinning internals — need a ruling + +1. **"should not flicker…second toggle"** — R14 portable; choreography targets `stashedOptimisticReads`, `_actions`, `_asyncReporters`, `el._value`; microtask counts implementation-timed. Keep R14, re-derive choreography. +2. **`during !== snapshot(state)`** — pins per-call fresh allocation with a live overlay. Sparse CoW satisfies it; a caching rewrite wouldn't. Contract or accident? +3. **layer-scope suite framing** — headers pin STORE_OPTIMISTIC_OVERRIDE/OWNERS/"merge chains". Behaviors portable; R20's entanglement SHAPE may itself be a transition-merge artifact — ruling before porting verbatim. +4. **"preserve array item proxy identity…reconciled"** — regression comment pins reconcile reading the override record; identity rule semantic, mechanism re-derived (Conflict 3). +5. **refetch-hold header** — pins firewall-computed vs store-layer split anatomy; assertions portable, header not. +6. **Microtask-count choreography generally** — rules portable; awaits need re-tuning checked against the rule, not just made green. + +Meta-note: test comments cite an existing ruling ledger (A16, A17, A20-superseded, B5a, re-ruled 2026-07-13). Rule-derived `__TEST__` assertions should cross-reference that ledger so verdict-inertness (R37/R38) and isPending-probe rules (R34) don't get re-litigated from stale comments. diff --git a/packages/solid-signals/rules-mining/projections.md b/packages/solid-signals/rules-mining/projections.md new file mode 100644 index 000000000..b24ae76db --- /dev/null +++ b/packages/solid-signals/rules-mining/projections.md @@ -0,0 +1,123 @@ +# Mined rules: projections + +Source suites: `sync` = `tests/store/createProjection.test.ts`, `async` = `tests/store/createProjection.async.test.ts`, `jsdom` = `tests/store/createProjection.jsdom.test.ts`. + +## Recompute semantics + +**R1 — The derive receives the projection's current state as a mutable draft that persists across runs**; prior runs' writes are visible and editable later. +- Evidence: sync "should observe key changes", "should not self track", "preserves inline object property writes when splicing draft arrays". + +**R2 — Draft reads inside the derive never register dependencies (no self-tracking)**, including `has`/index probes from array methods (`findIndex`, `splice`) and inspection traps from `console.log`. +- Evidence: sync "should not self track"; async "does not self-track through array splice has checks"; jsdom "does not loop when a draft is logged". + +**R3 — The derive runs eagerly at creation (before any read/subscriber) and re-runs on flush when tracked sources change, even with zero subscribers.** +- Evidence: jsdom "does not loop…" (runs===1 after createRoot, runs===2 after unobserved set+flush); async "isPending is false during initial async load". +- CONFLICT (note): eager-with-zero-subscribers must survive node laziness — derive scheduling can't be gated on node existence (R1-unobservability cuts both ways). + +**R4 — A returned value merges reconcile-style**: changed paths notify, absent keys delete, unchanged paths keep value and identity. +- Evidence: sync "should fork a signals values", "swaps in place when the derive returns a different entity"; async "yielding a value replaces the entire snapshot (no merge)". + +**R5 — The projection's root proxy identity is stable for its lifetime**: across entity swaps, shape changes, and root key mismatches (root key change merges in place, no throw). +- Evidence: sync "swaps in place…"; async "shape changes DO NOT cause proxy identity changes", "async projection preserves identity only for unchanged paths". + +**R6 — Keyed diff (default key `"id"`)**: key-matched subtrees merge in place preserving child proxy identity, skipping notification for unchanged slots; key mismatch replaces the subtree with a fresh proxy. +- Evidence: sync "keeps merging when identity is unchanged"; async "keyed identity mismatch replaces subtree identity". + +**R7 — Key matching is hierarchically scoped**: when the root entity's key changes, children are NOT merged across the entity change even if their own keys match. +- Evidence: sync "does not merge children across an entity change". +- CONFLICT (mild): §4 lacks the entity-scoping qualifier — the ported diff must carry it or the rewrite over-merges. + +**R8 — `{ key: null }` merges positionally** (proxy identity preserved regardless of key-field changes). + +**R9 — A proxy detached by an entity swap remains a coherent read view of its own (old) data** — never dead, never reflecting the new entity. +- Evidence: sync "does not merge children across an entity change" (`beforeSwap.title` still "one/a"). + +**R10 — After a root swap, the outgoing raw stops resolving to the projection root**; re-handed as nested data it wraps as a distinct proxy with its own values. +- Evidence: sync "the outgoing raw stops resolving to the projection root". +- CONFLICT: pins raw→proxy lookup lifecycle; doc states adoption *registers* `next` but not the unregister rule for the displaced raw. Needs explicit asymmetric rule: proxy keeps its backing; the lookup entry for the displaced raw is dropped/superseded. + +**R11 — `reconcile()` on a plain store still throws on root key mismatch**; the projection root's merge-in-place (R5) is a projection-specific relaxation. +- CONFLICT (mild): the single adoption channel must be policy-parameterized (two root-identity policies, one diff engine). + +## Firewall / isolation + +**R12 — Only subscribers of actually-changed properties rerun**; equal-value rewrites and writes to unobserved keys notify nobody. +- Evidence: sync "should observe key changes", "should fork a signals values", selection tests; async "async projection notifies only changed paths". + +**R13 — Deleting a key notifies its subscribers; subscribers of absent keys track and are notified on later creation.** +- Evidence: sync "simple selection", "double selection" (100 effects on mostly-absent keys; exactly the touched keys fire). +- CONFLICT (mild): requires nodes on nonexistent properties + delete notification — intersects O2. + +**R14 — Every subscriber of a changed property is notified exactly once per change.** + +**R15 — Projections compose** (projection reading another projection; downstream effects run once per upstream change with correct previous values). + +**R16 — `Object.keys` of a projection is tracked and notifies on key-set changes, including through a chained store backing.** +- CONFLICT (mild): the key-set node must bridge chained backings. + +## Chained backing (store-in-projection, #2941) + +**R17 — A derive returning a live store proxy adopts it live**: subsequent source-store writes flow through the projection without re-running the derive. +- Evidence: sync "derive returning a store adopts it live" (derive called once; seen = [1, 5555]). +- CONFLICT (MAJOR): §7's "recompute merges output into raw" cannot express live chaining — updates bypass recompute entirely. Needs a third adoption variant: cross-store backing adoption with subscription bridging (projection's proxies/nodes read through to and are notified by the source store's live graph). + +**R18 — Fine-grained isolation preserved through the chain**: a nested source-store write notifies only the projection subscribers of that nested path. + +**R19 — When the derive's return switches (store → plain → other store), subscribers see each new value and the previous chain is fully severed.** + +**R20 — Chained backing works for array roots** (structural + row-level edits flow). + +**R21 — `createStore(fn, seed)` is the same projection mechanism and chains identically.** + +**R22 — `snapshot()` of a chained projection returns plain data equal to the current view and detached from future source writes.** +- Evidence: sync "snapshot() of a chained projection returns plain detached data" (snap.a stays 1 after s.a = 99). +- CONFLICT (MAJOR): §2's settled snapshot = raw identity, zero copy; with in-place owned-raw mutation, a raw-identity snapshot would observe later writes. Either snapshot copies owned subtrees (identity preservation only for still-shared source subtrees — arguably the documented contract read strictly) or this expectation needs a ruling. Extends O1 (O1 covers pending lanes, not owned-raw aliasing). + +## Async + +**R23 — The seed is a draft for the derive, never observable (#2897)**: until first settle/yield, every read — tracked, untracked, enumeration/spread — throws NotReadyError. +- CONFLICT: store-wide status (gates every property read incl. untracked), not per-property lane value. Needs a store-level status home after layer deletion. + +**R24 — Draft writes during an in-flight async run are invisible until that run settles** (per-run atomic visibility). + +**R25 — Async generators publish one snapshot per yield**: bare `yield` publishes accumulated draft mutations; `yield value` replaces the entire state (no merge); each yield transforms again. + +**R26 — Latest-run-wins supersession**: superseded runs' later yields and pending draft writes are discarded entirely; if no run ever landed, stays NotReady. + +**R27 — Async recompute does not coarsen granularity**: after settle, only changed-path subscribers rerun. + +**R28 — `refresh(proj)` forces a new derive run; bare refresh is quiet** (no pending published; silent reveal). + +**R29 — `affects(proj)` + `refresh(proj)` is a declared reload**: subscribed effects see isPending true + stale value for the window, then settle. + +**R30 — With no effect subscribed, async work creates no transition** (isPending false throughout initial load). + +**R31 — With a subscribed effect, source-triggered async reruns are transitions** (pending true + stale during window); initial no-stale-data load is never pending. + +**R32 — Reading a pending async source inside the derive propagates NotReady to consumers** (Loading boundaries fall back); settle fires downstream effects exactly once with the settled value, never the seed (#2938). + +**R33 — Settlement is a status change, not a value diff**: boundaries and blocked effects release even when the settled value equals the seed. + +**R34 — Errored derives follow async memo rules**: after rejection ALL readers (settle-time, late tracked, untracked) throw the error (StatusError-wrapped; boundaries unwrap). Seed never served uninitialized; last-good never served after failed refetch. +- CONFLICT: same store-wide-status problem as R23. + +**R35 — A genuine tracked read on a later cycle retries an errored derive** (memo parity: never untracked, never inside isPending probe, at most once per cycle); successful retry serves fresh value. + +## Lifecycle + +**R36 — Disposing the owning root stops the projection** (no recomputes, no notifications afterward). + +## Tests pinning internals — need a ruling + +1. **Unkeyed nested-object identity replacement** — async yields replace an unkeyed nested object's proxy identity whenever content changes (tension with R6/R12 fine-grained merge). Rule on whether unkeyed objects must replace rather than merge — may encode an accident of the yield path. +2. **Microtask-count choreography** — exact `await Promise.resolve()` counts and `runs === 2` pins throughout. Rule: "seed unobservable until settle" is contract; "exactly two microtasks" is not. +3. **Family-map cleanup timing** (R10's test) — the when of unregistration is an internals decision. +4. **#2938 test comment** describes the current firewall mechanism; only the effect log is contract. +5. **"proj.a; // realize the projection"** in the chained-snapshot test — if snapshot works without a realizing read in the rewrite, noise; if not, an accidental laziness observable violating R1-unobservability. Ruling either way. + +## Major conflicts summary + +- **R17 (live chained backing)** — biggest gap: needs cross-store backing adoption with subscription bridging. +- **R22 (snapshot detachment)** — collides with zero-copy raw-identity snapshot under in-place owned-raw mutation. Extends O1. +- **R23/R34 (store-wide NotReady/error status)** — needs a status home per store, not per property. +- **R7/R11 — root-identity policy split + entity-scoped key matching** must parameterize the single diff engine. diff --git a/packages/solid-signals/rules-mining/reconcile-snapshot.md b/packages/solid-signals/rules-mining/reconcile-snapshot.md new file mode 100644 index 000000000..76e5f9546 --- /dev/null +++ b/packages/solid-signals/rules-mining/reconcile-snapshot.md @@ -0,0 +1,131 @@ +# Mined rules: reconcile, snapshot, utilities + +Source suites: `tests/store/reconcile.test.ts`, `tests/store/reconcile-captured-proxies.test.ts`, `tests/snapshot.test.ts`, `tests/snapshot-derived-store-rows.test.ts`, `tests/store/utilities.test.ts`. + +## A. Reconcile contract + +**R1 — Keyed object merge deletes absent keys.** Properties present in `next` update; properties absent from `next` are deleted (read `undefined`, removed from `in`/keys). +- Evidence: reconcile.test.ts — "Reconcile a simple object", "…on a nested path", "a symbol key removed by reconcile notifies as undefined". + +**R2 — Reconcile applies to any nested proxy, not just the root**, with identical semantics. +- Evidence: "Reconcile a simple object on a nested path", "Reconcile nested top level key mismatch", "Reconcile reorder a keyed array". + +**R3 — Keyed identity mismatch at the target throws** (key differs, or key present on target but missing from `next`). Post-throw state is deliberately unasserted (original expectations commented out) — the rewrite should decide and document atomicity. +- Evidence: "Reconcile top level key mismatch", "…nested…", "…key missing". + +**R4 — `key: null` / `key: ""` disables key matching**: positional merge, no root identity check. +- Evidence: "does not enforce root identity", "Reconcile overwrite in non-keyed merge mode", "merges arrays positionally, preserving slot proxy identity". + +**R5 — Key modes: string key, key function, none.** KeyFn's call set is observable (see R17). +- Evidence: string keys throughout; reconcile-captured-proxies — "never-subscribed branches are not walked by the diff". + +**R6 — Key-matched items preserve logical (proxy) identity across reorder, insert, delete.** +- Evidence: "Reconcile reorder a keyed array"; captured-proxies — "captured row proxy … survives reconcile". + +**R7 — Re-sent identical objects preserve raw identity: `snapshot(state.arr[i])` is `Object.is`-equal to the original.** CONFLICT (benign, verify): adoption satisfies this since raw becomes the incoming object; keep as explicit `__TEST__` rule. +- Evidence: "Reconcile reorder a keyed array". + +**R8 — Positional merge preserves slot proxy identity even when identifying fields change** (fixed-shape dashboard pattern). +- Evidence: "merges arrays positionally…", "Reconcile overwrite in non-keyed merge mode". + +**R9 — Only changed leaves notify** (changed `a` reruns its subscriber exactly once; `b` subscriber zero times). +- Evidence: "only changed leaves notify". + +**R10 — Kind changes (object↔array) at any position replace wholesale, never merge, and notify the property node.** +- Evidence: "Reconcile overwrite an object with an array" and 5 related tests. + +**R11 — Null entries and primitives are legal keyed-array members** (#2772). +- Evidence: "Reconcile array with nulls", "Keyed reconcile preserves null entries…", "…replaces a keyed object with a primitive". + +**R12 — Array resize notification matrix.** Shrink: tracked removed indices notify `undefined`; tracked `in` flips false; untracked reads agree with new length (no stale node values). Growth: tracked missing indices notify new value; `in` flips true. Trailing removal notifies `$TRACK`/ownKeys. Both keyed and non-keyed. +- Evidence: 7 resize tests in reconcile.test.ts. + +**R13 — Numeric-coercible non-index string props on arrays (`"1e3"`, `"1.5"`) survive resize**; node sync must be membership-based, not length-range-based. +- Evidence: "Reconcile array shrink preserves tracked named array props…". + +**R14 — Symbol keys have full parity with string keys under reconcile** (update/remove/add/nested/mixed). +- Evidence: "reconcile with symbol-keyed properties" block; captured-proxies symbol test. + +**R15 — Reconcile can assign, swap, and reorder values that are other stores' proxies.** CONFLICT (attention): adoption must handle `next` values that are live proxies of other stores — `storeLookup` resolution must cover proxy-valued incoming data. +- Evidence: "Reconcile swaps a property whose value is another store's proxy" + 2 more. + +**R16 — Captured proxies with a live subscriber anywhere below are diffed in place through never-tracked intermediate levels.** CONFLICT (design obligation): a node exists deep below an un-noded path; adoption must locate and notify deep descendant nodes (current impl: sticky `STORE_DESC` flag bubbled up the wrap chain). Reference-skip pruning must not prune subtrees sheltering subscribers. +- Evidence: captured-proxies — 4 tests. + +**R17 — Never-subscribed subtrees are pruned: the diff does not walk below their top-level pair** (observable via keyFn call set). +- Evidence: captured-proxies — "never-subscribed branches are not walked by the diff". + +**R18 — Captured-but-unobserved proxies may detach and go stale after reconcile** (pinned pruning contract); a key mismatch detaches even an observed captured proxy. +- Evidence: captured-proxies — "captured-but-unobserved proxies may detach", "key mismatch detaches the captured proxy". + +**R19 — `deep()` observes a reconcile as a single notification carrying the final plain data.** +- Evidence: utilities — deep "works with reconcile". + +**R20 (type-level) — `reconcile(next)` requires the complete store type.** + +## B. Reconcile + layers + +**R21 — A reconcile in the same batch after an unflushed setter write behaves identically to a clean reconcile.** CONFLICT (framing only): tests motivate via `STORE_OVERRIDE`/`applyStateSlow` routing (deleted); observable rule maps onto O7 (owned/diverged backing must full-diff). Port assertions, rewrite comments. The O7 re-send test (same prior reference after intervening setter write must still restore) does NOT exist — must be added. +- Evidence: reconcile — "…shrink clears tracked indices on the override path"; captured-proxies — "slow path (live override layer)…". + +**R22 — Reconcile inside an optimistic action window is tentatively visible; captured-proxy readers see exactly what tracked readers see, during and after settle.** CONFLICT (load-bearing): adoption must ride the optimistic lane — backing swap + notifications lane-scoped and revertable. "Adoption resets ownership" needs defined meaning when adoption is tentative (does rollback restore prior backing AND ownership?). Needs a ruling. +- Evidence: captured-proxies — "optimistic store: captured subscriber matches tracked-path behavior". + +## C. Snapshot contract + +**R23 — `snapshot()`/`deep()` always return plain non-proxy data** — including rows through derived stores, nested objects in them, chained views. +- Evidence: snapshot-derived-store-rows (5 tests); utilities deep test. + +**R24 — CoW identity preservation:** never-written store snapshots as the original source object (`===`); after a write, changed object + ancestors are new copies, unchanged siblings keep prior snapshot identity; repeated snapshots of untouched subtrees stable. Nuance: after privatization the "copy" must be the owned raw itself, stable across snapshot calls (tests compare successive snapshots by identity). +- Evidence: utilities — 5 identity tests; snapshot — "preserves symbols on an untouched nested store value". + +**R25 — Snapshot through a derived-store view returns the same raw object as through the base store** when nothing overridden. CONFLICT (attention): requires unwrapping chained proxy backings to base raw; the "same raw as base" identity only holds if the projection shares the base's raws — identity-skip interacts with `owned()` of another store's objects. Needs a cross-store ownership ruling. +- Evidence: snapshot-derived-store-rows — 2 tests. + +**R26 — Snapshot reflects in-flight optimistic overrides while the base stays untouched.** Confirms O1's "snapshot = current view, lane values included". +- Evidence: snapshot-derived-store-rows — "snapshot(view[i]) reflects an in-flight optimistic override". + +**R27 — Snapshot sees pending (unflushed) setter writes synchronously, while untracked proxy reads return the previous value until flush.** CONFLICT (MAJOR): §3's "urgent writes are synchronous commits" would make untracked reads see new values pre-flush, breaking R38's half. Either preserve visible-at-flush staging for untracked reads (contradicting write-through) or re-rule these tests. Sharpest observable contradiction in the set. +- Evidence: utilities — "returns new object if changed" (no flush) + comment; Clone Store "simple set". + +**R28 — Array holes and length survive snapshot/deep** (trailing delete keeps length; holes stay holes; explicit length truncation round-trips; overridden length 0 snapshots as `[]`). +- Evidence: utilities 4 hole/length tests; snapshot — "preserves an overridden array length of 0"; deep variant. + +**R29 — Symbol-keyed data round-trips through snapshot**: enumerable symbols preserved in copies; writes inside symbol subtrees captured; added-after-snapshot appear; deleted dropped; NON-enumerable symbols excluded from written copies; cycles preserved (`snap.node[meta] === snap.node`); shared refs stay shared; symbol-keyed store-in-store unwraps; symbols survive assignment into another store. +- Evidence: snapshot — 11 symbol tests. + +**R30 — Snapshot-scope machinery (setSnapshotCapture / markSnapshotScope / releaseSnapshotScope / clearSnapshots):** signals/memos created during capture freeze creation-time value for scoped readers; writes don't reach scoped readers until release; release schedules recompute (async) and is idempotent; nested scopes independent; pre-capture signals propagate normally; propagation skips snapshot-scoped subscribers; clearSnapshots resets; boundary-internal (ownedWrite) signals excluded. +- Evidence: snapshot — capture/scope suites. Core-signal machinery; nodes-as-core-signals inherits it. + +**R31 — Store properties written during capture preserve pre-write value for scoped readers; unwritten use current.** CONFLICT: current mechanism (`STORE_SNAPSHOT_PROPS` in set trap) is layer-adjacent; the write may hit a node-less property — needs node materialization on capture-time writes or a separate capture map. Design decision. +- Evidence: snapshot — 2 tests. + +**R32 — A pending async projection suppresses snapshot capture**; after resolve + release, readers see resolved value. CONFLICT (mild): guard must move to lane-scoped adoption writes. +- Evidence: snapshot — "pending projection skips snapshot capture" (2 tests). + +## D. Utilities + +**R33 — `merge` core contract:** lazy getters (`this` = source); later sources win incl. explicit `undefined`; key union via `in`/keys; value props copied by value; non-enumerable → enumerable on result; first source never mutated; nested objects not cloned; nested merges flatten; null/undefined/false sources ignored; non-object sources throw; array sources merge; prototype-pollution safe; own `toString` shadows. +- Evidence: utilities — merge describe (20 tests) + others. + +**R34 — `merge` reference-return optimization:** same reference for single arg, trailing falsy args, and when last source's own keys cover the union; new proxy otherwise; holds for store proxies. + +**R35 — `merge` over signal-of-object source is reactive with minimal notifications.** + +**R36 — `omit` contract:** removed keys disappear from get/`in`/keys incl. store-proxy sources; kept value props copied; descriptors cloned faithfully; pollution-safe; composes with merge. + +**R37 — `deep()` contract:** plain data; tracks entire reachable tree (leaf writes, push, branch replacement, symbol subtree writes, symbol add/delete, shared-object writes through other paths); one notification per flush with final value; preserves holes/length. CONFLICT (note): "handles shared references" requires shared raws resolving to a single node home; privatization of a multi-parent (DAG) child needs a ruling — path-copying assumes a tree. + +**R38 — Untracked read-through (via merge clone) shows pre-write values until flush.** CONFLICT (same as R27, MAJOR): contradicts write-through-immediately unless plain setter writes stay staged until flush. R27+R38 pin the visibility split (snapshot sees pending, untracked proxy reads don't). Needs explicit ruling. +- Evidence: utilities — Clone Store "simple set". + +## Tests pinning internals — need a ruling + +1. **reconcile.test.ts "perf invariant: symbol-record mark…"** — imports `symbolKeyedRecords`, `$TARGET`, `STORE_NODE`; asserts WeakSet lifecycle. Delete or re-express as rewrite-native perf invariant. +2. **utilities deep "subscribes to $TRACK at each level"** — counts `owner._deps === 4`; key-set consolidation could change it. Re-derive or replace with behavioral assertions. +3. **Override-path reconcile tests** (R21) — assertions portable; setup rationale names deleted machinery. Port assertions, rewrite comments; confirm setter-then-reconcile still exercises owned-backing diff. +4. **snapshot capture suite names** referencing `_snapshotProps`/`NO_SNAPSHOT`/`insertSubs` — assertions via public API stand (R30–R32); rename to behavior. +5. **snapshot-derived-store-rows header** — narrative in terms of `STORE_VALUE`/snapshotImpl fast path; assertions behavioral, rewrite the header. +6. **Key-mismatch tests' commented-out post-throw expectations** — latent question: is a throwing reconcile atomic? Decide and assert. + +**Gaps to add as rule-derived tests:** O7 re-send test; rollback of adoption inside an optimistic lane (R22); privatization of shared multi-parent children (R37); ruling test for R27/R38 visibility split. diff --git a/packages/solid-signals/scripts/scan-hangs.mjs b/packages/solid-signals/scripts/scan-hangs.mjs new file mode 100644 index 000000000..042320bc0 --- /dev/null +++ b/packages/solid-signals/scripts/scan-hangs.mjs @@ -0,0 +1,40 @@ +// Wedge scanner: runs each test file in an isolated vitest process with a +// hard timeout; reports TIMEOUT (wedge) / FAIL / PASS per file. +// Usage: node scripts/scan-hangs.mjs [timeoutMs] +import { execFile } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +const TIMEOUT = parseInt(process.argv[2] || "30000", 10); +const root = new URL("..", import.meta.url).pathname; + +const files = []; +const walk = dir => { + for (const e of readdirSync(join(root, dir), { withFileTypes: true })) { + if (e.isDirectory()) walk(join(dir, e.name)); + else if (e.name.endsWith(".test.ts")) files.push(join(dir, e.name)); + } +}; +walk("tests"); + +const runOne = file => + new Promise(resolve => { + const child = execFile( + "pnpm", + ["vitest", "run", "--no-file-parallelism", file], + { cwd: root, timeout: TIMEOUT, killSignal: "SIGKILL" }, + (err, stdout) => { + if (err && err.killed) return resolve("TIMEOUT"); + const m = /Tests\s+(?:(\d+) failed \| )?(\d+) passed/.exec(stdout); + if (!m) return resolve("ERROR"); + resolve(m[1] ? `FAIL(${m[1]}/${+m[1] + +m[2]})` : "PASS"); + } + ); + void child; + }); + +for (const f of files) { + const r = await runOne(f); + if (r !== "PASS") console.log(r.padEnd(12), f); +} +console.log("scan complete:", files.length, "files"); diff --git a/packages/solid-signals/scripts/size-attr.mjs b/packages/solid-signals/scripts/size-attr.mjs new file mode 100644 index 000000000..ea0df3cf6 --- /dev/null +++ b/packages/solid-signals/scripts/size-attr.mjs @@ -0,0 +1,22 @@ +import { build } from "esbuild"; +import { gzipSync } from "node:zlib"; + +const res = await build({ + entryPoints: ["src/index.ts"], + bundle: true, + format: "esm", + minify: true, + write: false, + metafile: true, + define: { __DEV__: "false", __TEST__: "false" }, + treeShaking: true, + logLevel: "silent" +}); +const meta = res.metafile; +const out = Object.values(meta.outputs)[0]; +const total = out.bytes; +const rows = Object.entries(out.inputs) + .map(([f, v]) => [f.replace("src/", ""), v.bytesInOutput]) + .sort((a, b) => b[1] - a[1]); +console.log("minified total:", total, "gzip:", gzipSync(res.outputFiles[0].contents).length); +for (const [f, b] of rows) console.log(String(b).padStart(7), f); diff --git a/packages/solid-signals/scripts/store-size.mjs b/packages/solid-signals/scripts/store-size.mjs new file mode 100644 index 000000000..40baaa518 --- /dev/null +++ b/packages/solid-signals/scripts/store-size.mjs @@ -0,0 +1,70 @@ +// Size gate (INTERNALS-STORE-STATE.md §5c): store subsystem bytes, measured +// per increment beside the perf columns. Bundles src entries with esbuild +// (build-time constants set like the prod rollup config), minifies with +// terser, reports raw/min/gzip. Store cost = full - core (treeshaken diff), +// the same attribution method as the original size audit. +// +// Usage: node scripts/store-size.mjs (from packages/solid-signals) + +import { build } from "esbuild"; +import { minify } from "terser"; +import { gzipSync } from "node:zlib"; +import { writeFileSync } from "node:fs"; + +const DEFINE = { + __DEV__: "false", + __TEST__: "false", + "globalThis.__DEV__": "false" +}; + +async function bundle(label, contents) { + const r = await build({ + stdin: { contents, resolveDir: new URL("..", import.meta.url).pathname, loader: "ts" }, + bundle: true, + format: "esm", + write: false, + define: DEFINE, + treeShaking: true, + logLevel: "silent" + }); + const raw = r.outputFiles[0].text; + const min = (await minify(raw, { module: true, compress: { passes: 3 }, mangle: true })).code; + const gz = gzipSync(min, { level: 9 }).length; + return { label, raw: raw.length, min: min.length, gz }; +} + +const full = await bundle("full (index.ts)", `export * from "./src/index.ts";`); +const core = await bundle( + "core-only (signals, no store)", + `export { createSignal, createMemo, createEffect, createRoot, flush, untrack, batch } from "./src/index.ts";` +); +const store = await bundle( + "store entry (createStore + reconcile)", + `export { createStore, reconcile, snapshot, deep } from "./src/index.ts";` +); +const optimistic = await bundle( + "optimistic store entry", + `export { createOptimisticStore } from "./src/index.ts";` +); +const rows = [full, core, store, optimistic]; +const pad = (s, n) => String(s).padStart(n); +console.log("entry".padEnd(38) + pad("raw", 10) + pad("min", 10) + pad("gzip", 10)); +for (const r of rows) + console.log(r.label.padEnd(38) + pad(r.raw, 10) + pad(r.min, 10) + pad(r.gz, 10)); +console.log( + "store attribution (full - core):".padEnd(38) + + pad("", 10) + + pad(full.min - core.min, 10) + + pad(full.gz - core.gz, 10) +); + +if (process.env.SIZE_JSON) { + writeFileSync( + process.env.SIZE_JSON, + JSON.stringify( + Object.fromEntries(rows.map(r => [r.label, { min: r.min, gz: r.gz }])), + null, + 2 + ) + "\n" + ); +} diff --git a/packages/solid-signals/src/core/core.ts b/packages/solid-signals/src/core/core.ts index ecf57d653..796e2dff1 100644 --- a/packages/solid-signals/src/core/core.ts +++ b/packages/solid-signals/src/core/core.ts @@ -1003,6 +1003,31 @@ export function read(el: Signal | Computed): T { return value; } +/** + * Store-rewrite setter guard: the rewrite parks writes in a pending backing + * (no setSignal at write time), so the owned-scope write protection must + * fire at the setter entry instead. Mirrors setSignal's guard condition + * minus the node-specific exemptions (ownedWrite/firewall), which don't + * apply to plain store setters. + */ +export function devGuardStoreSetterWrite(): void { + if (!__DEV__) return; + // Roots are not owned computation scopes — setters inside createRoot bodies + // are legal (legacy parity; the guard targets computed/effect bodies). + if (context && !(context as any)._root && !(context._config & CONFIG_CHILDREN_FORBIDDEN)) { + emitDiagnostic({ + code: "REACTIVE_WRITE_IN_OWNED_SCOPE", + kind: "write", + severity: "error", + message: REACTIVE_WRITE_IN_OWNED_SCOPE_SIGNAL_MESSAGE, + ownerId: context.id, + ownerName: (context as any)._name, + data: { operation: "setStore" } + }); + throw new Error(REACTIVE_WRITE_IN_OWNED_SCOPE_SIGNAL_MESSAGE); + } +} + export function setSignal(el: Signal | Computed, v: T | ((prev: T) => T)): T { if ( __DEV__ && diff --git a/packages/solid-signals/src/core/scheduler.ts b/packages/solid-signals/src/core/scheduler.ts index 1d91e013f..63d8be6f9 100644 --- a/packages/solid-signals/src/core/scheduler.ts +++ b/packages/solid-signals/src/core/scheduler.ts @@ -86,10 +86,6 @@ export let _hitUnhandledAsync = false; // releasing the slot in the parent store's node map. const transientStoreNodes = new Set>(); -export function registerTransientStoreNode(node: Signal): void { - transientStoreNodes.add(node); -} - function canUseSimpleSyncFlush(queue: GlobalQueue): boolean { const batch = queue._batch; return ( @@ -716,12 +712,23 @@ function commitPendingNode(n: Signal): void { if (n._pendingSignal || n._latestValueComputed) GlobalQueue._snapCompanions!(n); } +// Store commit hook (INTERNALS-STORE-STATE.md §3): installed by the store +// module at init (same treeshakeable pattern as _resolveOptimistic / +// _clearOptimisticStores). Folds committed store-node values into their +// backing objects at the same moment pending values commit — the single +// mutation point of the owned-raw model. +export let storeCommitHook: (() => void) | null = null; +export function setStoreCommitHook(fn: () => void): void { + storeCommitHook = fn; +} + function commitPendingNodes() { const pendingNodes = currentBatch._pendingNodes; for (let i = 0; i < pendingNodes.length; i++) { commitPendingNode(pendingNodes[i]); } pendingNodes.length = 0; + storeCommitHook?.(); } export function finalizePureQueue( diff --git a/packages/solid-signals/src/store/index.ts b/packages/solid-signals/src/store/index.ts index 98e0dca7d..7d2f5ff39 100644 --- a/packages/solid-signals/src/store/index.ts +++ b/packages/solid-signals/src/store/index.ts @@ -11,13 +11,52 @@ export type { } from "./store.js"; export type { Merge, Omit } from "./utils.js"; -export { isWrappable, createStore, $TRACK, $PROXY, $TARGET } from "./store.js"; +export { isWrappable, $TRACK, $PROXY, $TARGET } from "./store.js"; -export { createProjection } from "./projection.js"; +import type { NoFn, ProjectionOptions, Store, StoreOptions, StoreSetter } from "./store.js"; +import type { Refreshable } from "../core/index.js"; +import { + createStoreNext, + deepNext, + snapshotNext, + type SetStoreNextFunction +} from "./next/store.js"; +import { reconcileNextState } from "./next/reconcile.js"; +import { createStoreDerivedNext } from "./next/projection.js"; -export { createOptimisticStore } from "./optimistic.js"; +export { createProjectionNext as createProjection } from "./next/projection.js"; +export { createOptimisticStoreNext as createOptimisticStore } from "./next/optimistic.js"; -export { reconcile } from "./reconcile.js"; +/** Public createStore: plain form `(init, options?)` and derived writable + * form `(fn, seed, options?)`. */ +export function createStore( + store: NoFn | Store>, + options?: StoreOptions & { shallow?: boolean } +): [get: Store, set: StoreSetter]; +export function createStore( + fn: (store: T) => void | T | Promise | AsyncIterable, + store: Partial | Store>, + options?: ProjectionOptions +): [get: Refreshable>, set: StoreSetter]; +export function createStore(first: any, second?: any, third?: any): any { + if (typeof first === "function") return createStoreDerivedNext(first, second, third); + return createStoreNext(first, !!second?.shallow); +} + +export function reconcile( + value: T, + key: string | ((item: NonNullable) => any) | null = "id" +) { + return (state: U): T => reconcileNextState(value, state, key) as any; +} + +export function snapshot(value: T): T { + return snapshotNext(value); +} + +export function deep(value: T): T { + return deepNext(value); +} export { storePath } from "./storePath.js"; export type { @@ -28,4 +67,4 @@ export type { CustomPartial } from "./storePath.js"; -export { snapshot, deep, merge, omit } from "./utils.js"; +export { merge, omit } from "./utils.js"; diff --git a/packages/solid-signals/src/store/next/optimistic.ts b/packages/solid-signals/src/store/next/optimistic.ts new file mode 100644 index 000000000..a2c6f5ef4 --- /dev/null +++ b/packages/solid-signals/src/store/next/optimistic.ts @@ -0,0 +1,415 @@ +/** + * Store rewrite — optimistic stores (§3/§7, RUL-3): no store-side layer, no + * backup snapshots. Nodes in an optimistic family are ARMED core signals + * (`_overrideValue` slot), so every user write rides the engine's + * optimisticWrite — per-transaction ownership, entanglement, reverts, and + * flash-at-flush are inherited, not reimplemented. Membership edits live on + * armed presence nodes (the §6 overlay), so structural optimism reverts with + * the same per-transaction granularity (FINDING-2's fix by construction). + * + * Derived form = an optimistic projection: the derive's recompute and its + * async commits run under projectionWriteActive (authoritative landings + * commit silently beneath any active overrides). The transitionBlocked + * store-half (#2951) is installed here for next-shaped targets, chaining the + * legacy/engine checks. + */ +import { NOT_PENDING, STATUS_PENDING, unwrapOverride } from "../../core/constants.js"; +import { + computed, + CONFIG_AUTO_DISPOSE, + isEqual, + setSignal, + type Computed, + type Signal +} from "../../core/index.js"; +import { GlobalQueue, globalQueue, insertSubs, schedule } from "../../core/scheduler.js"; +import { installOptimisticEngine } from "../../core/optimistic.js"; +import { + $TARGET, + markRawIngest, + type NoFn, + type ProjectionOptions, + type Store, + type StoreSetter +} from "../store.js"; +import { runProjectionComputedNext } from "./projection.js"; +import { + bumpDeep, + getHasNode, + getKeySetNode, + getNode, + hasActiveOverride, + runAuthoritative, + storeSetterNext, + targetsEqual, + unwrapValue, + wrapNext +} from "./store.js"; +import { setOptHooks, storeNextLookup } from "./target.js"; +type KeyFn = (item: any) => any; +import { isRawValue, isWrappable, rawValuesUsed, setNextOptimisticViewResolver } from "../store.js"; +import type { StoreNextFamily, StoreNextTarget } from "./target.js"; + +let blockedInstalled = false; +function installNextBlockedHalf(): void { + if (blockedInstalled) return; + blockedInstalled = true; + // Late-bind the optimistic machinery into the plain store/reconcile paths + // (all call sites are fam?.opt-gated, so this always runs first) and the + // affects witness's view resolver. + setOptHooks({ notifyOptimisticWrites, optimisticView, applyTentative }); + setNextOptimisticViewResolver((t: StoreNextTarget, raw: any) => optimisticView(t, raw)); + // Scheduler flush tails call _clearOptimisticStores whenever tracked + // stores exist; next has no layer to clear — reverts are engine-native — + // so the hook only empties the batch set. + if (!GlobalQueue._clearOptimisticStores) { + GlobalQueue._clearOptimisticStores = (stores: Set) => { + stores.clear(); + }; + } + const chained = GlobalQueue._transitionBlocked!; + GlobalQueue._transitionBlocked = transition => { + for (const store of transition._optimisticStores) { + const t = (store as any)?.[$TARGET] as StoreNextTarget | undefined; + const fw: any = t?.fam?.node; + // The hold exists to keep optimistic state alive until the store's own + // truth lands (#2951). Once the family carries NO live overrides (a + // landing consumed them, or they never existed), a pending firewall is + // no reason to park the transaction — blocking then leaks it forever + // when the in-flight question is never answered (undisposed fixtures). + if (fw != null && fw._statusFlags & STATUS_PENDING && familyHasLiveOverrides(t!.fam!)) + return true; + } + return chained(transition); + }; +} + +function familyHasLiveOverrides(fam: { overlaid?: Set }): boolean { + const overlaid = fam.overlaid; + if (overlaid === undefined || overlaid.size === 0) return false; + for (const t of overlaid as Set) { + for (const bucket of [t.n, t.h] as const) { + if (bucket === null) continue; + for (const key of Reflect.ownKeys(bucket)) { + const node: any = bucket[key as any]; + if (node._overrideValue !== undefined && node._overrideValue !== NOT_PENDING) return true; + } + } + if (t.k !== null && t.k._overrideValue !== undefined && t.k._overrideValue !== NOT_PENDING) + return true; + } + overlaid.clear(); // nothing live — drop the bookkeeping + return false; +} + +export function createOptimisticStoreNext( + first: T | ((store: T) => void | T | Promise | AsyncIterable), + second?: NoFn | Store>, + options?: ProjectionOptions +): [get: Store, set: StoreSetter] { + // Engine first (armed nodes need optimisticWrite installed before any + // node exists), then the next-shape hooks. + installOptimisticEngine(); + installNextBlockedHalf(); + + const derived = typeof first === "function"; + if (!derived && options === undefined) options = second as ProjectionOptions | undefined; + const initialValue = (derived ? second : first) as T; + + const fam: StoreNextFamily = { + map: new WeakMap(), + node: null, + shallow: !!(options as any)?.shallow, + opt: true + }; + const store = wrapNext(initialValue as any, null, null, fam) as Store; + fam.px = store; + if (fam.shallow) { + ((store as any)[$TARGET] as StoreNextTarget as any).s = true; + markRawIngest(initialValue); + } + + if (derived) { + const fn = first as (store: T) => void | T | Promise | AsyncIterable; + // Async commits land outside the computed's sync body — re-apply the + // authoritative-write posture there too. Landings consume the family's + // tentative overrides (RUL-2: visible landed truth replaces optimism) — + // both the reconcile-channel commit and per-op post-await draft writes. + const consume = () => consumeOverridesNext(fam); + const wrapCommit = (write: () => void) => { + runAuthoritative(write); + consume(); + }; + let nodeOptions: { name?: string; loadingValue?: void } | undefined; + if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined }; + if (__DEV__ && options?.name) nodeOptions = { ...nodeOptions, name: options.name }; + const node = computed(() => { + runAuthoritative(() => + runProjectionComputedNext( + store, + fn, + options?.key === undefined ? "id" : options.key, + wrapCommit, + consume + ) + ); + }, nodeOptions) as Computed; + node._config &= ~CONFIG_AUTO_DISPOSE; + fam.node = node; + } + + return [store, ((fn: (draft: T) => void) => storeSetterNext(store, fn)) as StoreSetter]; +} + +// ---- optimistic-only store machinery (moved from next/store.ts / +// next/reconcile.ts so plain-store bundles tree-shake it) ---- + +/** Diff the draft against the current OPTIMISTIC VIEW (committed + active + * overrides — the same view the draft was seeded from) and emit engine writes + * for exactly the changed keys. Visible-view diffing keeps no-op writes from + * entangling lanes (RUL-10 / opt R38). */ +export function notifyOptimisticWrites(t: StoreNextTarget, pb: Record): void { + // A bare write while the store's own truth is in flight rides THAT + // transaction (#2951, legacy parity): entangle the firewall's transition so + // the override survives until the refetch settles instead of flash-reverting + // at plain flush end. The blocked-check store-half keeps that transaction + // from settling while the firewall is pending. + const fw: any = t.fam?.node; + if (fw?._transition) globalQueue.initTransition(fw._transition); + const old = t.v; + const visible = (key: PropertyKey, fallback: any): any => { + const node = t.n?.[key as any]; + return node !== undefined && hasActiveOverride(node) + ? unwrapOverride(node._overrideValue) + : fallback; + }; + const visiblePresent = (key: PropertyKey): boolean => { + const node = t.h?.[key as any]; + return node !== undefined && hasActiveOverride(node) + ? !!unwrapOverride(node._overrideValue) + : key in old; + }; + let structural = false; + const isArr = Array.isArray(pb); + for (const key of Reflect.ownKeys(pb)) { + if (isArr && key === "length") continue; + const nv = unwrapValue(pb[key as any]); + if (!visiblePresent(key)) { + // Optimistic add: value node + presence node + membership bump. + setSignal(getNode(t, key, old[key as any]), () => nv); + setSignal(getHasNode(t, key, key in old), true as any); + structural = true; + } else { + const ov = visible(key, old[key as any]); + if (!isEqual(ov, nv) && !targetsEqual(ov, nv)) { + setSignal(getNode(t, key, ov), () => nv); + if (isArr) structural = true; + } + } + } + for (const key of Reflect.ownKeys(old)) { + if (isArr && key === "length") continue; + if (key in pb || !visiblePresent(key)) continue; + // Optimistic delete: node reads undefined, presence flips, membership bumps. + setSignal(getNode(t, key, old[key as any]), () => undefined); + setSignal(getHasNode(t, key, true), false as any); + structural = true; + } + if (isArr) { + const oldLen = visible("length", (old as any[]).length); + if (oldLen !== (pb as any[]).length) { + setSignal(getNode(t, "length", oldLen), () => (pb as any[]).length); + structural = true; + } + } + if (structural) setSignal(getKeySetNode(t), v => v + 1); + // Deep-witness: optimistic value writes notify deep() subscribers too + // (structural ones already ride the key-set bump above). + bumpDeep(t); + // Discard the draft — committed raw is untouched (revert target by + // construction). Register the root store for the scheduler's settle hooks + // and the target for landing consumption (RUL-2). + t.pb = null; + (t.fam!.overlaid ??= new Set()).add(t); + GlobalQueue._trackOptimisticStore?.(t.fam!.px ?? t.px); +} + +/** + * Landing consumption (RUL-2): fresh authoritative data supersedes every + * tentative override in the family. Mirrors legacy clearProjectionOverride — + * drop the override, clear lane/ownership, notify subscribers whose visible + * value changes (reversion effects go to regular queues via the projection + * write posture the caller holds). + */ +export function consumeOverridesNext(fam: StoreNextFamily): void { + const overlaid = fam.overlaid; + if (overlaid === undefined || overlaid.size === 0) return; + runAuthoritative(() => { + for (const t of overlaid as Set) { + const drop = (node: Signal, committed: any) => { + if (!hasActiveOverride(node)) return; + const prev = unwrapOverride(node._overrideValue); + // Full legacy reset (clearOptimisticOverride parity): the landing is + // authoritative NOW — fold committed into the node directly instead + // of riding a transaction's commit (whose queues may be stashed with + // the transaction parked; the wake would strand until it settles). + node._overrideValue = NOT_PENDING; + (node as any)._overrideOwner = null; + (node as any)._optimisticLane = undefined; + node._pendingValue = NOT_PENDING; + node._value = committed; + if (!node._equals || !node._equals(prev, committed)) { + insertSubs(node, true); + schedule(); + } + }; + // Landing consumes STRUCTURAL optimism only (legacy layer parity): + // membership edits, array length, and the value overrides written WITH + // them (a key carrying an active presence override is an add/delete — + // classified BEFORE the adoption may have made the key exist in landed + // data). A pure value override on a key the landing carries stays with + // its owning transaction (rapid-toggle contract: a live action's edit + // of an existing entity rides on top of landed truth). + const isArr = Array.isArray(t.v); + const has = t.h; + let structuralKeys: Set | null = null; + if (has !== null) { + for (const key of Reflect.ownKeys(has)) { + if (hasActiveOverride(has[key as any])) (structuralKeys ??= new Set()).add(key); + } + } + const nodes = t.n; + if (nodes !== null) { + for (const key of Reflect.ownKeys(nodes)) { + const structural = + structuralKeys?.has(key) || !(key in t.v) || (isArr && key === "length"); + if (!structural) continue; + drop( + nodes[key as any], + isArr && key === "length" ? (t.v as any[]).length : t.v[key as any] + ); + } + } + if (has !== null) { + for (const key of Reflect.ownKeys(has)) drop(has[key as any], key in t.v); + } + if (t.k !== null && hasActiveOverride(t.k)) { + t.k._overrideValue = NOT_PENDING; + (t.k as any)._overrideOwner = null; + (t.k as any)._optimisticLane = undefined; + insertSubs(t.k, true); + schedule(); + } + } + overlaid.clear(); + }); +} + +/** Optimistic-view composition for snapshot/deep (O1: snapshot is the CURRENT + * view, lane values included; a fresh copy per call during pending windows — + * RUL-12). Returns `src` untouched when no override is active on `t`. */ +export function optimisticView( + t: StoreNextTarget, + src: Record +): Record { + if (t.fam?.opt !== true) return src; + let out: Record | null = null; + const ensure = () => (out ??= Array.isArray(src) ? [...(src as any[])] : { ...src }); + const nodes = t.n; + if (nodes !== null) { + for (const key of Reflect.ownKeys(nodes)) { + const node = nodes[key as any]; + if (!hasActiveOverride(node)) continue; + const ov = unwrapOverride(node._overrideValue); + if (key === "length" && Array.isArray(src)) { + if ((src as any[]).length !== ov) (ensure() as any[]).length = ov; + } else if (!isEqual(src[key as any], ov)) ensure()[key as any] = ov; + } + } + const has = t.h; + if (has !== null) { + for (const key of Reflect.ownKeys(has)) { + const node = has[key as any]; + if (!hasActiveOverride(node)) continue; + const present = !!unwrapOverride(node._overrideValue); + if (!present && key in (out ?? src)) delete ensure()[key as any]; + } + } + return out ?? src; +} + +function applyTentative(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null): void { + const base = t.pb ?? t.v; + const view = optimisticView(t, base); + const map = t.fam!.map; + const isArr = Array.isArray(incoming); + if (Array.isArray(view) !== isArr) return; // kind change at root: flat overrides below + const pairs: Array<[StoreNextTarget, any]> = []; + const pbLike: any = isArr ? [...(incoming as any[])] : shallowWithSymbols(incoming); + const match = (pv: any, nv: any): StoreNextTarget | null => { + if (!isWrappable(pv) || !isWrappable(nv)) return null; + if (rawValuesUsed && (isRawValue(pv) || isRawValue(nv))) return null; + if (Array.isArray(pv) !== Array.isArray(nv)) return null; + if (keyFn) { + const pk = keyFn(pv); + const nk = keyFn(nv); + if (pk !== undefined && nk !== undefined && pk !== nk) return null; + } + return map.get(unwrapValue(pv)) ?? null; + }; + if (isArr) { + const viewRows = view as any[]; + let viewByKey: Map | null = null; + for (let i = 0; i < (incoming as any[]).length; i++) { + const nv = (incoming as any[])[i]; + if (!isWrappable(nv)) continue; + let pv: any; + if (keyFn) { + const nk = keyFn(nv); + if (nk !== undefined) { + if (viewByKey === null) { + 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); + } + } + } + pv = viewByKey.get(nk); + } else pv = unwrapValue(viewRows[i]); + } else pv = unwrapValue(viewRows[i]); + const ct = match(pv, nv); + if (ct !== null) { + // Keep the existing row in the slot (identity preserved); recurse. + pbLike[i] = unwrapValue(pv); + pairs.push([ct, nv]); + } + } + } else { + for (const k of Reflect.ownKeys(incoming)) { + const pv = unwrapValue((view as any)[k]); + const nv = (incoming as any)[k]; + const ct = match(pv, nv); + if (ct !== null) { + pbLike[k] = pv; + pairs.push([ct, nv]); + } + } + } + // Flat overrides for this level (adds, removals, moved slots, length, leaf + // values) — preserve any live user draft backing across the call. + const priorPB = t.pb; + t.pb = null; + notifyOptimisticWrites(t, pbLike); + t.pb = priorPB; + for (let i = 0; i < pairs.length; i++) + applyTentative(pairs[i][0], unwrapValue(pairs[i][1]), keyFn); +} + +function shallowWithSymbols(src: any): any { + const out: any = {}; + for (const k of Reflect.ownKeys(src)) out[k] = src[k]; + return out; +} diff --git a/packages/solid-signals/src/store/next/projection.ts b/packages/solid-signals/src/store/next/projection.ts new file mode 100644 index 000000000..017efd102 --- /dev/null +++ b/packages/solid-signals/src/store/next/projection.ts @@ -0,0 +1,209 @@ +/** + * Store rewrite — projections (§7/§7b): a projection is a computed store. + * The derive runs inside a computed whose recompute merges its output into + * the projection's backing through the adoption channel (replace-mode root: + * entity changes merge in place, the root proxy is stable for life). Children + * wrap into the projection's own FAMILY (writes land here, never in a source + * family), and every family node carries the projection computed as its + * firewall — reads link the derive's status and lifecycle natively. The §6c + * status gate in the traps makes an uninitialized async derive's seed + * unobservable through every read surface. + * + * Mirrors the legacy runProjectionComputed shape (shadow runs for open + * loading windows, handleAsync landings, commit-through-setter) on next + * primitives; the generic draft write-traps are reused from the legacy + * module unchanged. + */ +import { + computed, + CONFIG_AUTO_DISPOSE, + getOwner, + handleAsync, + suppressComputedRecompute, + type Computed, + type Refreshable +} from "../../core/index.js"; + +import { projectionWriteActive, setProjectionWriteActive } from "../../core/scheduler.js"; +import { + $TARGET, + markRawIngest, + setWriteOverride, + STORE_VALUE, + type NoFn, + type ProjectionOptions, + type Store +} from "../store.js"; +import { reconcileNextState } from "./reconcile.js"; +import { storeSetterNext, wrapNext } from "./store.js"; +import type { StoreNextFamily } from "./target.js"; + +function createWriteTraps(isActive?: () => boolean, onDraftWrite?: () => void): ProxyHandler { + // Save/restore, never hard-reset: the draft can be driven from inside an + // enclosing authoritative-write scope (next-store optimistic derives), and + // a hard `false` would clobber it mid-derive. + const traps: ProxyHandler = { + get(_, prop) { + let value; + const was = projectionWriteActive; + setWriteOverride(true); + setProjectionWriteActive(true); + try { + value = _[prop]; + } finally { + setWriteOverride(false); + setProjectionWriteActive(was); + } + if (prop === $TARGET) return value; + return typeof value === "object" && value !== null ? new Proxy(value, traps) : value; + }, + has(_, prop) { + let value; + const was = projectionWriteActive; + setWriteOverride(true); + setProjectionWriteActive(true); + try { + value = prop in _; + } finally { + setWriteOverride(false); + setProjectionWriteActive(was); + } + return value; + }, + set(_, prop, value) { + if (isActive && !isActive()) return true; + const was = projectionWriteActive; + setWriteOverride(true); + setProjectionWriteActive(true); + try { + _[prop] = value; + onDraftWrite?.(); + } finally { + setWriteOverride(false); + setProjectionWriteActive(was); + } + return true; + }, + deleteProperty(_, prop) { + if (isActive && !isActive()) return true; + const was = projectionWriteActive; + setWriteOverride(true); + setProjectionWriteActive(true); + try { + delete _[prop]; + onDraftWrite?.(); + } finally { + setWriteOverride(false); + setProjectionWriteActive(was); + } + return true; + } + }; + return traps; +} + +function createProjectionNextInternal( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: Partial, + options?: ProjectionOptions +) { + const fam: StoreNextFamily = { + map: new WeakMap(), + node: null, + shallow: !!(options as any)?.shallow + }; + const store = wrapNext(seed as any, null, null, fam) as Store; + if (fam.shallow) { + // Shallow projection: the root is the only wrapped level — slot values + // serve raw, ingests sticky raw-mark (same t.s machinery as plain). + ((store as any)[$TARGET] as any).s = true; + markRawIngest(seed); + } + + let nodeOptions: { name?: string; loadingValue?: void } | undefined; + if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined }; + if (__DEV__ && options?.name) nodeOptions = { ...nodeOptions, name: options.name }; + const node = computed(() => { + if (!fam.node) fam.node = getOwner() as Computed; + runProjectionComputedNext(store, fn, options?.key === undefined ? "id" : options.key); + }, nodeOptions); + node._config &= ~CONFIG_AUTO_DISPOSE; + fam.node = node; + + return { store, node } as { + store: Refreshable>; + node: Computed; + }; +} + +export function createProjectionNext( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: Partial | Store>, + options?: ProjectionOptions +): Refreshable> { + return createProjectionNextInternal(fn, seed, options).store; +} + +/** Derived writable store (legacy parity): a projection whose public setter + * masks the recompute for the tick (core R31 — the manual write wins over a + * same-flush dependency change). */ +export function createStoreDerivedNext( + fn: (draft: T) => void | T | Promise | AsyncIterable, + seed: Partial | Store>, + options?: ProjectionOptions +): [Refreshable>, (f: (draft: T) => T | void) => void] { + const { store, node } = createProjectionNextInternal(fn, seed, options); + return [ + store, + (f: (draft: T) => T | void): void => { + // Mark the projection as manually written before notifying nodes. + suppressComputedRecompute(node as Computed); + storeSetterNext(store, f); + } + ]; +} + +export function runProjectionComputedNext( + wrappedStore: Store, + fn: (draft: T) => void | T | Promise | AsyncIterable, + key: string | ((item: NonNullable) => any) | null, + wrapCommit?: (write: () => void) => void, + onDraftWrite?: () => void +): Computed { + const owner = getOwner() as Computed; + let settled = false; + let result: void | T | Promise | AsyncIterable; + // Open loading window (seedLoadingValue): the observable store IS commit #0 + // for the whole first flight — the derive works a detached shadow of the + // seed so draft writes cannot tear through to readers (#2988). Every commit + // point reconciles the shadow through the normal commit path. + const shadow = owner._loading + ? (JSON.parse(JSON.stringify((wrappedStore as any)[$TARGET][STORE_VALUE])) as T) + : null; + const draft = new Proxy( + wrappedStore, + createWriteTraps(() => !settled || owner._inFlight === result, onDraftWrite) + ); + storeSetterNext( + draft, + s => { + result = fn((shadow ?? s) as T); + settled = true; + const commit = (v: void | T) => { + // Shadow run: commit a detached snapshot, never the shadow itself + // (adoption takes the value by identity — handing it the live shadow + // would fuse the draft to the observable store). + if (shadow && (v === undefined || v === (shadow as any))) + v = JSON.parse(JSON.stringify(shadow)) as T; + if (v === (s as any) || v === undefined) return; + const write = () => + storeSetterNext(wrappedStore, st => reconcileNextState(v, st, key, true), false); + wrapCommit ? wrapCommit(write) : write(); + }; + const sync = handleAsync(owner, result, commit); + if (!owner._loading) commit(sync as void | T); + }, + false + ); + return owner; +} diff --git a/packages/solid-signals/src/store/next/reconcile.ts b/packages/solid-signals/src/store/next/reconcile.ts new file mode 100644 index 000000000..7644c4378 --- /dev/null +++ b/packages/solid-signals/src/store/next/reconcile.ts @@ -0,0 +1,386 @@ +/** + * Store rewrite — reconcile, the adoption channel (INTERNALS-STORE-STATE.md + * §3, decision 2026-08-16c). Reconcile never merge-writes: it adopts `next` + * as the authoritative pending backing at every proxied level (pointer swap + * folded at flush commit), notification riding the fold's descriptor diff. + * + * Structural optimizations (all kept, per 2026-08-17 morning ruling): + * - Identity skip with completed proof: `incoming === backing && !owned` — + * sound because input is immutable by convention (R2a) and ownership marks + * the only writer the convention doesn't cover (us). Fixes FINDING-1. + * - Reachability pruning: descent happens only where a child TARGET exists + * (proxies exist only where read) — never-subscribed subtrees are never + * walked (recon-snap R17), while a subscriber deep below an untracked path + * keeps its chain walkable because wrapping created the intermediate + * targets (recon-snap R16). + * - Keyed matching ported semantics: key-matched rows keep proxy identity; + * key mismatch detaches (fresh proxy on next read, recon-snap R18); + * keyless items fall back positional; null/primitive slots are legal + * members (R11). Kind changes replace wholesale (R10). + */ +import { isEqual } from "../../core/index.js"; +import { + $PROXY, + $TARGET, + isRawValue, + isWrappable, + markRawIngest, + rawValuesUsed +} from "../store.js"; +import { + adoptPB, + hasAccessorFlag, + notifyFold, + notifyFoldTail, + bumpDeep, + notifyKeyDiff, + targetsEqual, + notifyKeyValue, + unwrapValue +} from "./store.js"; +import { + ownedRaw, + storeNextLookup, + type StoreNextFamily, + type StoreNextTarget, + optHooks +} from "./target.js"; +import { getWriteOverride } from "../store.js"; +import { projectionWriteActive } from "../../core/scheduler.js"; + +type KeyFn = (item: any) => any; + +export function reconcileNextState( + value: any, + state: any, + key: string | KeyFn | null | undefined, + replace = false +): void { + if (state == null) throw new Error(__DEV__ ? "Cannot reconcile null or undefined state" : ""); + const t: StoreNextTarget | undefined = state?.[$TARGET]; + if (t === undefined || t.px !== state) + throw new Error(__DEV__ ? "reconcile target is not a store proxy" : ""); + let keyFn: KeyFn | null = + key === null ? null : typeof key === "string" ? (item: any) => item?.[key] : (key as KeyFn); + // §7b chained backing: a projection derive returning a LIVE store proxy + // adopts the proxy itself as the backing — reads flow through the inner + // store's traps, so consumers subscribe to the inner graph and updates + // flow with no re-derive (#2941). The adoption diff still notifies THIS + // store's existing subscribers of the swap. + if (replace && value !== state && value?.[$TARGET] !== undefined) { + const prev = t.pb ?? t.v; + if (prev === value) return; // already chained to this store + adoptPB(t, value); + return; + } + const incoming = unwrapValue(value); + if (keyFn) { + // Root identity precondition — checked before ANY mutation, so a throwing + // reconcile is atomic by construction (RUL-12 ruling). Projections + // (replace=true) relax it: a root entity change merges in place — the + // root proxy is stable for life (proj R5/R11) — and children are NOT + // key-matched across the entity change (proj R7: keyFn drops to + // 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 (!replace) + throw new Error(__DEV__ ? "Cannot reconcile states with different identity" : ""); + // Entity change: wholesale swap. The root proxy is stable for life + // (proj R5) but NOTHING below survives — children are never matched + // across an entity change even when their own keys align (proj R7). + // Displaced-raw unregistration (proj R10): the outgoing raw stops + // resolving to this proxy; re-handed later it wraps fresh. + (t.fam?.map ?? storeNextLookup).delete(t.pb ?? t.v); + adoptPB(t, incoming); + return; + } + } + // Tentative channel (§6b, RUL-5): a user-context reconcile on an optimistic + // family parks as engine overrides — values, membership, and length ride + // armed nodes (reverting with their transaction); committed raw is never + // touched. Key-matched rows keep proxy identity by descending into the + // existing child targets instead of overriding their parent slots. + if (t.fam?.opt === true && !projectionWriteActive && !getWriteOverride()) { + optHooks!.applyTentative(t, incoming, keyFn); + return; + } + applyAdopt(t, incoming, keyFn, replace); +} + +function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj = false): void { + const prev = t.pb ?? t.v; + // The sound identity skip (O7): same reference AND we never diverged it. + if (incoming === prev && !ownedRaw.has(prev)) return; + const fam = t.fam; + // §6b (R28): the diff's previous-arrangement baseline is the LANE VIEW — + // optimistic rows must be visible to key matching so a landing carrying the + // same key recycles their proxies. Raw `prev` keeps the identity/ownership + // roles above; only matching reads the view. + const prevView = fam?.opt === true ? optHooks!.optimisticView(t, prev) : prev; + const nextArr = Array.isArray(incoming); + // Plain stores notify inline AFTER the descent (child registrations feed + // the fold diff's identity-preservation check); projections keep deferred + // folds (downstream holds can form later in the flush). + const eager = fam === null; + const shallow = t.s === true; + const old = t.v; + adoptPB(t, incoming, eager); + // 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); + if (Array.isArray(prevView) !== nextArr) { + if (eager) notifyFold(t, old, incoming); + return; + } + if (nextArr) { + const prevRows = prevView as any[]; + const nextRows = incoming as any[]; + // Fused array walk (eager mode): per-index notification rides the same + // loop as the descent (descend first — targetsEqual needs the child's + // re-registration, R9). Length, trailing removed indexes, and any other + // unvisited node keys land in the counted sweep below. + const nodes = eager ? t.n : null; + let nodesHit = 0; + if (keyFn && !shallow) { + // Positional-prefix fast path (legacy keyedMatch-walk parity): while + // rows key-match in place — the steady-state polling shape — descend + // directly with zero staging. The prevByKey map is built only for the + // misaligned remainder, and never at all on aligned ticks. + const plen = prevRows.length; + const nlen = nextRows.length; + let dkBumpedA = false; + let i = 0; + for (const end = Math.min(plen, nlen); i < end; i++) { + const nv = nextRows[i]; + const pvRaw = prevRows[i]; + // Routing heuristic only (aligned vs keyed remainder) — both routes + // notify identically and descend() is the one authoritative + // validator, so bare typeof gates suffice here; full isWrappable + // per row was the walk's dominant residual cost. + if ( + pvRaw !== nv && + !( + pvRaw !== null && + typeof pvRaw === "object" && + nv !== null && + typeof nv === "object" && + keyFn(pvRaw) === keyFn(nv) + ) + ) + break; // misaligned: fall to the keyed remainder below + // Identity skip inline (FINDING-1 guard), then descend the pair. + if ( + (pvRaw !== nv || (nv !== null && typeof nv === "object" && ownedRaw.has(nv))) && + nv !== null && + typeof nv === "object" + ) + descend(unwrapValue(pvRaw), nv, keyFn, fam, proj); + if ( + t.dk !== null && + !dkBumpedA && + !(nv !== null && typeof nv === "object" ? targetsEqual(pvRaw, nv) : isEqual(pvRaw, nv)) + ) { + bumpDeep(t); + dkBumpedA = true; + } + if (nodes !== null) { + const node = nodes[i]; + if (node !== undefined) { + nodesHit++; + notifyKeyValue(node, i as any, (old as any)[i], nv, old, incoming); + } + } + } + if (t.dk !== null && !dkBumpedA && i < nextRows.length) bumpDeep(t); + let prevByKey: Map | null = null; + for (; i < nextRows.length; i++) { + const nv = nextRows[i]; + // typeof gates route; descend validates (same contract as the prefix). + if (nv !== null && typeof nv === "object") { + const nk = keyFn(nv); + let pv: any; + if (nk !== undefined) { + if (prevByKey === null) { + prevByKey = new Map(); + for (let j = 0; 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); + } + } + } + pv = prevByKey.get(nk); + } else { + pv = unwrapValue(prevRows[i]); // keyless item: positional fallback + } + descend(pv, nv, keyFn, fam, proj); + } + if (nodes !== null) { + const node = nodes[i]; + if (node !== undefined) { + nodesHit++; + notifyKeyDiff(node, i as any, old, incoming, false); + } + } + } + } else { + const dlen = Math.min(prevRows.length, nextRows.length); + const nlen = nextRows.length; + let dkBumpedP = false; + for (let i = 0; i < nlen; i++) { + const nvP = nextRows[i]; + if (!shallow && i < dlen && nvP !== null && typeof nvP === "object") + descend(unwrapValue(prevRows[i]), nvP, keyFn, fam, proj); + if ( + t.dk !== null && + !dkBumpedP && + !(nvP !== null && typeof nvP === "object" + ? targetsEqual(prevRows[i], nvP) + : isEqual(prevRows[i], nvP)) + ) { + bumpDeep(t); + dkBumpedP = true; + } + if (nodes !== null) { + const node = nodes[i]; + if (node !== undefined) { + nodesHit++; + notifyKeyDiff(node, i as any, old, incoming, false); + } + } + } + } + if (eager) { + if (nodes !== null && nodesHit < t.nc) { + for (const key of Reflect.ownKeys(nodes)) { + // visited indexes are < nextRows.length; everything else sweeps + const idx = typeof key === "string" ? +key : NaN; + if (!(idx >= 0 && idx < nextRows.length)) + notifyKeyDiff(nodes[key as any], key, old, incoming, false); + } + } + notifyFoldTail(t, old, incoming); + } + return; + } else { + // FUSED adoption walk (eager mode): one pass fetches each key's pair, + // descends, then notifies its node inline — descend runs FIRST so the + // child's re-registration is visible to targetsEqual (identity-preserved + // 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. + const nodes = eager ? t.n : null; + let nodesHit = 0; + let dkBumped = false; + // The per-key body is inlined on purpose (legacy applyStateFast parity: + // an extracted helper costs a call per key on the hottest object-diff + // site). Reference-identical values early-continue BEFORE any other + // work — sound only with the ownership guard (FINDING-1: an owned + // backing is setter-diverged and must still diff). + for (const k in incoming) { + const nv = (incoming as any)[k]; + const ov = (old as any)[k]; + const isObj = nv !== null && typeof nv === "object"; + if ( + ov === nv && + (!isObj || !ownedRaw.has(nv)) && + (nodes === null || nodes[k] === undefined || !hasAccessorFlag(nodes[k])) + ) { + if (nodes !== null && nodes[k] !== undefined) nodesHit++; + continue; + } + if (isObj && !shallow) descend(unwrapValue((prevView as any)[k]), nv, keyFn, fam, proj); + // Deep-witness (dk): value changes must notify even with NO per-key + // node — deep() subscribes one node per record. Checked after descend + // so in-place adoptions (same logical slot) don't bump; child records + // carry their own witness. One flag + null check when unused. + if (t.dk !== null && !dkBumped && !(isObj ? targetsEqual(ov, nv) : isEqual(ov, nv))) { + bumpDeep(t); + dkBumped = true; + } + if (nodes !== null) { + const node = nodes[k]; + if (node !== undefined) { + nodesHit++; + notifyKeyValue(node, k, ov, nv, old, incoming); + } + } + } + const syms = Object.getOwnPropertySymbols(incoming); + for (let i = 0; i < syms.length; i++) { + const k = syms[i]; + const nv = (incoming as any)[k]; + if (!shallow && nv !== null && typeof nv === "object") + descend(unwrapValue((prevView as any)[k]), nv, keyFn, fam, proj); + if (nodes !== null) { + const node = nodes[k as any]; + if (node !== undefined) { + nodesHit++; + notifyKeyValue(node, k, (old as any)[k], nv, old, incoming); + } + } + } + if (eager) { + // Deleted-key nodes (in the map but absent from incoming) — counted + // fast-out: when every node was visited, skip the sweep entirely. + if (nodes !== null && nodesHit < t.nc) { + for (const key of Reflect.ownKeys(nodes)) { + if (!hasOwnP.call(incoming, key)) + notifyKeyDiff(nodes[key as any], key, old, incoming, false); + } + } + notifyFoldTail(t, old, incoming); + } + return; + } + if (eager) notifyFold(t, old, incoming); +} + +const hasOwnP = Object.prototype.hasOwnProperty; + +function descend( + pv: any, + nv: any, + keyFn: KeyFn | null, + fam: StoreNextFamily | null, + proj = false +): void { + if (pv === null || typeof pv !== "object" || nv === null || typeof nv !== "object") return; + // Lookup FIRST: a hit implies pv was wrappable and never raw-marked (only + // wrappables acquire targets; rawValues never wrap) — one WeakMap get + // replaces isWrappable(pv) + isRawValue(pv), and a miss prunes untracked + // subtrees before any further checks. + const ct = (fam?.map ?? storeNextLookup).get(pv); + if (ct === undefined) return; // nothing proxied below this pair + // The NEW side still validates fully: a frozen/platform/markRaw'd incoming + // value is a leaf for reconcile — replaced by reference, never recursed + // into (R42); the parent's slot notification covers the change. + if (!isWrappable(nv)) return; + if (rawValuesUsed && isRawValue(nv)) return; + nv = unwrapValue(nv); + // Kind change replaces wholesale, never merges (R10): a target's carrier + // class (array vs object) is fixed at creation, so the slot detaches and a + // fresh proxy of the right kind wraps the incoming value on next read. + if (Array.isArray(pv) !== Array.isArray(nv)) return; + if (keyFn) { + const pk = keyFn(pv); + 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; + } + // Reachability pruning (§6d) is MODE-dependent, both pinned: + // - keyed matching descends only where subscriptions exist at/below (`d`) — + // captured-but-unobserved proxies deliberately detach and go stale + // (recon-snap R18; subscribing is what buys liveness); + // - positional (key: null) pairing preserves slot identity unconditionally + // (recon-snap R8 — the fixed-shape dashboard pattern). + // Projection merges (replace mode) preserve key-matched identity + // UNCONDITIONALLY (proj R6: the slot keeps its proxy without needing a + // subscriber below); plain keyed reconcile detaches unobserved captures + // (recon-snap R18 — staleness is the pinned pruning contract). + if (!proj && keyFn !== null && !ct.d) return; + applyAdopt(ct, nv, keyFn, proj); +} diff --git a/packages/solid-signals/src/store/next/store.ts b/packages/solid-signals/src/store/next/store.ts new file mode 100644 index 000000000..4ba911585 --- /dev/null +++ b/packages/solid-signals/src/store/next/store.ts @@ -0,0 +1,1487 @@ +/** + * Store rewrite — increment 2: plain deep stores with pending-backing writes. + * Contract: INTERNALS-STORE-STATE.md. + * + * Write model (RUL-1, unified): the first draft write to a target creates its + * pending backing `pb` — a descriptor-preserving CoW clone. The draft mutates + * `pb` natively (array methods, defineProperty, deletes all just work). + * Reads: drafts and owner-context reads see `pb`; context-free reads see the + * committed `b` until flush. At flush commit (core's storeCommitHook), each + * written target folds: diff old `b` vs new backing notifies exactly the + * changed keys through equality-gated nodes, then `b` becomes the new + * backing. Setter return-value replacement parks the UNOWNED incoming object + * in `pb` — adoption: fold swaps it in, ownership resets (2026-08-16c). + * + * Nodes carry no pending state — they are pure subscription points; `pb` is + * the pending home. Laziness: a written target with no subscriptions folds as + * a pointer swap with zero node work. + */ +import { + $REFRESH, + CONFIG_CHILDREN_FORBIDDEN, + CONFIG_OWNED_WRITE, + NOT_PENDING, + STATUS_ERROR, + STATUS_PENDING, + STATUS_UNINITIALIZED, + unwrapOverride +} from "../../core/constants.js"; +import { + devGuardStoreSetterWrite, + isEqual, + read as readNode, + READ_SLOW, + readNodeFast, + setSignal, + signal, + untrack +} from "../../core/core.js"; +import { activeTransition, globalQueue, insertSubs } from "../../core/scheduler.js"; +import { getObserver, getOwner } from "../../core/owner.js"; +import { + GlobalQueue, + projectionWriteActive, + schedule, + setProjectionWriteActive, + setStoreCommitHook +} from "../../core/scheduler.js"; +import type { Signal } from "../../core/types.js"; +import { pendingCheckActive, strictRead } from "../../core/core.js"; +import { + DEV, + registerGraph, + throwPendingUntrackedRead, + warnStrictReadUntracked +} from "../../core/dev.js"; +import { + $AFFECTS, + $PROXY, + $TARGET, + $TRACK, + affectsScopesLive, + getWriteOverride, + inheritAffectsMarks, + isRawValue, + isWrappable, + markRawIngest, + markRawOne, + rawValuesUsed, + setNextAffectsNodeResolver, + setNextOptimisticViewResolver, + witnessAffectsMark +} from "../store.js"; +import { + devAssertNeverUserMutation, + ingestedRaw, + ownedRaw, + storeNextLookup, + type StoreNextFamily, + type StoreNextTarget, + optHooks +} from "./target.js"; + +// --------------------------------------------------------------------------- +// wrap / dedupe + +function createTarget( + value: Record, + parent: StoreNextTarget | null, + parentKey: PropertyKey | null, + fam: StoreNextFamily | null = parent?.fam ?? null +): StoreNextTarget { + // The proxy target carries the array exotic class when the value is an + // array, so Array.isArray(proxy) is true; the fields live on it directly. + // Direct field assignment in one fixed order (no Object.assign literal + // copy): every target shares a hidden-class transition chain — createTarget + // was the #2 store cost in the uibench creation profile. + const t: StoreNextTarget = (Array.isArray(value) ? [] : {}) as any; + t.v = value; + // Chained-backing flag (backing IS another store's proxy, §7b) — cached so + // the hot read path never does a per-read symbol lookup on the backing. + t.ch = (value as any)[$TARGET] !== undefined; + t.pb = null; + t.n = null; + t.h = null; + t.k = null; + t.dk = null; + t.u = parent; + t.pk = parentKey; + t.px = null; + t.d = false; + t.a = false; + t.sc = false; + t.nc = 0; + t.adopted = false; + t.fam = fam; + t.s = false; + t.px = new Proxy(t, traps); + // Legacy interop: shared machinery (affects walks, wrap dedupe) reads the + // proxy off looked-up targets as a field. + (t as any)[$PROXY] = t.px; + (fam?.map ?? storeNextLookup).set(value, t); + if (__TEST__ && ingestedRaw && !ownedRaw.has(value)) ingestedRaw.add(value); + return t; +} + +export function wrapNext>( + value: T, + parent: StoreNextTarget | null = null, + parentKey: PropertyKey | null = null, + fam: StoreNextFamily | null = parent?.fam ?? null +): T { + // markRaw'd values never wrap through ANY store (R42; sticky raw-marking + // is one half of the never-both-wrapped-and-raw invariant, RUL-12). + if (rawValuesUsed && isRawValue(value)) return value; + const existing = (fam?.map ?? storeNextLookup).get(value); + if (existing !== undefined) return existing.px; + const t: StoreNextTarget | undefined = (value as any)[$TARGET]; + if (t !== undefined && t.px === value) { + // Foreign-family proxies re-wrap into THIS family (writes stay isolated); + // same-family and plain-store proxies pass through. + if (fam === null || t.fam === fam) return value; + return createTarget(value as any, parent, parentKey, fam).px; + } + return createTarget(value, parent, parentKey, fam).px; +} + +/** Unwrap our own proxies to their current backing; leave everything else. */ +export function unwrapValue(v: any): any { + if (v == null || typeof v !== "object") return v; + const t: StoreNextTarget | undefined = v[$TARGET]; + if (t !== undefined && t.px === v && t.v !== undefined) return t.pb ?? t.v; + return v; +} + +// --------------------------------------------------------------------------- +// nodes: pure subscription points (values used only for equality gating) + +export function getNode(target: StoreNextTarget, key: PropertyKey, current: any): Signal { + const nodes = (target.n ??= Object.create(null)); + let node: Signal | undefined = nodes[key]; + if (node === undefined) { + const created: Signal = (node = signal( + current, + { + // Logical-slot equality: values resolving to the same child target + // are the same slot (privatization/adoption swap raw identity without + // changing the logical value — only changed leaves notify, R9). + equals: (a: any, b: any) => isEqual(a, b) || sameLogicalSlot(target, a, b), + unobserved() { + // A live affects() mark keeps the node addressable (sweep parity). + if ((created as any)._affectsCount) return; + if (target.n && target.n[key] === created) { + delete target.n[key]; + target.nc--; + } + } + }, + // Projection nodes carry the projection computed as their firewall: + // reads through them link the derive's status/lifecycle (§7b). + (target.fam?.node as any) ?? undefined + )); + // Store nodes are ownedWrite: the setter carries the owned-scope write + // guard; node-level setSignals are internal notification machinery. + created._config |= CONFIG_OWNED_WRITE; + // Accessor-ness resolved ONCE per node (no per-object descriptor scan): + // accessor keys serve through Reflect.get with the proxy receiver. + (created as any).acc = isOwnAccessor(target.pb ?? target.v, key); + // Wrap cache: the proxy last served for this key and the raw it wrapped. + // Raw-as-truth stores raw in nodes, so every object read needs a wrapper; + // one pointer compare (pxv === value) replaces the per-read WeakMap + // lookup in wrapNext — the dominant read-path cost vs legacy, whose + // nodes stored pre-wrapped values. A replaced child fails the compare + // and re-wraps; at most one stale proxy is pinned until the next read. + (created as any).px = undefined; + (created as any).pxv = undefined; + // Optimistic families: arm the override slot — setSignal routes armed + // nodes through the core engine (lanes, ownership, reverts all native). + if (target.fam?.opt) created._overrideValue = NOT_PENDING; + // A node born inside a live mark's identity scope inherits the mark + // (the declaration walk could only cover nodes existing then). + if (key !== $AFFECTS && affectsScopesLive()) inheritAffectsMarks(created, target.v, key); + nodes[key] = node; + target.nc++; + markDescendants(target); + } + return node; +} + +function sameLogicalSlot(target: StoreNextTarget, a: any, b: any): boolean { + if (a === null || typeof a !== "object" || b === null || typeof b !== "object") return false; + const map = target.fam?.map ?? storeNextLookup; + const at = map.get(a); + return at !== undefined && at === map.get(b); +} + +export function getHasNode( + target: StoreNextTarget, + key: PropertyKey, + present: boolean +): Signal { + const nodes = (target.h ??= Object.create(null)); + let node: Signal | undefined = nodes[key]; + if (node === undefined) { + const created: Signal = (node = signal( + present, + { + equals: isEqual, + unobserved() { + if ((created as any)._affectsCount) return; + if (target.h && target.h[key] === created) delete target.h[key]; + } + }, + (target.fam?.node as any) ?? undefined + )); + created._config |= CONFIG_OWNED_WRITE; + if (target.fam?.opt) created._overrideValue = NOT_PENDING; + if (affectsScopesLive()) inheritAffectsMarks(created as any, target.v, key); + nodes[key] = node; + markDescendants(target); + } + return node; +} + +export function getKeySetNode(target: StoreNextTarget): Signal { + let k = target.k; + if (k === null) { + const created: Signal = (k = signal( + 0, + { + equals: false, + unobserved() { + if (target.k === created) target.k = null; + } + }, + (target.fam?.node as any) ?? undefined + )); + created._config |= CONFIG_OWNED_WRITE; + if (target.fam?.opt) created._overrideValue = NOT_PENDING; + target.k = k; + markDescendants(target); + } + return k; +} + +function getDeepNode(target: StoreNextTarget): Signal { + let dk = target.dk; + if (dk === null) { + const created: Signal = (dk = signal( + 0, + { + equals: false, + unobserved() { + if (target.dk === created) target.dk = null; + } + }, + (target.fam?.node as any) ?? undefined + )); + created._config |= CONFIG_OWNED_WRITE; + if (target.fam?.opt) created._overrideValue = NOT_PENDING; + if (affectsScopesLive()) inheritAffectsMarks(created as any, target.v, $TRACK); + target.dk = dk; + markDescendants(target); + } + return dk; +} + +/** Deep-witness bump: any value/shape change on a record with a live deep() + * subscriber notifies it. One null check when unused. */ +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) + +/** target → committed backing at batch start (the fold diff's old side). */ +const foldOlds = new Map>(); +let hookInstalled = false; + +function cloneRaw(source: Record, t?: StoreNextTarget): Record { + // Descriptor-preserving shallow clone (R29: installed getters stay live; + // ruled 2026-08-17: frozen sources clone unfrozen — theirs stays frozen). + // Data descriptors normalize to writable+configurable (the clone is OURS to + // mutate — R51's "source-non-configurable is writable through the store"); + // enumerability and accessors are preserved. The scan doubles as the + // accessor-flag detector (free — we're enumerating descriptors anyway). + const descs = Object.getOwnPropertyDescriptors(source); + for (const key of Reflect.ownKeys(descs)) { + const d = (descs as any)[key]; + if (key === "length" && Array.isArray(source)) continue; + d.configurable = true; + if (!d.get && !d.set) d.writable = true; + else if (t) t.a = true; + } + return Array.isArray(source) + ? (Object.defineProperties([], descs) as any) + : Object.create(Object.getPrototypeOf(source), descs); +} + +function ensurePB(target: StoreNextTarget): Record { + let pb = target.pb; + if (pb === null) { + pb = target.pb = cloneRaw(target.v, target); + // Optimistic families: seed USER drafts from the OPTIMISTIC VIEW + // (committed + active node overrides), so follow-up writes compose on + // optimism instead of clobbering from base (#2951's compose half). + // AUTHORITATIVE drafts (projection recompute / write-override landings) + // seed from committed truth — seeding overrides there would fold a lane + // value into the committed home ("authority wins at reveal" would break). + if (target.fam?.opt && !projectionWriteActive && !getWriteOverride()) { + const nodes = target.n; + if (nodes !== null) { + for (const key of Reflect.ownKeys(nodes)) { + const node = nodes[key as any]; + if (hasActiveOverride(node)) pb[key as any] = unwrapOverride(node._overrideValue); + } + } + const has = target.h; + if (has !== null) { + for (const key of Reflect.ownKeys(has)) { + const node = has[key as any]; + if (hasActiveOverride(node) && !unwrapOverride(node._overrideValue)) + delete pb[key as any]; + } + } + } + ownedRaw.add(pb); + (target.fam?.map ?? storeNextLookup).set(pb, target); + queueFold(target); + } + return pb; +} + +/** + * Adoption (2026-08-16c): the incoming object becomes the committed backing + * IMMEDIATELY — reconcile is eagerly visible to every reader (shipped + * contract; only its notifications batch), unlike setter writes which stay + * pending until flush. Ownership resets (incoming is unowned/user data). Any + * staged draft clone folds into the diff and is discarded — next is the + * authoritative base (R21/R32). + */ +export function adoptPB( + target: StoreNextTarget, + incoming: Record, + eager = false +): void { + // Eager mode (plain-store adoption): the caller notifies inline after its + // descent — no foldOlds queue/drain round trip (the reconcile diff IS the + // fold diff; ~half of dbmon tick time was this duplication). + if (!eager) { + queueFold(target); // records the pre-batch old before we swap + target.adopted = true; + } + target.pb = null; + 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); +} + +function queueFold(target: StoreNextTarget): void { + if (foldOlds.has(target)) return; + if (foldOlds.size === 0) { + if (!hookInstalled) { + hookInstalled = true; + setStoreCommitHook(drainFolds); + } + schedule(); // once per batch — drain clears the map + } + foldOlds.set(target, target.v); +} + +/** Committed-time privatization for parent-chain slot updates (path copying). */ +function privatizeCommitted(target: StoreNextTarget): void { + if (ownedRaw.has(target.v)) return; + const clone = cloneRaw(target.v, target); + ownedRaw.add(clone); + storeNextLookup.set(clone, target); + target.v = clone; + target.ch = false; + if (target.u) { + privatizeCommitted(target.u); + devAssertNeverUserMutation(target.u.v); + target.u.v[target.pk!] = target.v; + } +} + +function drainFolds(): void { + if (foldOlds.size === 0) return; + const entries = [...foldOlds]; + foldOlds.clear(); + for (const [t, old] of entries) { + if (t.pb !== null) { + // Setter path: nodes were setSignal'd at setter exit (write-time + // notification — transitions/holds ride core machinery). Commit the + // backing only for keys whose nodes have committed; a still-pending + // node (transition-held) re-queues the target for the settling flush. + let held = false; + const pb = t.pb; + const nodes = t.n; + if (nodes !== null) { + for (const key of Reflect.ownKeys(nodes)) { + const node = nodes[key as any]; + if (node._pendingValue !== NOT_PENDING) { + held = true; + break; + } + } + } + if (held) { + foldOlds.set(t, old); // re-queue: commit happens when the hold settles + continue; + } + t.v = pb; + t.ch = false; // pb is always a plain clone + t.pb = null; + } + 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); + devAssertNeverUserMutation(t.u.v); + t.u.v[t.pk!] = t.v; + } + if (t.adopted) { + t.adopted = false; + notifyFold(t, old, t.v); + } + } +} + +/** + * Setter-exit notification (write channel): diff the draft's pending backing + * against committed and setSignal every changed OBSERVED key — write-time + * notification with commit deferred to node commit, so transition holds, + * isPending, affects, and lane machinery ride the core natively (§3's + * "pending home = the node when a node exists"). Unobserved keys stay in the + * pending backing and fold directly at commit. + */ +function notifyWrites(t: StoreNextTarget): void { + const pb = t.pb; + if (pb === null) return; + // Optimistic channel: user writes on an optimistic family become node-level + // engine writes (armed nodes route setSignal through optimisticWrite) — the + // committed backing is NEVER touched; the draft clone is discarded. Reverts, + // per-transaction ownership, and flash-at-flush are all core-native. + // Projection recompute writes (projectionWriteActive) and projection draft + // writes (write-override, incl. post-await async landings) are + // authoritative and take the plain channel below (they commit silently + // under overrides per the engine's no-revert-stash contract). + if (t.fam?.opt) { + if (!projectionWriteActive && !getWriteOverride()) { + optHooks!.notifyOptimisticWrites(t, pb); + return; + } + // Authoritative path on an optimistic family: armed nodes must commit + // silently (engine bypass) — without this, a landing's setSignals would + // create lanes and block their own transition's settle. + if (!projectionWriteActive) { + setProjectionWriteActive(true); + try { + notifyWrites(t); + } finally { + setProjectionWriteActive(false); + } + return; + } + } + const old = t.v; + // Devtools mutation hook: full-key diff (dev-only cost) so unobserved + // writes report too, matching the legacy set-trap hook. + if (__DEV__ && DEV.hooks.onStoreNodeUpdate) { + for (const key of Reflect.ownKeys(pb)) { + if (Array.isArray(pb) && key === "length") continue; + const ov = old[key as any]; + const nv = pb[key as any]; + if (!isEqual(ov, nv)) DEV.hooks.onStoreNodeUpdate(t.px, key, nv, ov); + } + for (const key of Reflect.ownKeys(old)) { + if (key in pb) continue; + DEV.hooks.onStoreNodeUpdate(t.px, key, undefined, old[key as any]); + } + } + const nodes = t.n; + if (nodes !== null) { + for (const key of Reflect.ownKeys(nodes)) { + const node = nodes[key as any]; + // Per-key accessor handling: the node's cached flag plus ONE getter + // probe on the incoming side (getters arriving via merge/adoption). + // Setter-only props read as data (value undefined) so lookupSetter is + // not consulted on this hot path; prototype getters never own nodes. + if ( + (node as any).acc === true || + (hasOwn.call(pb, key) && lookupGetter.call(pb, key) !== undefined) + ) { + (node as any).acc = isOwnAccessor(pb, key); + const od = Object.getOwnPropertyDescriptor(old, key); + const nd = Object.getOwnPropertyDescriptor(pb, key); + if ((od && (od.get || od.set)) || (nd && (nd.get || nd.set))) { + if (od?.get !== nd?.get || od?.set !== nd?.set || od?.value !== nd?.value) + setSignal(node, () => FORCE as any); + continue; + } + if (!isEqual(od?.value, nd?.value)) setSignal(node, () => nd?.value); + continue; + } + // No old-side pre-compare: t.v lags across multi-batch windows (a + // projection recompute can run before the prior fold commits) — the + // node's OWN current value is the true old side, and setSignal's + // internal equality already checks exactly that. + const nv = pb[key as any]; + setSignal(node, () => nv); + } + } + const has = t.h; + if (has !== null) { + for (const key of Reflect.ownKeys(has)) setSignal(has[key as any], key in pb); + } + // 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. + if (t.dk !== null) { + for (const key of 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)) { + bumpDeep(t); + break; + } + } + } + if (t.k !== null) { + const changed = + Array.isArray(pb) && Array.isArray(old) + ? arrayStructureChanged(old as any[], pb as any[]) + : membershipChanged(old, pb); + if (changed) setSignal(t.k, v => v + 1); + } + // 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 + // committed for context-free readers (spec-async "pends only the written + // leaf"). drainFolds commits when held-ness is knowable. + // - post-await async LANDINGS (write-override per-op, microtask context — + // no enclosing flush can capture them): the data-level commit is + // IMMEDIATE — landed truth shows to untracked readers even while a + // downstream consumer's own async still holds the effect-level reveal + // (spec-async "verdicts never inherit consumers' in-flight state"). + if (t.fam !== null && t.pb !== null && getWriteOverride()) { + const oldBacking = t.v; + t.pb = null; + t.v = pb; + t.ch = false; + if (t.u && t.u.v[t.pk!] === oldBacking) { + privatizeCommitted(t.u); + devAssertNeverUserMutation(t.u.v); + t.u.v[t.pk!] = pb; + } + } +} + +const FORCE: unique symbol = Symbol(); + +/** Same logical slot: both values resolve to one (re-pointed) child target — + * adoption preserved identity, so the slot did not change (R9). */ +export function targetsEqual(ov: any, nv: any): boolean { + if (ov === null || typeof ov !== "object") return false; + const ot = storeNextLookup.get(ov); + return ot !== undefined && ot === storeNextLookup.get(nv); +} + +function arrayStructureChanged(old: any[], neu: any[]): boolean { + if (old.length !== neu.length) return true; + for (let i = 0; i < neu.length; i++) { + const ov = old[i]; + const nv = neu[i]; + if (!isEqual(ov, nv) && !targetsEqual(ov, nv)) return true; + } + return false; +} + +function membershipChanged(old: Record, neu: Record): boolean { + const nk = Reflect.ownKeys(neu); + if (Reflect.ownKeys(old).length !== nk.length) return true; + for (const key of nk) if (!(key in old)) return true; + return false; +} + +/** + * The fold diff walks SUBSCRIPTION KEYS ONLY (legacy parity: `for key in + * nodes`): nodes exist exactly where something tracked, so unobserved data + * costs nothing here regardless of object size. Accessor safety rides the + * sticky `t.a` flag — a node's key was necessarily read, so the get trap has + * already seen whether it is an accessor. + */ +/** One node's fold notification (shared by notifyFold's walk and the fused + * adoption walk): accessor-aware compare + equality/identity-gated setSignal. */ +export function notifyKeyDiff( + node: Signal, + key: PropertyKey, + old: Record, + neu: Record, + // The incoming-side getter probe covers SETTER-channel arrivals (return- + // form merges, defineProperty) — those flow through notifyWrites/ + // notifyFold, which probe. The RECONCILE channel (fused walk) passes + // false: reconcile adopts immutable data by contract (R2a) and the pinned + // getter-preservation tests are all setter-channel; skipping ~2 Annex-B + // calls per key per tick is a measured dbmon win. + probe = true +): void { + if ( + (node as any).acc === true || + (probe && hasOwn.call(neu, key) && lookupGetter.call(neu, key) !== undefined) + ) { + (node as any).acc = isOwnAccessor(neu, key); + const od = Object.getOwnPropertyDescriptor(old, key); + const nd = Object.getOwnPropertyDescriptor(neu, key); + if ((od && (od.get || od.set)) || (nd && (nd.get || nd.set))) { + // Accessor involved: never invoke; force-notify on shape change so + // subscribers re-read (and re-track) through the trap. + if (od?.get !== nd?.get || od?.set !== nd?.set || od?.value !== nd?.value) + setSignal(node, () => FORCE as any); + return; + } + const ov = od?.value; + const nv = nd?.value; + if (!isEqual(ov, nv) && !targetsEqual(ov, nv)) + setSignal(node, typeof nv === "function" ? () => nv : (nv as any)); + } else { + const ov = old[key as any]; + const nv = neu[key as any]; + // Direct value write when not a function (setSignal treats functions as + // updaters) — saves a closure allocation per changed key on the fold + // hot path. + if (!isEqual(ov, nv) && !targetsEqual(ov, nv)) + setSignal(node, typeof nv === "function" ? () => nv : (nv as any)); + } +} + +/** Accessor-flag probe for the fused walk's early-continue (accessor keys + * can never identity-skip: their VALUE is the descriptor's product). */ +export function hasAccessorFlag(node: Signal): boolean { + return (node as any).acc === true; +} + +/** Fused-walk per-key notification with values already in hand: the caller + * fetched both sides and handled the identity skip; this applies the + * accessor branch (cached flag only — reconcile channel) or the plain + * equality/identity-gated write. */ +export function notifyKeyValue( + node: Signal, + key: PropertyKey, + ov: any, + nv: any, + old: Record, + neu: Record +): void { + if ((node as any).acc === true) { + notifyKeyDiff(node, key, old, neu, false); + return; + } + // The pre-compare is NOT redundant with the node's equals: setSignal parks + // a pending value and registers with the batch before equality applies at + // commit (RUL-1), so identity-preserved slots (adopted child containers — + // every row's fresh `queries` array) must be gated out HERE or each one + // pays the full write machinery every tick (measured: +0.5ms/tick dbmon). + if (!isEqual(ov, nv) && !targetsEqual(ov, nv)) + setSignal(node, typeof nv === "function" ? () => nv : (nv as any)); +} + +/** Presence + membership halves of a fold notification (shared tail). */ +export function notifyFoldTail( + t: StoreNextTarget, + old: Record, + neu: Record +): void { + const has = t.h; + if (has !== null) { + for (const key of Reflect.ownKeys(has)) setSignal(has[key as any], key in neu); + } + if (t.k !== null) { + const changed = + Array.isArray(neu) && Array.isArray(old) + ? arrayStructureChanged(old as any[], neu as any[]) + : membershipChanged(old, neu); + if (changed) setSignal(t.k, v => v + 1); + } +} + +export function notifyFold( + t: StoreNextTarget, + old: Record, + neu: Record +): void { + if (t.dk !== null && old !== neu) bumpDeep(t); + // Optimistic targets: adoption notifications are authoritative landings — + // bypass the engine (commit into _value; active overrides keep shadowing + // until their transaction settles, per the no-revert-stash contract). + if (t.fam?.opt && !projectionWriteActive) { + setProjectionWriteActive(true); + try { + notifyFold(t, old, neu); + } finally { + setProjectionWriteActive(false); + } + return; + } + const nodes = t.n; + if (nodes !== null) { + for (const key of Reflect.ownKeys(nodes)) { + notifyKeyDiff(nodes[key as any], key, old, neu); + } + } + const has = t.h; + if (has !== null) { + for (const key of Reflect.ownKeys(has)) setSignal(has[key as any], key in neu); + } + if (t.k !== null) { + // Key-set/$TRACK: objects notify on membership; arrays on any index or + // length change (mapArray and iteration re-read values — R15). + const changed = + Array.isArray(neu) && Array.isArray(old) + ? arrayStructureChanged(old as any[], neu as any[]) + : membershipChanged(old, neu); + if (changed) setSignal(t.k, v => v + 1); + } +} + +// --------------------------------------------------------------------------- +// traps + +/** >0 while inside a setter: writes allowed, reads are read-your-writes. */ +let writing = 0; + +/** Write scope keys (a family object or a plain store's root target): draft + * semantics — write permission, read-your-writes, tracking suppression — + * apply ONLY to targets under a scope being written. Reads of OTHER stores + * inside a setter track normally (they are dependencies: a projection derive + * reading another store must link it). */ +let writeScopes: Set | null = null; + +function scopeKey(target: StoreNextTarget): any { + if (target.fam !== null) return target.fam; + let t = target; + while (t.u !== null) t = t.u; + return t; +} + +function inDraft(target: StoreNextTarget): boolean { + return writeScopes !== null && writeScopes.has(scopeKey(target)); +} + +/** Shallow serve rule (#2932): raw-marked data serves VERBATIM, but a + * store-proxy slot value gets a boundary wrapper in THIS store's own family — + * write isolation through derived chains (downstream writes must never land + * upstream). markRawOne skips proxies for exactly this reason. */ +function serveShallow(target: StoreNextTarget, key: PropertyKey, v: any): any { + if (v !== null && typeof v === "object" && (v as any)[$TARGET] !== undefined) + return draftServe(target, wrapNext(v, target, key as any)); + return v; +} + +/** Draft reads extend write permission to reachable stores (legacy Writing + * semantics: wrapping a child through a draft get admits it — cross-store + * writes like `s.inner.a = 10` work when `inner` is another store's proxy). */ +function draftServe(target: StoreNextTarget, proxy: any): any { + if (writeScopes !== null && inDraft(target)) { + const ct: StoreNextTarget | undefined = proxy?.[$TARGET]; + if (ct !== undefined && ct.v !== undefined) writeScopes.add(scopeKey(ct)); + } + return proxy; +} + +/** Targets written during the current (outermost) setter — notified at exit. */ +const pendingNotify = new Set(); + +const UNSAFE_KEYS = new Set(["__proto__", "prototype", "constructor"]); + +/** Mirror of core read()'s context rule: the OWNER context (not the tracking + * observer) decides pending visibility, with Roots resolving to their parent + * computed (#2687 — untracked reads inside mapArray Roots see in-flight + * values mid-flush). CHILDREN_FORBIDDEN execution scopes (createTrackedEffect + * / onSettled callbacks) get COMMITTED visibility (#3006), same as core. */ +function inOwnerContext(): boolean { + const c: any = getOwner(); + if (c === null) return false; + const eff = c._root ? c._parentComputed : c; + return eff != null && !(eff._config & CONFIG_CHILDREN_FORBIDDEN); +} + +/** A pending fold is transition-held when any written node's parked value is + * stamped by a live transition (a plain batch parking — the lazy-recompute + * read case — has no transition stamp and serves fresh). */ +function foldHeld(target: StoreNextTarget): boolean { + const nodes = target.n; + if (nodes === null) return false; + for (const key of Reflect.ownKeys(nodes)) { + const node: any = nodes[key as any]; + if ( + node._pendingValue !== NOT_PENDING && + node._transition != null && + node._transition._done !== true + ) + return true; + } + return false; +} + +function readSource(target: StoreNextTarget): Record { + // Signal-parity visibility (core read(): owner-context reads serve + // _pendingValue, context-free reads serve committed — effects recompute + // BEFORE commitPendingNodes in the flush, so the pending view must be + // servable). Drafts (setter window OR projection write-override) and + // owner-context reads see the pending backing; context-free reads see + // committed. Node reads apply the same rule, so both homes agree. + if ( + target.pb !== null && + (inDraft(target) || + getWriteOverride() || + inOwnerContext() || + // A projection's pending backing is authoritative-elect: serve it to + // context-free readers too UNLESS a transition is holding the node + // commits (downstream async hold — stale committed is the contract). + (target.fam !== null && !foldHeld(target))) + ) + return target.pb; + return target.v; +} + +const hasOwn = Object.prototype.hasOwnProperty; +// Allocation-free own-accessor probe (replaces eager descriptor scans — the +// single biggest creation cost in the uibench profile): Annex-B lookups +// return the fn or undefined with no descriptor object. Own data properties +// shadow prototype accessors, so hasOwn + lookup is an exact own-check. +const lookupGetter = (Object.prototype as any).__lookupGetter__; +const lookupSetter = (Object.prototype as any).__lookupSetter__; +function isOwnAccessor(src: Record, key: PropertyKey): boolean { + return ( + hasOwn.call(src, key) && + (lookupGetter.call(src, key) !== undefined || lookupSetter.call(src, key) !== undefined) + ); +} + +/** Authoritative-write wrapper exported for the optimistic module: sets the + * scheduler's projectionWriteActive through THIS module's binding (proven to + * share the instance core reads — cross-module live-binding writes from other + * store modules were observed not to propagate under the test transform). */ +export function runAuthoritative(fn: () => T): T { + const was = projectionWriteActive; + setProjectionWriteActive(true); + try { + return fn(); + } finally { + setProjectionWriteActive(was); + } +} + +/** Active optimistic override on an armed node (armed slot idles at + * NOT_PENDING; undefined = unarmed plain node). */ +export function hasActiveOverride(node: Signal): boolean { + return node._overrideValue !== undefined && node._overrideValue !== NOT_PENDING; +} + +/** Context-aware node view for reads outside tracking: active override > + * held pending (owner context) > the BACKING value. Committed truth lives in + * the backing (single-home rule, O6) — node `_value` is never served here, + * so a lazy recompute's landing is immediately visible to the untracked + * reader that forced it (backing commits eagerly; node values fold at flush). + * FORCE sentinels never surface (they only bump subscribers of accessor + * keys, which are served by the trap, not the node). */ +function nodeValue(node: Signal, backing: any): any { + const v = hasActiveOverride(node) + ? unwrapOverride(node._overrideValue) + : node._pendingValue !== NOT_PENDING && inOwnerContext() + ? node._pendingValue + : backing; + return v === (FORCE as any) ? backing : v; +} + +/** Serve an own data key: node-first when a node exists (pending visibility, + * holds, lanes ride the node); backing otherwise. Chained backings (§7b: the + * backing IS another store's proxy) serve the read-through value — the outer + * node is linked only for adoption-swap notification, its value never + * shadows the live chain. */ +function serveDataKey( + target: StoreNextTarget, + key: PropertyKey, + backingValue: any, + src: Record, + node?: Signal +): any { + const chained = target.ch && src === target.v; + let v = backingValue; + // §6: on optimistic arrays LENGTH IS A VIEW, not a node value — one home + // (backing ± presence overrides) for both length and indices makes torn + // iteration impossible (a length node's value rides different visibility + // rails than index overrides mid-settle). The node still carries + // subscriptions; its value is never served here. + if (key === "length" && target.fam?.opt === true && !chained && Array.isArray(src)) { + if (!inDraft(target)) { + const node = target.n?.length; + if (node !== undefined) { + if (getObserver() !== null) readNode(node); + } else if (getObserver() !== null) { + readNode(getNode(target, key, backingValue)); + } + } + return (optHooks!.optimisticView(target, src) as any[]).length; + } + if (inDraft(target)) { + // Optimistic drafts before their first write have no pending backing yet; + // reads must still see the live optimistic view (compose, not clobber — + // #2951). Once ensurePB runs, the seeded clone carries the view. + if (target.fam?.opt && target.pb === null) { + const node = target.n?.[key as any]; + if (node !== undefined && hasActiveOverride(node)) v = unwrapOverride(node._overrideValue); + } + } else { + if (node !== undefined) { + // §7b: a lane value on the outer node SHADOWS read-through — an active + // override pierces the chained gate; otherwise chained backings always + // serve the live inner value. + if (getObserver() !== null) { + // read()'s plain-signal fast path hoisted over the call (legacy trap + // parity): READ_SLOW = a global read window or non-plain node. + let nv = readNodeFast(node); + if (nv === READ_SLOW) nv = readNode(node); + if (!chained || hasActiveOverride(node)) v = nv === (FORCE as any) ? backingValue : nv; + } else if (!chained || hasActiveOverride(node)) { + v = nodeValue(node, backingValue); + } + } else if (getObserver() !== null) { + readNode(getNode(target, key, backingValue)); + } + } + // Shallow stores serve data raw; store-proxy slots get boundary wrappers. + if (target.s) return serveShallow(target, key, v); + if (node !== undefined) { + // Wrap cache (see getNode): only wrappables are ever cached, so a hit + // skips isWrappable too — pointer-compare replaces both checks. + if ((node as any).pxv === v && v !== undefined) return draftServe(target, (node as any).px); + if (!isWrappable(v)) return v; + const p = wrapNext(v, target, key as any); + (node as any).px = p; + (node as any).pxv = v; + return draftServe(target, p); + } + if (!isWrappable(v)) return v; + return draftServe(target, wrapNext(v, target, key as any)); +} + +/** §6c store-wide status gate for reads that DON'T flow through a node: + * untracked/raw fallthrough must still throw while the derive is + * uninitialized (seed invisibility, proj R23) or errored (memo parity). + * TRACKED reads never call this — store nodes carry `_firewall`, so core + * read() links the node and throws the firewall's error itself (the node + * link is what wakes async-memo readers when the landing writes values; + * the firewall link rides the same read). */ +function firewallGate(target: StoreNextTarget): void { + const fw: any = target.fam?.node; + if (fw != null && fw._statusFlags & (STATUS_UNINITIALIZED | STATUS_ERROR)) readNode(fw); +} + +const traps: ProxyHandler = { + get(target, key, receiver) { + // One typeof gates every brand-symbol compare off the hot string path + // (four symbol comparisons per property read otherwise). + if (typeof key !== "string") { + if (key === $TARGET) return target; + if (key === $PROXY) return receiver; + // refresh()/isPending resolve the projection computed through $REFRESH. + if (key === $REFRESH) return target.fam?.node ?? undefined; + if (key === $TRACK) { + if (pendingCheckActive) witnessAffectsMark(target as any, key); + if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target); + if (!inDraft(target) && getObserver() !== null) { + readNode(getKeySetNode(target)); + // Structural chaining (§7b, #2864 / core R21): a chained backing's + // $TRACK reads through to the INNER store's key-set — structural + // notifications land on the source's own node, never on this + // wrapper view's. + const srcT = readSource(target); + if ((srcT as any)[$TARGET] !== undefined) (srcT as any)[$TRACK]; + } + return undefined; + } + // user symbols fall through to the generic path + } + if (pendingCheckActive) witnessAffectsMark(target as any, key); + if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target); + const src = readSource(target); + // Hot inline case: existing PLAIN node (non-accessor), unchained backing, + // tracked read of a present data key — the dbmon/uibench effect re-read + // shape. Skips serveDataKey's frame, the FORCE compare (only accessor + // keys ever hold the sentinel), and isWrappable for primitives. + if (target.ch === false && writeScopes === null) { + const nodeH = target.n?.[key as any]; + if (nodeH !== undefined && (nodeH as any).acc !== true && getObserver() !== null) { + let nv = readNodeFast(nodeH); + if (nv === READ_SLOW) nv = readNode(nodeH); + if (nv === null || typeof nv !== "object") return nv; + if (target.s) return serveShallow(target, key, nv); + if ((nodeH as any).pxv === nv) return (nodeH as any).px; + if (isWrappable(nv)) { + const p = wrapNext(nv, target, key); + (nodeH as any).px = p; + (nodeH as any).pxv = nv; + return p; + } + return nv; + } + } + // Dev strictRead: untracked store reads in labeled scopes (component + // bodies, effect callbacks) warn — the value can never update the reader. + if ( + __DEV__ && + strictRead && + !inDraft(target) && + typeof key === "string" && + getObserver() === null + ) { + // Safeguard parity with core read() (#2897): a component-body read of + // a REFETCHING derived store escalates — the untracked reader can never + // observe the in-flight update (strict-read matrix, opt R30–R34). + if (((target.fam?.node as any)?._statusFlags ?? 0) & STATUS_PENDING) + throwPendingUntrackedRead(strictRead, { nodeName: key }); + warnStrictReadUntracked(strictRead, { + nodeName: key, + data: { strictRead, property: key, source: "store" } + }); + } + // Accessor keys serve through Reflect.get with the PROXY receiver + // (R20/R29: internal reads track; the node is linked for shape-change + // notification but its value is never served). Accessor-ness comes from + // the node's cached flag; the first TRACKED read (which creates the + // node) probes once — untracked node-less reads take the plain path, + // where a raw-receiver getter still returns correct committed values. + const node0 = target.n?.[key as any]; + { + const acc = + node0 !== undefined + ? (node0 as any).acc === true + : !writing && getObserver() !== null && isOwnAccessor(src, key); + if (acc) { + if (!writing && getObserver() !== null) readNode(node0 ?? getNode(target, key, undefined)); + const v = Reflect.get(src, key, receiver); + if (target.s) return serveShallow(target, key, v); + return isWrappable(v) ? draftServe(target, wrapNext(v, target, key)) : v; + } + } + // Plain-data fast path: no descriptor allocation per read. + // Inherited pollution keys are never served (core R30) — checked before + // the proto-function branch can leak `constructor`. Interned-string + // compares beat a Set hash on this per-read path. + if ( + (key === "constructor" || key === "__proto__" || key === "prototype") && + !hasOwn.call(src, key) + ) + return undefined; + let v = (src as any)[key]; + if (v === undefined ? !hasOwn.call(src, key) : false) { + // Inherited: prototype getters/methods run with the proxy receiver. + v = Reflect.get(src, key, receiver); + if (typeof v === "function") return v; // proto methods untracked + // Reading a currently-absent own key subscribes to it (R12). + if (v === undefined && !writing) { + if (getObserver() !== null) readNode(getNode(target, key, undefined)); + const node = target.n?.[key]; + if (node) { + const nv = nodeValue(node, undefined); + if (target.s) return serveShallow(target, key, nv); + return isWrappable(nv) ? draftServe(target, wrapNext(nv, target, key)) : nv; + } + } else if (v === undefined && inDraft(target) && target.fam?.opt && target.pb === null) { + const node = target.n?.[key]; + if (node !== undefined && hasActiveOverride(node)) v = unwrapOverride(node._overrideValue); + } + if (target.s) return serveShallow(target, key, v); + return isWrappable(v) ? draftServe(target, wrapNext(v, target, key)) : v; + } + if (typeof v === "function" && !hasOwn.call(src, key)) return v; // proto method + return serveDataKey(target, key, v, src, node0); + }, + + has(target, key) { + if (key === $TARGET || key === $PROXY || key === $TRACK) return true; + if (pendingCheckActive) witnessAffectsMark(target as any, key); + if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target); + const src = readSource(target); + let present = key in src; + if (!inDraft(target)) { + if (getObserver() !== null) { + const node = getHasNode(target, key, present); + const nv = readNode(node); + if (hasActiveOverride(node)) present = !!nv; + } else { + const node = target.h?.[key as any]; + if (node !== undefined && hasActiveOverride(node)) + present = !!unwrapOverride(node._overrideValue); + } + } else if (target.fam?.opt && target.pb === null) { + const node = target.h?.[key as any]; + if (node !== undefined && hasActiveOverride(node)) + present = !!unwrapOverride(node._overrideValue); + } + return present; + }, + + ownKeys(target) { + if (pendingCheckActive) witnessAffectsMark(target as any); + if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target); + if (!inDraft(target) && getObserver() !== null) readNode(getKeySetNode(target)); + const keys = Reflect.ownKeys(readSource(target)); + // Optimistic membership overlay: presence-node overrides add/remove keys + // (per-transaction lifecycle rides the nodes — §6, FINDING-2's fix). + // Draft reads before the first write overlay too (pb, once created, is + // seeded with the view). + if (target.fam?.opt && target.h !== null && (!inDraft(target) || target.pb === null)) { + let set: Set | null = null; + for (const key of Reflect.ownKeys(target.h)) { + const node = target.h[key as any]; + if (!hasActiveOverride(node)) continue; + set ??= new Set(keys); + if (unwrapOverride(node._overrideValue)) set.add(key); + else set.delete(key); + } + if (set !== null) return [...set] as (string | symbol)[]; + } + return keys; + }, + + getOwnPropertyDescriptor(target, key) { + const desc = Object.getOwnPropertyDescriptor(readSource(target), key); + if (target.fam?.opt && !inDraft(target)) { + const node = target.h?.[key as any]; + if (node !== undefined && hasActiveOverride(node)) { + if (!unwrapOverride(node._overrideValue)) return undefined; // opt delete + if (desc === undefined) { + const vn = target.n?.[key as any]; + return { + value: vn !== undefined ? nodeValue(vn, undefined) : undefined, + writable: true, + enumerable: true, + configurable: true + }; + } + } + } + if (desc === undefined) return undefined; + // Array targets carry a real non-configurable `length` the proxy + // invariant forces us to report faithfully; everything else reports + // configurable via target indirection (core R51). + if (!(key === "length" && Array.isArray(target))) desc.configurable = true; + return desc; + }, + + set(target, key, value) { + // Writes require the target's draft scope OR the projection write + // override (post-await async draft writes arrive outside any window); + // everything else is silently ignored (R23). + const draft = inDraft(target); + const override = !draft && getWriteOverride(); + if (!draft && !override) return true; + if (key === "__proto__") return true; // pollution guard (core R30) + const pb = ensurePB(target); + pendingNotify.add(target); + // Own data keys literally named "prototype"/"constructor" land as data — + // defineProperty sidesteps a proto-chain setter named the same. + if (UNSAFE_KEYS.has(key)) { + Object.defineProperty(pb, key, { + value: unwrapValue(value), + writable: true, + enumerable: true, + configurable: true + }); + return true; + } + // Shallow slots store what was written VERBATIM — another store's proxy + // passes through by reference (#2932; markRawOne skips proxies), while + // deep stores unwrap to raw backings. + const uv = target.s ? value : unwrapValue(value); + pb[key as any] = uv; + // Shallow ingest: written records are sticky raw-marked (one entity is + // never both deep-wrapped and raw — R41/#2932, shared invariant). + if (target.s && uv !== null && typeof uv === "object") markRawOne(uv); + // Override-mode (post-await draft) writes have no setter exit — notify + // per-op (setSignal equality-gates repeats). + if (override) notifyWrites(target); + return true; + }, + + defineProperty(target, key, desc) { + const draft = inDraft(target); + const override = !draft && getWriteOverride(); + if (!draft && !override) return true; + if (key === "__proto__") return true; + if (desc.get || desc.set) target.a = true; + const pb = ensurePB(target); + pendingNotify.add(target); + if ("value" in desc) desc = { ...desc, value: unwrapValue(desc.value) }; + Object.defineProperty(pb, key, desc); + if (override) notifyWrites(target); + return true; + }, + + deleteProperty(target, key) { + const draft = inDraft(target); + const override = !draft && getWriteOverride(); + if (!draft && !override) return true; + const pb = ensurePB(target); + pendingNotify.add(target); + delete pb[key as any]; + if (override) notifyWrites(target); + return true; + } +}; + +// --------------------------------------------------------------------------- +// createStore + +export type SetStoreNextFunction = (fn: (draft: T) => T | void) => void; + +/** Low-level setter primitive: opens write mode on a next proxy, runs `fn`, + * emits write-time notifications at outermost exit, applies returned + * replacements as adoptions. `guard=false` skips the owned-scope dev guard — + * projection recomputes legitimately write from inside their computed. */ +export function storeSetterNext(proxy: T, fn: (draft: T) => T | void, guard = true): void { + if (__DEV__ && guard) devGuardStoreSetterWrite(); + const target: StoreNextTarget = (proxy as any)[$TARGET]; + const prevScopes = writeScopes; + writeScopes = new Set(); + writeScopes.add(scopeKey(target)); + writing++; + let result: any; + try { + // No untrack: the writing flag already disables store-node linking + // (draft reads never self-track, proj R2), while EXTERNAL reads (signals + // inside a projection derive) must keep tracking — they are the derive's + // dependencies. + result = fn(proxy); + } finally { + writing--; + writeScopes = prevScopes; + // Outermost setter exit: emit write-time notifications (setSignal per + // changed observed key) so transition holds and lanes engage now. + if (writing === 0 && pendingNotify.size) { + const touched = [...pendingNotify]; + pendingNotify.clear(); + for (const t of touched) notifyWrites(t); + } + } + if (result !== undefined && result !== proxy && isWrappable(result)) { + // Returned replacement: on an optimistic family (outside authoritative + // writes) the replacement is itself an optimistic edit — diff it against + // the visible view as engine writes (reverts at settle). Otherwise it is + // an adoption of the incoming object (unowned). + if (target.fam?.opt && !projectionWriteActive && !getWriteOverride()) { + optHooks!.notifyOptimisticWrites(target, unwrapValue(result)); + } else { + adoptPB(target, unwrapValue(result)); + } + } +} + +// Affects integration: the legacy affects machinery reads next targets +// structurally (aliased field names); only node CREATION dispatches here. +setNextAffectsNodeResolver((t: StoreNextTarget, key: PropertyKey) => + key === $AFFECTS + ? (getNode(t, $AFFECTS, undefined) as any) + : (getNode(t, key, (t.pb ?? t.v)[key as any]) as any) +); + +export function createStoreNext>( + init: T, + shallow = false +): [T, SetStoreNextFunction] { + if (shallow && __DEV__) { + // Never both deep-wrapped and raw (R41/R44): a value already tracked as + // a DEEP store cannot be ingested shallow. + const existing = storeNextLookup.get(init); + if (existing !== undefined && !(existing as any).s) + throw new Error("createStore({ shallow }): value is already tracked as a deep store"); + if ((init as any)[$TARGET]) + throw new Error("createStore({ shallow }): value is already a store proxy"); + } + const proxy = wrapNext(init); + if (shallow) { + ((proxy as any)[$TARGET] as StoreNextTarget).s = true; + markRawIngest(init); + } + if (__DEV__) registerGraph(proxy, getOwner()); + const setter: SetStoreNextFunction = fn => storeSetterNext(proxy, fn); + return [proxy, setter]; +} + +// --------------------------------------------------------------------------- +// snapshot (next targets): the backing IS the plain raw graph — zero copy. +// Sees pending (R27) by reading pb. Chained/owned-copy caching lands with the +// utilities increment; this covers the createStore-suite contract. + +function isNextProxy(value: any): boolean { + return ( + value !== null && + typeof value === "object" && + (value as any)[$TARGET] !== undefined && + ((value as any)[$TARGET] as StoreNextTarget).px === value + ); +} + +/** Tracking deep snapshot (`deep()` for next targets): subscribes to the + * key-set and every property 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]; + if (t0 === undefined || t0.px !== value) return value; + const visited = new Set(); + // One membership node + one deep-witness node PER RECORD (legacy $TRACK + // parity): the walk stays O(records) in subscriptions instead of O(paths) + // in per-key nodes, and it walks TARGETS directly — no per-child proxy + // round-trip (wrapNext → proxy → $TARGET trap) on the re-walk every + // effect run performs. + const walkT = (t: StoreNextTarget): void => { + const src = readSource(t); + if (visited.has(src)) return; + visited.add(src); + readNode(getKeySetNode(t)); + readNode(getDeepNode(t)); + const map = t.fam?.map ?? storeNextLookup; + for (const key of Reflect.ownKeys(src)) { + const desc = Object.getOwnPropertyDescriptor(src, key); + if (desc === undefined) continue; + if (desc.get || desc.set) { + t.a = true; + continue; // accessors track through their own reads when invoked + } + const child = desc.value; + if (child === null || typeof child !== "object") continue; + // Stored proxies (chained slots) resolve through their own target; + // raw children through the family map, created on first visit. + let ct: StoreNextTarget | undefined = (child as any)[$TARGET] ?? map.get(child); + if (ct === undefined) { + if (!isWrappable(child)) continue; + wrapNext(child, t, key); + ct = map.get(child); + if (ct === undefined) continue; // raw-marked: leaf by contract + } + walkT(ct); + } + }; + walkT(t0); + return snapshotNext(value); +} + +/** + * Snapshot with per-object registration resolution (RUL-12 DAG ruling): every + * reachable wrappable resolves through its target's CURRENT backing, so + * privatized subtrees are seen through any parent path. Identity-preserving: + * a subtree with no substitutions below returns its own object (zero copy for + * settled, never-diverged graphs). + */ +export function snapshotNext(value: T): T { + const t: StoreNextTarget | undefined = (value as any)?.[$TARGET]; + return snapshotWalk(value, new Map(), t?.fam ?? null); +} + +function snapshotWalk(value: any, seen: Map, fam: StoreNextFamily | null): any { + if (value === null || typeof value !== "object") return value; + // Resolve through the registration: proxies AND raws map to their target's + // current backing (stale raw pointers through other parents resolve here). + // Loops for chained backings (§7b: a projection's backing can be another + // store's proxy — snapshot unwraps to the base raw). + let src = value; + // Chained backings can pass through several targets; optimistic overrides + // on OUTER targets shadow the chain (§7b), so collect every opt target + // encountered and compose their views over the resolved base, innermost + // outward. + let optOwners: StoreNextTarget[] | null = null; + for (;;) { + let t: StoreNextTarget | undefined = src?.[$TARGET]?.v !== undefined ? src[$TARGET] : undefined; + if (t === undefined && fam !== null) t = fam.map.get(src); + if (t === undefined) t = storeNextLookup.get(src); + if (t === undefined) break; + if (t.fam !== null) fam = t.fam; + if (t.fam?.opt === true) (optOwners ??= []).push(t); + const backing = t.pb ?? t.v; + if (backing === src) break; + src = backing; + } + if (!isWrappable(src)) return src; + // Optimistic families: compose the visible view; a composed view is a fresh + // object and snapshots via the owned/copy path (pinned `not.toBe` identity). + if (optOwners !== null) { + let view: any = src; + for (let i = optOwners.length - 1; i >= 0; i--) + view = optHooks!.optimisticView(optOwners[i], view); + if (view !== src) { + const cachedView = seen.get(src); + if (cachedView !== undefined) return cachedView; + const isArr = Array.isArray(view); + const copy: any = isArr ? [] : Object.create(Object.getPrototypeOf(view)); + seen.set(src, copy); + for (const key of Reflect.ownKeys(view)) { + if (isArr && key === "length") continue; + const cv = (view as any)[key]; + copy[key] = cv !== null && typeof cv === "object" ? snapshotWalk(cv, seen, fam) : cv; + } + if (isArr) copy.length = (view as any[]).length; + return copy; + } + } + const cached = seen.get(src); + if (cached !== undefined) return cached; + + // OWNED (written) subtrees snapshot as copies (§7b: identity is only for + // subtrees "unmodified relative to source"): non-enumerable symbols are + // excluded (recon-snap R29), and the copy registers BEFORE descent so + // cycles keep identity (FINDING-3). + if (ownedRaw.has(src)) { + const isArr = Array.isArray(src); + const copy: any = isArr ? [] : Object.create(Object.getPrototypeOf(src)); + seen.set(src, copy); + for (const key of Reflect.ownKeys(src)) { + if (isArr && key === "length") continue; + const desc = Object.getOwnPropertyDescriptor(src, key)!; + if (typeof key === "symbol" && !desc.enumerable) continue; + if (desc.get || desc.set) { + Object.defineProperty(copy, key, desc); + continue; + } + const cv = desc.value; + const walked = cv !== null && typeof cv === "object" ? snapshotWalk(cv, seen, fam) : cv; + if (desc.enumerable && desc.writable && desc.configurable) copy[key] = walked; + else Object.defineProperty(copy, key, { ...desc, value: walked }); + } + if (isArr && copy.length !== (src as any[]).length) copy.length = (src as any[]).length; + return copy; + } + + // UNOWNED (shared/user) subtrees keep identity unless a descendant + // substituted; copy-on-substitution preserves the documented CoW contract. + seen.set(src, src); + let copy: any = null; + for (const key of Reflect.ownKeys(src)) { + const desc = Object.getOwnPropertyDescriptor(src, key); + if (!desc || desc.get || desc.set) continue; + const cv = desc.value; + if (cv === null || typeof cv !== "object") continue; + const walked = snapshotWalk(cv, seen, fam); + if (walked !== cv) { + if (copy === null) { + copy = Array.isArray(src) + ? [...(src as any[])] + : Object.create(Object.getPrototypeOf(src), Object.getOwnPropertyDescriptors(src)); + seen.set(src, copy); + } + copy[key] = walked; + } + } + return copy ?? src; +} diff --git a/packages/solid-signals/src/store/next/target.ts b/packages/solid-signals/src/store/next/target.ts new file mode 100644 index 000000000..030528bd5 --- /dev/null +++ b/packages/solid-signals/src/store/next/target.ts @@ -0,0 +1,115 @@ +/** + * Store rewrite — target & ownership (INTERNALS-STORE-STATE.md §1, §5b). + * + * The proxy wraps this internal target, never the raw (proxy-target + * indirection, decision 2026-08-16d). `b` is the single committed home; `pb` + * is the per-target pending backing (RUL-1's pending home): the CoW clone + * created at first draft write, mutated natively by the draft, folded into + * `b` at flush commit. Adoption (setter replacement / reconcile) parks an + * UNOWNED incoming object in `pb` — fold swaps it in and ownership resets. + * + * Creation budget (§5b): one minimal target + one proxy + one storeLookup + * 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"; + +/** Projection family (§7b): children wrap into the family's own map (writes + * land in the projection, never the source family), and every node created + * under the family carries the projection computed as its firewall. */ +export interface StoreNextFamily { + /** Optimistic family: nodes are born armed (`_overrideValue` slot) so every + * write rides the core optimistic engine — lanes, per-transaction ownership, + * reverts all core-native (§3, RUL-3). */ + opt?: boolean; + /** Root proxy (registered with the scheduler's optimistic-store set for the + * transitionBlocked store-half, #2951). */ + px?: any; + /** Targets currently carrying active node overrides (landing-consumption + * walk, RUL-2: visible landed truth replaces optimism). */ + overlaid?: Set; + map: WeakMap; + /** The projection computed — assigned after creation (accessor pattern). */ + node: Computed | null; + shallow?: boolean; +} + +export interface StoreNextTarget { + /** Committed backing: source object (shared) or owned clone. */ + v: Record; + /** Pending backing for the current flush (null when settled). */ + pb: Record | null; + /** cached: committed backing is another store's proxy (§7b chained). */ + ch: boolean; + /** live value-node count (deleted-key sweep fast-out in the fused walk). */ + nc: number; + /** Lazy per-property subscription nodes (real core signals). */ + n: Record> | null; + /** Lazy per-key presence nodes (`in` tracks presence, not value — R13). */ + h: Record> | null; + /** Lazy key-set node: membership/iteration/$TRACK subscriptions (§6). */ + k: Signal | 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). */ + dk: Signal | null; + /** Parent target (path copying walks this at commit). */ + u: StoreNextTarget | null; + /** Property key of this target in the parent's backing. */ + pk: PropertyKey | null; + /** The proxy for this target (stable outward identity). */ + px: any; + /** Sticky descendants flag (§6d). */ + d: boolean; + /** Sticky accessors-seen flag: an own accessor property was observed on + * this target (first-read scan, defineProperty, or clone scan). Gates the + * fold diff's descriptor-safe path and the get trap's descriptor path. */ + a: boolean; + /** Accessor scan performed (scan-once on first trap read; adopted data is + * not rescanned — legacy-parity behavior). */ + sc: boolean; + /** Backing was swapped by adoption this batch (fold diff-notifies it). */ + adopted: boolean; + /** Projection family, null for plain stores (§7b). */ + fam: StoreNextFamily | null; + /** Shallow store root (values served raw). */ + s: boolean; +} + +/** + * Ownership (first cut, decision 2026-08-16d): one WeakSet of store-owned + * backings serving both the production identity-skip guard and the __TEST__ + * no-mutation oracle. + */ +export const ownedRaw = new WeakSet(); + +/** raw → target. The only raw-keyed lookup; boundary mechanism (O8). */ +export const storeNextLookup = new WeakMap(); + +/** __TEST__ oracle: every object ingested from a user (never mutate). */ +export const ingestedRaw: WeakSet | null = __DEV__ ? new WeakSet() : null; + +export function devAssertNeverUserMutation(target: object): void { + if (!__TEST__ || !ingestedRaw) return; + if (ingestedRaw.has(target) && !ownedRaw.has(target)) { + throw new Error( + "[STORE-NEXT INV] write path mutated a user-provided (non-owned) object — CoW privatization was bypassed" + ); + } +} + +/** Injection table for optimistic-only store machinery (next/optimistic.ts). + * Every call site is gated on `fam?.opt`, and optimistic families can only + * be created by createOptimisticStore — whose install populates this — so + * the non-null assertions at the sites hold by construction. Keeping the + * implementations out of next/store.ts lets plain-store bundles tree-shake + * the optimistic channel entirely. */ +export interface OptStoreHooks { + notifyOptimisticWrites(t: any, pb: Record): void; + optimisticView(t: any, src: Record): Record; + applyTentative(t: any, incoming: any, keyFn: ((item: any) => any) | null): void; +} +export let optHooks: OptStoreHooks | null = null; +export function setOptHooks(h: OptStoreHooks): void { + optHooks = h; +} diff --git a/packages/solid-signals/src/store/optimistic.ts b/packages/solid-signals/src/store/optimistic.ts deleted file mode 100644 index f869e521a..000000000 --- a/packages/solid-signals/src/store/optimistic.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { STATUS_PENDING } from "../core/constants.js"; -import { - computed, - CONFIG_AUTO_DISPOSE, - NOT_PENDING, - type Computed, - type Refreshable -} from "../core/index.js"; -import { installOptimisticEngine } from "../core/optimistic.js"; -import { - currentTransition, - GlobalQueue, - insertSubs, - projectionWriteActive, - schedule, - setProjectionWriteActive, - type Transition -} from "../core/scheduler.js"; -import { runProjectionComputed } from "./projection.js"; -import { - $DELETED, - $TARGET, - $TRACK, - createStoreProxy, - getOverlayLayer, - isWrappable, - STORE_FIREWALL, - STORE_LOOKUP, - STORE_NODE, - STORE_OPTIMISTIC, - STORE_OPTIMISTIC_OVERRIDE, - STORE_OPTIMISTIC_OWNERS, - STORE_VALUE, - STORE_SHALLOW, - STORE_WRAP, - markRawIngest, - notifySelf, - storeSetter, - storeTraps, - visibleNodeValue, - wrap, - type NoFn, - type ProjectionOptions, - type Store, - type StoreNode, - type StoreSetter -} from "./store.js"; - -/** - * The store equivalent of `createOptimistic`. Writes inside an `action` - * transition are tentative — they show up immediately but auto-revert (or - * reconcile to the action's resolved value) once the transition finishes. - * - * Use this for optimistic UI on collection-shaped data. For single-value - * optimistic state, prefer `createOptimistic`. - * - * - Plain form: `createOptimisticStore(initialValue)`. - * - Derived form: `createOptimisticStore(fn, seed, options?)` — a projection - * store whose authoritative value is recomputed by `fn` and whose - * optimistic overlay reverts after each transition. - * - * `options.key` defaults to `"id"`; specify it only when your data uses a - * different identity field (e.g. `{ key: "uuid" }` or `{ key: t => t.slug }`), - * or `null` to merge positionally. Restating the default just adds noise. - * - * @example - * ```ts - * const [todos, setTodos] = createOptimisticStore([]); - * - * // Mutation: optimistic add, then in-place reconcile to the saved row. - * const addTodo = action(function* (text: string) { - * const tempId = crypto.randomUUID(); - * setTodos(t => { t.push({ id: tempId, text, pending: true }); }); - * const saved = yield api.createTodo(text); - * setTodos(t => { - * const i = t.findIndex(x => x.id === tempId); - * if (i >= 0) t[i] = saved; - * }); - * }); - * - * // Return form: filter is the natural shape for removal. - * const removeTodo = action(function* (id: string) { - * setTodos(t => t.filter(x => x.id !== id)); - * yield api.removeTodo(id); - * }); - * ``` - * - * @returns `[store: Store, setStore: StoreSetter]` - */ -export function createOptimisticStore( - store: NoFn | Store>, - options?: ProjectionOptions -): [get: Store, set: StoreSetter]; -export function createOptimisticStore( - fn: (store: T) => void | T | Promise | AsyncIterable, - store: Partial | Store>, - options?: ProjectionOptions -): [get: Refreshable>, set: StoreSetter]; -export function createOptimisticStore( - first: T | ((store: T) => void | T | Promise | AsyncIterable), - second?: NoFn | Store>, - options?: ProjectionOptions -): [get: Store, set: StoreSetter] { - // Register clear function with scheduler; store nodes marked - // STORE_OPTIMISTIC take the engine's write path, so install it before any - // node can be created. - installOptimisticEngine(); - if (!GlobalQueue._clearOptimisticStores) { - GlobalQueue._clearOptimisticStores = clearOptimisticStores; - // Store half of the engine's override blockage (#2951): signal-form - // createOptimistic carries the pending async and the override on ONE node, - // so transitionBlocked sees both; a derived optimistic STORE splits them — - // the layer sits on store targets while the in-flight truth lives on the - // firewall computed. Without this, the transition adopting a bare store - // write settled in the same flush that started the refetch and its settle - // consumed the layer mid-flight (follow-up writes then drafted from base, - // clobbering instead of composing). Optimistic state clears when truth - // lands or its transaction ends — never mid-refetch. Wrapped here (engine - // is already installed above) so store-free apps never carry the check. - const engineBlocked = GlobalQueue._transitionBlocked!; - GlobalQueue._transitionBlocked = transition => { - for (const store of transition._optimisticStores) { - if ((store[$TARGET]?.[STORE_FIREWALL]?._statusFlags ?? 0) & STATUS_PENDING) return true; - } - return engineBlocked(transition); - }; - } - const derived = typeof first === "function"; - // Plain form: the second slot carries options. - if (!derived && options === undefined) options = second as ProjectionOptions | undefined; - const initialValue = (derived ? second : first) as T; - const fn = derived - ? (first as (store: T) => void | T | Promise | AsyncIterable) - : undefined; - - // Create optimistic projection store - const { store: wrappedStore } = createOptimisticProjectionInternal(fn, initialValue, options); - - return [wrappedStore, (fn: (draft: T) => void): void => storeSetter(wrappedStore, fn)]; -} - -// Clear the optimistic overrides of a settling batch of stores and notify -// signals. Owns the whole batch (iterate + clear + reschedule) so the -// scheduler's flush tail carries only a size-guarded hook call. The -// completing transition scopes each clear to its own layer keys (#2899). -function clearOptimisticStores(stores: Set, completing: Transition | null): void { - for (const store of stores) { - const target = store[$TARGET] as StoreNode | undefined; - if (target?.[STORE_OPTIMISTIC_OVERRIDE]) clearOptimisticOverride(target, completing); - } - stores.clear(); - schedule(); -} - -/** - * Consume optimistic layer entries and reset their backing nodes to base. - * With `completing` (settle path, #2899) only entries the settling - * transaction owns are consumed — the layer is store-wide but concurrent - * actions revert independently, so keys stamped by a still-in-flight - * transition survive (node-level overrides already have this granularity via - * _optimisticNodes; this is the layer's half). `null` consumes ambient - * (transaction-less) entries at plain flush end. Omitted (projection landing: - * fresh authoritative data) consumes everything — the correction supersedes - * every tentative layer. - */ -function clearOptimisticOverride(target: StoreNode, completing?: Transition | null): void { - const override = target[STORE_OPTIMISTIC_OVERRIDE]; - if (!override) return; - const nodes = target[STORE_NODE]; - const owners = target[STORE_OPTIMISTIC_OWNERS]; - const scoped = completing !== undefined; - let cleared = false; - let remaining = false; - - // Use projectionWriteActive to bypass optimistic signal behavior (no lane creation) - // This ensures reversion effects go to regular queues, not lane queues - const wasProjectionWriteActive = projectionWriteActive; - setProjectionWriteActive(true); - try { - for (const key of Reflect.ownKeys(override)) { - if (scoped) { - let owner = owners?.[key] ?? null; - // Resolve merge chains (entangled actions settle as one); path-compress - // so later keys skip the walk. A dead owner (`_done === true`) settled - // through some other path — never strand its entry. A null owner is an - // ambient write: its batch belongs to whichever transaction adopted it - // (initTransition mid-batch) or to the plain flush, so it clears on - // whichever clear call reaches this store first. - if (owner) { - if (typeof owner._done === "object") owner = owners![key] = currentTransition(owner); - if (owner !== completing && owner._done !== true) { - remaining = true; - continue; - } - } - } - delete override[key]; - if (owners) delete owners[key]; - cleared = true; - const node = nodes?.[key]; - if (node) { - // Clear lane association so effects go to regular queue - node._optimisticLane = undefined; - // Re-read from base — this key left the optimistic layer above, so the - // overlay resolves to STORE_OVERRIDE or STORE_VALUE. - const layer = getOverlayLayer(target, key); - const baseValue = layer ? layer[key] : target[STORE_VALUE][key]; - const value = baseValue === $DELETED ? undefined : baseValue; - const next = isWrappable(value) ? wrap(value, target) : value; - const prev = visibleNodeValue(node); - node._overrideValue = NOT_PENDING; - node._overrideOwner = null; - node._pendingValue = NOT_PENDING; - node._value = next; - if (!node._equals || !node._equals(prev, next)) { - insertSubs(node, true); - schedule(); - } - } - } - if (!remaining) { - // Assignment, not delete: StoreNode targets share one pre-initialized - // hidden class (see createStoreProxy) and a delete would demote it. - target[STORE_OPTIMISTIC_OVERRIDE] = undefined; - target[STORE_OPTIMISTIC_OWNERS] = undefined; - } - // Notify $TRACK - if (cleared && nodes?.[$TRACK]) { - nodes[$TRACK]._optimisticLane = undefined; - notifySelf(target); - } - } finally { - setProjectionWriteActive(wasProjectionWriteActive); - } -} - -function createOptimisticProjectionInternal( - fn: ((draft: T) => void | T | Promise | AsyncIterable) | undefined, - initialValue: Partial, - options?: ProjectionOptions -) { - let node: Computed | undefined; - const wrappedMap = new WeakMap(); - - const shallow = !!(options as any)?.shallow; - const wrapper = (s: any) => { - s[STORE_WRAP] = wrapProjection; - s[STORE_LOOKUP] = wrappedMap; - if (shallow) { - s[STORE_SHALLOW] = true; - markRawIngest(s[STORE_VALUE]); - } - s[STORE_OPTIMISTIC] = true; // Mark as optimistic store - Object.defineProperty(s, STORE_FIREWALL, { - get() { - return node; - }, - configurable: true - }); - }; - - const wrapProjection = (source: Partial) => { - if (wrappedMap.has(source)) return wrappedMap.get(source); - if (source[$TARGET]?.[STORE_WRAP] === wrapProjection) return source; - const wrapped = createStoreProxy(source, storeTraps, wrapper); - wrappedMap.set(source, wrapped); - return wrapped; - }; - - const wrappedStore = wrapProjection(initialValue) as Store; - - // If there's a projection function, create a computed to drive it - if (fn) { - // All writes inside firewall recompute must go to STORE_OVERRIDE (base), not - // STORE_OPTIMISTIC_OVERRIDE. The outer wrap covers the sync body (including - // `fn(draft)` and the initial commit); `wrapCommit` re-applies the flag for - // async yields because they fire outside any enclosing try/finally. It also - // consumes stale optimistic overlays once fresh projected data lands. - const clearProjectionOverride = () => { - const target = wrappedStore[$TARGET] as StoreNode | undefined; - if (target?.[STORE_OPTIMISTIC_OVERRIDE]) clearOptimisticOverride(target); - }; - const wrapCommit = (write: () => void) => { - const wasProjectionWriteActive = projectionWriteActive; - setProjectionWriteActive(true); - try { - write(); - clearProjectionOverride(); - } finally { - setProjectionWriteActive(wasProjectionWriteActive); - } - }; - // seedLoadingValue: born-committed firewall, same as createProjection. - let nodeOptions: { name?: string; loadingValue?: void } | undefined; - if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined }; - if (__DEV__ && options?.name) nodeOptions = { ...nodeOptions, name: options.name }; - node = computed(() => { - setProjectionWriteActive(true); - try { - runProjectionComputed( - wrappedStore, - fn, - options?.key === undefined ? "id" : options.key, - wrapCommit, - clearProjectionOverride - ); - } finally { - setProjectionWriteActive(false); - } - }, nodeOptions) as Computed; - node._config &= ~CONFIG_AUTO_DISPOSE; - } - - return { store: wrappedStore, node } as { - store: Refreshable>; - node: Computed | undefined; - }; -} diff --git a/packages/solid-signals/src/store/projection.ts b/packages/solid-signals/src/store/projection.ts deleted file mode 100644 index eb0d22edf..000000000 --- a/packages/solid-signals/src/store/projection.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { - computed, - CONFIG_AUTO_DISPOSE, - getOwner, - handleAsync, - type Computed, - type Refreshable -} from "../core/index.js"; -import { setProjectionWriteActive } from "../core/scheduler.js"; -import { reconcileState } from "./reconcile.js"; -import { - $TARGET, - createStoreProxy, - setWriteOverride, - STORE_FIREWALL, - STORE_LOOKUP, - STORE_SHALLOW, - STORE_VALUE, - STORE_WRAP, - markRawIngest, - storeSetter, - storeTraps, - type NoFn, - type ProjectionOptions, - type Store -} from "./store.js"; - -export function createProjectionInternal( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial, - options?: ProjectionOptions -) { - let node; - const wrappedMap = new WeakMap(); - // A shallow projection's children are raw and never wrapped, so the - // wrapper only ever runs for the root — flagging unconditionally is safe. - const shallow = !!(options as any)?.shallow; - const wrapper = s => { - s[STORE_WRAP] = wrapProjection; - s[STORE_LOOKUP] = wrappedMap; - if (shallow) { - s[STORE_SHALLOW] = true; - markRawIngest(s[STORE_VALUE]); - } - Object.defineProperty(s, STORE_FIREWALL, { - get() { - return node; - }, - configurable: true - }); - }; - const wrapProjection = (source: Partial) => { - if (wrappedMap.has(source)) return wrappedMap.get(source); - if (source[$TARGET]?.[STORE_WRAP] === wrapProjection) return source; - const wrapped = createStoreProxy(source, storeTraps, wrapper); - wrappedMap.set(source, wrapped); - return wrapped; - }; - const wrappedStore = wrapProjection(seed) as Store; - - // seedLoadingValue: the firewall is born committed (the seed is commit #0); - // the internal handleAsync serves it during the derive's first flight. The - // node's own value channel is void, so the loading value itself is - // `undefined` — presence of the key is what flips the mode. - let nodeOptions: { name?: string; loadingValue?: void } | undefined; - if (options?.seedLoadingValue) nodeOptions = { loadingValue: undefined }; - if (__DEV__ && options?.name) nodeOptions = { ...nodeOptions, name: options.name }; - node = computed(() => { - if (!node) node = getOwner(); - runProjectionComputed(wrappedStore, fn, options?.key === undefined ? "id" : options.key); - }, nodeOptions); - node._config &= ~CONFIG_AUTO_DISPOSE; - - return { store: wrappedStore, node } as { - store: Refreshable>; - node: Computed; - }; -} - -/** - * Creates a derived (projected) store. Like `createMemo` but for stores: the - * derive function receives a mutable draft and either mutates it in place - * (canonical) or returns a new value. Either way the result is reconciled - * against the previous draft by `options.key` (default `"id"`), so surviving - * items keep their proxy identity — only added/removed items are - * created/disposed. - * - * If the derive returns a different entity than the one currently held (the - * `/users/1` → `/users/2` shape), the store swaps to it rather than merging, - * and nothing below it is treated as surviving. - * - * Returns the projected store directly (no setter — reads only). - * - * Use this when you want the structural-sharing / per-property tracking - * behaviour of a store on top of a derived computation. For simple read-only - * derivations, `createMemo` is lighter. - * - * @param fn receives the current draft; mutate it in place or return new - * data. Return is convenient for filter/derive shapes where mutation is - * awkward. - * @param seed the backing store value to wrap and reconcile into - * @param options `ProjectionOptions` — `name`, `key`. `key` defaults to - * `"id"`; specify it only when your data uses a different identity field - * (e.g. `{ key: "uuid" }` or `{ key: u => u.slug }`), or `null` to merge - * positionally with no keyed pass. - * - * @example - * ```ts - * // Mutation form — update individual fields on the draft. - * const summary = createProjection<{ total: number; active: number }>( - * draft => { - * draft.total = users().length; - * draft.active = users().filter(u => u.active).length; - * }, - * { total: 0, active: 0 } - * ); - * - * // Return form — produce a derived collection. Reconciled by `id` so each - * // surviving user keeps the same store identity across recomputes. - * const activeUsers = createProjection( - * () => allUsers().filter(u => u.active), - * [] - * ); - * ``` - * - * @see {@link https://github.com/solidjs/x-reactivity#createprojection} - */ -export function createProjection( - fn: (draft: T) => void | T | Promise | AsyncIterable, - seed: Partial | Store>, - options?: ProjectionOptions -): Refreshable> { - return createProjectionInternal(fn, seed, options).store; -} - -/** - * Shared projection computed body used by both `createProjection` and the derived - * form of `createOptimisticStore`. Encapsulates the write-trap draft, `storeSetter` - * wrapping, the `handleAsync` subscription with a setter callback, and the commit - * path (which must always go through `storeSetter` so the `writeOnly` guard is - * engaged during `reconcile`'s property reads). - * - * `wrapCommit` is invoked for every commit (sync return and each async yield) and - * lets callers layer extra context around the write — e.g. the optimistic store - * re-enters `setProjectionWriteActive` so reconciles target `STORE_OVERRIDE` - * instead of `STORE_OPTIMISTIC_OVERRIDE` even when an async yield fires outside - * the outer `setProjectionWriteActive` scope. - */ -export function runProjectionComputed( - wrappedStore: Store, - fn: (draft: T) => void | T | Promise | AsyncIterable, - key: string | ((item: NonNullable) => any) | null, - wrapCommit?: (write: () => void) => void, - onDraftWrite?: () => void -): Computed { - const owner = getOwner() as Computed; - let settled = false; - let result: void | T | Promise | AsyncIterable; - // Open loading window (seedLoadingValue): the observable store IS commit #0 - // for the whole first flight, so the derive works a detached shadow of the - // seed — draft writes (pre-await, or between yields) land on the shadow and - // cannot tear through to readers (#2988; store reads resolve from the live - // backing, and the born-committed firewall removed the status gate that hid - // windowless drafts). Every commit point — sync return, each yield, the - // async landing — reconciles the shadow through the normal commit path, so - // a fully-sync derive still lands immediately (commit #0 superseded before - // any observer runs, same as a sync answer superseding loadingValue). The - // JSON round-trip matches the server's frozen-seed copy (seedLock): a - // loading-window seed is renderable data by contract. Optimistic note: - // onDraftWrite (override clearing) shifts from write-time to commit-time - // for the shadow run — an invisible draft write must not clobber a visible - // optimistic override mid-window. - const shadow = owner._loading - ? (JSON.parse(JSON.stringify((wrappedStore as any)[$TARGET][STORE_VALUE])) as T) - : null; - const draft = new Proxy( - wrappedStore, - createWriteTraps(() => !settled || owner._inFlight === result, onDraftWrite) - ); - storeSetter(draft, s => { - result = fn(shadow ?? s); - settled = true; - const commit = (v: void | T) => { - // Shadow run: a void/self return is the mutation form — the shadow - // carries the writes and is what commits. Commit a detached snapshot, - // never the shadow itself: reconcile adopts a new root value by - // identity, and handing it the live shadow would fuse the draft to the - // observable store — later shadow writes would mutate the backing - // silently and the next yield would diff the shadow against itself. - if (shadow && (v === undefined || v === shadow)) v = JSON.parse(JSON.stringify(shadow)) as T; - if (v === s || v === undefined) return; - const write = () => storeSetter(wrappedStore, s => reconcileState(v, s, key, true)); - wrapCommit ? wrapCommit(write) : write(); - }; - const sync = handleAsync(owner, result, commit); - // A still-open window after handleAsync means the return was the - // commit-#0 fall-through, not a landing — real landings arrive through - // the setter. A closed one is a genuine sync landing (windowless nodes - // were never open); commit it. - if (!owner._loading) commit(sync); - }); - return owner; -} - -export function createWriteTraps( - isActive?: () => boolean, - onDraftWrite?: () => void -): ProxyHandler { - const traps: ProxyHandler = { - get(_, prop) { - let value; - setWriteOverride(true); - setProjectionWriteActive(true); - try { - value = _[prop]; - } finally { - setWriteOverride(false); - setProjectionWriteActive(false); - } - if (prop === $TARGET) return value; - return typeof value === "object" && value !== null ? new Proxy(value, traps) : value; - }, - has(_, prop) { - let value; - setWriteOverride(true); - setProjectionWriteActive(true); - try { - value = prop in _; - } finally { - setWriteOverride(false); - setProjectionWriteActive(false); - } - return value; - }, - set(_, prop, value) { - if (isActive && !isActive()) return true; - setWriteOverride(true); - setProjectionWriteActive(true); - try { - _[prop] = value; - onDraftWrite?.(); - } finally { - setWriteOverride(false); - setProjectionWriteActive(false); - } - return true; - }, - deleteProperty(_, prop) { - if (isActive && !isActive()) return true; - setWriteOverride(true); - setProjectionWriteActive(true); - try { - delete _[prop]; - onDraftWrite?.(); - } finally { - setWriteOverride(false); - setProjectionWriteActive(false); - } - return true; - } - }; - return traps; -} diff --git a/packages/solid-signals/src/store/reconcile.ts b/packages/solid-signals/src/store/reconcile.ts deleted file mode 100644 index 52b8e8e45..000000000 --- a/packages/solid-signals/src/store/reconcile.ts +++ /dev/null @@ -1,863 +0,0 @@ -import { setSignal, untrack } from "../core/index.js"; -import { - $DELETED, - $PROXY, - $TARGET, - $TRACK, - getKeys, - getStoreSymbols, - isWrappable, - STORE_DESC, - STORE_HAS, - STORE_LOOKUP, - STORE_NODE, - STORE_OPTIMISTIC_OVERRIDE, - STORE_OVERRIDE, - STORE_SHALLOW, - STORE_VALUE, - STORE_WRAP, - notifySelf, - storeLookup, - lookupTarget, - markRawIngest, - isRawValue, - rawValuesUsed, - symbolKeyedRecords, - wrap -} from "./store.js"; - -// Enumerate a node record's keys. Keeps the common string-key path on the -// `Object.keys` fast path; only records currently holding a user symbol node -// (marked by `getNode`) pay for symbol enumeration. `$TRACK` is the only -// internal symbol a node record can hold and callers handle it separately. -function nodeKeys(nodes: Record): PropertyKey[] { - const keys: PropertyKey[] = Object.keys(nodes); - if (symbolKeyedRecords.has(nodes)) { - const syms = Object.getOwnPropertySymbols(nodes); - for (let i = 0, len = syms.length; i < len; i++) { - if (syms[i] !== $TRACK) keys.push(syms[i]); - } - } - return keys; -} - -function unwrap(value: any) { - // Primitives can't be store proxies; skip the symbol lookups (which box the - // primitive) for the common leaf case. - if (value === null || typeof value !== "object") return value; - return value[$TARGET]?.[STORE_VALUE] ?? value; -} - -function getOverrideValue(value: any, override: any, key: PropertyKey, optOverride?: any) { - if (optOverride && key in optOverride) return optOverride[key]; - return override && key in override ? override[key] : value[key]; -} - -// Append `o`'s *enumerable* own symbol keys from a pre-fetched symbol list. -function addEnumSymbols(o: any, syms: symbol[], keys: Set) { - for (let i = 0, len = syms.length; i < len; i++) { - if (Object.prototype.propertyIsEnumerable.call(o, syms[i])) keys.add(syms[i]); - } -} - -function getAllKeys(value, override, next) { - // Symbols are merged explicitly below; keep the common string-key path on - // Object.keys() and avoid reflecting the base symbols twice. - const keys = getKeys(value, override) as PropertyKey[]; - const nextKeys = Object.keys(next); - // `value` can be a wrapped store (store-in-store) whose ownKeys trap tracks; - // mirror `getKeys` and enumerate its symbols untracked in that case. - const valueSyms = (value as any)[$TARGET] - ? untrack(() => Object.getOwnPropertySymbols(value)) - : Object.getOwnPropertySymbols(value); - const nextSyms = Object.getOwnPropertySymbols(next); - // Symbol-free diff (the overwhelmingly common case) stays on the exact - // pre-#2851 path, including the identical-key-sets fast path from #2756. - if (valueSyms.length === 0 && nextSyms.length === 0) { - if (keys.length === nextKeys.length) { - let same = true; - for (let i = 0; i < keys.length; i++) { - if (keys[i] !== nextKeys[i]) { - same = false; - break; - } - } - if (same) return keys; - } - const set = new Set(keys); - for (let i = 0; i < nextKeys.length; i++) set.add(nextKeys[i]); - return Array.from(set); - } - // Symbol-aware diff (#2851): base symbols join the set, then override - // adds/deletes are re-applied so a `$DELETED` symbol stays deleted, then - // `next`'s keys (a key present in next is never deleted by the diff). - const set = new Set(keys); - addEnumSymbols(value, valueSyms, set); - if (override) { - for (const key of Reflect.ownKeys(override)) { - override[key] === $DELETED ? set.delete(key) : set.add(key); - } - } - for (let i = 0; i < nextKeys.length; i++) set.add(nextKeys[i]); - addEnumSymbols(next, nextSyms, set); - return Array.from(set); -} - -// `setSignal(node, v)` calls `v` when it's a function — that's the updater -// overload, not a leaf replacement. A function-valued leaf (a store'd -// callback prop) must be wrapped in an outer arrow so setSignal unwraps back -// to the real function instead of invoking it and committing its return -// value. Mirrors store.ts's own `notifyStoreProperty` guard for plain writes. -function asSignalValue(value: T): T | (() => T) { - return (typeof value === "function" ? () => value : value) as any; -} - -// Array entries can be `null`/`undefined`/primitives, not just keyed objects. -// These helpers keep the keyed paths from passing a non-object to `keyFn` (which -// assumes an object) or to `wrap()` (which assumes a wrappable value). -function wrapValue(value: any, target: any) { - return asSignalValue(isWrappable(value) ? wrap(value, target) : value); -} - -function itemKey(item: any, keyFn: (item: NonNullable) => any) { - return isWrappable(item) ? keyFn(item) : item; -} - -function keyedMatch(a: any, b: any, keyFn: (item: NonNullable) => any) { - return a === b || (isWrappable(a) && isWrappable(b) && keyFn(a) === keyFn(b)); -} - -// A pair of array slots may only be merged into when both sides are real store -// children of the SAME container kind. An array and an object are different -// shapes, and merging one into the other leaves the slot's proxy permanently -// mismatched with its value (an array target holding an object, so -// `Array.isArray`/spread/`map` lie). The object diff has always applied this -// rule; the array paths reach the same recursion through `keyedMatch` / -// positional pairing, where two keyless wrappables "match" regardless of kind. -function recursablePair(previous: any, next: any): boolean { - return ( - isWrappable(previous) && - isWrappable(next) && - !(rawValuesUsed && (isRawValue(previous) || isRawValue(next))) && - Array.isArray(previous) === Array.isArray(next) - ); -} - -// Array reconciliation updates the slots it visits, then swaps STORE_VALUE. -// Previously tracked keys that are absent from `next` still need invalidating, -// and `in` dependencies should follow the new value's membership. Use -// membership rather than length arithmetic so sparse arrays and named array -// props behave like normal property reads. -// Inline key iteration on purpose: these loops run per array target per -// reconcile pass, and a shared helper means a closure + per-key call in -// instruction counts. String-only records (the common case) iterate in -// place; symbol-bearing records take the nodeKeys array path. -function syncArrayNodeMembership(target: any, next: any) { - let nodes = target[STORE_NODE]; - if (nodes) { - if (symbolKeyedRecords.has(nodes)) { - const keys = nodeKeys(nodes); - for (let i = 0, len = keys.length; i < len; i++) { - keys[i] in next || setSignal(nodes[keys[i]], undefined); - } - } else { - for (const key in nodes) { - key in next || setSignal(nodes[key], undefined); - } - } - } - if ((nodes = target[STORE_HAS])) { - if (symbolKeyedRecords.has(nodes)) { - const keys = nodeKeys(nodes); - for (let i = 0, len = keys.length; i < len; i++) { - setSignal(nodes[keys[i]], keys[i] in next); - } - } else { - for (const key in nodes) { - setSignal(nodes[key], key in next); - } - } - } -} - -// Recurse into a matched wrappable child pair without manufacturing a proxy: -// resolve the child's target through the lookup and dispatch directly. A -// lookup miss means the child was never observed — no proxy, no nodes, no -// subscribers anywhere below — so the parent's swap making `next` -// authoritative IS the whole update; a later read wraps next's child on -// demand. This turns the diff from O(previous graph) into O(observed graph) -// and drops the wrap()/$PROXY/$TARGET round-trip per visited child. -// Wrap-family stores (projections/optimistic) own child proxy creation and -// keep the proxy-based recursion. -function applyStateChild( - next: any, - prevRaw: any, - target: any, - keyFn: (item: NonNullable) => any -) { - if (target[STORE_WRAP] !== undefined) { - applyState(next, wrap(prevRaw, target), keyFn); - return; - } - const childTarget = prevRaw[$TARGET] ?? storeLookup.get(prevRaw); - if (childTarget === undefined) return; - next = unwrap(next); - if (childTarget[STORE_SHALLOW]) { - applyStateShallow(next, childTarget, keyFn); - } else if (childTarget[STORE_OVERRIDE] || childTarget[STORE_OPTIMISTIC_OVERRIDE]) { - applyStateSlow(next, childTarget, keyFn); - } else { - applyStateFast(next, childTarget, keyFn); - } -} - -// Reconcile a single array slot: recurse into a wrappable pair, otherwise replace -// the node's value outright (covers object→primitive and primitive→object). -function applyArrayItem( - next: any, - previous: any, - target: any, - node: any, - keyFn: (item: NonNullable) => any -) { - if (recursablePair(previous, next)) { - const wrapped = wrap(previous, target); - node && setSignal(node, wrapped); - applyState(next, wrapped, keyFn); - } else node && setSignal(node, wrapValue(next, target)); -} - -/** - * The captured-proxy half of the object diff (#2902): descend into keyed- - * matching children that have NO node at this level but shelter subscribers - * somewhere below (their target's sticky `STORE_DESC` flag, bubbled up by - * `getNode`). Without this, a proxy captured through untracked reads — a - * `` row handed to a child component — detaches from the diff the - * moment no intermediate level happens to be tracked, and its live - * subscribers go permanently stale. Never-subscribed branches have no flag - * and stay pruned exactly as before; keys the main loop already visited - * (node present) are skipped. Callers gate on the parent's own flag and on - * `$TRACK` absence (an enumeration-tracked record already diffs every key). - */ -function applyDescendants( - previous: any, - next: any, - target: any, - nodes: any, - keyFn: (item: NonNullable) => any, - override?: any, - optOverride?: any -) { - const lookup = target[STORE_LOOKUP] || storeLookup; - if (override) { - const keys = getKeys(previous, override).concat(getStoreSymbols(previous, override)); - for (let i = 0, len = keys.length; i < len; i++) { - const key = keys[i]; - if (nodes?.[key]) continue; // main loop already diffed this slot - const previousValue = unwrap(getOverrideValue(previous, override, key, optOverride)); - if (!isWrappable(previousValue)) continue; - descendInto(previousValue, next[key], lookup, keyFn); - } - return; - } - // No-override path (every applyStateFast call): iterate in place instead of - // building keys + symbols + concat arrays per object per pass. The cheap - // bails (noded key, primitive value) stay inline — only genuine descent - // candidates pay a call. - for (const key in previous) { - if (nodes?.[key]) continue; // main loop already diffed this slot - const previousValue = unwrap(previous[key]); - if (!isWrappable(previousValue)) continue; - descendInto(previousValue, next[key], lookup, keyFn); - } - const syms = Object.getOwnPropertySymbols(previous); - for (let i = 0, len = syms.length; i < len; i++) { - if (Object.prototype.propertyIsEnumerable.call(previous, syms[i])) { - if (nodes?.[syms[i]]) continue; - const previousValue = unwrap(previous[syms[i]]); - if (!isWrappable(previousValue)) continue; - descendInto(previousValue, next[syms[i]], lookup, keyFn); - } - } -} - -function descendInto( - previousValue: any, - rawNext: any, - lookup: any, - keyFn: (item: NonNullable) => any -) { - const childTarget = lookupTarget(previousValue, lookup); - if (!childTarget?.[STORE_DESC]) return; - const nextValue = unwrap(rawNext); - if ( - previousValue === nextValue || - !isWrappable(nextValue) || - Array.isArray(previousValue) !== Array.isArray(nextValue) || - (keyFn(previousValue) != null && keyFn(previousValue) !== keyFn(nextValue)) - ) - return; - if (childTarget[STORE_SHALLOW]) { - applyStateShallow(nextValue, childTarget, keyFn); - } else if (childTarget[STORE_OVERRIDE] || childTarget[STORE_OPTIMISTIC_OVERRIDE]) { - applyStateSlow(nextValue, childTarget, keyFn); - } else { - applyStateFast(nextValue, childTarget, keyFn); - } -} - -// Dispatcher: every applyState call (including recursion) checks for the -// presence of override / optimistic-override slots once and routes to the -// appropriate body. The fast body never calls `getOverrideValue` and never -// branches on a `fastPath` boolean, so V8 sees a tighter, more inlinable -// shape for the overwhelmingly common case of plain stores. -function applyState(next: any, state: any, keyFn: (item: NonNullable) => any) { - // Array items and root calls can pass a store proxy as `next`; normalize to - // its raw value or the swap would set a store's STORE_VALUE to its own proxy. - next = unwrap(next); - const target = state?.[$TARGET]; - if (!target) return; - if (target[STORE_SHALLOW]) { - applyStateShallow(next, target, keyFn); - } else if (target[STORE_OVERRIDE] || target[STORE_OPTIMISTIC_OVERRIDE]) { - applyStateSlow(next, target, keyFn); - } else { - applyStateFast(next, target, keyFn); - } -} - -// Shallow boundary diff: the target's own keys are reactive, its values are -// raw records replaced by reference — no recursion, no wrapping. Arrays merge -// positionally (below a shallow boundary the VALUE is the identity; keyed -// row identity belongs to the consumer, e.g. ). Incoming -// wrappables are sticky-marked raw so they present raw through every store. -// One pass over a shallow target's node record: replace changed slots with -// raw next values, null out slots absent from next. Returns whether anything -// differed. -function shallowDiffNodes( - nodes: any, - next: any, - prevAt: (key: PropertyKey) => any, - skipLength: boolean -): boolean { - let changed = false; - for (const key in nodes) { - if (skipLength && key === "length") continue; - if (key in next) { - const v = next[key]; - if (v !== prevAt(key)) { - changed = true; - setSignal(nodes[key], asSignalValue(v)); - } - } else { - changed = true; - setSignal(nodes[key], undefined); - } - } - return changed; -} - -function applyStateShallow(next: any, target: any, keyFn: (item: NonNullable) => any) { - const previous = target[STORE_VALUE]; - const override = target[STORE_OVERRIDE]; - const optOverride = target[STORE_OPTIMISTIC_OVERRIDE]; - if (next === previous && !override && !optOverride) return; - // Setter-staged writes fold into the diff: previous values resolve through - // the override layers (so a replaced slot compares against what readers - // saw), and the regular override clears with the swap — reconcile makes - // `next` the authoritative base, same as the deep slow path. - const prevAt = (key: PropertyKey) => { - const v = getOverrideValue(previous, override, key, optOverride); - return v === $DELETED ? undefined : v; - }; - target[STORE_OVERRIDE] = undefined; - const fam = target[STORE_LOOKUP]; - fam !== undefined ? fam.set(next, target[$PROXY]) : storeLookup.set(next, target); - target[STORE_VALUE] = next; - markRawIngest(next); - - const nodes = target[STORE_NODE]; - const tracked = nodes && nodes[$TRACK]; - let changed = false; - if (Array.isArray(previous)) { - const prevLength = override?.length ?? optOverride?.length ?? previous.length; - if (nodes) { - changed = shallowDiffNodes(nodes, next, prevAt, true); - if (nodes.length && prevLength !== next.length) setSignal(nodes.length, next.length); - } - if (!changed && (tracked || target[STORE_HAS])) { - // Slots without nodes still feed $TRACK enumerators / `in` probes. - if (prevLength !== next.length) changed = true; - else { - for (let i = 0, len = next.length; i < len; i++) { - if (prevAt(i) !== next[i]) { - changed = true; - break; - } - } - } - } - } else { - if (nodes) { - changed = shallowDiffNodes(nodes, next, prevAt, false); - } - if (!changed && (tracked || target[STORE_HAS])) changed = true; - } - let has = target[STORE_HAS]; - if (has) { - for (const key in has) { - setSignal(has[key], key in next); - } - } - changed && notifySelf(target); -} - -function applyStateFast(next: any, target: any, keyFn: (item: NonNullable) => any) { - const previous = target[STORE_VALUE]; - if (next === previous) return; - const arrayNodes = target[STORE_NODE]; - - // swap - { - const fam = target[STORE_LOOKUP]; - fam !== undefined ? fam.set(next, target[$PROXY]) : storeLookup.set(next, target); - } - target[STORE_VALUE] = next; - - // merge - if (Array.isArray(previous)) { - let changed = false; - const prevLength = (previous as any).length; - if (next.length && prevLength && isWrappable(next[0]) && keyFn(next[0]) != null) { - let i, j, start, end, newEnd, item, newIndicesNext, keyVal; - - for ( - start = 0, end = Math.min(prevLength, next.length); - start < end && keyedMatch((item = previous[start]), next[start], keyFn); - start++ - ) { - // keyedMatch established both sides wrappable unless they're the - // SAME reference — and an identical slot is a guaranteed no-op - // (the child's STORE_VALUE tracks the previous graph), so skip - // the recursion dispatch entirely. Raw-marked values are leaves: - // replace the slot node instead of recursing. - if (item !== next[start]) { - if (!recursablePair(item, next[start])) { - arrayNodes?.[start] && setSignal(arrayNodes[start], wrapValue(next[start], target)); - } else applyStateChild(next[start], item, target, keyFn); - } - } - - // Every position key-matched at equal length (the steady-state shape - // of a polling tick): membership, length, and order are unchanged — - // nothing below could do observable work, so skip the staging - // allocations and membership sync outright. - if (start === next.length && start === prevLength) return; - - const temp = new Array(next.length), - newIndices = new Map(); - - for ( - end = prevLength - 1, newEnd = next.length - 1; - end >= start && newEnd >= start && keyedMatch((item = previous[end]), next[newEnd], keyFn); - end--, newEnd-- - ) { - temp[newEnd] = item; - } - - if (start > newEnd || start > end) { - for (j = start; j <= newEnd; j++) { - changed = true; - arrayNodes?.[j] && setSignal(arrayNodes[j], wrapValue(next[j], target)); - } - - for (; j < next.length; j++) { - changed = true; - applyArrayItem(next[j], temp[j], target, arrayNodes?.[j], keyFn); - } - - syncArrayNodeMembership(target, next); - (changed || prevLength !== next.length) && notifySelf(target); - prevLength !== next.length && - arrayNodes?.length && - setSignal(arrayNodes.length, next.length); - return; - } - - newIndicesNext = new Array(newEnd + 1); - - for (j = newEnd; j >= start; j--) { - item = next[j]; - keyVal = itemKey(item, keyFn); - i = newIndices.get(keyVal); - newIndicesNext[j] = i === undefined ? -1 : i; - newIndices.set(keyVal, j); - } - - for (i = start; i <= end; i++) { - item = previous[i]; - keyVal = itemKey(item, keyFn); - j = newIndices.get(keyVal); - - if (j !== undefined && j !== -1) { - temp[j] = item; - j = newIndicesNext[j]; - newIndices.set(keyVal, j); - } - } - - for (j = start; j < next.length; j++) { - if (j in temp) { - applyArrayItem(next[j], temp[j], target, arrayNodes?.[j], keyFn); - } else arrayNodes?.[j] && setSignal(arrayNodes[j], wrapValue(next[j], target)); - } - if (start < next.length) changed = true; - } else if (next.length) { - for (let i = 0, len = next.length; i < len; i++) { - const item = previous[i]; - if (recursablePair(item, next[i])) { - if (item !== next[i]) applyStateChild(next[i], item, target, keyFn); - } else { - if (item !== next[i]) changed = true; - arrayNodes?.[i] && setSignal(arrayNodes[i], wrapValue(next[i], target)); - } - } - } - - syncArrayNodeMembership(target, next); - if (prevLength !== next.length) { - changed = true; - arrayNodes?.length && setSignal(arrayNodes.length, next.length); - } - changed && notifySelf(target); - return; - } - - // values - let nodes = target[STORE_NODE]; - let tracked; - if (nodes) { - tracked = nodes[$TRACK]; - // The per-key body is duplicated across both loops on purpose: this is - // the hottest object-diff site and a shared helper costs a per-key call - // in instruction counts (CodSpeed regressed ~7% on deep-tree reconciles - // with the extracted form). - if (tracked || symbolKeyedRecords.has(nodes)) { - const keys = tracked ? getAllKeys(previous, undefined, next) : nodeKeys(nodes); - for (let i = 0, len = keys.length; i < len; i++) { - const key = keys[i]; - const node = nodes[key]; - const previousValue = unwrap(previous[key]); - const nextValue = unwrap(next[key]); - if (previousValue === nextValue) continue; - if ( - !previousValue || - !isWrappable(previousValue) || - !isWrappable(nextValue) || - // Raw-marked values are leaves replaced by reference — a "wrappable - // pair" is only recursable when both sides are actual store children. - (rawValuesUsed && (isRawValue(previousValue) || isRawValue(nextValue))) || - Array.isArray(previousValue) !== Array.isArray(nextValue) || - (keyFn(previousValue) != null && keyFn(previousValue) !== keyFn(nextValue)) - ) { - tracked && setSignal(tracked, void 0); - node && setSignal(node, wrapValue(nextValue, target)); - } else applyStateChild(nextValue, previousValue, target, keyFn); - } - } else { - // Untracked, string-only node records (the overwhelmingly common case) - // iterate in place — nodeKeys() allocated a fresh key array per object - // per pass, which dominates allocation on large-graph reconciles. - for (const key in nodes) { - const node = nodes[key]; - const previousValue = unwrap(previous[key]); - const nextValue = unwrap(next[key]); - if (previousValue === nextValue) continue; - if ( - !previousValue || - !isWrappable(previousValue) || - !isWrappable(nextValue) || - // Raw-marked values are leaves replaced by reference — a "wrappable - // pair" is only recursable when both sides are actual store children. - (rawValuesUsed && (isRawValue(previousValue) || isRawValue(nextValue))) || - Array.isArray(previousValue) !== Array.isArray(nextValue) || - (keyFn(previousValue) != null && keyFn(previousValue) !== keyFn(nextValue)) - ) { - tracked && setSignal(tracked, void 0); - node && setSignal(node, wrapValue(nextValue, target)); - } else applyStateChild(nextValue, previousValue, target, keyFn); - } - } - } - if (!tracked && target[STORE_DESC]) applyDescendants(previous, next, target, nodes, keyFn); - - // has - if ((nodes = target[STORE_HAS])) { - const keys = nodeKeys(nodes); - for (let i = 0, len = keys.length; i < len; i++) { - const key = keys[i]; - setSignal(nodes[key], key in next); - } - } -} - -function applyStateSlow(next: any, target: any, keyFn: (item: NonNullable) => any) { - const previous = target[STORE_VALUE]; - const override = target[STORE_OVERRIDE]; - const optOverride = target[STORE_OPTIMISTIC_OVERRIDE]; - let nodes = target[STORE_NODE]; - - // swap - { - const fam = target[STORE_LOOKUP]; - fam !== undefined ? fam.set(next, target[$PROXY]) : storeLookup.set(next, target); - } - target[STORE_VALUE] = next; - target[STORE_OVERRIDE] = undefined; - - // merge - if (Array.isArray(previous)) { - let changed = false; - const prevLength = getOverrideValue(previous, override, "length", optOverride); - if (next.length && prevLength && isWrappable(next[0]) && keyFn(next[0]) != null) { - let i, j, start, end, newEnd, item, newIndicesNext, keyVal; - - for ( - start = 0, end = Math.min(prevLength, next.length); - start < end && - keyedMatch( - (item = getOverrideValue(previous, override, start, optOverride)), - next[start], - keyFn - ); - start++ - ) { - if (item !== next[start] && isWrappable(item) && isWrappable(next[start])) { - if (!recursablePair(item, next[start])) { - nodes?.[start] && setSignal(nodes[start], wrapValue(next[start], target)); - } else applyState(next[start], wrap(item, target), keyFn); - } - } - - const temp = new Array(next.length), - newIndices = new Map(); - - for ( - end = prevLength - 1, newEnd = next.length - 1; - end >= start && - newEnd >= start && - keyedMatch( - (item = getOverrideValue(previous, override, end, optOverride)), - next[newEnd], - keyFn - ); - end--, newEnd-- - ) { - temp[newEnd] = item; - } - - if (start > newEnd || start > end) { - for (j = start; j <= newEnd; j++) { - changed = true; - nodes?.[j] && setSignal(nodes[j], wrapValue(next[j], target)); - } - - for (; j < next.length; j++) { - changed = true; - applyArrayItem(next[j], temp[j], target, nodes?.[j], keyFn); - } - - const nextLength = next.length; - syncArrayNodeMembership(target, next); - (changed || prevLength !== nextLength) && notifySelf(target); - prevLength !== nextLength && nodes?.length && setSignal(nodes.length, nextLength); - return; - } - - newIndicesNext = new Array(newEnd + 1); - - for (j = newEnd; j >= start; j--) { - item = next[j]; - keyVal = itemKey(item, keyFn); - i = newIndices.get(keyVal); - newIndicesNext[j] = i === undefined ? -1 : i; - newIndices.set(keyVal, j); - } - - for (i = start; i <= end; i++) { - item = getOverrideValue(previous, override, i, optOverride); - keyVal = itemKey(item, keyFn); - j = newIndices.get(keyVal); - - if (j !== undefined && j !== -1) { - temp[j] = item; - j = newIndicesNext[j]; - newIndices.set(keyVal, j); - } - } - - for (j = start; j < next.length; j++) { - if (j in temp) { - applyArrayItem(next[j], temp[j], target, nodes?.[j], keyFn); - } else nodes?.[j] && setSignal(nodes[j], wrapValue(next[j], target)); - } - if (start < next.length) changed = true; - } else if (next.length) { - for (let i = 0, len = next.length; i < len; i++) { - const item = getOverrideValue(previous, override, i as any, optOverride); - if (recursablePair(item, next[i])) { - if (item !== next[i]) applyState(next[i], wrap(item, target), keyFn); - } else { - if (item !== next[i]) changed = true; - nodes?.[i] && setSignal(nodes[i], wrapValue(next[i], target)); - } - } - } - - const nextLength = next.length; - - syncArrayNodeMembership(target, next); - if (prevLength !== nextLength) { - changed = true; - nodes?.length && setSignal(nodes.length, nextLength); - } - changed && notifySelf(target); - return; - } - - // values - let tracked; - if (nodes) { - tracked = nodes[$TRACK]; - const keys = tracked ? getAllKeys(previous, override, next) : nodeKeys(nodes); - for (let i = 0, len = keys.length; i < len; i++) { - const key = keys[i]; - const node = nodes[key]; - const previousValue = unwrap(getOverrideValue(previous, override, key, optOverride)); - let nextValue = unwrap(next[key]); - if (previousValue === nextValue) continue; - if ( - !previousValue || - !isWrappable(previousValue) || - !isWrappable(nextValue) || - (rawValuesUsed && (isRawValue(previousValue) || isRawValue(nextValue))) || - Array.isArray(previousValue) !== Array.isArray(nextValue) || - (keyFn(previousValue) != null && keyFn(previousValue) !== keyFn(nextValue)) - ) { - tracked && setSignal(tracked, void 0); - node && setSignal(node, wrapValue(nextValue, target)); - } else applyState(nextValue, wrap(previousValue, target), keyFn); - } - } - if (!tracked && target[STORE_DESC]) - applyDescendants(previous, next, target, nodes, keyFn, override, optOverride); - - // has - if ((nodes = target[STORE_HAS])) { - const keys = nodeKeys(nodes); - for (let i = 0, len = keys.length; i < len; i++) { - const key = keys[i]; - setSignal(nodes[key], key in next); - } - } -} - -// No-key reconcile: every item reports "no key", which routes array diffs to -// the positional branch and object descent to plain per-property merging. -const NOKEY = () => null; - -// Identity-as-key: distinct objects never match, so every slot takes the diff's -// "not the same entity" branch and is replaced by reference rather than merged -// into. Slots holding the same raw on both sides still keep their proxy. -const IDENTITY = (item: any) => item; - -/** - * Shared body of `reconcile()` and the projection commit. `replace` is the - * only difference: a projection commit is a value swap, not a merge — its root - * proxy is a cell handed out by `createProjection` that can never change - * reference, so a derive returning a different entity is not the slot mistake - * `reconcile()` throws on. Nothing below the root survives that swap, which is - * the rule the keyed diff already applies at a nested slot on a key mismatch. - * - * @internal - */ -export function reconcileState(value: any, state: any, key: any, replace: boolean) { - if (state == null) throw new Error(__DEV__ ? "Cannot reconcile null or undefined state" : ""); - // A projection commit whose derive returns a foreign store adopts it as the - // live backing (store-in-store chain — the same shape as a store-proxy - // seed): reads route through the inner store's own graph, so its updates - // flow with no re-derive, and a derive with no dependencies never recomputes - // (#2941). The diff below still runs against raw values so THIS store's - // existing subscribers see the swap; the live proxy is installed after. - // Shallow projections keep their raw-ingest contract and never chain. - let chain: any; - const target = replace ? state[$TARGET] : undefined; - if (target !== undefined) { - if (value?.[$TARGET] !== undefined && value[$TARGET] !== target && !target[STORE_SHALLOW]) { - if (target[STORE_VALUE] === value) return; // already chained to this store - chain = value; - } - // Re-diffing a previously chained backing goes through its raw — reads - // off the outgoing proxy would subscribe this computed to a store it is - // about to drop. - while ((target[STORE_VALUE] as any)?.[$TARGET] !== undefined) - target[STORE_VALUE] = unwrap(target[STORE_VALUE]); - } - if (key === null) applyState(value, state, NOKEY); - else { - let keyFn = typeof key === "string" ? item => item[key] : key; - const eq = keyFn(state); - if (eq !== undefined && keyFn(value) !== eq) { - if (!replace) - throw new Error(__DEV__ ? "Cannot reconcile states with different identity" : ""); - // Or the outgoing raw keeps resolving to this proxy and surfaces the - // incoming entity wherever it reappears in this family. - const t = state[$TARGET]; - if (t && t[STORE_VALUE] !== unwrap(value)) t[STORE_LOOKUP]?.delete(t[STORE_VALUE]); - keyFn = IDENTITY; - } - applyState(value, state, keyFn); - } - if (chain !== undefined) target[STORE_VALUE] = chain; -} - -/** - * Returns a draft-mutating function that smart-merges `value` into a store, - * preserving fine-grained reactivity: only changed leaves trigger updates. - * - * With a `key` (default `"id"`), array items whose key matches between old - * and new states keep their identity (updated in place, moves and removals - * update the corresponding signals) — the shape for keyed server payloads. - * Items without the key field fall back to positional matching. - * - * With `key: null`, matching is purely positional: index N of the new array - * merges into index N of the old, and object properties merge recursively — - * the classic pattern for fixed-shape data that churns in place (dashboards, - * monitors), where no keyed diff pass is needed or wanted. - * - * Merging into a slot that holds a *different* entity throws — the caller - * picked the slot, so a key mismatch there is a bug. - * - * @param value the next state to merge in - * @param key property name (string) or extractor function for stable - * identity (default `"id"`); pass `null` for positional merging - * - * @example - * ```ts - * const [todos, setTodos] = createStore([]); - * - * async function refresh() { - * const fresh = await api.getTodos(); - * setTodos(reconcile(fresh)); // diff-merge by `id` - * } - * - * // fixed-shape polling data — positional merge - * setStats(reconcile(nextStats, null)); - * ``` - */ -export function reconcile( - value: T, - key: string | ((item: NonNullable) => any) | null = "id" -) { - return (state: U) => reconcileState(value, state, key, false); -} diff --git a/packages/solid-signals/src/store/store.ts b/packages/solid-signals/src/store/store.ts index 42e9038d1..d0140002b 100644 --- a/packages/solid-signals/src/store/store.ts +++ b/packages/solid-signals/src/store/store.ts @@ -1,50 +1,7 @@ -import { - STATUS_ERROR, - STATUS_PENDING, - STATUS_UNINITIALIZED, - unwrapOverride -} from "../core/constants.js"; -import { - pendingCheckActive, - READ_SLOW, - readNodeFast, - snapshotCaptureActive, - snapshotSources, - strictRead -} from "../core/core.js"; -import { - DEV, - registerGraph, - throwPendingUntrackedRead, - warnStrictReadUntracked -} from "../core/dev.js"; -import { - $REFRESH, - getObserver, - getOwner, - isEqual, - NO_SNAPSHOT, - NOT_PENDING, - NotReadyError, - read, - setSignal, - signal, - STORE_SNAPSHOT_PROPS, - suppressComputedRecompute, - untrack, - type Computed, - type Refreshable, - type Signal -} from "../core/index.js"; -import { - activeTransition, - GlobalQueue, - globalQueue, - projectionWriteActive, - registerTransientStoreNode, - type Transition -} from "../core/scheduler.js"; -import { createProjectionInternal } from "./projection.js"; +import { getObserver, type Signal } from "../core/index.js"; +import type { Refreshable } from "../core/index.js"; +import { GlobalQueue } from "../core/scheduler.js"; +import { storeNextLookup } from "./next/target.js"; /** A read-only view of a store's value as seen by consumers. Mutate it via the paired `StoreSetter`. */ export type Store = Readonly; @@ -113,53 +70,24 @@ export const $TRACK = Symbol(__DEV__ ? "STORE_TRACK" : 0), // the record witnesses it into the active isPending() probe. $AFFECTS = Symbol(__DEV__ ? "STORE_AFFECTS" : 0); +// Structural field names of store targets (StoreNextTarget aliases these, so +// shared machinery — affects walks, tests — reads targets via the consts). export const STORE_VALUE = "v", - STORE_OVERRIDE = "o", - STORE_OPTIMISTIC_OVERRIDE = "x", STORE_NODE = "n", STORE_HAS = "h", - STORE_CUSTOM_PROTO = "c", - STORE_WRAP = "w", - STORE_LOOKUP = "l", - STORE_FIREWALL = "f", - STORE_OPTIMISTIC = "p", - STORE_OPTIMISTIC_OWNERS = "t", STORE_PARENT = "u", STORE_DESC = "d", STORE_SHALLOW = "s"; -const STORE_SELF_PENDING = Symbol(__DEV__ ? "STORE_SELF_PENDING" : 0); +/** Structural view of a store target as shared machinery sees it (the real + * shape is `StoreNextTarget` in ./next/target.ts). */ export type StoreNode = { [$PROXY]: any; [STORE_VALUE]: Record; - [STORE_OVERRIDE]?: Record; - [STORE_OPTIMISTIC_OVERRIDE]?: Record; - // Per-key transition ownership of the optimistic layer (#2899): stamped at - // write time so a settling action only consumes its own entries. `null` = - // ambient write (clears at flush end). Nodes already get this granularity - // via _optimisticNodes; this is the layer's half. - [STORE_OPTIMISTIC_OWNERS]?: Record; [STORE_NODE]?: DataNodes; [STORE_HAS]?: DataNodes; - [STORE_CUSTOM_PROTO]?: boolean; - [STORE_WRAP]?: (value: any, target?: StoreNode) => any; - [STORE_LOOKUP]?: WeakMap; - [STORE_FIREWALL]?: Computed; - [STORE_OPTIMISTIC]?: boolean; - [STORE_SNAPSHOT_PROPS]?: Record; - // Wrap-time back-link to the target this one was first wrapped under. - // Serves node-presence bubbling (#2902); roots and store-in-store roots - // have none. First wrapper wins — diamond reachability is untracked. [STORE_PARENT]?: StoreNode; - // Shallow boundary: this target's own keys are reactive, its values are - // raw records replaced by reference. Values ingested below the boundary are - // sticky-marked raw (rawValues) so they present raw through every store. [STORE_SHALLOW]?: boolean; - // Sticky "this subtree (self included) carries signal nodes" flag, set by - // getNode and bubbled up STORE_PARENT. Reconcile's object diff descends - // into node-less children only when this is set, so never-subscribed - // branches stay pruned (#2902). Never cleared: precision degrades toward - // over-descent only for subscribe-then-unsubscribe churn. [STORE_DESC]?: boolean; }; @@ -178,69 +106,14 @@ export type NotWrappable = | undefined | SolidStore.Unwrappable[keyof SolidStore.Unwrappable]; -// Every StoreNode field is initialized up front, in one fixed order, so all -// targets share a single hidden class. The traps and reconcile read these -// fields on their hottest paths; fields added lazily in varying orders made -// those loads megamorphic across a large store graph (dictionary-mode probes -// on every trap hit). Assignments elsewhere must never `delete` a field — -// write `undefined` instead, or the shape degrades again. -function initStoreFields(newTarget: any) { - newTarget[STORE_OVERRIDE] = undefined; - newTarget[STORE_OPTIMISTIC_OVERRIDE] = undefined; - newTarget[STORE_OPTIMISTIC_OWNERS] = undefined; - newTarget[STORE_NODE] = undefined; - newTarget[STORE_HAS] = undefined; - newTarget[STORE_CUSTOM_PROTO] = undefined; - newTarget[STORE_WRAP] = undefined; - newTarget[STORE_LOOKUP] = undefined; - newTarget[STORE_FIREWALL] = undefined; - newTarget[STORE_OPTIMISTIC] = undefined; - newTarget[STORE_SNAPSHOT_PROPS] = undefined; - newTarget[STORE_PARENT] = undefined; - newTarget[STORE_DESC] = undefined; - newTarget[STORE_SHALLOW] = undefined; - newTarget[$PROXY] = null; -} - -export function createStoreProxy( - value: T, - traps: ProxyHandler = storeTraps, - extend?: (target: StoreNode) => void -) { - let newTarget; - if (Array.isArray(value)) { - newTarget = [] as any; - newTarget[STORE_VALUE] = value; - initStoreFields(newTarget); - } else { - newTarget = { [STORE_VALUE]: value } as any; - initStoreFields(newTarget); - const unwrapped = (value as any)?.[$TARGET]?.[STORE_VALUE] ?? value; - const proto = Object.getPrototypeOf(unwrapped); - if (proto !== null && proto !== Object.prototype) { - newTarget[STORE_CUSTOM_PROTO] = true; - } - } - extend && extend(newTarget); - return (newTarget[$PROXY] = new Proxy(newTarget, traps)); -} - -// The global lookup maps raw value -> StoreNode TARGET (not proxy): reconcile -// and the unwrap/snapshot walks resolve targets through it constantly, and a -// target hit is a plain field read away from its proxy while a proxy hit -// costs a trap to get back to the target. Per-family STORE_LOOKUP maps -// (projections/optimistic) still map raw -> proxy — their wrap functions own -// that contract — so mixed-lookup consumers resolve through lookupTarget(). -export const storeLookup = new WeakMap(); -// Node records that hold at least one user (non-`$TRACK`) symbol-keyed node. -// Lets reconcile enumerate symbols only for records that need it (#2851). -export const symbolKeyedRecords = new WeakSet(); -export function lookupTarget(value: any, lookup?: WeakMap): StoreNode | undefined { - if (lookup !== undefined && lookup !== storeLookup) { +function lookupTarget(value: any, lookup?: WeakMap): StoreNode | undefined { + // Family maps (projections/optimistic) map raw -> target; the global next + // lookup maps raw -> target too. Proxies resolve through $TARGET directly. + if (lookup !== undefined) { const p = lookup.get(value); - if (p !== undefined) return p[$TARGET]; + if (p !== undefined) return p[$TARGET] ?? p; } - return storeLookup.get(value); + return storeNextLookup.get(value) as any; } // Values marked raw never acquire a proxy identity: wrap() serves them as-is // everywhere — deep stores hold them as leaf values replaced by reference. @@ -267,15 +140,14 @@ export function isRawValue(value: any): boolean { export function markRaw(value: T): T { if (isWrappable(value)) { - if (__DEV__ && storeLookup.has(value as object)) - throw new Error("markRaw: value is already tracked by a store"); + if (__DEV__ && false) throw new Error("markRaw: value is already tracked by a store"); rawValuesUsed = true; rawValues.add(value as object); } return value; } -function markRawOne(v: any) { +export function markRawOne(v: any) { if (isWrappable(v)) { // A store proxy is already tracked elsewhere: the shallow boundary passes // it through by reference (replaced, never edited — same slot semantics @@ -285,7 +157,7 @@ function markRawOne(v: any) { // wrapping it in their own family, and their writes landed in the // upstream store's override layer (#2932). if (v[$TARGET] !== undefined) return; - if (__DEV__ && storeLookup.has(v)) + if (__DEV__ && storeNextLookup.has(v)) throw new Error( "shallow store: an ingested record is already tracked as a deep store — one value cannot present both wrapped and raw" ); @@ -302,48 +174,6 @@ export function markRawIngest(container: any) { } } -export function wrap>(value: T, target?: StoreNode): T { - // Raw is raw in every family: the mark must preempt family wrapping too, - // or a shallow projection/optimistic store would proxy its raw records. - if (rawValuesUsed && rawValues.has(value)) return value; - if (target?.[STORE_WRAP]) { - const p = target[STORE_WRAP](value, target); - const t: StoreNode | undefined = p[$TARGET]; - if (t && !t[STORE_PARENT] && t !== target) t[STORE_PARENT] = target; - return p; - } - const t = storeLookup.get(value); - if (t !== undefined) return t[$PROXY]; - let p = value[$PROXY]; - if (!p) { - p = createStoreProxy(value); - const newTarget = p[$TARGET]; - storeLookup.set(value, newTarget); - if (target) newTarget[STORE_PARENT] = target; - } - return p; -} - -// Shallow store root: the target itself is fully reactive (per-key nodes, -// membership, $TRACK); its values are served raw. Seed values are marked at -// creation; reconcile and the set trap mark on ingest. -export function wrapShallow>(value: T): T { - const existing = storeLookup.get(value); - if (existing !== undefined) { - if (existing[STORE_SHALLOW]) return existing[$PROXY]; - if (__DEV__) - throw new Error("createStore({ shallow }): value is already tracked as a deep store"); - } - if (__DEV__ && (value as any)[$TARGET]) - throw new Error("createStore({ shallow }): value is already a store proxy"); - const p = createStoreProxy(value); - const newTarget = p[$TARGET]; - newTarget[STORE_SHALLOW] = true; - storeLookup.set(value, newTarget); - markRawIngest(value); - return p; -} - const OBJECT_PROTO = Object.prototype; // Per-prototype memo for the custom-proto branch of isWrappable: the verdict // is fully determined by the prototype (tag and Node lineage both live on @@ -383,32 +213,8 @@ let writeOverride = false; export function setWriteOverride(value: boolean) { writeOverride = value; } - -function writeOnly(proxy: any) { - return writeOverride || !!Writing?.has(proxy); -} - -function unwrapStoreValue(value: any, map?: Map, lookup?: WeakMap) { - const target = value?.[$TARGET] || lookupTarget(value, lookup); - if (!target) return value; - const override = target[STORE_OVERRIDE]; - if (!override) return target[STORE_VALUE]; - if (!map) map = new Map(); - if (map.has(value)) return map.get(value); - - const source = target[STORE_VALUE]; - const isArray = Array.isArray(source); - const result = isArray ? [] : Object.create(Object.getPrototypeOf(source)); - map.set(value, result); - lookup = target[STORE_LOOKUP] ?? storeLookup; - - for (const key of getStoreKeys(source, override)) { - if (isArray && key === "length") continue; - const next = key in override ? override[key] : source[key]; - if (next !== $DELETED) result[key] = unwrapStoreValue(next, map, lookup); - } - if (isArray) result.length = override.length ?? source.length; - return result; +export function getWriteOverride(): boolean { + return writeOverride; } function isPrototypePollutionKey(property: PropertyKey) { @@ -437,126 +243,6 @@ function ownEnumerableKeysPlain(o: object): (string | symbol)[] { return (Object.keys(o) as (string | symbol)[]).concat(ownEnumerableSymbols(o)); } -/** - * Single chokepoint for the store's layered value resolution: returns the - * override layer (optimistic first, then regular) that shadows `property`, or - * `undefined` when the base `STORE_VALUE` is authoritative. Every trap must - * resolve through this — hand-inlining the layer order is how the optimistic - * layer gets missed (#2850). - */ -export function getOverlayLayer( - target: StoreNode, - property: PropertyKey -): Record | undefined { - const opt = target[STORE_OPTIMISTIC_OVERRIDE]; - if (opt && property in opt) return opt; - const override = target[STORE_OVERRIDE]; - if (override && property in override) return override; - return undefined; -} - -/** - * The value a store leaf's backing signal currently shows to readers: active - * override, else held pending value, else committed value. - */ -export function visibleNodeValue(node: DataNode): any { - return node._overrideValue !== undefined && node._overrideValue !== NOT_PENDING - ? unwrapOverride(node._overrideValue) - : node._pendingValue !== NOT_PENDING - ? node._pendingValue - : node._value; -} - -function hasOwnStoreProperty(target: StoreNode, property: PropertyKey) { - // Override layers are null-prototype objects, so `in` is an own check. - const layer = getOverlayLayer(target, property); - if (layer) return layer[property] !== $DELETED; - return Object.prototype.hasOwnProperty.call(unwrapStoreValue(target[STORE_VALUE]), property); -} - -function hasInheritedAccessor(source: Record, property: PropertyKey): boolean { - let current = Object.getPrototypeOf(source); - while (current && current !== Object.prototype) { - const desc = Reflect.getOwnPropertyDescriptor(current, property); - if (desc) return !!desc.get; - current = Object.getPrototypeOf(current); - } - return false; -} - -function getNodes(target: StoreNode, type: typeof STORE_NODE | typeof STORE_HAS): DataNodes { - let nodes = target[type]; - if (!nodes) target[type] = nodes = Object.create(null) as DataNodes; - return nodes; -} - -function getNode( - target: StoreNode, - nodes: DataNodes, - property: PropertyKey, - value: T, - equals: false | ((a: any, b: any) => boolean) = isEqual, - snapshotProps?: Record -): DataNode { - if (nodes[property]) return nodes[property]!; - const s = signal( - value, - { - equals: equals, - unobserved() { - if (nodes[property] === s) { - delete nodes[property]; - // Drop the symbol-record mark once the last user symbol node is - // gone, so reconcile's fast path stops probing a now string-only - // record. Runs only on symbol-node cleanup (cold), never on reconcile. - if ( - typeof property === "symbol" && - property !== $TRACK && - property !== $AFFECTS && - symbolKeyedRecords.has(nodes) - ) { - const syms = Object.getOwnPropertySymbols(nodes); - let hasUserSymbol = false; - for (let i = 0, len = syms.length; i < len; i++) { - if (syms[i] !== $TRACK && syms[i] !== $AFFECTS) { - hasUserSymbol = true; - break; - } - } - if (!hasUserSymbol) symbolKeyedRecords.delete(nodes); - } - } - } - }, - target[STORE_FIREWALL] as Computed | undefined - ); - if (target[STORE_OPTIMISTIC]) { - s._overrideValue = NOT_PENDING; - } - if (snapshotProps && property in snapshotProps) { - const sv = snapshotProps[property]; - s._snapshotValue = sv === undefined ? NO_SNAPSHOT : sv; - snapshotSources?.add(s); - } - if (typeof property === "symbol" && property !== $TRACK && property !== $AFFECTS) - symbolKeyedRecords.add(nodes); - // A node born inside a live mark's identity scope inherits the mark - // (the declaration walk could only cover nodes that existed then). The - // record's own $AFFECTS carrier is the mark's channel, never a member. - if (property !== $AFFECTS && affectsScopes.size) - inheritAffectsMarks(s, target[STORE_VALUE], property); - // Node presence bubbles up the wrap chain (sticky), so reconcile can see - // "subscribers live somewhere below" through node-less intermediate - // records — the captured-proxy diff gate (#2902). Amortized O(1): stops at - // the first already-flagged ancestor. - let t: StoreNode | undefined = target; - while (t && !t[STORE_DESC]) { - t[STORE_DESC] = true; - t = t[STORE_PARENT]; - } - return (nodes[property] = s); -} - /** * Scope inheritance for late-created nodes: every live mark whose identity * scope contains the owning record's raw — and, for keyed marks, whose key @@ -564,7 +250,7 @@ function getNode( * exactly as long as the scope's carrier — the release hook below drops * them with the entry. */ -function inheritAffectsMarks(node: DataNode, raw: object, property: PropertyKey): void { +export function inheritAffectsMarks(node: DataNode, raw: object, property: PropertyKey): void { // A live scope exists, so affects.ts already installed the mark engine. for (const [carrier, entry] of affectsScopes) { if ( @@ -598,6 +284,28 @@ interface AffectsScope { } const affectsScopes = new Map(); +/** Next-store node factory for affects carriers/slots: injected by the + * rewrite module (next targets alias the legacy field names, so everything + * here EXCEPT node creation works on them structurally). */ +export let nextAffectsNodeResolver: ((target: any, key: PropertyKey) => DataNode) | null = null; +export function setNextAffectsNodeResolver(fn: (target: any, key: PropertyKey) => DataNode): void { + nextAffectsNodeResolver = fn; +} + +/** Next-store optimistic view for the declaration walk (optimistic rows + * pushed before the declaration are in motion too — legacy reads its write + * overlays; next composes armed-node overrides). */ +export let nextOptimisticViewResolver: ((target: any, raw: any) => any) | null = null; +export function setNextOptimisticViewResolver(fn: (target: any, raw: any) => any): void { + nextOptimisticViewResolver = fn; +} + +/** @internal birth inheritance for nodes created inside a live mark window — + * exported for the rewrite's node factories. */ +export function affectsScopesLive(): boolean { + return affectsScopes.size > 0; +} + /** * Snapshots the identities reachable from `value` into `scope`, reading * through write overlays (an optimistic row pushed before the declaration is @@ -620,40 +328,45 @@ function walkAffectsScope( ): void { if (!isWrappable(value)) return; const target: StoreNode | undefined = value[$TARGET] || lookupTarget(value, lookup); - const raw = target ? target[STORE_VALUE] : value; + // Next targets: walk the pending backing when present (a draft's writes are + // in motion too) and cover BOTH identities in the scope. + let raw = target ? ((target as any).pb ?? target[STORE_VALUE]) : value; if (visited.has(raw)) return; visited.add(raw); entry.scope.add(raw); - let override: Record | undefined; + if (target && (target as any).pb) entry.scope.add(target[STORE_VALUE]); + // Next optimistic families: enumerate the VISIBLE view (armed-node + // overrides compose membership/values the raw doesn't carry). + if (target && (target as any).fam?.opt && nextOptimisticViewResolver) + raw = nextOptimisticViewResolver(target, raw); if (target) { collectRecordNodes(target[STORE_NODE], found); collectRecordNodes(target[STORE_HAS], found); - override = mergedOverlay(target); - // Carry the effective lookup into untouched descendants. Default stores - // use the global lookup just like snapshotImpl; without it, nested raw - // objects fall back to string-only enumeration and symbol branches vanish. - lookup = target[STORE_LOOKUP] ?? lookup ?? storeLookup; + // The key-set and deep-witness nodes are record-level channels: a deep() + // probe reads ONLY these (one pair per record), so a declared affects + // scope must mark them like any property node. + if ((target as any).k) found.push((target as any).k); + if ((target as any).dk) found.push((target as any).dk); + // Carry the effective lookup into untouched descendants (family maps for + // projections/optimistic stores; the global next lookup otherwise). + lookup = (target as any).fam?.map ?? lookup ?? storeNextLookup; } + // Overlays are gone (next has no layer): raw enumeration; the optimistic + // view composition above already folded armed-node membership/values in. if (Array.isArray(raw)) { - const len = override?.length ?? raw.length; - for (let i = 0; i < len; i++) { - const v = override && i in override ? override[i] : raw[i]; - if (v !== $DELETED) walkAffectsScope(v, entry, found, lookup, visited); + for (let i = 0, len = raw.length; i < len; i++) { + walkAffectsScope(raw[i], entry, found, lookup, visited); } - // Arrays can also carry symbol metadata. Enumerate symbols separately to - // avoid scanning large index lists twice. Outside a store tree, retain the - // existing index-only walk. - const symbols = target || lookup ? getStoreSymbols(raw, override) : []; + const symbols = Object.getOwnPropertySymbols(raw); for (let i = 0, l = symbols.length; i < l; i++) { - const key = symbols[i]; - const desc = getPropertyDescriptor(raw, override, key); + const desc = Object.getOwnPropertyDescriptor(raw, symbols[i]); if (!desc || desc.get) continue; walkAffectsScope(desc.value, entry, found, lookup, visited); } } else { - const keys = target || lookup ? getStoreKeys(raw, override) : getKeys(raw, override); + const keys = Reflect.ownKeys(raw); for (let i = 0, l = keys.length; i < l; i++) { - const desc = getPropertyDescriptor(raw, override, keys[i]); + const desc = Object.getOwnPropertyDescriptor(raw, keys[i]); if (!desc || desc.get) continue; walkAffectsScope(desc.value, entry, found, lookup, visited); } @@ -688,15 +401,29 @@ export function witnessAffectsMark(target: StoreNode, property?: PropertyKey): v const own = target[STORE_NODE]?.[$AFFECTS]; if (own?._affectsCount) GlobalQueue._witnessAffects!(own); if (affectsScopes.size) { - const raw = target[STORE_VALUE]; + // Chained backings (§7b): a wrapper's STORE_VALUE can be another store's + // proxy — marks cover by identity of the BASE raw, so resolve the chain + // and check every identity along it. + let raw = target[STORE_VALUE]; for (const [carrier, entry] of affectsScopes) { if ( carrier !== own && carrier._affectsCount && - entry.scope.has(raw) && (entry.key === undefined || entry.key === property) - ) - GlobalQueue._witnessAffects!(carrier); + ) { + let r: any = raw; + for (;;) { + if (entry.scope.has(r)) { + GlobalQueue._witnessAffects!(carrier); + break; + } + const t: StoreNode | undefined = r?.[$TARGET]; + if (t === undefined) break; + const backing = (t as any).pb ?? t[STORE_VALUE]; + if (backing === r) break; + r = backing; + } + } } } } @@ -713,7 +440,6 @@ export function witnessAffectsMark(target: StoreNode, property?: PropertyKey): v * @internal */ export function getStoreAffectsNodes(target: StoreNode, key?: PropertyKey): DataNode[] { - const nodes = getNodes(target, STORE_NODE); GlobalQueue._releaseAffectsScope ||= node => { const entry = affectsScopes.get(node as DataNode); if (!entry) return; @@ -722,25 +448,14 @@ export function getStoreAffectsNodes(target: StoreNode, key?: PropertyKey): Data GlobalQueue._releaseAffectsMark!(entry.inherited[i]); }; if (key === undefined) { - const carrier = getNode(target, nodes, $AFFECTS, undefined, false); + const carrier = nextAffectsNodeResolver!(target, $AFFECTS); let entry = affectsScopes.get(carrier); if (!entry) affectsScopes.set(carrier, (entry = { scope: new Set(), inherited: [] })); const result = [carrier]; - walkAffectsScope(target[$PROXY], entry, result, target[STORE_LOOKUP], new Set()); + walkAffectsScope(target[$PROXY], entry, result, (target as any).fam?.map, new Set()); return result; } - let node = nodes[key]; - if (!node) { - const layer = getOverlayLayer(target, key); - const raw = layer ? layer[key] : target[STORE_VALUE][key]; - node = upsertStoreNode( - target, - nodes, - key, - raw === $DELETED ? undefined : raw, - target[STORE_SNAPSHOT_PROPS] - ); - } + const node = (target as any).n?.[key] ?? nextAffectsNodeResolver!(target, key); // Keyed marks resolve by identity too (#2904): another store family's // proxy can share this record's raw (a derived store swaps its backing to // the source's raw when its projection lands), and reads through it never @@ -749,804 +464,6 @@ export function getStoreAffectsNodes(target: StoreNode, key?: PropertyKey): Data let entry = affectsScopes.get(node); if (!entry) affectsScopes.set(node, (entry = { scope: new Set(), inherited: [], key })); entry.scope.add(target[STORE_VALUE]); + if ((target as any).pb) entry.scope.add((target as any).pb); return [node]; } - -export function trackSelf(target: StoreNode, symbol: symbol = $TRACK) { - if (!getObserver()) return; - read(getNode(target, getNodes(target, STORE_NODE), symbol, undefined, false)); - // Store-in-store: structural notifications (reconcile, notifySelf) land on - // the wrapped source's own self-node, never on this wrapper view's. Chain - // the read through so enumeration/$TRACK on the wrapper observes them - // (#2864). Property reads already chain naturally via the inner get trap. - // An override layer on the view is a hold (A17) — the shown structure is - // the overlay's, so don't subscribe through it; clearing the layer notifies - // this view's own self-node and the re-run re-establishes the chain. - if ( - symbol === $TRACK && - !target[STORE_OVERRIDE] && - !target[STORE_OPTIMISTIC_OVERRIDE] && - target[STORE_VALUE][$TARGET] - ) - (target[STORE_VALUE] as any)[$TRACK]; -} - -export function notifySelf(target: StoreNode) { - const node = target[STORE_NODE]?.[$TRACK]; - node && - setSignal( - node, - target[STORE_OPTIMISTIC] && !projectionWriteActive ? STORE_SELF_PENDING : undefined - ); -} - -/** - * The write overlay a walk must read through: optimistic writes shadow - * regular pending writes, the same resolution order as every proxy trap and - * `reconcile` (#2850). Merging allocates only in the rare both-present case - * (a derived optimistic store with an in-flight projection commit). - */ -export function mergedOverlay(target: StoreNode): Record | undefined { - const override = target[STORE_OVERRIDE]; - const opt = target[STORE_OPTIMISTIC_OVERRIDE]; - return override && opt ? { ...override, ...opt } : (opt ?? override); -} - -function getKeysImpl( - source: Record, - override: Record | undefined, - enumerable: boolean, - symbols: boolean -): PropertyKey[] { - // Plain objects can't trigger proxy traps — only pay for the untrack - // closure when the source is itself a wrapped store (store-in-store). - const baseKeys = (source as any)[$TARGET] - ? untrack(() => - enumerable - ? symbols - ? ownEnumerableKeys(source) - : Object.keys(source) - : Reflect.ownKeys(source) - ) - : enumerable - ? symbols - ? ownEnumerableKeysPlain(source) - : Object.keys(source) - : Reflect.ownKeys(source); - return override ? mergeOverrideKeys(baseKeys, override) : baseKeys; -} - -export function getKeys( - source: Record, - override: Record | undefined, - enumerable: boolean = true -): PropertyKey[] { - return getKeysImpl(source, override, enumerable, false); -} - -export function getStoreKeys( - source: Record, - override: Record | undefined -): PropertyKey[] { - return getKeysImpl(source, override, true, true); -} - -export function getStoreSymbols( - source: Record, - override: Record | undefined -): symbol[] { - const symbols = (source as any)[$TARGET] - ? untrack(() => ownEnumerableSymbols(source)) - : ownEnumerableSymbols(source); - return override ? (mergeOverrideKeys(symbols, override, true) as symbol[]) : symbols; -} - -// Shared override-layer merge for key enumeration: adds live override keys, -// drops $DELETED ones. `symbolsOnly` scopes the override scan for the -// array-metadata passes. -function mergeOverrideKeys( - baseKeys: PropertyKey[], - override: Record, - symbolsOnly?: boolean -): PropertyKey[] { - const keys = new Set(baseKeys); - const overrides = symbolsOnly - ? Object.getOwnPropertySymbols(override) - : Reflect.ownKeys(override); - for (const key of overrides) { - if (override[key] !== $DELETED) keys.add(key); - else keys.delete(key); - } - return Array.from(keys); -} - -export function getPropertyDescriptor( - source: Record, - override: Record | undefined, - property: PropertyKey -): PropertyDescriptor | undefined { - if (override && property in override) { - if (override[property] === $DELETED) return void 0; - const overrideDesc = Reflect.getOwnPropertyDescriptor(override, property); - if (overrideDesc?.get || overrideDesc?.set) return overrideDesc; - // Plain writes live in the override while the source keeps its old value. - // Preserve the source descriptor flags, but report the current override - // value. Source accessors cannot be patched with a value, and inherited - // properties have no source own descriptor, so those keep their descriptor. - const baseDesc = Reflect.getOwnPropertyDescriptor(source, property); - if (!baseDesc) return overrideDesc; - if (baseDesc.get || baseDesc.set) return baseDesc; - // Reflect returns a fresh descriptor, so patching in place is safe and - // avoids an allocation on Object.keys/spread over written stores. - baseDesc.value = override[property]; - return baseDesc; - } - return Reflect.getOwnPropertyDescriptor(source, property); -} - -function prepareStoreWrite(target: StoreNode, store: any, property: PropertyKey) { - if (target[STORE_OPTIMISTIC]) { - const firewall = target[STORE_FIREWALL]; - if (firewall?._transition) { - globalQueue.initTransition(firewall._transition); - } - } - const state = target[STORE_VALUE]; - const base = state[property]; - if ( - snapshotCaptureActive && - typeof property !== "symbol" && - !((target[STORE_FIREWALL]?._statusFlags ?? 0) & STATUS_PENDING) - ) { - if (!target[STORE_SNAPSHOT_PROPS]) { - target[STORE_SNAPSHOT_PROPS] = Object.create(null); - snapshotSources?.add(target); - } - if (!(property in target[STORE_SNAPSHOT_PROPS]!)) { - target[STORE_SNAPSHOT_PROPS]![property] = base; - } - } - const useOptimistic = target[STORE_OPTIMISTIC] && !projectionWriteActive; - const overrideKey = useOptimistic ? STORE_OPTIMISTIC_OVERRIDE : STORE_OVERRIDE; - return { base, overrideKey, state }; -} - -/** - * Registers the store for transition reversion. Called only once a write is - * known to be effective — ineffective writes (same value, delete of an absent - * property) are no-ops and must not entangle the store. Optimistic writes are - * verdict-inert (question-scoped pending model): no mask is armed — the write - * neither pends its own slot nor silences anyone else's. - */ -function armOptimisticStoreWrite(target: StoreNode, store: any): void { - // STORE_OPTIMISTIC is only set by createOptimisticStore, which installs the - // optimistic engine before wrapping. - if (target[STORE_OPTIMISTIC] && !projectionWriteActive) { - GlobalQueue._trackOptimisticStore!(store); - } -} - -/** - * Records which transition owns an optimistic layer entry (#2899), so a - * settling action only consumes its own keys — the layer is store-wide, but - * concurrent actions writing disjoint keys must revert independently, exactly - * like optimistic signal nodes do via the transition's _optimisticNodes. - * `activeTransition` is the write's transaction (action() opens it before the - * body runs); null marks an ambient write, which clears at plain flush end — - * unless its flush's transition is blocked on the store's own in-flight truth - * (pending firewall, #2951), in which case it rides that transaction to - * settle. Same-key writes across actions keep last-write-wins layer - * semantics. - */ -function stampOptimisticOwner(target: StoreNode, overrideKey: string, property: PropertyKey): void { - if (overrideKey === STORE_OPTIMISTIC_OVERRIDE) - (target[STORE_OPTIMISTIC_OWNERS] ??= Object.create(null))[property] = activeTransition; -} - -function upsertStoreNode( - target: StoreNode, - nodes: DataNodes, - property: PropertyKey, - prev: any, - snapshotProps?: Record -): DataNode { - if (nodes[property]) return nodes[property]!; - const initial = isWrappable(prev) ? wrap(prev, target) : prev; - const node = getNode(target, nodes, property, initial, isEqual, snapshotProps); - registerTransientStoreNode(node); - return node; -} - -function notifyStoreProperty( - target: StoreNode, - property: PropertyKey, - mode: "set" | "invalidate" | "delete", - value?: any, - prev?: any, - prevHas?: boolean -) { - // Cold writes upsert a transient pending node so untracked reads batch like signals. - // Skip for projection writes (different commit semantics) and for optimistic stores - // (whose whole purpose is immediate visibility via STORE_OPTIMISTIC_OVERRIDE). - const skipUpsert = projectionWriteActive || target[STORE_OPTIMISTIC]; - const newHas = mode !== "delete"; - const existingHas = target[STORE_HAS]?.[property]; - if (existingHas) { - setSignal(existingHas, newHas); - } else if (!skipUpsert && mode !== "invalidate" && prevHas !== newHas) { - const hasNode = upsertStoreNode(target, getNodes(target, STORE_HAS), property, prevHas); - setSignal(hasNode, newHas); - } - const nodes = getNodes(target, STORE_NODE); - if (mode === "set") { - if (nodes[property]) { - setSignal(nodes[property], () => (isWrappable(value) ? wrap(value, target) : value)); - } else if (!skipUpsert) { - const node = upsertStoreNode(target, nodes, property, prev, target[STORE_SNAPSHOT_PROPS]); - setSignal(node, () => (isWrappable(value) ? wrap(value, target) : value)); - } - } else if (mode === "invalidate") { - if (nodes[property]) { - setSignal(nodes[property], {} as any); - delete nodes[property]; - } - } else { - if (nodes[property]) { - setSignal(nodes[property], undefined); - } else if (!skipUpsert) { - const node = upsertStoreNode(target, nodes, property, prev, target[STORE_SNAPSHOT_PROPS]); - setSignal(node, undefined); - } - } - notifySelf(target); -} - -let Writing: Set | null = null; - -/** - * A derived store follows async memo rules (#2897 ruling): its seed is a - * draft for the derive function, never an observable value, and an errored - * derive is an error state, never a silent stale/seed serve. Until the - * firewall first resolves there is nothing to read, so every consumer path - * throws NotReady — tracked reads through their node (core read()), and the - * untracked fall-throughs in the traps through this guard. Returning the - * seed leaked it; returning `undefined` would break non-nullable types. - * Callers exempt the firewall itself (the derive function works its own - * draft while uninitialized). - * - * Error rail: a firewall carrying STATUS_ERROR throws its error for every - * late reader — memo parity, where read()'s error branch does the same for - * plain computeds. Rejection clears STATUS_UNINITIALIZED at commit, so - * without this check late readers silently got the seed while settle-time - * subscribers saw the error. - * - * Loading rail: the veto requires the firewall to still be in flight, not - * just flagged: STATUS_UNINITIALIZED's clear is deferred to batch commit, - * so during the settle flush a firewall that has already recomputed — and - * reconciled real values into STORE_VALUE — still carries the stale flag. - * STATUS_PENDING is the live bit (it clears eagerly at settle, mirroring - * core read()'s verdict), so gating on it stops the guard from throwing a - * fresh NotReadyError that nothing would ever sweep. #2944: mapArray's - * keyed diff reads items inside its internal owner (untracked by design) - * in exactly this window, and the stale throw wedged permanently. - */ -function throwIfUnreadable(target: StoreNode): void { - const firewall = target[STORE_FIREWALL]; - if (!firewall) return; - const flags = firewall._statusFlags; - if (flags & STATUS_ERROR || (flags & STATUS_UNINITIALIZED && flags & STATUS_PENDING)) - throw firewall._error ?? new NotReadyError(firewall); -} - -export const storeTraps: ProxyHandler = { - get(target, property, receiver) { - if (property === $TARGET) return target; - if (property === $PROXY) return receiver; - if (property === $REFRESH) return target[STORE_FIREWALL]; - if (pendingCheckActive) witnessAffectsMark(target, property); - if (property === $TRACK) { - trackSelf(target); - return receiver; - } - // Hot path: an existing node on a plain target — no firewall (so neither - // selfRead nor the uninitialized guard can apply), no override layers, - // no write scope, and a raw (non-proxy) source. This is the shape of - // every effect re-read of a settled store; it pays one node read and the - // wrap check, nothing else. Any exotic bit falls through to the full - // resolution below, and dev strictRead keeps its warning path. - if ( - (!__DEV__ || !strictRead) && - target[STORE_FIREWALL] === undefined && - target[STORE_OVERRIDE] === undefined && - target[STORE_OPTIMISTIC_OVERRIDE] === undefined && - !writeOverride && - (Writing === null || !Writing.has(receiver)) - ) { - const nodes = target[STORE_NODE]; - const node = nodes && nodes[property]; - if (node !== undefined && target[STORE_VALUE][$TARGET] === undefined) { - // readNodeFast is read()'s plain-signal fast path hoisted over the - // call; READ_SLOW means a global read window (latest/pending-check/ - // transition/lane/snapshot capture) or a node layer is active, and - // only then does the full read() resolution have anything to do. - let value = readNodeFast(node); - if (value === READ_SLOW) value = read(node); - if (value === $DELETED) value = undefined; - // Every node-writing site wraps wrappables before setSignal (see the - // dev assertion below), so re-wrapping on read is redundant — except - // during snapshot capture, where read() can surface a raw captured - // value seeded from snapshot props. - if (!snapshotCaptureActive) { - if (__DEV__ && isWrappable(value) && wrap(value, target) !== value) { - throw new Error( - "store node invariant violated: node held an unwrapped wrappable value" - ); - } - return value; - } - return isWrappable(value) ? wrap(value, target) : value; - } - } - const selfRead = getObserver() === target[STORE_FIREWALL]; - const nodes = getNodes(target, STORE_NODE); - const tracked = selfRead ? undefined : nodes[property]; - const source = target[STORE_VALUE]; - if ( - !tracked && - !target[STORE_OVERRIDE] && - !target[STORE_OPTIMISTIC_OVERRIDE] && - !target[STORE_CUSTOM_PROTO] && - !target[STORE_OPTIMISTIC] && - !target[STORE_SNAPSHOT_PROPS] && - !source[$TARGET] && - !(property in source) && - getObserver() && - !selfRead && - !writeOnly(receiver) - ) { - return read(getNode(target, nodes, property, undefined)); - } - const overlay = getOverlayLayer(target, property); - const overridden = !!overlay; - const proxySource = !!target[STORE_VALUE][$TARGET]; - const storeValue = overlay ?? target[STORE_VALUE]; - if (!tracked) { - const desc = Object.getOwnPropertyDescriptor(storeValue, property); - if (desc && desc.get) return desc.get.call(receiver); - if (!desc && !overridden && target[STORE_CUSTOM_PROTO]) { - const source = unwrapStoreValue(storeValue); - if (hasInheritedAccessor(source, property)) { - return Reflect.get(storeValue, property, receiver); - } - } - } - if (writeOnly(receiver)) { - if (isPrototypePollutionKey(property) && !hasOwnStoreProperty(target, property)) - return undefined; - let value = - tracked && (overridden || !proxySource) ? visibleNodeValue(tracked) : storeValue[property]; - value === $DELETED && (value = undefined); - if (!isWrappable(value)) return value; - // Shallow boundary: records are replaced, never edited in place. Reads - // inside a setter serve the raw so read-then-replace, filter/pop and - // projection derives all work; in-place mutation of a raw is inert by - // construction — the same contract as a markRaw child in a deep store. - if (target[STORE_SHALLOW]) return value; - const wrapped = wrap(value, target); - Writing?.add(wrapped); - return wrapped; - } - let value = tracked - ? overridden || !proxySource - ? read(nodes[property]) - : (read(nodes[property]), storeValue[property]) - : storeValue[property]; - value === $DELETED && (value = undefined); - if (!tracked) { - if ( - !overridden && - typeof value === "function" && - !Object.prototype.hasOwnProperty.call(storeValue, property) - ) { - let proto; - return !Array.isArray(target[STORE_VALUE]) && - (proto = Object.getPrototypeOf(target[STORE_VALUE])) && - proto !== Object.prototype - ? value.bind(storeValue) - : value; - } else if (getObserver() && !selfRead) { - return read( - getNode( - target, - nodes, - property, - isWrappable(value) ? wrap(value, target) : value, - isEqual, - target[STORE_SNAPSHOT_PROPS] - ) - ); - } - } - if (__DEV__ && strictRead && typeof property === "string") { - // Safeguard parity with core read() (#2897): untracked store reads skip - // node creation (and with it read()'s PENDING_ASYNC_UNTRACKED_READ - // check), so a derived store's in-flight firewall must be consulted - // here — otherwise a component-body read of a refetching store silently - // returns a value the reader can never observe updating. - if ((target[STORE_FIREWALL]?._statusFlags ?? 0) & STATUS_PENDING) - throwPendingUntrackedRead(strictRead, { nodeName: property }); - warnStrictReadUntracked(strictRead, { - nodeName: property, - data: { strictRead, property, source: "store" } - }); - } - // Untracked fall-through (tracked reads already threw via their node in - // read(); the dev strictRead error above wins first for memo parity). - // Observer-present reads must NOT re-consult the flag here: during the - // settle flush the firewall has recomputed (first values live on the - // pending rail, served by read() above) but its UNINITIALIZED clear is - // deferred to batch commit — vetoing read()'s verdict with the stale flag - // threw a fresh NotReadyError for an already-settled source, which no - // sweep would ever release (#2938: projection over an async store wedged - // its Loading boundary on `undefined`). - if (!selfRead && !getObserver()) throwIfUnreadable(target); - return isWrappable(value) ? wrap(value, target) : value; - }, - - has(target, property) { - if (property === $PROXY || property === $TRACK || property === "__proto__") return true; - if (pendingCheckActive) witnessAffectsMark(target, property); - const hasLayer = getOverlayLayer(target, property); - const has = hasLayer ? hasLayer[property] !== $DELETED : property in target[STORE_VALUE]; - - if (writeOnly(target[$PROXY]) || getObserver() === target[STORE_FIREWALL]) return has; - const nodes = getNodes(target, STORE_HAS); - // If a has-node already exists, it carries the batched presence — `read()` - // returns `_value` (committed) for untracked reads and the pending value for - // downstream computes. This keeps `in` consistent with value reads. - if (nodes[property]) return read(nodes[property]); - // No node yet: `has` reflects committed presence (no pending write could change - // it without first upserting a has-node at the write site). Create + read only - // when tracking; leave untracked reads node-free. - if (getObserver()) { - return read(getNode(target, nodes, property, has)); - } - throwIfUnreadable(target); - return has; - }, - - set(target, property, rawValue) { - if (property === "__proto__") return true; - const store = target[$PROXY]; - if (writeOnly(store)) { - untrack(() => { - const { base, overrideKey, state } = prepareStoreWrite(target, store, property); - const prevLayer = getOverlayLayer(target, property); - const prev = prevLayer ? prevLayer[property] : base; - const prevHas = prevLayer - ? prevLayer[property] !== $DELETED - : property in target[STORE_VALUE]; - // Shallow slots hold store proxies verbatim (pass-through reference, - // never raw-marked — see markRawOne/#2932); everything else unwraps - // and marks as usual. - const passThrough = !!target[STORE_SHALLOW] && (rawValue as any)?.[$TARGET] !== undefined; - const value = passThrough ? rawValue : unwrapStoreValue(rawValue); - if (target[STORE_SHALLOW] && !passThrough && isWrappable(value)) { - // Flip the live gate too: a bare add was inert unless something else - // had already marked a raw somewhere (wrap() checks rawValuesUsed - // first), so the documented set-trap ingest mark silently no-oped in - // apps whose only shallow data arrived through writes. - rawValuesUsed = true; - rawValues.add(value); - } - // Symbol-keyed writes on arrays are metadata, not index writes — never run - // them through the numeric index/length machinery (`parseInt` on a symbol - // throws). #2769 - const index = typeof property === "string" ? Number(property) : -1; - const isArrayIndexWrite = - Array.isArray(state) && - Number.isInteger(index) && - index >= 0 && - index < 4294967295 && - String(index) === property; - const nextIndex = isArrayIndexWrite ? index + 1 : 0; - const len = isArrayIndexWrite && (getOverlayLayer(target, "length") ?? state).length; - const nextLength = isArrayIndexWrite && nextIndex > len ? nextIndex : undefined; - - if (prev === value && nextLength === undefined) return true; - armOptimisticStoreWrite(target, store); - if (value !== undefined && value === base && nextLength === undefined) { - delete target[overrideKey]?.[property]; - if (overrideKey === STORE_OPTIMISTIC_OVERRIDE) - delete target[STORE_OPTIMISTIC_OWNERS]?.[property]; - } else { - const override = target[overrideKey] || (target[overrideKey] = Object.create(null)); - override[property] = value; - stampOptimisticOwner(target, overrideKey, property); - if (nextLength !== undefined) { - override.length = nextLength; - stampOptimisticOwner(target, overrideKey, "length"); - } - } - notifyStoreProperty(target, property, "set", value, prev, prevHas); - // Shrinking an array's length must remove the truncated indices, otherwise - // they leak through `has`, `ownKeys`, and (tracked) index reads from the - // underlying value. Mark each as deleted and notify so reactive reads update. #2768 - if ( - Array.isArray(state) && - property === "length" && - typeof value === "number" && - typeof prev === "number" && - value < prev - ) { - const override = target[overrideKey] || (target[overrideKey] = Object.create(null)); - for (let i = value; i < prev; i++) { - if (override[i] === $DELETED) continue; - const prevIndex = i in override ? override[i] : state[i]; - if (!(i in override) && !(i in state)) continue; - override[i] = $DELETED; - stampOptimisticOwner(target, overrideKey, i); - notifyStoreProperty(target, i, "delete", undefined, prevIndex, true); - } - } - // notify length change - if (Array.isArray(state) && property !== "length" && nextLength !== undefined) { - const nodes = getNodes(target, STORE_NODE); - if (nodes.length) { - setSignal(nodes.length, nextLength); - } else if (!projectionWriteActive && !target[STORE_OPTIMISTIC]) { - const node = upsertStoreNode( - target, - nodes, - "length", - len, - target[STORE_SNAPSHOT_PROPS] - ); - setSignal(node, nextLength); - } - } - if (__DEV__) DEV.hooks.onStoreNodeUpdate?.(target[$PROXY], property, value, prev); - }); - } - return true; - }, - - defineProperty(target, property, descriptor) { - if (property === "__proto__") return true; - const store = target[$PROXY]; - if (writeOnly(store)) { - untrack(() => { - const { base, overrideKey } = prepareStoreWrite(target, store, property); - armOptimisticStoreWrite(target, store); - const normalizedDescriptor = - "value" in descriptor - ? { - ...descriptor, - value: unwrapStoreValue(descriptor.value) - } - : descriptor; - Object.defineProperty( - target[overrideKey] || (target[overrideKey] = Object.create(null)), - property, - normalizedDescriptor - ); - stampOptimisticOwner(target, overrideKey, property); - - notifyStoreProperty(target, property, "invalidate"); - - if (__DEV__) { - const next = - "value" in normalizedDescriptor - ? normalizedDescriptor.value - : normalizedDescriptor.get?.call(store); - DEV.hooks.onStoreNodeUpdate?.(target[$PROXY], property, next, base); - } - }); - } - return true; - }, - - deleteProperty(target, property) { - if (property === "__proto__") return true; - // Check both optimistic and regular override for existing $DELETED - const optDeleted = target[STORE_OPTIMISTIC_OVERRIDE]?.[property] === $DELETED; - const regDeleted = target[STORE_OVERRIDE]?.[property] === $DELETED; - if (writeOnly(target[$PROXY]) && !optDeleted && !regDeleted) { - untrack(() => { - const useOptimistic = target[STORE_OPTIMISTIC] && !projectionWriteActive; - const overrideKey = useOptimistic ? STORE_OPTIMISTIC_OVERRIDE : STORE_OVERRIDE; - const prevLayer = getOverlayLayer(target, property); - const prev = prevLayer ? prevLayer[property] : target[STORE_VALUE][property]; - if ( - property in target[STORE_VALUE] || - (target[STORE_OVERRIDE] && property in target[STORE_OVERRIDE]) - ) { - armOptimisticStoreWrite(target, target[$PROXY]); - (target[overrideKey] || (target[overrideKey] = Object.create(null)))[property] = $DELETED; - stampOptimisticOwner(target, overrideKey, property); - } else if (target[overrideKey] && property in target[overrideKey]) { - armOptimisticStoreWrite(target, target[$PROXY]); - delete target[overrideKey][property]; - if (overrideKey === STORE_OPTIMISTIC_OVERRIDE) - delete target[STORE_OPTIMISTIC_OWNERS]?.[property]; - } else return true; - notifyStoreProperty(target, property, "delete", undefined, prev, true); - }); - } - return true; - }, - - ownKeys(target: StoreNode) { - if (pendingCheckActive) witnessAffectsMark(target); - if (getObserver() !== target[STORE_FIREWALL]) { - trackSelf(target); - // trackSelf no-ops untracked, so enumeration of an unresolved derived - // store would otherwise leak the seed's structure (#2897). The write - // path is exempt (like the get/has traps' writeOnly early returns): - // the first landing's reconcile enumerates the store while - // STATUS_UNINITIALIZED is still set — it IS the initialization. - if (!getObserver() && !writeOnly(target[$PROXY])) throwIfUnreadable(target); - } - // Merge optimistic override with regular override for key enumeration - let keys = getKeys(target[STORE_VALUE], target[STORE_OVERRIDE], false); - if (target[STORE_OPTIMISTIC_OVERRIDE]) { - const keySet = new Set(keys); - for (const key of Reflect.ownKeys(target[STORE_OPTIMISTIC_OVERRIDE])) { - if (target[STORE_OPTIMISTIC_OVERRIDE][key] !== $DELETED) keySet.add(key); - else keySet.delete(key); - } - keys = Array.from(keySet); - } - return keys as ArrayLike; - }, - - getOwnPropertyDescriptor(target: StoreNode, property: PropertyKey) { - if (property === $PROXY) return { value: target[$PROXY], writable: true, configurable: true }; - // Check optimistic override first, but use base descriptor structure for compatibility - if (target[STORE_OPTIMISTIC_OVERRIDE] && property in target[STORE_OPTIMISTIC_OVERRIDE]) { - if (target[STORE_OPTIMISTIC_OVERRIDE][property] === $DELETED) return undefined; - const optDesc = Reflect.getOwnPropertyDescriptor(target[STORE_OPTIMISTIC_OVERRIDE], property); - if (optDesc?.get || optDesc?.set || !(property in target[STORE_VALUE])) return optDesc; - // Get base descriptor structure, override just the value - const baseDesc = getPropertyDescriptor(target[STORE_VALUE], target[STORE_OVERRIDE], property); - if (baseDesc) { - const targetDesc = Reflect.getOwnPropertyDescriptor(target, property); - const configurable = !targetDesc || targetDesc.configurable ? true : baseDesc.configurable; - return { ...baseDesc, configurable, value: target[STORE_OPTIMISTIC_OVERRIDE][property] }; - } - return { - value: target[STORE_OPTIMISTIC_OVERRIDE][property], - writable: true, - enumerable: true, - configurable: true - }; - } - const desc = getPropertyDescriptor(target[STORE_VALUE], target[STORE_OVERRIDE], property); - // The proxy target is an internal node object, not the original source. When the - // source has a non-configurable property that does not also exist as non-configurable - // on the proxy target, the proxy invariant is violated: the engine requires that a - // property reported as non-configurable must actually be non-configurable on the - // target object. Override configurable to true only in that case. - if (desc && !desc.configurable) { - const targetDesc = Reflect.getOwnPropertyDescriptor(target, property); - if (!targetDesc || targetDesc.configurable) return { ...desc, configurable: true }; - } - return desc; - }, - - getPrototypeOf(target) { - return Object.getPrototypeOf(target[STORE_VALUE]); - } -}; - -export function storeSetter(store: Store, fn: (draft: T) => T | void): void { - const prevWriting = Writing; - Writing = new Set(); - Writing.add(store); - try { - const value = fn(store); - if (value !== store && value !== undefined) { - if (Array.isArray(value)) { - for (let i = 0, len = value.length; i < len; i++) store[i] = value[i]; - (store as any).length = value.length; - } else { - const keys = new Set([...ownEnumerableKeys(store), ...ownEnumerableKeys(value)]); - keys.forEach(key => { - if (key in value) store[key] = (value as any)[key]; - else delete (store as any)[key]; - }); - } - } - } finally { - Writing.clear(); - Writing = prevWriting; - } -} - -/** - * Creates a deeply-reactive store backed by a Proxy. Reads track each property - * accessed; only the parts that change trigger updates. - * - * Store properties hold **plain values**, not accessors. The proxy already - * tracks reads per-property — wrapping a value in `() => state.foo` produces - * a getter that *won't* track when called, which looks like a reactivity bug - * but is just a category error. If you have a signal-shaped piece of state, - * make it a property of the store (`{ foo: 1 }`) rather than nesting an - * accessor inside (`{ foo: () => signal() }`). - * - * The setter takes a **draft-mutating** function — mutate the draft in place - * (canonical). The callback may also return a new value: arrays are replaced - * by index (length adjusted), objects are shallow-diffed at the top level - * (keys present in the returned value are written, missing keys deleted). Use - * the return form for shapes where mutation is awkward — most commonly - * removing items via `filter`. The setter does **not** do keyed reconciliation; - * for that, use the derived/projection form (or `createProjection`). - * - * - Plain form: `createStore(initialValue)` — wraps a value in a reactive - * proxy. - * - Derived form: `createStore(fn, seed, options?)` — a *projection store* - * whose contents are computed by `fn(draft)`. `fn` may be sync, async, or - * an `AsyncIterable`; the projection's result reconciles against the - * existing store by `options.key` (default `"id"`) for stable identity. - * - * @example - * ```ts - * const [state, setState] = createStore({ - * user: { name: "Ada", age: 36 }, - * todos: [] as { id: string; text: string; done: boolean }[] - * }); - * - * // Canonical: mutate the draft in place. - * setState(s => { s.user.age = 37; }); - * setState(s => { s.todos.push({ id: "1", text: "x", done: false }); }); - * - * // Return form: reach for it when mutation is awkward. - * setState(s => s.todos.filter(t => !t.done)); // remove items - * setState(s => ({ ...s, user: { name: "Grace", age: 85 } })); // shallow replace - * ``` - * - * @example - * ```ts - * // Derived store — auto-fetches & reconciles by `id`. - * const [users] = createStore( - * async () => fetch("/users").then(r => r.json()), - * [] as User[] - * ); - * ``` - * - * @returns `[store: Store, setStore: StoreSetter]` - */ -export function createStore( - store: NoFn | Store>, - options?: StoreOptions & { shallow?: boolean } -): StoreReturn; -export function createStore( - fn: (store: T) => void | T | Promise | AsyncIterable, - store: Partial | Store>, - options?: ProjectionOptions -): ProjectionStoreReturn; -export function createStore( - first: T | ((store: T) => void | T | Promise | AsyncIterable), - second?: NoFn | Store>, - options?: ProjectionOptions -): StoreReturn | ProjectionStoreReturn { - const derived = typeof first === "function", - wrappedStore = derived - ? createProjectionInternal(first, second as NoFn | Store>, options).store - : (second as (StoreOptions & { shallow?: boolean }) | undefined)?.shallow - ? wrapShallow(first as any) - : wrap(first); - - if (__DEV__) registerGraph(wrappedStore, getOwner()); - - return [ - wrappedStore, - derived - ? (fn: (draft: T) => void): void => { - // Mark the projection as manually written before notifying property nodes. - suppressComputedRecompute((wrappedStore as any)[$REFRESH]); - storeSetter(wrappedStore, fn); - } - : (fn: (draft: T) => void): void => storeSetter(wrappedStore, fn) - ]; -} diff --git a/packages/solid-signals/src/store/utils.ts b/packages/solid-signals/src/store/utils.ts index 368e3fbbd..f3fafdaf3 100644 --- a/packages/solid-signals/src/store/utils.ts +++ b/packages/solid-signals/src/store/utils.ts @@ -1,191 +1,7 @@ import { pendingCheckActive } from "../core/core.js"; import { SUPPORTS_PROXY } from "../core/index.js"; import { createMemo } from "../signals.js"; -import { - $DELETED, - $PROXY, - $TARGET, - $TRACK, - getKeys, - getStoreKeys, - getStoreSymbols, - getPropertyDescriptor, - isWrappable, - mergedOverlay, - ownEnumerableKeys, - STORE_LOOKUP, - STORE_VALUE, - storeLookup, - lookupTarget, - isRawValue, - rawValuesUsed, - trackSelf, - witnessAffectsMark, - wrap, - type StoreNode -} from "./store.js"; - -function snapshotImpl( - item: any, - track: boolean, - map?: Map, - lookup?: WeakMap -): T { - let target: StoreNode | undefined, isArray, override, result, unwrapped, v; - if (!isWrappable(item)) return item; - if (map && map.has(item)) return map.get(item) as T; - if (!map) map = new Map(); - if ((target = item[$TARGET] || lookupTarget(item, lookup))) { - if (track) { - trackSelf(target, $TRACK); - // A tracked walk reads THROUGH the record without touching the proxy - // traps — witness the record's affects() channel like a trap read would. - if (pendingCheckActive) witnessAffectsMark(target); - } - override = mergedOverlay(target); - // A derived store's STORE_VALUE is the inner store's live proxy - // (store-in-store: createOptimisticStore/createProjection over a store). - // Without an overlay of its own, the fast path below would map to — and - // could return — that proxy verbatim. Recurse instead so the - // inner store's own target branch unwraps it (chains of any depth). - // With an overlay, a fresh `result` is always built, so the walk over the - // inner proxy already copies plain values. - if (!override && (target[STORE_VALUE] as StoreNode)[$TARGET]) { - unwrapped = snapshotImpl(target[STORE_VALUE], track, map, lookup); - map.set(item, unwrapped); - return unwrapped as T; - } - isArray = Array.isArray(target[STORE_VALUE]); - map.set( - item, - override - ? (result = isArray ? [] : (Object.create(Object.getPrototypeOf(target[STORE_VALUE])) as T)) - : target[STORE_VALUE] - ); - item = target[STORE_VALUE]; - lookup = target[STORE_LOOKUP] ?? storeLookup; - } else { - isArray = Array.isArray(item); - map.set(item, item); - } - if (isArray) { - const len = override?.length ?? item.length; - for (let i = 0; i < len; i++) { - v = override && i in override ? override[i] : item[i]; - if (v === $DELETED) continue; - if (track && isWrappable(v) && !(rawValuesUsed && isRawValue(v))) wrap(v, target); - if ((unwrapped = snapshotImpl(v, track, map, lookup)) !== v || result) { - if (!result) map.set(item, (result = [...item])); - result[i] = unwrapped; - } - } - // Enumerate array symbols separately to avoid scanning indices twice. - // Spread copies omit symbols, so assign them after the numeric walk. - const symbols = lookup ? getStoreSymbols(item, override) : []; - for (let i = 0, l = symbols.length; i < l; i++) { - const prop = symbols[i]; - const desc = getPropertyDescriptor(item, override, prop); - if (!desc || desc.get) continue; - v = override && prop in override ? override[prop] : item[prop]; - if (track && isWrappable(v) && !(rawValuesUsed && isRawValue(v))) wrap(v, target); - unwrapped = snapshotImpl(v, track, map, lookup); - if (unwrapped !== v || result) { - if (!result) map.set(item, (result = Object.assign([...item], item))); - result[prop] = unwrapped; - } - } - // Deleted trailing slots are skipped above, so restore length to preserve - // holes instead of truncating the copy (#2846) — mirrors unwrapStoreValue. - if (result) result.length = len; - } else if (!override) { - // Specialized walk for the common no-overlay case (from #2756): the own - // descriptor gives the value directly, so each property is read once with - // no overlay membership checks. - // A lookup means this object belongs to an immutable store backing tree, - // even if that nested value has not needed its own proxy yet. - const keys = lookup ? getStoreKeys(item, undefined) : getKeys(item, undefined); - for (let i = 0, l = keys.length; i < l; i++) { - const prop = keys[i]; - const desc = Object.getOwnPropertyDescriptor(item, prop)!; - if (desc.get) continue; - v = desc.value; - if (track && isWrappable(v) && !(rawValuesUsed && isRawValue(v))) wrap(v, target); - if ((unwrapped = snapshotImpl(v, track, map, lookup)) !== v || result) { - if (!result) { - result = Object.create(Object.getPrototypeOf(item)) as Record; - Object.assign(result, item); - } - result[prop] = unwrapped; - } - } - } else { - // An override only exists on a store record, and the target branch above - // always set `lookup` alongside it — so this branch is always store-keyed. - const keys = getStoreKeys(item, override); - for (let i = 0, l = keys.length; i < l; i++) { - let prop = keys[i]; - const desc = getPropertyDescriptor(item, override, prop)!; - if (desc.get) continue; - v = prop in override ? override[prop] : item[prop]; - if (track && isWrappable(v) && !(rawValuesUsed && isRawValue(v))) wrap(v, target); - if ((unwrapped = snapshotImpl(v, track, map, lookup)) !== item[prop] || result) { - if (!result) { - result = Object.create(Object.getPrototypeOf(item)) as Record; - Object.assign(result, item); - } - result[prop] = unwrapped; - } - } - } - return result || item; -} - -/** - * Returns a plain (non-proxy, non-reactive) deep copy of a store value. - * Reading via `snapshot` does **not** subscribe to changes — use this when you - * need to hand a stable plain object to non-reactive code (logging, - * serialization, structured-clone, network payloads, etc.). - * - * Returns the original object identity for any sub-tree that wasn't modified - * relative to the proxy's underlying source. - * - * @example - * ```ts - * const [state] = createStore({ user: { name: "Ada" }, todos: [] }); - * - * console.log(JSON.stringify(snapshot(state))); // safe, non-reactive copy - * ``` - */ -export function snapshot(item: T): T; -export function snapshot(item: T, map?: Map, lookup?: WeakMap): T; -export function snapshot(item: any, map?: Map, lookup?: WeakMap): T { - return snapshotImpl(item, false, map, lookup); -} - -/** - * Returns a plain (non-proxy) deep copy **and** subscribes the current - * tracking scope to every nested change in the source store. Any write - * anywhere in the subtree invalidates the consumer. - * - * Use this when you need plain data inside a reactive scope and want to - * react to deep mutations (e.g. passing a snapshot to `reconcile()` or to a - * memo that should rerun on any nested change). For most read paths, prefer - * direct property access — Solid stores already track per-property reads - * with no `deep()` wrapper needed. - * - * @example - * ```ts - * const [state] = createStore({ a: { b: { c: 1 } } }); - * - * createEffect( - * () => deep(state), // reruns on any nested change - * plain => sendToWorker(plain) // worker gets a non-proxy copy - * ); - * ``` - */ -export function deep(store: T): T { - return snapshotImpl(store, true) as T; -} +import { $PROXY, ownEnumerableKeys } from "./store.js"; function trueFn() { return true; diff --git a/packages/solid-signals/tests/optimistic-signal-refetch-hold.test.ts b/packages/solid-signals/tests/optimistic-signal-refetch-hold.test.ts new file mode 100644 index 000000000..d27a0539f --- /dev/null +++ b/packages/solid-signals/tests/optimistic-signal-refetch-hold.test.ts @@ -0,0 +1,90 @@ +/** + * RUL-4 parity evidence (INTERNALS-STORE-STATE.md) — signal-form counterpart + * of optimistic-store-refetch-hold.test.ts (#2951). + * + * The store fix's rationale claims signal-form createOptimistic rides an + * in-flight refetch by construction (pending async + override cohabit one + * node, so transitionBlocked sees both). No signal-form test pinned that — + * this file does: a bare (transaction-less) optimistic write made while the + * signal's own truth is in flight must hold until truth lands; with settled + * truth it keeps flash semantics (reverts at plain flush end). + */ +import { expect, test } from "vitest"; +import { createEffect, createOptimistic, createRoot, createSignal, flush } from "../src/index.js"; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +function setup() { + let setCount!: (v: number) => void; + let setOpt!: (v: string) => void; + let dispose!: () => void; + const resolvers: ((v: string) => void)[] = []; + const views: string[] = []; + + createRoot(d => { + dispose = d; + const [count, _setCount] = createSignal(0); + setCount = _setCount; + const [opt, _setOpt] = createOptimistic(async () => { + count(); + return await new Promise(r => resolvers.push(r)); + }); + setOpt = _setOpt; + createEffect( + () => opt(), + v => { + views.push(v); + } + ); + }); + return { setCount, setOpt, resolvers, views, dispose }; +} + +test("signal parity: a bare optimistic write during an in-flight refetch holds until truth lands", async () => { + const { setCount, setOpt, resolvers, views, dispose } = setup(); + flush(); + await tick(); + resolvers[0]("A"); + await tick(); + flush(); + expect(views.at(-1)).toBe("A"); + + // Dep write starts a (slow) refetch; the bare optimistic write in the same + // tick must survive the flush that starts it. + setCount(1); + setOpt("B*"); + flush(); + await tick(); + expect(views.at(-1)).toBe("B*"); + + // A later tick's bare write during the same in-flight refetch also holds. + setOpt("C*"); + flush(); + await tick(); + expect(views.at(-1)).toBe("C*"); + + // Truth lands: replaces the optimism. + resolvers[1]("B"); + await tick(); + flush(); + await tick(); + flush(); + expect(views.at(-1)).toBe("B"); + dispose(); +}); + +test("signal parity: with settled truth a bare optimistic write keeps flash semantics", async () => { + const { setOpt, resolvers, views, dispose } = setup(); + flush(); + await tick(); + resolvers[0]("A"); + await tick(); + flush(); + expect(views.at(-1)).toBe("A"); + + // No refetch in flight: the ambient write reverts at plain flush end. + setOpt("X*"); + flush(); + expect(views.at(-1)).toBe("A"); + dispose(); +}); diff --git a/packages/solid-signals/tests/store/adoption-lane-rollback.test.ts b/packages/solid-signals/tests/store/adoption-lane-rollback.test.ts new file mode 100644 index 000000000..f6a2c085c --- /dev/null +++ b/packages/solid-signals/tests/store/adoption-lane-rollback.test.ts @@ -0,0 +1,127 @@ +/** + * Rule test (INTERNALS-STORE-STATE.md RUL-5 / recon-snap R22 gap) — a + * reconcile performed inside an optimistic action window is tentatively + * visible and must FULLY revert at settle: values, array length, key + * membership, and captured-proxy views all restore to committed state. + * "Adoption resets ownership" must have a defined meaning when the adoption + * itself is tentative — rollback restores prior backing and prior structure. + */ +import { expect, test } from "vitest"; +import { + action, + createEffect, + createOptimisticStore, + createRoot, + flush, + reconcile +} from "../../src/index.js"; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +test("reconcile inside an action window is tentative: full revert at settle", async () => { + type Row = { id: string; v: number }; + let s!: { rows: Row[]; tag?: string }; + let setS!: (fn: (d: { rows: Row[]; tag?: string }) => void) => void; + const views: number[][] = []; + const lengths: number[] = []; + + createRoot(() => { + [s, setS] = createOptimisticStore<{ rows: Row[]; tag?: string }>({ + rows: [ + { id: "a", v: 1 }, + { id: "b", v: 2 } + ] + }); + createEffect( + () => s.rows.map(r => r.v), + v => { + views.push(v); + } + ); + createEffect( + () => s.rows.length, + l => { + lengths.push(l); + } + ); + }); + flush(); + expect(views.at(-1)).toEqual([1, 2]); + + const capturedRow = s.rows[0]; + + let resolveWork!: () => void; + const run = action(function* () { + setS(d => { + reconcile( + { + rows: [ + { id: "a", v: 10 }, + { id: "c", v: 30 }, + { id: "d", v: 40 } + ], + tag: "tentative" + }, + "id" + )(d); + }); + yield new Promise(r => (resolveWork = r)); + })(); + flush(); + + // Tentatively visible: values, structure, key membership, captured proxy. + expect(views.at(-1)).toEqual([10, 30, 40]); + expect(lengths.at(-1)).toBe(3); + expect(s.tag).toBe("tentative"); + expect("tag" in s).toBe(true); + expect(capturedRow.v).toBe(10); + + // Settle: values, structure, and captures restore. + resolveWork(); + await run; + await tick(); + flush(); + + expect(views.at(-1)).toEqual([1, 2]); + expect(lengths.at(-1)).toBe(2); + expect(capturedRow.v).toBe(1); + expect(s.rows[0]).toBe(capturedRow); +}); + +// FINDING-2 (rules-mining/FINDINGS.md): failed on shipped — a key ADDED by a +// reconcile inside the action window survived settle. FIXED by the rewrite's +// tentative reconcile channel (§6b): membership rides armed presence nodes, +// so additions revert with their transaction exactly like deletes (RUL-8's +// key-set prediction, landed 2026-08-18). +test("a key added by an in-window reconcile reverts at settle", async () => { + let s!: { rows: { id: string; v: number }[]; tag?: string }; + let setS!: (fn: (d: { rows: { id: string; v: number }[]; tag?: string }) => void) => void; + createRoot(() => { + [s, setS] = createOptimisticStore<{ rows: { id: string; v: number }[]; tag?: string }>({ + rows: [{ id: "a", v: 1 }] + }); + createEffect( + () => s.rows.map(r => r.v), + () => {} + ); + }); + flush(); + + let resolveWork!: () => void; + const run = action(function* () { + setS(d => { + reconcile({ rows: [{ id: "a", v: 1 }], tag: "tentative" }, "id")(d); + }); + yield new Promise(r => (resolveWork = r)); + })(); + flush(); + expect(s.tag).toBe("tentative"); + + resolveWork(); + await run; + await tick(); + flush(); + + expect(s.tag).toBe(undefined); + expect("tag" in s).toBe(false); +}); diff --git a/packages/solid-signals/tests/store/createProjection.async.test.ts b/packages/solid-signals/tests/store/createProjection.async.test.ts index 45552aeff..eb39b3b27 100644 --- a/packages/solid-signals/tests/store/createProjection.async.test.ts +++ b/packages/solid-signals/tests/store/createProjection.async.test.ts @@ -252,7 +252,12 @@ describe("Projection async behavior", () => { expect(proj.a).toBe(3); }); - it("yielded values preserve identity only for unchanged subtrees", async () => { + // RULED (INTERNALS-STORE-STATE.md RUL-12, 2026-08-17): unkeyed nested + // objects MERGE in place — the legacy yield-path's identity replacement was + // an accident, inconsistent with positional/keyed merge semantics + // everywhere else. Assertions rewritten to the ruled contract: proxy + // identity is preserved; values and membership still update. + it("yielded values merge unkeyed subtrees in place (identity preserved)", async () => { let proj; createRoot(() => { @@ -275,13 +280,13 @@ describe("Projection async behavior", () => { await Promise.resolve(); await Promise.resolve(); - expect(proj.nested).not.toBe(firstNested); + expect(proj.nested).toBe(firstNested); expect(proj.nested.x).toBe(1); expect(proj.nested.y).toBeUndefined(); expect(firstY).toBe(2); }); - it("yielded values replace changed subtrees", async () => { + it("yielded values merge changed unkeyed subtrees in place", async () => { let proj; createRoot(() => { @@ -303,7 +308,7 @@ describe("Projection async behavior", () => { await Promise.resolve(); await Promise.resolve(); - expect(proj.nested).not.toBe(firstNested); + expect(proj.nested).toBe(firstNested); expect(proj.nested.x).toBe(10); expect(proj.nested.y).toBeUndefined(); }); diff --git a/packages/solid-signals/tests/store/next-smoke.test.ts b/packages/solid-signals/tests/store/next-smoke.test.ts new file mode 100644 index 000000000..93c2723a6 --- /dev/null +++ b/packages/solid-signals/tests/store/next-smoke.test.ts @@ -0,0 +1,139 @@ +/** + * Store rewrite increment 1 smoke — plain deep stores against the doc's + * core rules: wrapping/identity, per-property tracking, signal-parity + * batching (RUL-1 matrix), CoW privatization (no source mutation), draft + * read-your-writes, transient-node laziness. + */ +import { describe, expect, it } from "vitest"; +import { createEffect, createRoot, flush } from "../../src/index.js"; +import { createStoreNext } from "../../src/store/next/store.js"; +import { ownedRaw, storeNextLookup } from "../../src/store/next/target.js"; + +describe("store-next increment 1", () => { + it("wraps, tracks per-property, and batches like signals", () => { + const source = { a: 1, b: 2, nested: { c: 3 } }; + const [s, setS] = createStoreNext(source); + expect(s.nested).not.toBe(source.nested); // wrapped + expect(s.nested).toBe(s.nested); // stable proxy identity + + const seenA: number[] = []; + const seenC: number[] = []; + createRoot(() => { + createEffect( + () => s.a, + v => { + seenA.push(v); + } + ); + createEffect( + () => s.nested.c, + v => { + seenC.push(v); + } + ); + }); + flush(); + expect(seenA).toEqual([1]); + expect(seenC).toEqual([3]); + + setS(d => { + d.a = 10; + }); + // R24/R25: untracked context-free reads see committed until flush. + expect(s.a).toBe(1); + flush(); + expect(s.a).toBe(10); + expect(seenA).toEqual([1, 10]); + expect(seenC).toEqual([3]); // untouched leaf did not notify + + // Same-value write: no notification (R10). + setS(d => { + d.a = 10; + }); + flush(); + expect(seenA).toEqual([1, 10]); + + // Nested write notifies only the nested subscriber. + setS(d => { + d.nested.c = 30; + }); + flush(); + expect(seenC).toEqual([3, 30]); + expect(seenA).toEqual([1, 10]); + }); + + it("drafts are read-your-writes; sources are never mutated (CoW)", () => { + const source = { a: 1, nested: { c: 3 } }; + const [s, setS] = createStoreNext(source); + createRoot(() => { + createEffect( + () => s.nested.c, + () => {} + ); + }); + flush(); + + setS(d => { + d.a = 5; + expect(d.a).toBe(5); // read-your-writes inside the draft + d.nested.c = 7; + expect(d.nested.c).toBe(7); + }); + flush(); + + // View updated; user's source untouched at every level. + expect(s.a).toBe(5); + expect(s.nested.c).toBe(7); + expect(source.a).toBe(1); + expect(source.nested.c).toBe(3); + + // Backing privatized (owned), original still resolves to the same proxy. + expect(storeNextLookup.get(source)).toBeDefined(); + expect(ownedRaw.has(storeNextLookup.get(source)!.v)).toBe(true); + expect(storeNextLookup.get(source)!.v).not.toBe(source); + expect(storeNextLookup.get(source)!.px).toBe(s); + }); + + it("writes outside the setter are silently ignored", () => { + const [s] = createStoreNext({ a: 1 } as { a: number }); + expect(() => { + (s as any).a = 99; + }).not.toThrow(); + expect(s.a).toBe(1); + }); + + it("unobserved writes leave no permanent node (transient sweep)", () => { + const source = { a: 1, b: 2 }; + const [s, setS] = createStoreNext(source); + setS(d => { + d.a = 42; + }); + flush(); + expect(s.a).toBe(42); + expect(source.a).toBe(1); + const target = storeNextLookup.get(source)!; + // Post-flush, the write-created node was swept (no subscribers). + expect(target.n?.a).toBeUndefined(); + // Committed value lives in owned backing alone (single home). + expect(target.v.a).toBe(42); + }); + + it("key add and delete round-trip", () => { + const [s, setS] = createStoreNext({ a: 1 } as Record); + setS(d => { + d.z = 9; + }); + flush(); + expect(s.z).toBe(9); + expect("z" in s).toBe(true); + expect(Object.keys(s)).toEqual(["a", "z"]); + + setS(d => { + delete d.z; + }); + flush(); + expect(s.z).toBeUndefined(); + expect("z" in s).toBe(false); + expect(Object.keys(s)).toEqual(["a"]); + }); +}); diff --git a/packages/solid-signals/tests/store/pr3017-function-leaves.test.ts b/packages/solid-signals/tests/store/pr3017-function-leaves.test.ts new file mode 100644 index 000000000..98663500e --- /dev/null +++ b/packages/solid-signals/tests/store/pr3017-function-leaves.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest"; +import { + createEffect, + createRoot, + createStore, + flush, + reconcile, + createOptimisticStore +} from "../../src/index.js"; + +describe("#3017: reconcile stores function leaves by identity", () => { + test("object leaf with live subscriber", () => { + const [state, setState] = createStore<{ onClick: () => any }>({ onClick: () => "original" }); + createRoot(() => { + createEffect( + () => state.onClick, + () => {} + ); + }); + flush(); + let called = false; + const next = () => ((called = true), "next"); + setState(reconcile({ onClick: next }, null)); + flush(); + expect(called).toBe(false); + expect(state.onClick).toBe(next); + }); + + test("array item with live subscriber", () => { + const fns = [() => 1, () => 2]; + const [state, setState] = createStore<{ items: Array<() => number> }>({ items: [...fns] }); + createRoot(() => { + createEffect( + () => state.items[1], + () => {} + ); + }); + flush(); + let called = false; + const repl = () => ((called = true), 99); + setState(s => { + reconcile([fns[0], repl], null)(s.items); + }); + flush(); + expect(called).toBe(false); + expect(state.items[1]).toBe(repl); + }); + + test("plain setter write of a function leaf", () => { + const [state, setState] = createStore<{ cb: () => string }>({ cb: () => "a" }); + createRoot(() => { + createEffect( + () => state.cb, + () => {} + ); + }); + flush(); + let called = false; + const next = () => ((called = true), "b"); + setState(s => { + s.cb = next; + }); + flush(); + expect(called).toBe(false); + expect(state.cb).toBe(next); + }); + + test("optimistic store: function leaf behaves exactly like a scalar (never invoked)", () => { + // Ambient optimistic writes revert at settle (flush end with nothing in + // flight) — that applies to functions and scalars IDENTICALLY. The #3017 + // invariant here is only that the function is never invoked as an + // updater, in the draft or at revert. + const orig = () => "a"; + const [state, setState] = createOptimisticStore<{ cb: () => string; x: number }>({ + cb: orig, + x: 1 + }); + createRoot(() => { + createEffect( + () => [state.cb, state.x], + () => {} + ); + }); + flush(); + let called = false; + const next = () => ((called = true), "b"); + let inDraft: any; + setState(s => { + s.cb = next; + s.x = 2; + inDraft = s.cb; + }); + flush(); + expect(called).toBe(false); + expect(inDraft).toBe(next); // draft read-your-writes serves it by identity + expect(state.cb).toBe(orig); // reverted at settle — same as… + expect(state.x).toBe(1); // …the scalar control + }); +}); diff --git a/packages/solid-signals/tests/store/reconcile-dbmon-ab.bench.ts b/packages/solid-signals/tests/store/reconcile-dbmon-ab.bench.ts new file mode 100644 index 000000000..3598fb6be --- /dev/null +++ b/packages/solid-signals/tests/store/reconcile-dbmon-ab.bench.ts @@ -0,0 +1,126 @@ +// dbmon workload benches for the store implementation (CodSpeed per-PR +// tracking). Formerly an A/B against the legacy implementation — legacy is +// deleted; the rows remain as absolute regression tripwires. +import { afterAll, bench } from "vitest"; +import { createRenderEffect, createRoot, createStore, flush, reconcile } from "../../src/index.js"; + +const ROWS = 1000; + +function makeData(count: number, frame: number) { + const out = new Array(count); + for (let i = 0; i < count; i++) { + const queries = new Array(5); + for (let q = 0; q < 5; q++) { + const v = ((i * 31 + q * 7 + frame * 13) % 100) / 10; + queries[q] = { + elapsed: v.toFixed(2), + className: v > 6 ? "warn_long" : v > 3 ? "warn" : "short" + }; + } + const c = (i * 17 + frame * 5) % 30; + out[i] = { + id: i, + name: `cluster-${i}`, + count: c, + countClass: c > 20 ? "label-important" : c > 10 ? "label-warning" : "label-success", + queries + }; + } + return out; +} + +let sink = 0; +const consume = (v: unknown) => { + sink += typeof v === "string" ? v.length : (v as number); +}; + +function subscribeRows(state: any) { + for (let i = 0; i < ROWS; i++) { + const db = state.rows[i]; + createRenderEffect(() => db.name, consume); + createRenderEffect(() => db.count, consume); + createRenderEffect(() => db.countClass, consume); + for (let q = 0; q < 5; q++) { + createRenderEffect(() => db.queries[q].elapsed, consume); + createRenderEffect(() => db.queries[q].className, consume); + } + } +} + +function setupNext() { + let applyTick!: (fresh: any[]) => void; + const dispose = createRoot(d => { + const [state, setState] = createStore({ rows: makeData(ROWS, 0) }); + subscribeRows(state); + applyTick = fresh => + setState((s: any) => { + reconcile(fresh, "id")(s.rows); + }); + return d; + }); + flush(); + return { applyTick, dispose }; +} + +function runTicks(applyTick: (fresh: any[]) => void, partial: boolean) { + for (let frame = 1; frame <= 5; frame++) { + let fresh = makeData(ROWS, frame); + if (partial) { + const prev = makeData(ROWS, frame - 1); + fresh = prev.map((row, i) => (i < ROWS / 10 ? fresh[i] : row)); + } + applyTick(fresh); + flush(); + } +} + +function subscribeShallow(state: any) { + for (let i = 0; i < ROWS; i++) { + createRenderEffect(() => state[i], consume as any); + } +} + +function setupShallowNext() { + let applyTick!: (fresh: any[]) => void; + const dispose = createRoot(d => { + const [state, setState] = createStore(makeData(ROWS, 0), { shallow: true } as any); + subscribeShallow(state); + applyTick = fresh => setState(reconcile(fresh, null) as any); + return d; + }); + flush(); + return { applyTick, dispose }; +} + +const next = setupNext(); +const shallowNext = setupShallowNext(); + +bench( + "dbmon full tick", + () => { + runTicks(next.applyTick, false); + }, + { time: 4000, warmupIterations: 3 } +); + +bench( + "dbmon partial tick", + () => { + runTicks(next.applyTick, true); + }, + { time: 4000, warmupIterations: 3 } +); + +bench( + "dbmon shallow full tick", + () => { + runTicks(shallowNext.applyTick, false); + }, + { time: 3000, warmupIterations: 3 } +); + +afterAll(() => { + next.dispose(); + shallowNext.dispose(); + if (sink === Infinity) console.log("impossible"); +}); diff --git a/packages/solid-signals/tests/store/reconcile-resend-identity.test.ts b/packages/solid-signals/tests/store/reconcile-resend-identity.test.ts new file mode 100644 index 000000000..0c03d71fd --- /dev/null +++ b/packages/solid-signals/tests/store/reconcile-resend-identity.test.ts @@ -0,0 +1,158 @@ +/** + * O7 rule test (INTERNALS-STORE-STATE.md §8) — reconcile identity fast-path + * soundness. "Reconcile makes `next` the authoritative base": re-sending a + * previously-ingested reference after intervening setter writes must restore + * the incoming values — reference identity must not short-circuit the diff + * when the store's view has diverged from that reference. + * + * The suites cover the same-batch case (staged override forces the slow + * path); this pins the *flushed* case: writes committed to nodes, raw + * untouched (2.0 never mutates sources), then the ORIGINAL object re-sent. + * `incoming === STORE_VALUE` is true while node values have diverged — an + * identity skip here serves stale writes instead of the authoritative next. + */ +import { describe, expect, it } from "vitest"; +import { + createEffect, + createRoot, + createSignal, + createStore, + flush, + reconcile +} from "../../src/index.js"; + +describe("reconcile re-send identity (O7)", () => { + it("re-sending the original reference after a flushed setter write restores its values", () => { + const data = { id: 1, a: 1, b: 2 }; + const [s, setS] = createStore(data); + const seen: number[] = []; + createRoot(() => { + createEffect( + () => s.a, + v => { + seen.push(v); + } + ); + }); + flush(); + expect(seen).toEqual([1]); + + // Diverge the store's view from the ingested reference; commit it. + setS(d => { + d.a = 99; + }); + flush(); + expect(s.a).toBe(99); + expect(data.a).toBe(1); // source never mutated + + // Re-send the SAME reference: next is the authoritative base. + setS(reconcile(data, "id")); + flush(); + expect(s.a).toBe(1); + expect(seen).toEqual([1, 99, 1]); + }); + + // FINDING-1 (rules-mining/FINDINGS.md): failed on the legacy store — + // applyStateFast's `next === previous` early-return skipped the diff while + // a committed node held a diverged value. FIXED by the rewrite's ownership + // guard (`incoming === backing && !owned(backing)`); flipped to plain `it` + // when plain stores began serving from src/store/next (2026-08-17). + it("re-sending the original array after a flushed row write restores row values", () => { + const rows = [ + { id: "a", v: 1 }, + { id: "b", v: 2 } + ]; + const [s, setS] = createStore({ rows }); + const seen: number[] = []; + createRoot(() => { + createEffect( + () => s.rows[0].v, + v => { + seen.push(v); + } + ); + }); + flush(); + + setS(d => { + d.rows[0].v = 50; + }); + flush(); + expect(s.rows[0].v).toBe(50); + expect(rows[0].v).toBe(1); + + setS(d => { + reconcile(rows, "id")(d.rows); + }); + flush(); + expect(s.rows[0].v).toBe(1); + expect(seen).toEqual([1, 50, 1]); + }); + + // Control (passes on shipped): a FRESH reference carrying the original + // values restores the setter write — shipped's per-key node writes already + // compare against the current view. This pins the ruled contract + // (2026-08-17: reconcile's diff baseline is the CURRENT VIEW, signal + // parity) and shows FINDING-1 is narrowly the same-reference early-return, + // not the diff itself. + it("a fresh object carrying the original values also restores a flushed setter write", () => { + const rows = [{ id: "a", v: 1 }]; + const [s, setS] = createStore({ rows }); + createRoot(() => { + createEffect( + () => s.rows[0].v, + () => {} + ); + }); + flush(); + + setS(d => { + d.rows[0].v = 50; + }); + flush(); + expect(s.rows[0].v).toBe(50); + + setS(d => { + reconcile([{ id: "a", v: 1 }], "id")(d.rows); + }); + flush(); + expect(s.rows[0].v).toBe(1); + }); + + it("control: a derived store recompute re-returning the same reference reclaims a manual write", () => { + const data = { id: 1, a: 1 }; + let setTick!: (v: number) => void; + let s!: { id: number; a: number }; + let setS!: (fn: (d: { id: number; a: number }) => void) => void; + createRoot(() => { + const [tick, _setTick] = createSignal(0); + setTick = _setTick; + [s, setS] = createStore( + () => { + tick(); + return data; + }, + { id: 0, a: 0 } + ); + createEffect( + () => s.a, + () => {} + ); + }); + flush(); + expect(s.a).toBe(1); + + setS(d => { + d.a = 7; + }); + flush(); + expect(s.a).toBe(7); + + // Recompute returns the identical reference; it is the authoritative + // output of the derive — the manual write must not survive it (core R31: + // "the next source change reclaims the slot"). + setTick(1); + flush(); + expect(s.a).toBe(1); + }); +}); diff --git a/packages/solid-signals/tests/store/reconcile.test.ts b/packages/solid-signals/tests/store/reconcile.test.ts index 714570d33..87c9fbe6c 100644 --- a/packages/solid-signals/tests/store/reconcile.test.ts +++ b/packages/solid-signals/tests/store/reconcile.test.ts @@ -647,28 +647,6 @@ describe("reconcile with symbol-keyed properties", () => { expect(has).toBe(true); }); - test("perf invariant: symbol-record mark is set while tracked and cleared once unobserved", async () => { - // Guards the fast-path optimization: only records that currently hold a - // user symbol node are enumerated for symbols on reconcile. Asserts the - // internal mark rather than behavior (the mark is invisible to behavior). - const { symbolKeyedRecords, $TARGET, STORE_NODE } = await import("../../src/store/store.js"); - const [store] = createStore>({ id: 1, [META]: "x" }); - let dispose!: () => void; - createRoot(d => { - dispose = d; - createEffect( - () => store[META], - () => {} - ); - }); - flush(); - const nodes = (store as any)[$TARGET][STORE_NODE]; - expect(symbolKeyedRecords.has(nodes)).toBe(true); - dispose(); - flush(); - expect(symbolKeyedRecords.has(nodes)).toBe(false); // no monotonic leak - }); - test("nested symbol-keyed value reconciles", () => { const [state, setState] = createStore>({ id: 1, diff --git a/packages/solid-signals/tests/store/shared-child-multiparent.test.ts b/packages/solid-signals/tests/store/shared-child-multiparent.test.ts new file mode 100644 index 000000000..d4f5d91ff --- /dev/null +++ b/packages/solid-signals/tests/store/shared-child-multiparent.test.ts @@ -0,0 +1,86 @@ +/** + * Rule test (INTERNALS-STORE-STATE.md RUL-12 / recon-snap R37 gap) — a shared + * object reachable through two paths of the same store is ONE logical node: + * a write through path A must be visible through path B on reads, snapshot, + * and subscriptions. Under CoW this is the multi-parent (DAG) privatization + * case: path-copying walks one ancestor chain, so correctness requires + * per-object registration resolution during traversal, not blind raw-pointer + * following. + */ +import { describe, expect, it } from "vitest"; +import { createEffect, createRoot, createStore, flush, snapshot } from "../../src/index.js"; + +describe("shared child reachable via two parents", () => { + it("write through path A is visible through path B: reads, snapshot, subscription", () => { + const shared = { count: 1 }; + const [s, setS] = createStore({ a: { child: shared }, b: { child: shared } }); + const seen: number[] = []; + createRoot(() => { + createEffect( + () => s.b.child.count, + v => { + seen.push(v); + } + ); + }); + flush(); + expect(seen).toEqual([1]); + + // Same logical node through both paths. + expect(s.a.child).toBe(s.b.child); + + setS(d => { + d.a.child.count = 2; + }); + flush(); + + // Path-B read, subscription, and snapshot all see the path-A write. + expect(s.b.child.count).toBe(2); + expect(seen).toEqual([1, 2]); + expect(snapshot(s.b).child.count).toBe(2); + expect(snapshot(s).b.child.count).toBe(2); + + // Snapshot coherence: the shared child is one object in the copy too. + const snap = snapshot(s); + expect(snap.a.child).toBe(snap.b.child); + + // Source object was never mutated. + expect(shared.count).toBe(1); + }); + + it("cycle through two paths stays coherent after a write (reads)", () => { + const node: any = { name: "n", self: null }; + node.self = node; + const [s, setS] = createStore({ root: node }); + expect(s.root.self).toBe(s.root); + + setS(d => { + d.root.name = "renamed"; + }); + flush(); + + expect(s.root.self.name).toBe("renamed"); + const snap = snapshot(s.root); + expect(snap.name).toBe("renamed"); + expect(snap.self.name).toBe("renamed"); + expect(node.name).toBe("n"); + }); + + // FINDING-3 (rules-mining/FINDINGS.md): failed on the legacy store — the + // copy routine registered copies AFTER descending, breaking cycle identity + // for written cyclic objects. FIXED by the rewrite's snapshot walk (owned + // copies register before descent); flipped to plain `it` 2026-08-18. + it("snapshot preserves cycle identity on a written cyclic object", () => { + const node: any = { name: "n", self: null }; + node.self = node; + const [s, setS] = createStore({ root: node }); + + setS(d => { + d.root.name = "renamed"; + }); + flush(); + + const snap = snapshot(s.root); + expect(snap.self).toBe(snap); + }); +}); diff --git a/packages/solid-signals/tests/store/utilities.test.ts b/packages/solid-signals/tests/store/utilities.test.ts index 9dfc635b3..642ff694b 100644 --- a/packages/solid-signals/tests/store/utilities.test.ts +++ b/packages/solid-signals/tests/store/utilities.test.ts @@ -529,7 +529,11 @@ describe("omit Props", () => { }); describe("deep", () => { - test("subscribes to $TRACK at each level", () => { + // RULED (INTERNALS-STORE-STATE.md, recon-snap pin 2): pins the LEGACY graph + // shape (one $TRACK dep per level). The rewrite's deep() subscribes the + // key-set node plus per-key nodes per level — deep tracking behavior is + // covered behaviorally by the sibling tests. Skipped, not ported. + test.skip("subscribes to $TRACK at each level", () => { const [state, setState] = createStore({ list: [{ a: 1 }, { b: 2 }] }); let o: any; createRoot(() => { diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 06d2a5294..167b73330 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -99,7 +99,13 @@ module.exports = [ // note), latest() wake-only lane demotion (#3009, ~+50 B in // recomputeLane), effect-phase read gating (#3006, ~+20 B). All on // always-retained core paths. - limit: "13.5 KB", + // + // Store rewrite: ratcheted 13.5 -> 12.2 KB, measured at 11.98. The + // single-implementation store (legacy deleted) plus the tree-shakeable + // optimistic channel (injection table installed by createOptimisticStore; + // plain-store graphs retain none of it) took −1.26 KB out of this + // scenario. Locked in at the ~2% headroom convention. + limit: "12.2 KB", modifyEsbuildConfig }, { @@ -163,8 +169,13 @@ module.exports = [ // 2.0.0-rc: 23.3 -> 23.65 KB, measured at 23.19 — the same batch as the // no-store scenario (flatten + #3006 + #3009 + lazy export), restoring // the ~2% headroom convention. + // + // Store rewrite: ratcheted 23.65 -> 23.3 KB, measured at 22.84 (this + // scenario imports every store family, so it keeps the optimistic + // channel and pays the injection seam; the −470 B is the legacy + // deletion net of the rewrite). ~2% headroom. path: "hydrating-store-app.js", - limit: "23.65 KB", + limit: "23.3 KB", modifyEsbuildConfig }, {