diff --git a/.changeset/entangle-tear-consolidation-pass-1.md b/.changeset/entangle-tear-consolidation-pass-1.md new file mode 100644 index 000000000..fb8c49f91 --- /dev/null +++ b/.changeset/entangle-tear-consolidation-pass-1.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Entangle-tear consolidation pass 1: untracked store reads now serve committed truth for held-truth-masked nodes (the until()-flip steal parks node truth past the flush, so the backing is ahead of the committed world — tracked and untracked readers previously disagreed); patch-channel deliveries and structural applies defer on parked truth, riding the holding transaction's commit via the holder named by the nodes' own `_transition` — one seam decision that follows merges and steals with no scheduler-specific mirrors. diff --git a/.changeset/fix-eager-repair-patch-gate.md b/.changeset/fix-eager-repair-patch-gate.md new file mode 100644 index 000000000..f8b615bf4 --- /dev/null +++ b/.changeset/fix-eager-repair-patch-gate.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Gate the eager adoption parent-slot repair on patch consumers existing: ancestor committed raws are only handed to patch consumers, and in patch-less apps the ungated repair's privatization cascade re-cloned every freshly adopted interior backing per reconcile (an extra tree copy) while the identity swaps turned downstream equality gates into keyset/deep bump storms — an 11% CodSpeed regression on the listened-paths reconcile bench with zero channels registered. Patch-mode behavior is unchanged. diff --git a/.changeset/fix-patch-channel-3123-landing-integration.md b/.changeset/fix-patch-channel-3123-landing-integration.md new file mode 100644 index 000000000..99b2490c1 --- /dev/null +++ b/.changeset/fix-patch-channel-3123-landing-integration.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Integrate the patch channel with #3123's landing-consumption semantics: an equal landing no longer flashes committed state through value patches (the raw payload fast path is gated off optimistic families — deliveries take the override-composing proxy read, classic-effect parity), and a contradicting landing now notifies authoritatively — one regular-timed value delivery coalesced with the adoption's emission, plus a row-ops resync at the landing so driven lists learn the baseline flipped. A CONTINUATION landing's reckoning (wipe + retained-edit replay) notifies as one: per-edit row-ops frames are suppressed during replay and the landing emission carries the re-derived composed view, so a driven list never sees the bare landed base or intermediate replay drafts that classic readers never render. diff --git a/.changeset/fix-patch-channel-audit7-runtime.md b/.changeset/fix-patch-channel-audit7-runtime.md new file mode 100644 index 000000000..fb7897f2f --- /dev/null +++ b/.changeset/fix-patch-channel-audit7-runtime.md @@ -0,0 +1,16 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Re-audit-7 runtime hardening, invariant-tested: adoption demotion probes the +incoming backing statelessly (sticky scan flags no longer trusted across +swaps), prototype-accessor records (class instances) reject patch admission, +normal and optimistic queues coalesce on separate stamps, structural queue +entries snapshot their consumers at emission while value entries resolve the +live list at drain (a consumer list recreated during a held transition or +merge receives the commit exactly once, effect parity), released entries +from independently-settling transitions coalesce per channel, shallow slot +rebuilds are build-before-destroy (a throwing replacement leaves the old row +mounted AND live), and a hydration claim failure surrenders the list's +entire server region including trailing unclaimed rows. diff --git a/.changeset/fix-patch-channel-audit8.md b/.changeset/fix-patch-channel-audit8.md new file mode 100644 index 000000000..8b43f4f75 --- /dev/null +++ b/.changeset/fix-patch-channel-audit8.md @@ -0,0 +1,17 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +"@solidjs/universal": patch +--- + +Re-audit-8 hardening: manifest deep-path probing at admission (nested +getters present at registration take the tracked fallback), committed-view +initial applies, structural operations bind their captured records via +patchProxyFor, tentative reconciles bubble ancestors at lane timing with +settle-held twins, generation-stamped drains eliminate duplicate applies to +freshly mounted consumers, forced ancestor bubbles coalesce per batch, +non-integer numeric keys are statically patch-ineligible in both compilers, +and createRenderer exports/documents rowProof plus an untracked commit +phase for its patchDriver. diff --git a/.changeset/fix-patch-channel-audit9.md b/.changeset/fix-patch-channel-audit9.md new file mode 100644 index 000000000..5bb38a9ff --- /dev/null +++ b/.changeset/fix-patch-channel-audit9.md @@ -0,0 +1,17 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +"@solidjs/universal": patch +--- + +Re-audit-9 hardening: committed-visible skip semantics (mounts are never +stranded stale; held/tentative payloads always deliver), held-view and +optimistic-view initial applies, write-free manifest-read effect fallback +(web + universal), tentative reconciles emit their view on the record's own +channel with immediate lane-timed accessor demotion, per-queue forced-stamp +clearing with merge repair, isWrappable bind guards, server-entry +patchDriver/rowProof exports, function-intermediate deep probes, +safe-integer-only manifest keys, and unchanged reconciles no longer force +ancestor re-applies. diff --git a/.changeset/fix-patch-channel-round10-10.md b/.changeset/fix-patch-channel-round10-10.md new file mode 100644 index 000000000..c598b959d --- /dev/null +++ b/.changeset/fix-patch-channel-round10-10.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Round-10.10 audit fixes: demotion envelope computes read each step exactly once (deep roots no longer double-read, so unstable getters track the value they commit) and descend through functions (accessor carriers); iterable manifests preserve symbol keys instead of stringifying them; the channel WIDE_WRITE twin moved into the attribution engine (same thresholds, memo, and metadata as graph wide-writes); ancestor bubble stamps carry the originating child as their cause; and structural row-ops/slot channels fire the same registration fan-out milestones. diff --git a/.changeset/fix-patch-channel-round10-11.md b/.changeset/fix-patch-channel-round10-11.md new file mode 100644 index 000000000..c7bba65e3 --- /dev/null +++ b/.changeset/fix-patch-channel-round10-11.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Round-10.11 audit fixes: demotion bubbles ancestors from inside demoteToEffects itself (a fold on a previously-demoted child with persistent delivery machinery hit an empty demote and froze ancestor patches — primitive-owned bubbling closes every such seam); envelope traversal uses a root-aligned deep index (linear, built once per interned manifest); coalesced ancestor bumps append every originating child to the pending stamp's causes; and structural row-ops/slot dispatches ride the same engine wide-write policy (consumer-list memo keys). diff --git a/.changeset/fix-patch-channel-round10-13.md b/.changeset/fix-patch-channel-round10-13.md new file mode 100644 index 000000000..4d89125e9 --- /dev/null +++ b/.changeset/fix-patch-channel-round10-13.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Round-10.13 audit fixes (structural lifecycle): row-ops/slot dispatches defer into collapsed owner queues per entry and re-derive live state at release via the resync forms (baseline-relative ops would be stale), and consumers registered during a held structural commit receive the settle-time live resync instead of staying permanently stale — never the baseline-relative ops (round-7 exclusion refined, not reversed). The two #3123-coupled landing findings are deliberately deferred while that work settles upstream. diff --git a/.changeset/fix-patch-channel-round10-5.md b/.changeset/fix-patch-channel-round10-5.md new file mode 100644 index 000000000..23a1f03f5 --- /dev/null +++ b/.changeset/fix-patch-channel-round10-5.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Round-10.5 audit fixes: the delivery pending-dedup is transition-aware (scheduler owns merge bookkeeping — every bump under a transition reaches the signal), deep-path admission currency-probes aliased raw slots, payload-less (bubbled) deliveries re-probe the ancestor's deep manifest and demote getters, demoted entries are severed against stale held callbacks, list resyncs rebuild across family changes, shallow swaps keep raw retention, and optimistic revert sites no longer double-bubble ancestors. diff --git a/.changeset/fix-patch-channel-round10-6.md b/.changeset/fix-patch-channel-round10-6.md new file mode 100644 index 000000000..70c24f73d --- /dev/null +++ b/.changeset/fix-patch-channel-round10-6.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Round-10.6 audit fixes: direct object-valued manifest roots are alias-currency-probed (admission declines, payload-less deliveries demote), demotion re-drives schedule through held owner queues instead of force-running, and delivery dedup is transaction-scoped for both plain and optimistic bumps (repeats within one transaction skip; a different transaction always reaches the scheduler). diff --git a/.changeset/fix-patch-channel-round10-7.md b/.changeset/fix-patch-channel-round10-7.md new file mode 100644 index 000000000..d323327bb --- /dev/null +++ b/.changeset/fix-patch-channel-round10-7.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Round-10.7 audit fixes: delivery dedup stamps are canonicalized through currentTransition and released at delivery (no merged-away transition retention, correct dedup across merges), held-owner demotion re-drives isolate their first scheduled run per entry, and an explicit unbind after demotion cancels the queued redrive (demotion severing split from the unbind mark). diff --git a/.changeset/fix-patch-channel-round10-8.md b/.changeset/fix-patch-channel-round10-8.md new file mode 100644 index 000000000..db50dae0a --- /dev/null +++ b/.changeset/fix-patch-channel-round10-8.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Round-10.8 audit fixes (final round): demotion re-drive compute passes capture throws per entry (a throwing getter routes to its boundary instead of halting held siblings during scheduling), and each re-driven entry owns a disposable root — unbind cancels the fallback effect whether queued or live, retiring the "demoted rows outlive removal" edge. diff --git a/.changeset/fix-patch-channel-round10-9.md b/.changeset/fix-patch-channel-round10-9.md new file mode 100644 index 000000000..e176d3dd9 --- /dev/null +++ b/.changeset/fix-patch-channel-round10-9.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Round-10.9 audit fixes (demotion fallback lifecycle): demoted bodies with manifests compute by reading their OWN declared envelope (per entry, never the channel union) so NaN/unstable-getter compares can't fire DOM writes inside tracked computations; a failed compute skips its commit instead of force-applying after a swallowed error; re-drive roots are id-transparent (classic fallback owner/hydration depth); and the manifest-less full-scan poison (akAll) is ref-counted, releasing with its last consumer. diff --git a/.changeset/fix-patch-channel-round10.md b/.changeset/fix-patch-channel-round10.md new file mode 100644 index 000000000..8a0c87e90 --- /dev/null +++ b/.changeset/fix-patch-channel-round10.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Round-10 audit fixes for node-delivery patch channels: ancestor bubbling moved into the bump primitive (post-await landings, optimistic nested writes, and channel-less seams all reach ancestor consumers), eager child adoptions path-copy the ancestor chain so late mounts read current raws, dispatch defers entries whose owner queue is holding (Loading/reveal parity), demotion fanout is per-entry isolated, subject swaps build from the optimistic visible array with family-identity retention, and the deferred-demotion latch dies with its consumers. diff --git a/.changeset/fix-patch-channel-round6-hardening.md b/.changeset/fix-patch-channel-round6-hardening.md new file mode 100644 index 000000000..160830847 --- /dev/null +++ b/.changeset/fix-patch-channel-round6-hardening.md @@ -0,0 +1,13 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Patch-channel round-6 hardening: prod-sound getter demotion via accessed-key +recording with bounded adoption probes (replaces the dev-only check), +same-channel transition-merge coalescing (one live-resolving apply per record +when merged transactions both queued it), structural row/slot queue entries +now respect unbinds and error routing like value patches, fixed-window +dispatch for the single-consumer alias, initial list construction severs +registrations and removes claimed DOM on throw (client + hydration), and +failed-apply identity resync now triggers actively on slot ticks. diff --git a/.changeset/fix-patch-fold-audit-round.md b/.changeset/fix-patch-fold-audit-round.md new file mode 100644 index 000000000..954d51605 --- /dev/null +++ b/.changeset/fix-patch-fold-audit-round.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Close the fold-audit P1s on the patch channel and the #3164 reveal seam: held slot deliveries defer per index instead of collapsing to the first (later slots stayed stale behind holds), the late-registrant sweep runs once at drain end against the highest emission watermark (a resync followed by a later item's stale ops double-built rows), staged truth no longer merges into raw shallow rows (in-place mutation was visible before the reveal with no notification — raw children now replace their slot wholesale), and staged-truth folds emit row ops and slot ticks at the reveal (the optimistic-family gates exist for override materializations; a root array retained only through a descendant's override was revealing silently). diff --git a/.changeset/fix-patch-structural-audit-followup.md b/.changeset/fix-patch-structural-audit-followup.md new file mode 100644 index 000000000..6830471ee --- /dev/null +++ b/.changeset/fix-patch-structural-audit-followup.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Structural-audit follow-up on the patch channel: slot-patch registrations now carry the registration-sequence stamp (shallow lists mounted during held windows resynced instead of staying stale), landing consumptions notify structural consumers with drain-time resolution instead of an emission-time draft snapshot (classic-parity by construction across back-to-back continuation landings), late-registrant resyncs dedup per drain, and the superseded-work gate re-resolves standalone slot ticks live instead of dropping them. Two upstream continuation-reckoning findings (same-microtask landing swallowed; until()-gated action wedged on the swallowed echo) are pinned as expected-fail tests for the #3123 seam. diff --git a/.changeset/fix-patch-structural-audit-round.md b/.changeset/fix-patch-structural-audit-round.md new file mode 100644 index 000000000..57662b2db --- /dev/null +++ b/.changeset/fix-patch-structural-audit-round.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Close the structural-audit findings on the patch channel's row/slot machinery: the late-registrant resync sweep is rebuilt on registration-sequence numbers (fixed window at both edges, hold-honoring deferral, O(#late) suffix scan instead of quadratic rescans), drain-resolved structural resyncs read the visible optimistic view instead of committed backing, slot ticks coalesced past a shrink are skipped, and a landing consumption stamps a structural generation so stale transition-held row/slot work can no longer replay over its resync at settle. diff --git a/.changeset/fix-primitive-row-retention.md b/.changeset/fix-primitive-row-retention.md new file mode 100644 index 000000000..8a727f879 --- /dev/null +++ b/.changeset/fix-primitive-row-retention.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Primitive rows participate in structural identity matching keyed by their value: buildRowOps only admitted wrappable rows to the occurrence-aware key queues, so a primitive permutation emitted all-new sources and rebuilt every moved row instead of retaining nodes — classic value-identity now moves them, with duplicate occurrences already sound through the existing queues. diff --git a/.changeset/fix-version-chain-fold-audit-2.md b/.changeset/fix-version-chain-fold-audit-2.md new file mode 100644 index 000000000..30a744a13 --- /dev/null +++ b/.changeset/fix-version-chain-fold-audit-2.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Close the second fold-audit round on the structural version chain: consumers mounted inside a writing transition initialize their version baseline from the full emitted version (they read the speculative view — the old visible-version init replayed stashed ops over DOM already built from them), staged reveals ride exactly one channel (aligned windows are slot ticks, length changes are row ops — never both for one replacement), staged-reveal identity diffs key rows through the family map so re-seated raws on retained rows no longer rebuild stable proxies, held structural releases fast-forward the applied version (no redundant follow-up resync), and version arithmetic drops its signed-32 coercions (the 2^31 wrap eventually suppressed delivery permanently). diff --git a/.changeset/fix-version-chain-fold-audit-3.md b/.changeset/fix-version-chain-fold-audit-3.md new file mode 100644 index 000000000..e8b334009 --- /dev/null +++ b/.changeset/fix-version-chain-fold-audit-3.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Close the third fold-audit round: the version baseline for structural consumers keys off the registrant's HOLDING owner queue instead of the execution-time transition flag (parked actions have no ambient transition while held-boundary content still reads speculative state — the flag-based init replayed stashed row ops over speculative DOM), staged shallow reveals classify equal-length windows by reference movement rather than length alone (a reorder is structure — moved rows were rebuilt via slot ticks and lost identity/focus), and the reveal marks itself consumed on BOTH channels so the settle loop's resync can never double a reveal the fold already delivered. diff --git a/.changeset/fix-version-chain-fold-audit-4.md b/.changeset/fix-version-chain-fold-audit-4.md new file mode 100644 index 000000000..7cdb2e90b --- /dev/null +++ b/.changeset/fix-version-chain-fold-audit-4.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Close the fourth fold-audit round on the structural version chain: the visible version bumps at emission for every structural emitter — adoption commits eagerly, so every reader's init read includes every emitted walk state, parked windows and ambient mounts alike, deleting the deferred-visibility machinery and the held-queue registration special case entirely (no reader anywhere can replay stashed row ops it already rendered); reveal marks are epoch-stamped and set only on proven emission (a boolean lingered on descendant-retained roots the settle loop never visits, and a no-op fold could suppress the only revert resync a driven list needed); and equal-length primitive permutations classify as structure (classic keys primitive rows by value — reorders move rows instead of rewriting slot contents, preserving identity and focus). diff --git a/.changeset/flip-patch-driver-default-on.md b/.changeset/flip-patch-driver-default-on.md new file mode 100644 index 000000000..760ed5c3c --- /dev/null +++ b/.changeset/flip-patch-driver-default-on.md @@ -0,0 +1,8 @@ +--- +"@solidjs/babel-plugin": minor +"@solidjs/compiler": minor +--- + +Patch mode is now the default in both compilers: eligible pure member-read +bindings compile to `patchDriver` templates and eligible store lists to +`rowProof` rows without any configuration. Opt out with `patchDriver: false`. diff --git a/.changeset/fold-audit-6-dn-override-matcher-key-spaces.md b/.changeset/fold-audit-6-dn-override-matcher-key-spaces.md new file mode 100644 index 000000000..1e1d3b4bd --- /dev/null +++ b/.changeset/fold-audit-6-dn-override-matcher-key-spaces.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Patch channel fold audit 6: deliveries consume delivery-node overrides so a settle-drain revert bump can never leak one onto a still-open lane (INV-6 at quiescence); the row matcher separates object-keyed and value-keyed key spaces so mixed primitive/object identities never collide (and the identity prefix scan is kind-aware); `undefined` rows and sparse holes match by sentinel so plain moves retain their rows. diff --git a/.changeset/node-delivery-mount-pass.md b/.changeset/node-delivery-mount-pass.md new file mode 100644 index 000000000..c026d24f9 --- /dev/null +++ b/.changeset/node-delivery-mount-pass.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Node-delivery mount pass: per-channel delivery machinery (signal + effect) is now built lazily at the first consumer-visible emission instead of at registration, the effect is a detached single-source primitive (`deliveryEffect`, no root/owner allocation), and the machinery persists across consumer churn instead of disposing on last unbind (held write-time emissions survive unbound windows; re-binding rows reuse the node). dbmon mount returns to channel parity (6.4 ms) while keeping node delivery's tick win. diff --git a/.changeset/node-delivery-port-wip.md b/.changeset/node-delivery-port-wip.md new file mode 100644 index 000000000..2a241ba9f --- /dev/null +++ b/.changeset/node-delivery-port-wip.md @@ -0,0 +1,8 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +WIP (experiment branch only): full node-delivery port — registerPatch owns +a per-channel delivery effect with manifest-shaped prev snapshots; value +queue machinery deleted; structural queue slimmed. Harness triage pending. diff --git a/.changeset/node-delivery-prototype.md b/.changeset/node-delivery-prototype.md new file mode 100644 index 000000000..9590fb822 --- /dev/null +++ b/.changeset/node-delivery-prototype.md @@ -0,0 +1,9 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +PROTOTYPE (flag-gated, dormant): node-driven patch delivery — one bare +version signal per patched record bumped at the existing emission seams; +the driver applies compiled bodies from a scheduler-timed effect. Enabled +only via globalThis.__PATCH_NODE__; no behavior change otherwise. diff --git a/.changeset/patch-attribution-cause-chains.md b/.changeset/patch-attribution-cause-chains.md new file mode 100644 index 000000000..70b6701c0 --- /dev/null +++ b/.changeset/patch-attribution-cause-chains.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Attribution cause chains thread through patch deliveries: emission seams re-stamp the delivery signal with the record's store path and value transition (via a new `patchEmit` hook), and delivery effects name themselves `patchDelivery(store.path)` — so "why did this run" for a patch-applied DOM update reads as the record's write, with previews, instead of an anonymous counter. Dev-only. diff --git a/.changeset/patch-channel-fanout-diagnostics.md b/.changeset/patch-channel-fanout-diagnostics.md new file mode 100644 index 000000000..45fd8ef7d --- /dev/null +++ b/.changeset/patch-channel-fanout-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Channel-side fan-out diagnostics (attribution parity): patch consumers are invisible to graph subscriber counts, so mass registration on one record now fires the HUGE_FAN_OUT milestones and wide dispatches fire the WIDE_WRITE warning from the channel itself (dev-only, same codes and thresholds as the graph twins). WIDE_WRITE's advice text updated for the removed selector primitive. diff --git a/.changeset/patch-channel-fold-reconciliation.md b/.changeset/patch-channel-fold-reconciliation.md new file mode 100644 index 000000000..9634fef6a --- /dev/null +++ b/.changeset/patch-channel-fold-reconciliation.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Reconcile the patch channel with the #3164 fold ruling: the branch's landing-consumption integration (landing emission hook, superseded-work generation stamps) is deleted with the contract it served — under fold, staged truth rides the channel's existing transition-held write semantics and the atomic reveal rides the settle drain's resync loop. Re-applies the primitive-owned emission gate the rewrite reverted (local-consumer-list gating silenced ancestor channels) and re-pins the landing invariants to fold semantics: interim landings under retained optimism are invisible to value and structural channels alike, with the flip atomic at settle, at classic-effect parity. diff --git a/.changeset/patch-channel-size-consolidation.md b/.changeset/patch-channel-size-consolidation.md new file mode 100644 index 000000000..5dd57e1c1 --- /dev/null +++ b/.changeset/patch-channel-size-consolidation.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Consolidate patch-channel internals (size pass 2): one held-owner-queue probe, one deferred-run shape for held consumers, shared registration prologue and structural unbind. Behavior-neutral; compressed size unchanged (repetition was already compression-free), raw minified −34 B. diff --git a/.changeset/patch-channel-size-pass.md b/.changeset/patch-channel-size-pass.md new file mode 100644 index 000000000..8b766a6f8 --- /dev/null +++ b/.changeset/patch-channel-size-pass.md @@ -0,0 +1,6 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +--- + +Patch-channel size pass: the drain-side read-recording proxy is deleted — manifest-less registrations poison the key union (`akAll`) and adoption/delivery probes full-scan instead (compiled output always ships manifests, so only hand-written callers pay wider probes); `applyEntries` collapses to its single delivery mode; error-routing and deferred-halt shapes consolidate into shared helpers. Value tier 16.22 → 16.05 kB, list tier 18.79 → 18.60 kB (brotli), ratchets tightened. diff --git a/.changeset/patch-driver-samevaluezero-rebuild.md b/.changeset/patch-driver-samevaluezero-rebuild.md new file mode 100644 index 000000000..1488c4df9 --- /dev/null +++ b/.changeset/patch-driver-samevaluezero-rebuild.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +The patch-mode list driver's rebuild check now uses SameValueZero, agreeing with the matcher's Map-based equality: a moved NaN row keeps its DOM node (parity with classic's diff, which gets this for free from Map semantics). diff --git a/.changeset/patch-manifest-emission.md b/.changeset/patch-manifest-emission.md new file mode 100644 index 000000000..c34add28d --- /dev/null +++ b/.changeset/patch-manifest-emission.md @@ -0,0 +1,18 @@ +--- +"@solidjs/signals": patch +"@solidjs/web": patch +"@solidjs/babel-plugin": minor +"@solidjs/compiler": minor +"@solidjs/universal": patch +--- + +Patch templates now emit a STATIC read manifest (re-audit 7): every member +path the compiled body can read — ternary/logical branches and nested chains +included — hoisted to one module-scope array per distinct manifest +(`var _mf$ = ["flag", "a", "queries.0.elapsed"]`) and passed as +`patchDriver`'s third argument. The runtime's accessor-demotion probes use +this complete envelope; runtime read-recording could never see an untaken +branch. Eligibility tightens accordingly: standalone `{subject}` reads and +string keys containing "." compile classic. `@solidjs/universal`'s +documented `Renderer` interface now includes the `patchDriver` member +`createRenderer()` has always synthesized. diff --git a/.changeset/patch-observability-round10-12.md b/.changeset/patch-observability-round10-12.md new file mode 100644 index 000000000..351f13f00 --- /dev/null +++ b/.changeset/patch-observability-round10-12.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Round-10.12 observability fixes (dev-only): structural queue items carry their channel backref so wide-dispatch memos key on stable identity (sliced snapshot lists warned every flush); structural row-ops/slot dispatches record synthetic attribution events (name, causes, count, timing); coalesced bubble origins are name-deduped; a parent self-emission within one pending window carries earlier child causes forward (consumed stamps, marked at delivery, carry nothing); and demoted children contribute name-only origins instead of stale pre-demotion stamps. diff --git a/.changeset/patch-structural-version-chain.md b/.changeset/patch-structural-version-chain.md new file mode 100644 index 000000000..d743993fb --- /dev/null +++ b/.changeset/patch-structural-version-chain.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Replace the structural channels' snapshot/watermark/sweep machinery with per-entry applied-version chains: emissions stamp a per-kind structural version, entries apply an item only on an unbroken chain from what their registration read (baseline soundness by arithmetic), and any gap takes exactly one resync at the end of the flush, after every queue. This closes the audited class wholesale — cross-window coverage errors, lane-resync-before-stale-ops ordering, and duplicate sweep deliveries have no mechanism left to be wrong in — and held-window registrants improve to receiving real baseline-sound ops. Reveals dedup to one notification (the settle loop skips staged-fold targets), revert-form resyncs resolve committed truth only, and late-mounted bindings repair their ancestor raw chain at registration (gated to stale aliases of the same child — never tentative rows). diff --git a/packages/babel-plugin/src/config.ts b/packages/babel-plugin/src/config.ts index ab2f8dab4..c7fc6283f 100644 --- a/packages/babel-plugin/src/config.ts +++ b/packages/babel-plugin/src/config.ts @@ -23,14 +23,12 @@ export interface PluginConfig { contextToCustomElements: boolean; staticMarker: string; effectWrapper: string | false; - /** Patch-mode driver import name (DESIGN-PATCH-CHANNEL.md): when set, - * template scopes whose dynamic bindings are pure member reads of one - * subject compile to a compiled patch body + driver call instead of the - * grouped effect. DORMANT (off) by default while the patch channel - * completes on its stage-2 branch (extraction ruling, solid DESIGN §16): - * compiled output must not import driver exports the release core only - * stubs. Set to the driver's export name (e.g. "patchDriver") to opt a - * build in against a channel-bearing core. */ + /** + * Patch-mode dual driver import name — DEFAULT-ON (`"patchDriver"`): every + * eligible template compiles to the store patch channel; the runtime falls + * back to classic effects per subject at runtime. Set `false` to compile + * fully classic output. + */ patchDriver: string | boolean; memoWrapper: string | false; validate: boolean; @@ -67,7 +65,7 @@ const config: PluginConfig = { contextToCustomElements: true, staticMarker: "@static", effectWrapper: "effect", - patchDriver: false, + patchDriver: "patchDriver", memoWrapper: "memo", validate: true, inlineStyles: true, diff --git a/packages/babel-plugin/src/dom/template.ts b/packages/babel-plugin/src/dom/template.ts index f59782746..4de94eb29 100644 --- a/packages/babel-plugin/src/dom/template.ts +++ b/packages/babel-plugin/src/dom/template.ts @@ -10,7 +10,7 @@ import { wrapForEffect } from "../shared/utils"; import { setAttr } from "./element"; -import { analyzePatchEligibility, substituteSubject } from "../shared/patch"; +import { analyzePatchEligibility, collectSubjectPaths, substituteSubject } from "../shared/patch"; import type { NodePath } from "@babel/traverse"; import type { DynamicBinding, ProgramScopeData, TemplateRecord, TransformResult } from "../types"; @@ -314,11 +314,29 @@ function wrapPatchMode( ); } const driverId = registerImportMethod(path, config.patchDriver as string, undefined); + // Static read manifest (re-audit 7, P1-1): the runtime's demotion probes + // need the body's FULL read envelope — branches included — which only the + // compiler knows. HOISTED to one module-scope array per distinct manifest + // (like _tmpl$): the runtime interns processed manifests BY ARRAY + // IDENTITY, so a per-call literal would re-process on every row bind. + const manifest = collectSubjectPaths( + dynamics.map(d => d.value as t.Expression), + subject + ); + const data = path.scope.getProgramParent().data as ProgramScopeData; + const manifests = data.patchManifests || (data.patchManifests = []); + const manifestKey = JSON.stringify(manifest); + let entry = manifests.find(m => m.key === manifestKey); + if (!entry) { + entry = { id: path.scope.generateUidIdentifier("mf$"), key: manifestKey, paths: manifest }; + manifests.push(entry); + } return { stmt: t.expressionStatement( t.callExpression(driverId, [ t.identifier(subject), - t.arrowFunctionExpression([nId, pId, fId], t.blockStatement(stmts)) + t.arrowFunctionExpression([nId, pId, fId], t.blockStatement(stmts)), + t.cloneNode(entry.id) ]) ), subject diff --git a/packages/babel-plugin/src/shared/patch.ts b/packages/babel-plugin/src/shared/patch.ts index d6aad5d68..598d97fff 100644 --- a/packages/babel-plugin/src/shared/patch.ts +++ b/packages/babel-plugin/src/shared/patch.ts @@ -27,18 +27,33 @@ import * as t from "@babel/types"; // Node types allowed inside an eligible binding expression (Tier 1+2). -function isEligibleExpr(node: t.Node, subject: string): boolean { +// `asMemberBase` marks the position at the root of a member chain: the bare +// subject identifier is ONLY eligible there (re-audit 7) — a standalone +// `{subject}` read has no key envelope, so the static manifest could never +// cover it; those scopes keep classic effects. +function isEligibleExpr(node: t.Node, subject: string, asMemberBase = false): boolean { switch (node.type) { case "Identifier": - return node.name === subject || node.name === "undefined"; + return (asMemberBase && node.name === subject) || node.name === "undefined"; case "MemberExpression": { const m = node as t.MemberExpression; if (m.computed) { - if (!t.isStringLiteral(m.property) && !t.isNumericLiteral(m.property)) return false; + // Literal keys only — and nothing that stringifies with a ".", + // which would collide with the manifest's path separator: + // dotted string keys (re-audit 7) and non-integer numeric keys + // (re-audit 8 — `state[1.2]` would probe as state["1"]["2"]). + if (t.isStringLiteral(m.property)) { + if (m.property.value.indexOf(".") !== -1) return false; + } else if (!t.isNumericLiteral(m.property) || !Number.isSafeInteger(m.property.value)) { + // Safe integers only (re-audit 9): 1e20 is "integer" but its + // string form diverges between engines/formatters — and the Oxc + // mirror casts through i64. + return false; + } } else if (!t.isIdentifier(m.property)) { return false; } - return isEligibleExpr(m.object, subject); + return isEligibleExpr(m.object, subject, true); } case "StringLiteral": case "NumericLiteral": @@ -136,6 +151,53 @@ export function analyzePatchEligibility(values: t.Expression[]): PatchEligibilit return { subject }; } +/** Collect the STATIC read manifest (re-audit 7, P1-1): every member path + * rooted at the subject, dot-joined ("label", "queries.0.elapsed"). The + * grammar makes this complete — keys are identifier/literal-only, so every + * read any branch can perform is syntactically present. The runtime probes + * exactly these keys/paths at adoption seams; runtime recording could never + * see an untaken ternary branch. Order: dynamics order, chains innermost- + * first within each expression, first occurrence kept (mirrored byte-for- + * byte by the Oxc compiler). */ +export function collectSubjectPaths(values: t.Expression[], subject: string): string[] { + const paths: string[] = []; + const chainOf = (m: t.MemberExpression): string | null => { + const segs: string[] = []; + let cur: t.Node = m; + while (t.isMemberExpression(cur)) { + const prop = cur.property; + if (t.isIdentifier(prop) && !cur.computed) segs.push(prop.name); + else if (t.isStringLiteral(prop)) segs.push(prop.value); + else if (t.isNumericLiteral(prop)) segs.push(String(prop.value)); + else return null; + cur = cur.object; + } + if (!t.isIdentifier(cur) || cur.name !== subject) return null; + return segs.reverse().join("."); + }; + const walk = (node: t.Node): void => { + if (t.isMemberExpression(node)) { + const chain = chainOf(node); + if (chain !== null) { + if (paths.indexOf(chain) === -1) paths.push(chain); + return; // the whole chain is consumed — don't descend + } + } + for (const key of Object.keys(node)) { + const value: any = (node as any)[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (item && typeof item.type === "string") walk(item); + } + } else if (value && typeof value.type === "string") { + walk(value); + } + } + }; + for (const v of values) walk(v); + return paths; +} + /** Clone `expr` substituting the subject identifier with `replacement`. * Safe because eligibility rejected functions/shadowing constructs. */ export function substituteSubject( diff --git a/packages/babel-plugin/src/shared/postprocess.ts b/packages/babel-plugin/src/shared/postprocess.ts index f4c15edd9..2be4cc8f4 100644 --- a/packages/babel-plugin/src/shared/postprocess.ts +++ b/packages/babel-plugin/src/shared/postprocess.ts @@ -57,6 +57,22 @@ export default (path: NodePath, state: PluginPass) => { domTemplates.length > 0 && appendTemplatesDOM(path, domTemplates); ssrTemplates.length > 0 && appendTemplatesSSR(path, ssrTemplates); } + // Hoisted patch read manifests (re-audit 7): one module-scope array per + // distinct manifest, above the template declarations. Identity-stable + // arrays let the runtime intern the processed key sets once per module. + if (data.patchManifests?.length) { + path.node.body.unshift( + t.variableDeclaration( + "var", + data.patchManifests.map(m => + t.variableDeclarator( + t.cloneNode(m.id), + t.arrayExpression(m.paths.map(p => t.stringLiteral(p))) + ) + ) + ) + ); + } // Compile-time row proofs (DESIGN-PATCH-CHANNEL §3c): wrap each function // recorded by recordPureRow with the runtime's `rowProof` marker so the diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts index b835b7fca..63ca2f1ad 100644 --- a/packages/babel-plugin/src/types.ts +++ b/packages/babel-plugin/src/types.ts @@ -28,6 +28,10 @@ export interface ProgramScopeData { * the param itself. Wrapped with `rowProof` at program exit so the list * driver can engage without the (removed) runtime purity probe. */ pureRows?: Set; + /** Distinct patch read manifests (re-audit 7), hoisted to module scope at + * program exit (`var _mf$ = ["label", "queries.0.elapsed"]`) so the + * runtime can intern processed manifests by array identity. */ + patchManifests?: { id: t.Identifier; key: string; paths: string[] }[]; } export type BabelFileWithMetadata = { diff --git a/packages/babel-plugin/test/__dom_compatible_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dom_compatible_fixtures__/attributeExpressions/output.js index 4f717ccca..17c359c52 100644 --- a/packages/babel-plugin/test/__dom_compatible_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dom_compatible_fixtures__/attributeExpressions/output.js @@ -2,6 +2,7 @@ import { template as _$template } from "r-dom"; import { delegateEvents as _$delegateEvents } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { applyRef as _$applyRef } from "r-dom"; +import { patchDriver as _$patchDriver } from "r-dom"; import { insert as _$insert } from "r-dom"; import { memo as _$memo } from "r-dom"; import { addEvent as _$addEvent } from "r-dom"; @@ -14,6 +15,10 @@ import { ref as _$ref } from "r-dom"; import { claimElement as _$claimElement } from "r-dom"; import { spread as _$spread } from "r-dom"; import { mergeProps as _$mergeProps } from "r-dom"; +var _mf$ = ["button"], + _mf$2 = ["foo--bar"], + _mf$3 = ["foo.bar"], + _mf$4 = ["style"]; var _tmpl$ = /*#__PURE__*/ _$template(`

Welcome

`), _tmpl$2 = /*#__PURE__*/ _$template(`
`), _tmpl$3 = /*#__PURE__*/ _$template(`
`), @@ -381,31 +386,37 @@ const template32 = _tmpl$4(); const template33 = [ (() => { var _el$43 = _tmpl$19(); - _$effect( - () => styles.button, - (_v$, _$p) => { - _$className(_el$43, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$.button; + if (_f$ || _v$ !== _p$.button) _$className(_el$43, _v$); + }, + _mf$ ); return _el$43; })(), (() => { var _el$44 = _tmpl$19(); - _$effect( - () => styles["foo--bar"], - (_v$, _$p) => { - _$className(_el$44, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$["foo--bar"]; + if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$44, _v$); + }, + _mf$2 ); return _el$44; })(), (() => { var _el$45 = _tmpl$19(); - _$effect( - () => styles.foo.bar, - (_v$, _$p) => { - _$className(_el$45, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$.foo.bar; + if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$45, _v$); + }, + _mf$3 ); return _el$45; })(), @@ -654,11 +665,13 @@ var _el$100 = _tmpl$4(); _$style(_el$100, /* @static */ styleProp.style); const template85 = _el$100; var _el$101 = _tmpl$4(); -_$effect( - () => styleProp.style, - (_v$, _$p) => { - _$style(_el$101, _v$, _$p); - } +_$patchDriver( + styleProp, + (_n$, _p$, _f$) => { + const _v$ = _n$.style; + if (_f$ || _v$ !== _p$.style) _$style(_el$101, _v$); + }, + _mf$4 ); const template86 = _el$101; const style = { diff --git a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js index 78d9c4c56..bcd6401da 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js @@ -2,6 +2,7 @@ import { template as _$template } from "r-dom"; import { delegateEvents as _$delegateEvents } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { applyRef as _$applyRef } from "r-dom"; +import { patchDriver as _$patchDriver } from "r-dom"; import { insert as _$insert } from "r-dom"; import { memo as _$memo } from "r-dom"; import { addEvent as _$addEvent } from "r-dom"; @@ -14,6 +15,10 @@ import { ref as _$ref } from "r-dom"; import { claimElement as _$claimElement } from "r-dom"; import { spread as _$spread } from "r-dom"; import { mergeProps as _$mergeProps } from "r-dom"; +var _mf$ = ["button"], + _mf$2 = ["foo--bar"], + _mf$3 = ["foo.bar"], + _mf$4 = ["style"]; var _tmpl$ = /*#__PURE__*/ _$template(`

Welcome`), _tmpl$2 = /*#__PURE__*/ _$template(`
`), _tmpl$3 = /*#__PURE__*/ _$template(`
`), @@ -376,31 +381,37 @@ const template32 = _tmpl$4(); const template33 = [ (() => { var _el$43 = _tmpl$19(); - _$effect( - () => styles.button, - (_v$, _$p) => { - _$className(_el$43, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$.button; + if (_f$ || _v$ !== _p$.button) _$className(_el$43, _v$); + }, + _mf$ ); return _el$43; })(), (() => { var _el$44 = _tmpl$19(); - _$effect( - () => styles["foo--bar"], - (_v$, _$p) => { - _$className(_el$44, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$["foo--bar"]; + if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$44, _v$); + }, + _mf$2 ); return _el$44; })(), (() => { var _el$45 = _tmpl$19(); - _$effect( - () => styles.foo.bar, - (_v$, _$p) => { - _$className(_el$45, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$.foo.bar; + if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$45, _v$); + }, + _mf$3 ); return _el$45; })(), @@ -655,11 +666,13 @@ var _el$100 = _tmpl$4(); _$style(_el$100, /* @static */ styleProp.style); const template85 = _el$100; var _el$101 = _tmpl$4(); -_$effect( - () => styleProp.style, - (_v$, _$p) => { - _$style(_el$101, _v$, _$p); - } +_$patchDriver( + styleProp, + (_n$, _p$, _f$) => { + const _v$ = _n$.style; + if (_f$ || _v$ !== _p$.style) _$style(_el$101, _v$); + }, + _mf$4 ); const template86 = _el$101; const style = { diff --git a/packages/babel-plugin/test/__dom_hydratable_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dom_hydratable_fixtures__/attributeExpressions/output.js index bcca24d22..28ab62169 100644 --- a/packages/babel-plugin/test/__dom_hydratable_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dom_hydratable_fixtures__/attributeExpressions/output.js @@ -1,5 +1,6 @@ import { template as _$template } from "r-dom"; import { delegateEvents as _$delegateEvents } from "r-dom"; +import { patchDriver as _$patchDriver } from "r-dom"; import { getNextMarker as _$getNextMarker } from "r-dom"; import { scope as _$scope } from "r-dom"; import { insert as _$insert } from "r-dom"; @@ -17,6 +18,10 @@ import { ref as _$ref } from "r-dom"; import { claimElement as _$claimElement } from "r-dom"; import { spread as _$spread } from "r-dom"; import { mergeProps as _$mergeProps } from "r-dom"; +var _mf$ = ["button"], + _mf$2 = ["foo--bar"], + _mf$3 = ["foo.bar"], + _mf$4 = ["style"]; var _tmpl$ = /*#__PURE__*/ _$template(`

Welcome`), _tmpl$2 = /*#__PURE__*/ _$template(`
`), _tmpl$3 = /*#__PURE__*/ _$template(`
`), @@ -393,31 +398,37 @@ const template32 = _$getNextElement(_tmpl$4); const template33 = [ (() => { var _el$47 = _$getNextElement(_tmpl$19); - _$effect( - () => styles.button, - (_v$, _$p) => { - _$className(_el$47, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$.button; + if (_f$ || _v$ !== _p$.button) _$className(_el$47, _v$); + }, + _mf$ ); return _el$47; })(), (() => { var _el$48 = _$getNextElement(_tmpl$19); - _$effect( - () => styles["foo--bar"], - (_v$, _$p) => { - _$className(_el$48, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$["foo--bar"]; + if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$48, _v$); + }, + _mf$2 ); return _el$48; })(), (() => { var _el$49 = _$getNextElement(_tmpl$19); - _$effect( - () => styles.foo.bar, - (_v$, _$p) => { - _$className(_el$49, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$.foo.bar; + if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$49, _v$); + }, + _mf$3 ); return _el$49; })(), @@ -677,11 +688,13 @@ var _el$104 = _$getNextElement(_tmpl$4); _$style(_el$104, /* @static */ styleProp.style); const template85 = _el$104; var _el$105 = _$getNextElement(_tmpl$4); -_$effect( - () => styleProp.style, - (_v$, _$p) => { - _$style(_el$105, _v$, _$p); - } +_$patchDriver( + styleProp, + (_n$, _p$, _f$) => { + const _v$ = _n$.style; + if (_f$ || _v$ !== _p$.style) _$style(_el$105, _v$); + }, + _mf$4 ); const template86 = _el$105; const style = { diff --git a/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js index a76e693c9..59e4748d7 100644 --- a/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js @@ -3,6 +3,7 @@ import { delegateEvents as _$delegateEvents } from "r-dom"; import { createTextNode as _$createTextNode } from "r-custom"; import { insertNode as _$insertNode } from "r-custom"; import { createElement as _$createElement } from "r-custom"; +import { patchDriver as _$patchDriver } from "r-custom"; import { insert as _$insert } from "r-dom"; import { memo as _$memo } from "r-custom"; import { addEvent as _$addEvent } from "r-dom"; @@ -15,6 +16,10 @@ import { ref as _$ref } from "r-dom"; import { claimElement as _$claimElement } from "r-dom"; import { spread as _$spread } from "r-dom"; import { mergeProps as _$mergeProps } from "r-custom"; +var _mf$ = ["button"], + _mf$2 = ["foo--bar"], + _mf$3 = ["foo.bar"], + _mf$4 = ["style"]; var _tmpl$ = /*#__PURE__*/ _$template(`

Welcome`), _tmpl$2 = /*#__PURE__*/ _$template(`
`), _tmpl$3 = /*#__PURE__*/ _$template(`
`), @@ -366,31 +371,37 @@ const template32 = _tmpl$4(); const template33 = [ (() => { var _el$43 = _tmpl$19(); - _$effect( - () => styles.button, - (_v$, _$p) => { - _$className(_el$43, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$.button; + if (_f$ || _v$ !== _p$.button) _$className(_el$43, _v$); + }, + _mf$ ); return _el$43; })(), (() => { var _el$44 = _tmpl$19(); - _$effect( - () => styles["foo--bar"], - (_v$, _$p) => { - _$className(_el$44, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$["foo--bar"]; + if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$44, _v$); + }, + _mf$2 ); return _el$44; })(), (() => { var _el$45 = _tmpl$19(); - _$effect( - () => styles.foo.bar, - (_v$, _$p) => { - _$className(_el$45, _v$, _$p); - } + _$patchDriver( + styles, + (_n$, _p$, _f$) => { + const _v$ = _n$.foo.bar; + if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$45, _v$); + }, + _mf$3 ); return _el$45; })(), @@ -657,11 +668,13 @@ var _el$105 = _tmpl$4(); _$style(_el$105, /* @static */ styleProp.style); const template85 = _el$105; var _el$106 = _tmpl$4(); -_$effect( - () => styleProp.style, - (_v$, _$p) => { - _$style(_el$106, _v$, _$p); - } +_$patchDriver( + styleProp, + (_n$, _p$, _f$) => { + const _v$ = _n$.style; + if (_f$ || _v$ !== _p$.style) _$style(_el$106, _v$); + }, + _mf$4 ); const template86 = _el$106; const style = { diff --git a/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js b/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js index da0880255..d053dde84 100644 --- a/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js +++ b/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js @@ -4,6 +4,7 @@ import { getNextMarker as _$getNextMarker } from "r-dom"; import { insert as _$insert } from "r-dom"; import { scope as _$scope } from "r-dom"; import { memo as _$memo } from "r-dom"; +import { patchDriver as _$patchDriver } from "r-dom"; import { spread as _$spread } from "r-dom"; import { mergeProps as _$mergeProps } from "r-dom"; import { ref as _$ref } from "r-dom"; @@ -17,6 +18,7 @@ import { setProperty as _$setProperty } from "r-dom"; import { addEvent as _$addEvent } from "r-dom"; import { delegateEvents as _$delegateEvents } from "r-dom"; import { runHydrationEvents as _$runHydrationEvents } from "r-dom"; +var _mf$ = ["button"], _mf$2 = ["foo--bar"], _mf$3 = ["foo.bar"], _mf$4 = ["style"]; var _tmpl$ = /* @__PURE__ */ _$template(`

Welcome`); var _tmpl$2 = /* @__PURE__ */ _$template(`
`); var _tmpl$3 = /* @__PURE__ */ _$template(`
`); @@ -307,23 +309,26 @@ const template32 = _$getNextElement(_tmpl$4); const template33 = [ (() => { var _el$51 = _$getNextElement(_tmpl$21); - _$effect(() => styles.button, (_v$, _$p) => { - _$className(_el$51, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$.button; + if (_f$ || _v$ !== _p$.button) _$className(_el$51, _v$); + }, _mf$); return _el$51; })(), (() => { var _el$52 = _$getNextElement(_tmpl$21); - _$effect(() => styles["foo--bar"], (_v$, _$p) => { - _$className(_el$52, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$["foo--bar"]; + if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$52, _v$); + }, _mf$2); return _el$52; })(), (() => { var _el$53 = _$getNextElement(_tmpl$21); - _$effect(() => styles.foo.bar, (_v$, _$p) => { - _$className(_el$53, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$.foo.bar; + if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$53, _v$); + }, _mf$3); return _el$53; })(), (() => { @@ -550,9 +555,10 @@ _$style( ); const template85 = _el$108; var _el$109 = _$getNextElement(_tmpl$4); -_$effect(() => styleProp.style, (_v$, _$p) => { - _$style(_el$109, _v$, _$p); -}); +_$patchDriver(styleProp, (_n$, _p$, _f$) => { + const _v$ = _n$.style; + if (_f$ || _v$ !== _p$.style) _$style(_el$109, _v$); +}, _mf$4); const template86 = _el$109; const style = { background: "red", diff --git a/packages/compiler/__tests__/fixtures/dom/attributeExpressions/output.js b/packages/compiler/__tests__/fixtures/dom/attributeExpressions/output.js index a9cb1bbbe..22b0be5c1 100644 --- a/packages/compiler/__tests__/fixtures/dom/attributeExpressions/output.js +++ b/packages/compiler/__tests__/fixtures/dom/attributeExpressions/output.js @@ -1,6 +1,7 @@ import { template as _$template } from "r-dom"; import { insert as _$insert } from "r-dom"; import { memo as _$memo } from "r-dom"; +import { patchDriver as _$patchDriver } from "r-dom"; import { createComponent as _$createComponent } from "r-dom"; import { spread as _$spread } from "r-dom"; import { mergeProps as _$mergeProps } from "r-dom"; @@ -14,6 +15,7 @@ import { setAttribute as _$setAttribute } from "r-dom"; import { claimElement as _$claimElement } from "r-dom"; import { addEvent as _$addEvent } from "r-dom"; import { delegateEvents as _$delegateEvents } from "r-dom"; +var _mf$ = ["button"], _mf$2 = ["foo--bar"], _mf$3 = ["foo.bar"], _mf$4 = ["style"]; var _tmpl$ = /* @__PURE__ */ _$template(`

Welcome`); var _tmpl$2 = /* @__PURE__ */ _$template(`
`); var _tmpl$3 = /* @__PURE__ */ _$template(`
`); @@ -288,23 +290,26 @@ const template32 = _tmpl$4(); const template33 = [ (() => { var _el$45 = _tmpl$21(); - _$effect(() => styles.button, (_v$, _$p) => { - _$className(_el$45, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$.button; + if (_f$ || _v$ !== _p$.button) _$className(_el$45, _v$); + }, _mf$); return _el$45; })(), (() => { var _el$46 = _tmpl$21(); - _$effect(() => styles["foo--bar"], (_v$, _$p) => { - _$className(_el$46, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$["foo--bar"]; + if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$46, _v$); + }, _mf$2); return _el$46; })(), (() => { var _el$47 = _tmpl$21(); - _$effect(() => styles.foo.bar, (_v$, _$p) => { - _$className(_el$47, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$.foo.bar; + if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$47, _v$); + }, _mf$3); return _el$47; })(), (() => { @@ -526,9 +531,10 @@ _$style( ); const template85 = _el$102; var _el$103 = _tmpl$4(); -_$effect(() => styleProp.style, (_v$, _$p) => { - _$style(_el$103, _v$, _$p); -}); +_$patchDriver(styleProp, (_n$, _p$, _f$) => { + const _v$ = _n$.style; + if (_f$ || _v$ !== _p$.style) _$style(_el$103, _v$); +}, _mf$4); const template86 = _el$103; const style = { background: "red", diff --git a/packages/compiler/__tests__/fixtures/dynamic/attributeExpressions/output.js b/packages/compiler/__tests__/fixtures/dynamic/attributeExpressions/output.js index 351331f58..3b5117d10 100644 --- a/packages/compiler/__tests__/fixtures/dynamic/attributeExpressions/output.js +++ b/packages/compiler/__tests__/fixtures/dynamic/attributeExpressions/output.js @@ -4,6 +4,7 @@ import { createElement as _$createElement2 } from "r-custom"; import { template as _$template } from "r-dom"; import { insert as _$insert } from "r-dom"; import { memo as _$memo } from "r-custom"; +import { patchDriver as _$patchDriver } from "r-custom"; import { spread as _$spread } from "r-dom"; import { mergeProps as _$mergeProps } from "r-custom"; import { ref as _$ref } from "r-dom"; @@ -15,6 +16,7 @@ import { setAttribute as _$setAttribute } from "r-dom"; import { claimElement as _$claimElement } from "r-dom"; import { addEvent as _$addEvent } from "r-dom"; import { delegateEvents as _$delegateEvents } from "r-dom"; +var _mf$ = ["button"], _mf$2 = ["foo--bar"], _mf$3 = ["foo.bar"], _mf$4 = ["style"]; var _tmpl$ = /* @__PURE__ */ _$template(`

Welcome`); var _tmpl$2 = /* @__PURE__ */ _$template(`
`); var _tmpl$3 = /* @__PURE__ */ _$template(`
`); @@ -280,23 +282,26 @@ const template32 = _tmpl$4(); const template33 = [ (() => { var _el$45 = _tmpl$21(); - _$effect(() => styles.button, (_v$, _$p) => { - _$className(_el$45, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$.button; + if (_f$ || _v$ !== _p$.button) _$className(_el$45, _v$); + }, _mf$); return _el$45; })(), (() => { var _el$46 = _tmpl$21(); - _$effect(() => styles["foo--bar"], (_v$, _$p) => { - _$className(_el$46, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$["foo--bar"]; + if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$46, _v$); + }, _mf$2); return _el$46; })(), (() => { var _el$47 = _tmpl$21(); - _$effect(() => styles.foo.bar, (_v$, _$p) => { - _$className(_el$47, _v$, _$p); - }); + _$patchDriver(styles, (_n$, _p$, _f$) => { + const _v$ = _n$.foo.bar; + if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$47, _v$); + }, _mf$3); return _el$47; })(), (() => { @@ -528,9 +533,10 @@ _$style( ); const template85 = _el$105; var _el$106 = _tmpl$4(); -_$effect(() => styleProp.style, (_v$, _$p) => { - _$style(_el$106, _v$, _$p); -}); +_$patchDriver(styleProp, (_n$, _p$, _f$) => { + const _v$ = _n$.style; + if (_f$ || _v$ !== _p$.style) _$style(_el$106, _v$); +}, _mf$4); const template86 = _el$106; const style = { background: "red", diff --git a/packages/compiler/__tests__/parity/expected-cross/dom-patch/ssr_hydratable_fixtures--insertChildren.diff b/packages/compiler/__tests__/parity/expected-cross/dom-nopatch/ssr_hydratable_fixtures--insertChildren.diff similarity index 100% rename from packages/compiler/__tests__/parity/expected-cross/dom-patch/ssr_hydratable_fixtures--insertChildren.diff rename to packages/compiler/__tests__/parity/expected-cross/dom-nopatch/ssr_hydratable_fixtures--insertChildren.diff diff --git a/packages/compiler/__tests__/parity/harness.js b/packages/compiler/__tests__/parity/harness.js index d41bfd8e3..b39b3faa1 100644 --- a/packages/compiler/__tests__/parity/harness.js +++ b/packages/compiler/__tests__/parity/harness.js @@ -73,10 +73,11 @@ const modes = { requireImportSource: false } }, - // Patch-mode parity (re-audit blocker 6): the SAME dom corpus with the - // dual driver on — patch grammar (wrapPatchMode/rowProof stamping) must - // stay byte-identical across backends, ratcheted like every other mode. - "dom-patch": { + // Classic-output parity: patch mode is DEFAULT-ON, so the plain `dom` + // mode covers the patch grammar; this tier fences the EXPLICIT OPT-OUT + // (patchDriver: false) — fully classic output must stay byte-identical + // across backends too. + "dom-nopatch": { fixtureDir: "__dom_fixtures__", options: { moduleName: "r-dom", @@ -84,7 +85,7 @@ const modes = { wrapConditionals: true, contextToCustomElements: true, requireImportSource: false, - patchDriver: "patchDriver" + patchDriver: false } }, "dom-hydratable": { @@ -200,7 +201,7 @@ function readFixtureSource(mode, fixture) { // Same parser-blocked subset carve-out as babel-fixtures.test.js: Oxc cannot // parse hyphenated JSX member segments (``). function supportedSubset(mode, fixture, source) { - if ((mode === "dom" || mode === "dom-patch") && fixture === "namespaceElements") { + if ((mode === "dom" || mode === "dom-nopatch") && fixture === "namespaceElements") { return [ source.slice(source.indexOf("const template ="), source.indexOf("const template4")), source.slice(source.indexOf("const template6")) @@ -210,8 +211,8 @@ function supportedSubset(mode, fixture, source) { } function compileBabel(code, options) { - // Patch mode is DORMANT by default in both compilers; the dom-patch mode - // above opts in explicitly so parity covers the patch grammar too. + // Patch mode is DEFAULT-ON in both compilers (the dom modes cover the + // patch grammar); dom-nopatch fences the explicit opt-out. return babel.transformSync(code, { babelrc: false, configFile: false, diff --git a/packages/compiler/__tests__/renderer-contract.test.js b/packages/compiler/__tests__/renderer-contract.test.js new file mode 100644 index 000000000..9c3894b79 --- /dev/null +++ b/packages/compiler/__tests__/renderer-contract.test.js @@ -0,0 +1,166 @@ +/** + * Renderer-surface contract harness (re-audit 7). INVARIANT: every name the + * compilers can emit as a runtime import must exist on the documented + * surface of the module it imports from — for dom output that is + * `@solidjs/web`'s real export list; for universal output it is the + * `Renderer` interface `createRenderer()` implements. A compiler feature + * that adds an import (the patch flip did) must extend the runtime surface + * AND its documented type in the same change, or every custom renderer + * following the docs breaks at module linking. + */ +const fs = require("fs"); +const path = require("path"); +const { transform } = require("../index"); + +// Corpus chosen to pull EVERY emission family: patch-eligible member-read +// bindings (patchDriver under default-on), a keyed store list row +// (rowProof), classic dynamic bindings, events (delegated + bound), +// refs, spreads, style/class helpers, fragments, and insert holes. +const CORPUS = ` +import { For, Show } from "solid-js"; +const a =
; +const b =
    + {row =>
  • } +
; +const c = ; +const d = <> + e} /> + +; +`; + +function importsFrom(code, moduleName) { + const names = new Set(); + const re = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*"${moduleName}"`, "g"); + let m; + while ((m = re.exec(code)) !== null) { + for (const piece of m[1].split(",")) { + const name = piece + .trim() + .split(/\s+as\s+/)[0] + .trim(); + if (name) names.add(name); + } + } + return [...names]; +} + +async function webExportSurface() { + const web = await import(path.resolve(__dirname, "../../web/dist/web.js")); + return new Set(Object.keys(web)); +} + +function rendererInterfaceKeys() { + const src = fs.readFileSync(path.resolve(__dirname, "../../universal/src/universal.ts"), "utf8"); + const start = src.indexOf("export interface Renderer<"); + const body = src.slice(start, src.indexOf("\n}", start)); + const keys = new Set(); + for (const line of body.split("\n")) { + const m = /^\s{2}(\w+)[<(?:]/.exec(line); + if (m) keys.add(m[1]); + } + return keys; +} + +async function universalRuntimeSurface() { + const { createRenderer } = await import( + path.resolve(__dirname, "../../universal/dist/universal.js") + ); + const stub = () => {}; + return Object.keys( + createRenderer({ + createElement: stub, + createTextNode: stub, + replaceText: stub, + isTextNode: stub, + setProperty: stub, + insertNode: stub, + removeNode: stub, + getParentNode: stub, + getFirstChild: stub, + getNextSibling: stub + }) + ); +} + +describe("compiled imports ⊆ documented runtime surface", () => { + it("dom output (default options) links against @solidjs/web", async () => { + const surface = await webExportSurface(); + const out = transform(CORPUS, { filename: "c.jsx", moduleName: "@solidjs/web" }); + const names = importsFrom(out.code, "@solidjs/web"); + // The corpus must actually exercise the patch tier, or this test + // silently stops guarding the flip. + expect(names).toContain("patchDriver"); + const missing = names.filter(n => !surface.has(n)); + expect(missing).toEqual([]); + }); + + it("universal output links against the documented Renderer interface", () => { + const iface = rendererInterfaceKeys(); + const out = transform(CORPUS, { + filename: "c.jsx", + generate: "universal", + moduleName: "r-custom" + }); + const names = importsFrom(out.code, "r-custom"); + expect(names.length).toBeGreaterThan(0); + const missing = names.filter(n => !iface.has(n)); + expect(missing).toEqual([]); + }); + + it("the Renderer interface documents the FULL createRenderer surface (type ⊇ runtime)", async () => { + const iface = rendererInterfaceKeys(); + const runtime = await universalRuntimeSurface(); + const undocumented = runtime.filter(n => !iface.has(n)); + // A member createRenderer ships but the type omits is invisible to + // every custom renderer following the docs — patchDriver's exact hole. + expect(undocumented).toEqual([]); + }); + + it("every patch-tier import dom output can emit exists on the createRenderer surface", async () => { + // Custom dom-flavored renderers re-export createRenderer members as + // their module surface; default-on `` output imports rowProof, so + // the surface must carry the WHOLE patch tier, not just patchDriver. + const runtime = new Set(await universalRuntimeSurface()); + const out = transform(CORPUS, { filename: "c.jsx", moduleName: "@solidjs/web" }); + const names = importsFrom(out.code, "@solidjs/web"); + const patchTier = names.filter(n => n === "patchDriver" || n === "rowProof"); + expect(patchTier.sort()).toEqual(["patchDriver", "rowProof"]); + const missing = patchTier.filter(n => !runtime.has(n)); + expect(missing).toEqual([]); + }); + + it("the SERVER entry links every patch-tier import (SSR pipelines load dom-compiled modules)", async () => { + const server = await import(path.resolve(__dirname, "../../web/dist/server.js")); + expect(typeof server.patchDriver).toBe("function"); + expect(typeof server.rowProof).toBe("function"); + }); + + it("unsafe-integer numeric keys are patch-ineligible (manifest formatting diverges)", () => { + const out = transform( + "const row = state.rows[0];\nconst v =
;", + { filename: "c.jsx", moduleName: "@solidjs/web" } + ); + expect(out.code.includes("patchDriver")).toBe(false); + }); + + it("non-integer numeric keys are patch-ineligible (dot-collision with manifest paths)", () => { + // `state[1.2]` would manifest as "1.2" and probe as state["1"]["2"] — + // the compiler must compile such scopes classic instead. + const out = transform( + "const row = state.rows[0];\nconst v =
;", + { filename: "c.jsx", moduleName: "@solidjs/web" } + ); + expect(out.code.includes("patchDriver")).toBe(false); + // Integer keys stay eligible. + const ok = transform( + "const row = state.rows[0];\nconst v =
;", + { filename: "c.jsx", moduleName: "@solidjs/web" } + ); + expect(ok.code.includes("patchDriver")).toBe(true); + expect(ok.code.includes('"queries.0.elapsed"')).toBe(true); + }); +}); diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 56f006a26..ba727df7c 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -311,13 +311,10 @@ fn dom_transform_config(options: &CompileOptions, built_ins: Vec) -> Dom wrap_conditionals: options.wrap_conditionals, memo_wrapper: wrapper_name(&options.memo_wrapper, "memo"), // DORMANT by default (extraction ruling, solid DESIGN §16): compiled - // output must not import driver exports the release core only stubs. - // Wrapper::Default resolves to DISABLED for the patch driver; opt in - // with an explicit name against a channel-bearing core. - patch_driver: match &options.patch_driver { - Wrapper::Default => None, - other => wrapper_name(other, "patchDriver"), - }, + // Patch mode is DEFAULT-ON (the shipped core carries the driver; + // benchmarks and apps compile identically). Opt out with + // `patchDriver: false`. + patch_driver: wrapper_name(&options.patch_driver, "patchDriver"), static_marker: options.static_marker.clone(), omit_nested_closing_tags: options.omit_nested_closing_tags, omit_last_closing_tag: options.omit_last_closing_tag, diff --git a/packages/compiler/src/dom/dynamics.rs b/packages/compiler/src/dom/dynamics.rs index b23d01e0b..52cc6f37f 100644 --- a/packages/compiler/src/dom/dynamics.rs +++ b/packages/compiler/src/dom/dynamics.rs @@ -290,11 +290,17 @@ impl<'a> AstDomTransform<'a, '_> { self.template_state.uses_patch_driver = true; let body = self.arrow_with_statements(span, vec!["_n$", "_p$", "_f$"], statements); let subject_expr = self.identifier_expression(span, &subject); + // Static read manifest (re-audit 7, P1-1): the runtime's demotion + // probes need the body's FULL read envelope — branches included — + // which only the compiler knows. Mirrors Babel byte-for-byte. + let manifest = crate::shared::patch::collect_subject_paths(&values, &subject); + let manifest_local = self.manifest_local(manifest); + let manifest_expr = self.identifier_expression(span, &manifest_local); let driver_local = format!("_${driver}"); Some(( self.ast().statement_expression( span, - self.call_identifier(span, &driver_local, vec![subject_expr, body]), + self.call_identifier(span, &driver_local, vec![subject_expr, body, manifest_expr]), ), subject, )) diff --git a/packages/compiler/src/dom/template.rs b/packages/compiler/src/dom/template.rs index 405b0d396..9c1b4b994 100644 --- a/packages/compiler/src/dom/template.rs +++ b/packages/compiler/src/dom/template.rs @@ -15,6 +15,10 @@ use crate::shared::ast::{ use crate::shared::ast_builder::AstBuilder; pub(crate) struct DomTemplateState { pub(crate) templates: std::vec::Vec, + /// Hoisted patch read manifests (re-audit 7): one module-scope array per + /// distinct manifest so the runtime interns by array identity. + pub(crate) manifests: std::vec::Vec, + pub(crate) manifest_index: usize, pub(crate) uses_template: bool, pub(crate) uses_get_next_element: bool, pub(crate) uses_get_next_marker: bool, @@ -50,6 +54,12 @@ pub(crate) struct DomTemplateState { pub(crate) template_index: usize, } +pub(crate) struct DomManifest { + pub(crate) paths: std::vec::Vec, + /// Generated `_mf$N` local (collision-checked against source names). + pub(crate) name: String, +} + pub(crate) struct DomTemplate { pub(crate) html: String, /// Babel's `templateWithClosingTags`: the same markup without attributes @@ -109,6 +119,8 @@ impl DomTemplateState { pub(crate) fn new() -> Self { Self { templates: std::vec::Vec::new(), + manifests: std::vec::Vec::new(), + manifest_index: 0, uses_template: false, uses_get_next_element: false, uses_get_next_marker: false, @@ -280,6 +292,9 @@ impl<'a> AstDomTransform<'a, '_> { } } } + if !self.template_state.manifests.is_empty() { + statements.push(self.manifests_declaration()); + } for template in &self.template_state.templates { statements.push(self.template_declaration(template)); } @@ -567,6 +582,58 @@ impl<'a> AstDomTransform<'a, '_> { import_named(self.allocator, module, imported, local) } + /// One `var _mf$ = [...], _mf$2 = [...]` declaration (mirrors Babel's + /// program-exit unshift; identity-stable arrays for runtime interning). + fn manifests_declaration(&self) -> Statement<'a> { + let span = Span::new(0, 0); + let ast = self.ast(); + let mut declarators = ast.vec(); + for manifest in &self.template_state.manifests { + let elements = ast.vec_from_iter(manifest.paths.iter().map(|path| { + oxc_ast::ast::ArrayExpressionElement::StringLiteral(ast.alloc_string_literal( + span, + ast.str(path), + None, + )) + })); + let init = ast.expression_array(span, elements); + declarators.push(ast.variable_declarator( + span, + oxc_ast::ast::VariableDeclarationKind::Var, + ast.binding_pattern_binding_identifier(span, ast.ident(&manifest.name)), + None, + Some(init), + false, + )); + } + Statement::VariableDeclaration(ast.alloc_variable_declaration( + span, + oxc_ast::ast::VariableDeclarationKind::Var, + declarators, + false, + )) + } + + /// Find-or-create the hoisted local for a manifest (dedup by paths). + pub(crate) fn manifest_local(&mut self, paths: std::vec::Vec) -> String { + if let Some(existing) = self + .template_state + .manifests + .iter() + .find(|candidate| candidate.paths == paths) + { + return existing.name.clone(); + } + let name = crate::shared::utils::next_unique_manifest_id( + &mut self.template_state.manifest_index, + &self.bindings, + ); + self.template_state + .manifests + .push(DomManifest { paths, name: name.clone() }); + name + } + fn template_declaration(&self, template: &DomTemplate) -> Statement<'a> { let span = Span::new(0, 0); let template_literal = self.template_literal_expression(span, &template.html); diff --git a/packages/compiler/src/shared/patch.rs b/packages/compiler/src/shared/patch.rs index 534f3016e..29c3eaf6a 100644 --- a/packages/compiler/src/shared/patch.rs +++ b/packages/compiler/src/shared/patch.rs @@ -14,23 +14,43 @@ use oxc_ast::ast::{BinaryOperator, Expression, UnaryOperator}; use oxc_ast_visit::{VisitMut, walk_mut}; /// Node types allowed inside an eligible binding expression (Tier 1+2). -fn is_eligible_expr(node: &Expression<'_>, subject: &str) -> bool { +/// `as_member_base` marks the root position of a member chain: the bare +/// subject identifier is ONLY eligible there (re-audit 7) — a standalone +/// `{subject}` read has no key envelope for the static manifest, so those +/// scopes keep classic effects. Mirrors the Babel plugin exactly. +fn is_eligible_expr(node: &Expression<'_>, subject: &str, as_member_base: bool) -> bool { match node { - Expression::Identifier(ident) => ident.name == subject || ident.name == "undefined", + Expression::Identifier(ident) => { + (as_member_base && ident.name == subject) || ident.name == "undefined" + } Expression::StaticMemberExpression(member) => { - !member.optional && is_eligible_expr(&member.object, subject) + !member.optional && is_eligible_expr(&member.object, subject, true) } Expression::ComputedMemberExpression(member) => { if member.optional { return false; } - if !matches!( - member.expression, - Expression::StringLiteral(_) | Expression::NumericLiteral(_) - ) { - return false; + // Literal keys only — and nothing that stringifies with a ".", + // which would collide with the manifest's path separator: + // dotted string keys (re-audit 7) and non-integer numeric keys + // (re-audit 8 — `state[1.2]` would probe as state["1"]["2"]). + match &member.expression { + Expression::StringLiteral(lit) => { + if lit.value.contains('.') { + return false; + } + } + Expression::NumericLiteral(lit) => { + // Safe integers only (re-audit 9): matches Babel's + // Number.isSafeInteger — larger integral literals + // format differently through the i64 cast. + if lit.value.fract() != 0.0 || lit.value.abs() > 9_007_199_254_740_991.0 { + return false; + } + } + _ => return false, } - is_eligible_expr(&member.object, subject) + is_eligible_expr(&member.object, subject, true) } Expression::StringLiteral(_) | Expression::NumericLiteral(_) @@ -38,9 +58,9 @@ fn is_eligible_expr(node: &Expression<'_>, subject: &str) -> bool { | Expression::NullLiteral(_) | Expression::BigIntLiteral(_) => true, Expression::ConditionalExpression(cond) => { - is_eligible_expr(&cond.test, subject) - && is_eligible_expr(&cond.consequent, subject) - && is_eligible_expr(&cond.alternate, subject) + is_eligible_expr(&cond.test, subject, false) + && is_eligible_expr(&cond.consequent, subject, false) + && is_eligible_expr(&cond.alternate, subject, false) } Expression::BinaryExpression(binary) => { if matches!( @@ -49,22 +69,26 @@ fn is_eligible_expr(node: &Expression<'_>, subject: &str) -> bool { ) { return false; } - is_eligible_expr(&binary.left, subject) && is_eligible_expr(&binary.right, subject) + is_eligible_expr(&binary.left, subject, false) + && is_eligible_expr(&binary.right, subject, false) } Expression::LogicalExpression(logical) => { - is_eligible_expr(&logical.left, subject) && is_eligible_expr(&logical.right, subject) + is_eligible_expr(&logical.left, subject, false) + && is_eligible_expr(&logical.right, subject, false) } Expression::UnaryExpression(unary) => { if matches!(unary.operator, UnaryOperator::Delete) { return false; } - is_eligible_expr(&unary.argument, subject) + is_eligible_expr(&unary.argument, subject, false) } Expression::TemplateLiteral(template) => template .expressions .iter() - .all(|expression| is_eligible_expr(expression, subject)), - Expression::ParenthesizedExpression(paren) => is_eligible_expr(&paren.expression, subject), + .all(|expression| is_eligible_expr(expression, subject, false)), + Expression::ParenthesizedExpression(paren) => { + is_eligible_expr(&paren.expression, subject, false) + } _ => false, } } @@ -115,7 +139,7 @@ pub(crate) fn analyze_patch_eligibility(values: &[&Expression<'_>]) -> Option( walk_mut::walk_expression(&mut substituter, &mut clone); clone } + +/// Collect the STATIC read manifest (re-audit 7, P1-1): every member path +/// rooted at the subject, dot-joined. Order mirrors the Babel plugin +/// byte-for-byte: dynamics order, chains consumed whole at first +/// encounter, first occurrence kept. +pub(crate) fn collect_subject_paths(values: &[&Expression<'_>], subject: &str) -> Vec { + fn chain_of(node: &Expression<'_>, subject: &str) -> Option { + let mut segs: Vec = Vec::new(); + let mut cur = node; + loop { + match cur { + Expression::StaticMemberExpression(member) => { + segs.push(member.property.name.to_string()); + cur = &member.object; + } + Expression::ComputedMemberExpression(member) => { + match &member.expression { + Expression::StringLiteral(lit) => segs.push(lit.value.to_string()), + Expression::NumericLiteral(lit) => { + // Eligibility admits INTEGER keys only (re-audit + // 8); match JS String(n) for them. + #[allow(clippy::cast_possible_truncation)] + segs.push((lit.value as i64).to_string()); + } + _ => return None, + } + cur = &member.object; + } + Expression::Identifier(ident) => { + if ident.name == subject { + segs.reverse(); + return Some(segs.join(".")); + } + return None; + } + _ => return None, + } + } + } + fn walk(node: &Expression<'_>, subject: &str, paths: &mut Vec) { + if matches!( + node, + Expression::StaticMemberExpression(_) | Expression::ComputedMemberExpression(_) + ) { + if let Some(chain) = chain_of(node, subject) { + if !paths.contains(&chain) { + paths.push(chain); + } + return; // the whole chain is consumed + } + } + match node { + Expression::StaticMemberExpression(member) => walk(&member.object, subject, paths), + Expression::ComputedMemberExpression(member) => walk(&member.object, subject, paths), + Expression::ConditionalExpression(cond) => { + walk(&cond.test, subject, paths); + walk(&cond.consequent, subject, paths); + walk(&cond.alternate, subject, paths); + } + Expression::BinaryExpression(binary) => { + walk(&binary.left, subject, paths); + walk(&binary.right, subject, paths); + } + Expression::LogicalExpression(logical) => { + walk(&logical.left, subject, paths); + walk(&logical.right, subject, paths); + } + Expression::UnaryExpression(unary) => walk(&unary.argument, subject, paths), + Expression::TemplateLiteral(template) => { + for expression in &template.expressions { + walk(expression, subject, paths); + } + } + Expression::ParenthesizedExpression(paren) => walk(&paren.expression, subject, paths), + _ => {} + } + } + let mut paths: Vec = Vec::new(); + for value in values { + walk(value, subject, &mut paths); + } + paths +} diff --git a/packages/compiler/src/shared/utils.rs b/packages/compiler/src/shared/utils.rs index 8a390e455..71949e37b 100644 --- a/packages/compiler/src/shared/utils.rs +++ b/packages/compiler/src/shared/utils.rs @@ -522,6 +522,29 @@ pub(crate) fn next_unique_template_id( } } +/// `_mf$`-family ids for hoisted patch read manifests (re-audit 7), same +/// numbering/collision rules as `_tmpl$` (Babel: generateUidIdentifier). +pub(crate) fn manifest_id(index: usize) -> String { + if index == 0 { + "_mf$".to_string() + } else { + format!("_mf${}", index + 1) + } +} + +pub(crate) fn next_unique_manifest_id( + index: &mut usize, + bindings: &crate::shared::bindings::BindingTable, +) -> String { + loop { + let name = manifest_id(*index); + *index += 1; + if !bindings.is_taken(&name) { + return name; + } + } +} + /// Mirror of the Babel plugin's `canChildSlotAllocateIds`: whether a child /// slot can produce hydratable content that consumes hydration ids. Shared by /// the dom and ssr generates so marking can never desync between them. diff --git a/packages/compiler/types.d.ts b/packages/compiler/types.d.ts index 2ccd6b93f..37c0a044d 100644 --- a/packages/compiler/types.d.ts +++ b/packages/compiler/types.d.ts @@ -21,12 +21,15 @@ export interface TransformOptions { omitLastClosingTag?: boolean; serverComponents?: boolean; /** - * Patch-mode dual driver (dormant by default): `true` or an import name - * (`"patchDriver"`) opts compiled templates whose bindings are pure member - * reads of one subject into the store patch channel. The loader normalizes - * `true` to the default import name (the napi wrapper mapping treats bare - * booleans as "default", which this option reads as disabled). - * @default false + * Patch-mode dual driver, ON BY DEFAULT: compiled templates whose bindings + * are pure member reads of one subject register on the store patch channel + * (emitting `patchDriver`/`rowProof` imports with a static read manifest); + * ineligible scopes keep classic effects. Set `false` to compile every + * scope classic; a string overrides the driver's import name. NOTE: the + * runtime module (`moduleName`) must export `patchDriver`/`rowProof` — + * `@solidjs/web` does, and `createRenderer()` provides `patchDriver` for + * universal renderers. + * @default "patchDriver" */ patchDriver?: boolean | string; /** Default `["For", "Show", "Switch", "Match", "Loading", "Reveal", "Portal", "Repeat", "Dynamic", "Errored"]`. */ diff --git a/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md new file mode 100644 index 000000000..f3cabbbf3 --- /dev/null +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -0,0 +1,761 @@ +# Audit brief — rounds 6–9 + patch-mode default flip + node delivery + +## SHIP RULING (2026-09-01, Ryan) — ship as-is pending final audit; coverage expansions go to a FUTURE RC + +Deferred to a future RC (documented driver contract for THIS release is +deep keyed stores via reconcile — the benched, octane-class +configuration): +- `keyed={fn}` For engagement: accessor-row binding + compiler grammar + for `param().member` bodies (the identity-ruling follow-up). Today it + declines to classic by defensive contract pin. +- Shallow logical-key retention: a `keyFn` seam in the driver's + identityOps (shallow retention is reference-keyed; logically-keyed + fresh rows rebuild per replacement — correct per the shallow + contract, not driver-grade retention). +- Row-value multiplexing over the list channel (mount lever, est. −1 to + −1.5 ms on dbmon: kills per-row channels/delivery nodes/registrations + at mount; the version chain makes late materialization sound), the + channel shape diet, and the same-shape admission-scan fast path. +- The two upstream it.fails pins (same-microtask landing swallow, + wedged until()) — #3123/#3164 reckoning machinery, tracked upstream. + +## Perf verification (2026-09-01, post fold-audit-2) — DRIVER RESTORED TO OCTANE-CLASS + +The octane-bar sweep, after fixing the harness (an ORPHANED months-old +preview server on :5200 had been serving a stale build — every earlier +"solid" column this cycle was that ghost; and the babel-preset fixture +path never stamps `$ll`, so the driver column silently ran classic — +`solid-compiled` with explicit `patchDriver` through the preset is the +real driver fixture, and its rows are attribute-only BY DESIGN since +text holes disqualify the purity proof): + + op octane classic-next driver-branch (a65c3ca1) + mount 4.40 16.40 7.10 + tick 1.80 6.10 2.20 + tick_partial 0.80 1.30 0.50 + remount 4.70 9.90 5.00 + sort 2.20 2.90 2.50 + unmount 1.70 2.50 0.30 + +- Driver ≈ historical ledger (6.6/2.1/0.6) THROUGH the version chain: + the redesign kept the wins. vs octane: tick 1.22x, partial and + unmount AHEAD, remount 1.06x; mount holds the known 1.6x gap. +- Classic-vs-classic (native compile both sides): ≤5% bench delta, + PROFILE-PARITY on tick totals (306 vs 311 ms / 50 ticks) — the + earlier "+8-10%" was compile-vintage + the stale server; mount keeps + a real parse component (+10 KB runtime in the bundle). +- Mount-gap attribution (30 mounts profiled): store-model cost — + createTarget + wrapNext + accessor scans ≈ 0.6 ms/mount + GC + pressure; DOM costs match octane. The remaining 1.6x is the price of + wrapped stores, not a regression; next lever would be lazy target + creation at bind time. + +## Round 10.19 (2026-09-01) — STRUCTURAL VERSION CHAIN (redesign, closes the finding class) + +Five findings at a4c439b7 (maxRq cross-window coverage, lane-sweep-before- +regular-ops, reveal emission overlap, duplicate slot sweeps, eager-repair +late-mount cliff) — four of them in the sweep/watermark machinery the last +three rounds accreted. Per the standing decision: the class is closed by +REDESIGN, not a fifth round of point fixes. + +- **The chain**: every structural emission stamps `svAt = ++pc.sv` (rows) + or `++pc.svs` (slots — separate consumer lists, separate chains). The + channel tracks the VISIBLE version (`svv`/`svvs`): bumped when items + enter the live queue (commit-coincident emissions immediately, stashed + ones at releaseBatch, lane emissions at emission). Entries initialize + `av` to the visible version — exactly what their registration read + covered — and apply an item's payload only on an unbroken chain + (`av === svAt-1`, the item's baseline IS the entry's last-seen state). + At-or-below `av` skips; any gap marks the entry for ONE resync at the + END of the flush, after every queue. Deleted: emission snapshots as the + membership authority, PcSweep/noteSweep/sweepList/runLateSweeps, the + maxRq/winEnd watermarks, drainGen/dg dedup, rq/sq stamps. +- Findings 1/2/4 become unaskable: no watermark to be wrong, no per-queue + sweep to order, no per-item repetition to dedup. Held-window + registrants IMPROVE: their chain connects, so they receive real + baseline-sound ops at release instead of a rebuild (pin updated). +- **Reveal overlap (finding 3)**: the settle loop skips staged-fold + targets (`t.sf`) — the fold's own emission carries the reveal; pinned + by a single-rebuild assertion on the reveal test. +- **Late-mount cliff (finding 5)**: `repairAncestorSlots` at registration + (channelTarget) fixes the registered target's ancestor chain — gated to + STALE ALIASES OF THE SAME CHILD only (map-resolved): the first cut + wrote tentative rows' backings into committed truth (all four + optimistic equivalence failures, one root cause) — a tentative row + registering mid-flight has no committed slot, and absent/different + slots must never be written. +- Revert-form resyncs (settle loop, cm-stamped items) resolve COMMITTED + raw only — never `pb` (a draft lingering at the drain via row escape is + exactly the state that died), never the composing proxy. +- Size: +230/+110/+20 B (three ratchets: 17.15/19.6/27.4) — flat cost; + the ~150 B/round accretion this class caused stops here. + +## Round 10.18 (2026-09-01) — fold audit (4 P1s: slots under holds, sweep ordering, reveal coverage) + +Audit at b31e9929 confirmed the consecutive-landing divergence resolved by +the fold integration and found four P1s — two channel-side, two in the +fold reveal seam. All fixed, harness-first: + +- **P1 per-index held-slot defers**: deferIntoQueue's shared `hq` flag + collapsed a multi-slot batch to its first index — later indexes stayed + permanently stale behind a hold. Slot items now dedup per (entry, + index) via `entry.hqs`; row items keep the single-run dedup (their + resync reads live truth — repeats are waste). +- **P1 drain-END late sweep**: the per-item sweep resynced a late + registrant to the LIVE view and then a LATER item — whose emission + snapshot legitimately included that entry — re-applied baseline- + relative ops on top, double-building the row. The sweep now runs once + per drain per channel against the HIGHEST emission watermark: entries + at/below `maxRq` rode a snapshot (real, baseline-sound ops); the sweep + covers exactly (maxRq, winEnd] — still a suffix tail scan. +- **P1 staged descent never mutates raws**: stagedApply recursed into + children READ THROUGH the draft — for SHALLOW stores that read returns + the raw row, and the merge mutated committed truth in place: visible + to every reader BEFORE the reveal, notified to no one. `stagedChild` + gates descent on the child being a live draft proxy; raw children + replace their slot wholesale (parks and reveals like any slot write). +- **P1 reveal coverage for structural channels**: both fold-site row-ops + emissions gated on `t.fam?.opt !== true` — right for OVERRIDE + materializations (lane channel), wrong for STAGED TRUTH folding at the + reveal: a root array whose retention came from a DESCENDANT override + is not overlaid, so the settle loop's resync never reached it and the + gate silenced the fold's own ops. `t.sf` (set in ensurePB when an + optimistic-family draft is written under the authoritative posture — + exactly the staging bracket) exempts staged folds, and a slot-diff + twin ticks changed slots (staged reveals bypass every walk). +- Size: +40/+52/+180/+110 B across the four affected tiers (sweep + bookkeeping is most of it) — four ratchets with notes. + +## Round 10.17 (2026-09-01) — #3164 FOLD reconciliation (rebase onto the re-ruled contract) + +Upstream re-ruled landing consumption (a536e29b): the three interim #3123 +mechanisms (equality-scoped consumption, retained-setter replay, echo mask) +are REMOVED — truth landings FOLD into the retaining transaction and reveal +atomically at its settle. Channel consequences: + +- **The branch's landing integration is DELETED, not ported** — by design. + Under fold, landing visibility rides mechanisms the channel already + handles: staged truth is transition-held (the channel's held-write + semantics apply by construction), and the atomic reveal rides the settle + drain's existing resync loop. `emitLandingConsumption` / + `emitRowOpsLanding` and the superseded-work generation (`pc.sg`, its + drain gate, item stamps) died with the contract they served (~90 B back). + The registration-sequence window (`rq`/`sq`) and every sweep/hold/slot + fix stays — those are contract-independent. +- **Re-applied the round-10.5 emission-gate fix** the upstream rewrite + reverted: the settle loop's value emission gated on the LOCAL consumer + list again (silences ancestors, round-10 P1-3) — back to primitive-owned + gating. Two invariant tests caught it immediately. +- **Landing tests re-pinned to fold semantics**: an interim landing under + retained optimism is INVISIBLE to both channels (classic keeps the + optimistic view, count and all — the channel must not run ahead); the + flip is atomic at settle, channel and classic together. +- **The two upstream pins STILL FAIL under fold** (same-microtask second + landing swallowed; until()-gated action wedged on it) — the re-ruling + did not close them; they keep riding as it.fails. +- Size: two ratchets for upstream fold bytes stacking on branch costs + (createStore tier 15.15, store-family app 27.25); the deleted landing + machinery clawed most of round-10.16's bytes back. +- NOTE: based on an UNPUSHED next (a536e29b) — pushes held until it lands + on origin. + +## Round 10.16 (2026-08-31) — structural-audit follow-up (2 P1 + 2 P2) + TWO OPEN upstream findings + +- **FIXED P1 (slot registrants unstamped)**: `registerSlotPatchNext` now + stamps `sq` like row-ops registrations — without it the suffix scan read + 0 and broke immediately; shallow lists mounted during held windows + stayed permanently stale. The regression test is NON-VACUOUS now: the + surviving slot's resync MUST arrive (an empty tick list was how the + vacuous `every()` hid the miss), and slot emissions in the test go + through the reconcile walk (the only slot-tick emitter). +- **FIXED P1 (landing emission ahead of classic)**: `emitRowOpsLanding` + hook — LANE-timed (the ambient transaction at consumption is an + optimistic action's; the regular queue would stash the item there and a + reverting action drops its stash) but DRAIN-RESOLVED (the emission-time + composed snapshot read the mid-reckoning draft: a parked or superseded + landing's topology reached the DOM while classic held the previous view + until its commit). visibleStructRows at drain reads exactly what + classic renders at that moment. Probed across five interleavings + (spaced/same-microtask × echo/non-echo × blind/until-gated), frames at + classic parity throughout. +- **FIXED P2 (item-local resync repeats)**: per-drain generation stamp — + several held items on one channel each ran the sweep; entries now + resync once per drain (row form; slot items stay per-item — distinct + indices are distinct deliveries). +- **FIXED P2 (generation gate dropped standalone slot ticks)**: sg-stale + ROW items drop (the landing's resync covers them); sg-stale SLOT items + are standalone value notifications the row resync does NOT cover — they + re-resolve against the live visible view and keep their delivery + (range-gated for slots the landing deleted). +- **OPEN upstream ×2 (pinned `it.fails` in createOptimisticStore.test.ts)**: + (1) a second continuation landing arriving in the same microtask chain + is SWALLOWED — committed truth loses the landed row. Channel-independent: + reproduces with a bare async-generator projection, no actions, no + consumers, classic effects only. (2) downstream of it, an action whose + until() waits on the swallowed echo wedges forever (authoritative truth + never carries the row). Both sit in the #3123 continuation-reckoning + machinery — flagged, not unilaterally fixed (active upstream seam). +- Size: hydrating-store-app DOWN 27.18 → 27.13 (emission-time composition + deleted); patch tiers +61/+111 B (stamps, drain gate, hook) — two + ratchets (16.7 / 19.3). + +## Round 10.15 (2026-08-31) — structural audit (6 findings) + review-commit integration + +The structural audit's six findings clustered into three root causes; the +reviewer's own commit (3e12ffdb, one-reckoning landing notification) landed +mid-round and covers the continuation-coherence finding at the source — +audited here and composed with, not replaced. All six closed: + +- **F1 sweep holds + fixed window / F6 quadratic (one mechanism)**: the + 10.13 late-registrant sweep is rebuilt on REGISTRATION SEQUENCE numbers — + entries stamp `sq = ++pc.rq`, items stamp the watermark at emission. Late + entries are a SUFFIX of the (append-ordered) live list: the sweep is a + tail scan, O(#late), breaking at the first in-snapshot entry (was + indexOf-per-entry, O(consumers²)). The window is FIXED at both edges: + `sq > item.rq` (in-snapshot entries excluded) and `sq <= drain-start rq` + (mid-drain registrants excluded — they initialized from current state). + Held owner queues defer via deferIntoQueue exactly like the snapshot + path — never through the hold. CONTRACT REFINEMENT pinned by test: plain + stores emit at the FOLD, so pre-flush registrants are IN the snapshot + and receive real (baseline-correct) ops — the sweep's genuine audience + is lane items and stash windows. +- **F2 visible-view resyncs**: every drain-resolved structural `next` + (resync forms, late sweeps, held releases) resolves through + `visibleStructRows` — optimistic families read the override-composing + proxy, never bare committed backing (a held-release rebuild mid-window + dropped tentative rows). Consumers canonicalize via patchableRaw, so + proxy rows keep identity retention. +- **F3 deleted-slot gate**: a slot tick coalesced with a later shrink is + skipped at the drain (snap AND resync paths) — never delivered as + `(si, undefined)` against a row that no longer exists. +- **F4 superseded work**: `emitLandingConsumption` bumps the channel's + structural generation (`pc.sg`); items stamp it at emission and the + drains skip stale-generation items — transition-held ops from the + pre-landing baseline can no longer replay at settle over the + consumption's own resync. +- **F5 continuation coherence**: review commit 3e12ffdb (audited): wipe + + replay notify as ONE reckoning — `replaying` suppresses write-site + frames (try/finally-safe, per-edit throw isolation verified), and the + landing notification carries the optimisticView-composed snapshot after + both halves. Composed with F4's generation bump inside + emitLandingConsumption. +- **Fifth-posture pin (partial-survivor abort)**: an aborted retainer dies + alone while a sibling's edit survives the re-derivation — pinned at + classic parity with an oracle-anchored test (currently at parity through + entanglement + engine ordering; the pin guards the seam the one- + reckoning suppression leans on). +- Size: +27 B store-family app, +41 B list tier (stamps, gates, sweep) — + two ratchets with notes. hydrating-store-app 27.2, patch-lists 19.15. + +## Round 10.14 (2026-08-31) — #3123 landed; the two PAUSED P1s FIXED + +Rebased onto next with #3123's final landing-consumption semantics +(retained-setter replay, equality-scoped consumption, echo dedupe). +Both paused items reproduced RED against the settled seam, then fixed: + +- **FIXED P1 (equal-landing flash)**: `emitPatch`'s raw payload fast path + (`pc.np`) served the adoption's committed backing directly to + deliveries — bypassing `visibleView`, which routes optimistic families + through the override-composing proxy. An EQUAL landing (overrides + HELD) flashed committed state through value patches while classic + effects read override-masked nodes. The stash is now gated on + `t.fam?.opt !== true`: optimistic-family deliveries always take the + proxy read — the same visibility rule visibleView already pinned; the + fast path was an accidental bypass. Non-optimistic perf paths + (dbmon-class) keep the payload. +- **FIXED P1 (contradicting-landing notification)**: landing consumption + (`consumeOverridesNext` → `wipeStructuralOverrides`) emitted through + the OPTIMISTIC lane for an authoritative change — timing divergence + from the classic reversion effects (regular queues), duplicate + delivery against the adoption's own emission, and NO structural + resync (consumption removes the target from `overlaid` before the + settle drain's resync loop reads it — the driven list was never + told). The wipe now takes a `landing` posture: a regular `emitPatch` + bump (coalesces with the adoption's emission into ONE delivery on the + classic schedule) plus the row-ops RESYNC form at the landing (held + optimistic ops are baseline-relative; consumption changed the + baseline under them). The settle-drain call site keeps the lane form + — its own resync loop covers structure there. +- Invariant harness: "landings integrate with the patch channel at + classic-effect parity" — equal-landing no-flash (with classic parity + oracle) and contradicting-landing single-delivery + resync-at-landing. +- Size: hydrating-store-app 27.10 → 27.08, patch-lists 19.02 → 19.07 — + inside existing budgets, no ratchets. + +## Round 10.13 (2026-08-31) — structural holds/late registrants; #3123 items PAUSED + +- FIXED P1: structural row/slot dispatch defers into collapsed owner + queues (per-entry, same held probe as values) and re-derives LIVE state + at release via the RESYNC forms — row ops are baseline-relative and + would be stale by then; slot values read the release moment. +- FIXED P1: consumers registered between a HELD emission and its drain + take the live resync at settle instead of permanent staleness (the + round-7 "receive nothing" pin is refined: never the baseline-relative + OPS, always the identity-aligned rebuild — a no-op for the ambient + no-hold race). +- PAUSED (by Ryan's call, #3123 still in flux upstream): equal-landing + flash through value patches + contradicting-landing optimistic + notification. Both are the patch channel's integration with the NEW + landing-consumption semantics — fixing against a moving seam re-fixes + next week. Revisit when #3123 settles. +- Synthetic structural events: cause/phase/identity/cost semantics noted + as incomplete (P3 polish). + +## Round 10.8 FIXES (2026-08-31) — scheduled demotion-effect lifecycle (audit PASSED) + +- **P1 compute capture**: the re-drive's TRACKED pass is wrapped per + entry — a throwing getter routes to the entry's boundary (reads before + the throw stay tracked) instead of halting through the effect's own + error machinery during creation/scheduling, which poisoned the system + before held siblings released. Unhandled errors defer one halt after + the fanout, the dispatch contract. +- **P2 unbind disposes the fallback**: each re-driven entry owns a ROOT + (`entry.dd`); unbind disposes it — queued or live, the demoted effect + neither applies at release nor stays subscribed. This RETIRES the + round-8 accepted edge (demoted list rows outliving removal): driver + per-row unbinds now sever demoted bodies too. The round-6 "late unbind + is inert" expectation updated to the new contract. +- Worth-it ledger (same runtime, same fixture, patchDriver off vs on): + classic 15.1 / 7.9 / 1.2 (mount/tick/partial) vs patch 6.6 / 2.1 / 0.6 + — 2.3× / 3.8× / 2× on dbmon. Size: value tier +3.4 kB brotli over the + no-store CSR floor, list driver +2.6 kB on top. + +## Round 10.7 FIXES (2026-08-31) — stamps, held fanout, unbind-cancel + THE HOLD REPRO + +- **P1 stamp retention + P2 merge dedup** (one mechanism): dedup stamps + are stored AND compared through `currentTransition` (canonical — A¹B² + after a merge dedups to two bumps, not three) and RELEASED at delivery + (`bt`/`bo` nulled once `dv` syncs) — no merged-away transition object + outlives its pending bump. +- **P1 held-fanout isolation**: scheduled (held-owner) demotion re-drives + once-guard their FIRST run — a throw routes per-entry and defers one + halt, so queued healthy siblings still install at release (the same + contract as the immediate path's creation try/catch). Later runs keep + classic effect error semantics. +- **P2 unbind-cancel**: demotion severing split from user unbind (`dm` vs + `u`) — dispatch and held callbacks skip both, the redrive skips only + `u`: an explicit unbind after demotion cancels the queued redrive + instead of installing an effect nothing owns. +- **THE HOLD REPRO LANDED** (auditor's recipe — thank you): two sibling + Loading boundaries under sequential reveal order with collapsed + reveals; consumers in the collapsed SECOND boundary behind the pending + frontier. The classic sink genuinely holds ("v1" through a "v2" write), + and the test is RED without the queue-held probe (patch raced to "v2") + — the round-10 P1-4 fix is now end-to-end verified, replacing the + parity-shaped placeholder. + +Gates: 1,420 signals / 687 web / 352 SSR / 150 hydrate / 32 tasks; dbmon +6.6 / 2.1 / 0.6 (unchanged); two small ratchets (16.2 → 16.3, +18.75 → 18.85 — canonical stamps + once-guarded redrives). + +## Round 10.6 FIXES (2026-08-31) — response to the 2-P1/2-P2 follow-up + +- **P1 flat-alias manifests**: `rootKeysCurrent` — manifest ROOT keys with + raw-object values are currency-probed against the family map at + admission AND on payload-less deliveries (demote), closing the + `dp === null` bypass (`["right"]`-style direct object reads). Primitive + roots skip on a typeof; dbmon ticks (payload hits) never probe. +- **P1 demotion vs holds**: the demotion re-drive checks the entry's owner + queue with the same held probe as dispatch — HELD owners get + `schedule: true` (initial run enqueued through their own queue, released + with the boundary), warm owners keep the immediate run (lane-timed + demotions need it: the global queue is stashed in flight). +- **P2 dedup granularity**: transaction-SCOPED — repeats within one + transition dedup again (`pc.bt` stamp); a different transition always + writes (scheduler owns merging). Optimistic bumps gained the same + same-transaction dedup (`pc.bo`, stamped separately: a held plain write + is not lane-visible), which also absorbs the tentative-reconcile + + notifyOptimisticWrites double emission at the primitive. +- **Boundary-hold test**: reworked to the collapsed-accessor composition — + and STILL does not observably enter the held state (both sinks apply; + same for `together` re-pend and plain re-pend). The dispatch/demotion + hold routing mirrors `CollectionQueue.run`'s gate exactly, but we could + not produce a public-API composition where the CLASSIC sink holds. + REQUEST: the auditor's hold repro composition, to be added verbatim as + the regression test. + +Gates: 1,420 signals / 687 web / 352 SSR / 150 hydrate / 32 tasks; dbmon +6.6 / 2.0 / 0.6 (unchanged); two size ratchets (value tier 16.1 → 16.2, +list tier 18.6 → 18.75 — currency probes + dedup stamps + hold-aware +demotion). + +## Round 10.5 FIXES (2026-08-31) — response to the 6-variant follow-up + +- **F1 (the flagged regression): pending-dedup is now transition-aware.** + Under an active transition every bump reaches `setSignal` — entanglement + and merging are scheduler bookkeeping keyed on writes, and dedup never + outranks the scheduler. Outside transitions the dedup (and the dbmon + walk economics it exists for) is unchanged. Conservative fix; coverage + is the existing transition-merge invariants (a deterministic + A-resolves-while-B-pends repro was not reduced — flag it again if the + scenario survives this). +- **F2: alias currency probe.** `deepPathsPlain` with a target now probes + interior RAW steps against the family map — a slot holding a backing its + target has since adopted away from is a stale alias path and DECLINES to + classic (proxy reads stay right). Eager path-copying keeps canonical + chains current; this closes the second-parent alias. +- **F3 (RED-verified): payload-less deliveries re-probe the deep + manifest.** A child-subject adoption can carry a getter into a path only + the ANCESTOR's manifest reads; the bubbled (payload-less) delivery now + probes and demotes, so the getter evaluates tracked. dbmon ticks are all + payload hits — zero probe cost on the hot path. +- **F4: demoted entries are severed** (`u`) so a boundary-held deferred + callback (or any straggler snapshot) skips them — no duplicate untracked + application after demote-then-redrive. +- **F5: resyncs honor the bound family.** `identityOps` rebuilds when the + subject's family differs from the family the retained rows were built + under (`boundFam`, advanced only by a fully successful apply — a + throwing swap build leaves it old, so recovery rebuilds). +- **F6: shallow swaps keep raw retention** (no family-bound row + registrations; classic parity for DOM identity/focus). +- **F7: optimistic double-bubble removed** — revert sites emit ONCE (the + primitive self-gates and bubbles), and the tentative walk-level bubble + is gone (the tentative gate's own emission bubbles). + +Gates: 1,420 signals / 687 web / 352 SSR / 150 hydrate / 32 tasks; sizes +under limits; dbmon 6.6 / 2.1 / 0.6 (unchanged). + +## Round 10 FIXES (2026-08-31) — response to the 7-P1 audit + +All seven blockers and the three follow-ups addressed, harness-first (four +new signals invariants + four new driver invariants, each RED pre-fix). +The STRUCTURAL move: emission stopped being a per-seam convention. + +- **Bubbling is primitive-owned** (P1 landings, P1 optimistic writes, and + the standing class): every bump walks the ancestor chain inside + `bumpOne`/`bumpOneOptimistic` — no emission seam decides about + ancestors, so none can forget. Pending-dedup (`bc !== dv` exits in two + reads) keeps N-row walks from multiplying signal writes; `emitPatchLocal` + is now literally `emitPatch`. The channel-less landing seam bubbles + explicitly, as does the demotion branch. +- **Deep-path mounts** (P1-1): fixed at the SOURCE, not the reader — eager + child adoptions path-copy the ancestor chain (`privatizeCommitted`), + exactly like the fold drain always did for queued adoptions. The + committed raw a mount reads is always current. (The first attempt — + proxy-reading deep-path initial applies — cost +8 ms dbmon mount by + wrapping every nested object per row; reverted, lesson recorded.) +- **Boundary holds** (P1-4): dispatch defers a held registrant's entry + INTO its owner queue (probe injected by boundaries.ts — null when no + boundary machinery loads; entries re-apply from the queue at release, + reading that moment's visible view). Render-effect parity by + construction; regression is parity-shaped (reveal-order composition). +- **Demotion fanout** (P1-5): per-entry isolation with the dispatch loop's + own error routing (`routeEntryError` shared), one deferred halt after + all healthy siblings are live. +- **Swap visibility** (P1-6): subject swaps build from the OPTIMISTIC + visible array (initial-engagement parity). **Family retention** (P1-7): + `storeFamilyOf` token gates identity retention — a family change + rebuilds rows instead of keeping DOM bound to channels the new subject + never emits on. +- **P2s**: the dmq latch dies with its consumers (cleared at last unbind, + consumed inert, and a registration that STARTS a list opens a fresh + generation); consumer-less built channels stop bumping outside + transitions (the held-window pin is transition-scoped); retired queue + fields deleted from the channel shape, all node-delivery fields declared + and initialized in `pcOf`. +- Finding downgraded during repro: direct setter writes on optimistic + stores OUTSIDE an action revert by design (they are overrides of derived + truth) — two audit-adjacent "swallowed write" repros were this + semantic, not defects. + +Gates: 1,419 signals / 687 web / 352 SSR / 150 hydrate / 32 turbo tasks; +sizes under limits (one ratchet: store-heavy hydrating tier 26.45 → +26.55 kB, measured 26.47). dbmon unchanged: 6.5 / 2.0 / 0.6. + +## Round 10 — node-delivery architecture (SUPERSEDES value-queue delivery) + +Branch `patch-node-delivery-proto`. Value patches no longer ride bespoke +queues: each channel owns one version SIGNAL (`pc.dn`) bumped at emission +seams and ONE detached render effect that dispatches every consumer with a +per-entry `prev` baseline. Transitions, holds, lanes, merges, and mount +order are scheduler-owned by construction. Structural (row-ops/slot) queues +are unchanged from rounds 6–9. The following round-9 mechanisms are +RETIRED — findings against them are moot: generation/`cm` skip rules, +`qa/qe/qo/qeo` value stamps and `clearStamp`, drain-side accessor probes +(`deepProbeFails`/`optProbeFails`), `forcedNext`, value-entry transition +merge repair (merge is now a plain structural move). + +### New seams (attack surface) + +1. **Lazy machinery creation** (`bumpDelivery`): signal + effect are built + at the first consumer-visible emission, skipped iff machinery was never + built AND `pc.p === null`. Soundness pin: once built, NEVER torn down — + a held write bumping during an unbound window must deliver to a + consumer registering before the settle. Never-built channels skip + silently (registration `pv` baselines reflect prior writes). +2. **Never-dispose persistence**: last unbind only nulls `pc.p`; the node + takes the inert return on later bumps; re-registrations reuse it; + reclamation is by record death (node↔signal cycle is GC-collectable). + Perf-over-memory ruling: ~200 B dormant per record outliving consumers. +3. **`deliveryEffect` primitive** (core/effect.ts): detached single-source + effect — no root, no owner, created under `runWithOwner(null)` (global + queue). Initial run is a subscribe-only no-op (`bc === dv` guard). + Errors route per-entry to registrant owners inside the commit; an + unboundaried error defers `haltReactivity` one phase (siblings apply). +4. **Payload fast path** (`pc.np`/`pc.npb`): self emissions stash raw next + state bc-tagged; deliveries read it raw, else resolve `visibleView` + (optimistic proxy / deep-path proxy / held mask / committed). A stash + without a bump (no-consumer window) can never false-match: `np` is only + served when `npb === bc` and any later bump increments `bc`. +5. **Per-entry `pv` baselines**: REFERENCES to raw backings (adoption swaps + make them immutable); the in-place overlay fold clones just-in-time + (`prepareInPlaceFold`); optimistic views snapshot UNTRACKED through the + proxy. The compare IS the delivery decision — no counters. +6. **Deferred demotion** (`pc.dmq`): tentative getter-bearing views mark + the channel; the delivery effect (clean, lane-timed context) runs + `demoteToEffects` so re-driven bodies subscribe correctly. + +### Evidence + +- Full gates green: 1,415 signals / 683 web / 352 SSR / 150 hydrate / 32 + turbo tasks; every size scenario UNDER its channel-era limit (net + smaller: core −157 B, store tier −112 B, patchDriver tier −169 B). +- Perf A/B vs the audited channel state (36d1d385), same session: + dbmon ties or wins every op (mount 6.4=6.4, tick 2.1<2.2, remount + 4.6<5.0); jfb 10 ops parity; uibench 96 scenarios parity (34.4 vs 35.2 + summed medians). Unmount bench column shown to be a concurrent-GC + write-barrier artifact, identical on both builds (design doc §22). + +## Round 9 (response to the 11-finding audit) + +- **P1 gen-stale mounts** — the skip rule now applies only to entries + emitted from COMMITTED-VISIBLE state (`cm`: setter drafts, held + adoptions, and transaction stashes never skip); `patchableRaw` serves the + held view (`hv`) for masked targets, and mounts anchor to the same + visibility an untracked proxy reader sees (invariant test uses that + oracle directly). Ambient eager adoptions self-correct (mounts read the + swapped backing) — pinned by test. +- **P1 optimistic-window mounts** — manifested initial applies read the + OPTIMISTIC VIEW через the proxy (untracked) for family records. +- **P1 fallback compute writes** — the manifest IS the read set: the + effect fallback's compute pass reads the declared envelope directly and + never runs the body (NaN/unstable-getter compares can fire setters + inside tracked computations). Applied to web AND universal drivers; + manifest-less callers keep dual-run. +- **P1 tentative accessor safety** — tentative reconciles now emit the + TENTATIVE VIEW on the record's own channel at lane timing (they + previously never told the channel at all — effects saw the view, patches + did not); the optimistic drain probes non-forced payloads and demotes + getter-bearing views IMMEDIATELY (the global render queue is stashed + in-flight, so deferral would postpone visibility to settle). +- **P1 stamp granularity** — forced entries clear only the stamp they hold + (lane vs settle); transition merges retarget/dedupe forced stamps. +- **P1 isWrappable guard** on captured-record binds; **P1 server entry** + exports patchDriver (notSup, same class as template) and rowProof + (identity — callable in isomorphic modules); **P1 function + intermediates** demote conservatively (accessor carriers, never plain); + **P1 safe-integer keys** only (both compilers — 1e20 formats divergently + through the i64 mirror). +- **P2 unchanged reconciles** don't bubble (identity-skip mirrored at the + top); **P2 merge** repairs forced stamps (above). + +CI: the attribution warn-count flake is fixed two-sided (the harness mutes +its expected demotion warnings; the attribution test counts its own +diagnostic's warns, not the process-global total). + +dbmon: 6.3 / 2.1 / 0.6 — within noise of rounds 7-8. Sizes ratcheted with +dated notes (~+0.2-0.4 kB per tier). + +**Architectural note (for the next design conversation):** most P1s across +rounds 7-9 are visibility-rule divergences — the channel bypasses the +reactive graph, so every visibility rule nodes enforce implicitly is +replicated by hand at each seam. Two structural candidates are on the +table: centralizing visibleView()/shouldDeliver() decisions, or +NODE-DRIVEN DELIVERY (one hidden node per patched record; compiled bodies +unchanged) which would inherit transition/lane/hold timing by +construction. The latter is being prototyped before the next round. + +--- + +## Round 8 (response to the 8-finding audit) + +- **P1 admission nested getters** — `patchableRaw` deep-probes the manifest + at registration; getter-bearing paths take the tracked fallback from the + start. Admission also reads the COMMITTED backing (root cause under the + P2 duplicate-apply finding: `pb ?? v` leaked deferred transition drafts to + mid-transition mounts). +- **P1 structural builds** — rows bind their operation's CAPTURED record + (`patchProxyFor` resolves raws through the list target's wrap, riding a + createTarget-installed hook: a direct wrapNext import would retain the + whole trap engine in store-less bundles — +3.7 kB, caught by the size + gate). +- **P1 tentative ancestor bubble** — lane-timed forced entries for + in-flight visibility PLUS settle-held twins (revert/landing re-applies + resolved truth to ancestor expressions). +- **P1 renderer surface** — `rowProof` on createRenderer (identity — + universal keeps classic lists), Renderer type, README; contract test pins + the whole patch tier. +- **P1 decimal keys** — non-integer numeric keys statically ineligible + (both compilers), same class as dotted string keys. +- **P2 duplicate applies** — generation-stamped entries: consumers + registered after emission (initialized from that state) are skipped; + transition releases exempt themselves (their late consumers saw the + pre-commit view). The fold path was verified UNREACHABLE for this + (the walk queues value entries before structural ops by design — test + pins it); the cross-queue optimistic window was real. +- **P2 forced coalescing** — one forced ancestor re-apply per container per + batch (`qf`/`qfo` stamps), effect parity. +- **P2 universal untrack** — commit phase untracked, matching web. + +dbmon: identical to round-7 finals (mount 6.4, tick 2.0, partial 0.5 — +quiet machine, both orders). Byte cost ~+0.3 kB store apps / +0.3 kB patch +tiers, ratcheted with notes. + +--- + +## Round 7 (response to the 9-finding audit) + +All nine findings verified against a RED invariant harness first (commit +order: harness → fixes), then fixed: + +- **P1 recording completeness** — runtime recording replaced by a + compiler-emitted STATIC read manifest (both compilers, hoisted `_mf$` + arrays, interned by identity at registration). Deep paths probe as a + prefix tree at adoption gates and forced applies; targeted reconciles now + bubble ancestors; forced applies for deep-path channels read through the + proxy (eager adoption does not rewrite ancestor raw slots). Bare-subject + reads and dotted string keys are statically ineligible. Residue: + manifest-less hand-written `registerPatch` callers keep best-effort + recording (documented). +- **P1 sticky sc** — adoption gates probe the emission's ACTUAL object + (incoming/just-committed), statelessly. +- **P1 prototype getters** — non-plain prototypes reject admission (class + instances keep tracked effects); overlay drafts still work over class + prototypes (own-key scan semantics unchanged). +- **P1 renderer surface** — `Renderer` type + README + `createRenderer` + re-export list now include `patchDriver`; contract tests pin compiled + imports ⊆ documented surfaces per generate mode. (Verified: universal + output never imports patch symbols; the link-break class was dom-generate + custom runtimes, same as any dom runtime surface addition.) +- **P1 structural late registrants** — structural queues snapshot entry + refs at emission (unbinds still sever via shared `u` marks); VALUE queues + are the documented dual — they resolve the consumer list LIVE at drain + (fixes the merge/recreated-list miss) and coalesce across same-flush + releases (effect-parity oracle tests). +- **P1 slot rebuild atomicity** — build-before-destroy; a throwing + replacement leaves the old row mounted AND live. +- **P2 hydration region** — a throwing claim removes completed, claimed, + and trailing server rows. +- **P2 stamp collision** — normal/optimistic queues coalesce on separate + stamp pairs. +- **P2 merge collision list** — subsumed by live value-list resolution. + +New permanent infrastructure: `patch-invariants.test.ts` (channel +contracts), `for.patchinvariants.spec.tsx` + hydration slice (driver throw- +atomicity matrix over every build entry point), `renderer-contract.test.js` +(imports ⊆ surface), and PINV-1..3 per-flush ledger checks wired into the +`__TEST__` invariant infra. + +Perf: quiet-machine dbmon tick 2.1 ms (round-6: 1.9; classic: 6.7) — the ++0.2 is the deep-path probe, taken twice through the profiler (manifest +interning + prefix-tree probing + leaf inlining recovered the initial 2.5). +Mount ~7.2–7.5 vs 6.4 pre-audit; the final hoisting pass eliminated the +remaining intern misses per the profile but needs a quiet-machine +confirmation run (a parallel build was loading the box). + +--- + +# Original brief — round 6 + default flip + +**Scope:** `next..patch-hardening-r6`. Two bodies of work: (A) fixes for the +six round-6 findings against `adf10e9b`, (B) the patch-mode DEFAULT-ON flip +(both compilers). Everything below states what changed, the soundness claim, +and — most useful to attack — the *reasoning* each claim depends on. + +## A. Round-6 findings + +### A1. Prod-sound getter demotion (was: dev-only — reverted) +The dev-only trade is gone. Design: **accessed-key recording + bounded +probes**. +- `patchDriver` (web) runs the registration-time initial force-apply through + a recording `Proxy` and hands the read set to `registerPatch(record, fn, + keys)`. Hydration registrations (no initial apply) record at their FIRST + drain apply instead (`applyEntries`, `entry.k`). +- The channel unions keys into `pc.ak` (deduped array). Both adoption + emission seams (reconcile walk, fold commit) gate on `targetKeysPlain`: + probe ONLY `ak`'s keys for own getters on the adopted backing; `ak === null` + (registered-but-never-applied) falls back to the full scan. +- **Claim to attack #1:** the recorded set is COMPLETE because patch bodies + are grammar-guaranteed sequences of `if (force || n.k !== p.k) { write + reading n.k }` — under force the COMPARES short-circuit but every WRITE + executes and reads its keys; under non-force first applies the compares + read both sides. Is there any compiled body shape whose key read is + conditional on something other than `force`/compare? (Eligibility grammar: + pure member chains of one subject — check `wrapPatchMode` emission shapes.) +- **Claim to attack #2:** `ak` is a UNION across registrations and never + shrinks; adoption probes are `O(|ak|)` per patched-record adoption. + Measured on dbmon: tick 1.8 ms vs 1.7 no-check vs 1.9 full-scan (midday + machine; re-measure welcome). + +### A2. Transition-merge collisions coalesce (`scheduler.ts`) +Same-channel entries in BOTH stashes now merge to ONE entry that resolves +`next` LIVE at drain (`entry.t = pc.t`, drain reads `t.pb ?? t.v`), keeping +the destination's `prev`. **Attack:** the `prev` choice — both captures are +committed pre-write values of the same record; are there merge orders where +they differ and the kept one is wrong? Also the opaque backref contract +(core mutating `entry.pc.qa/qe/t`) — is any other holder of these fields +surprised? + +### A3. Row/slot queued work respects unbinds (`patch.ts`) +Emitters no longer clone wrapper entries; queue items carry the LIVE +registration list plus payload (`ops` / `si`), dispatched by +`applyStructural` with the same unbound-mark (`entry.u`) + disposed-owner +checks and error routing as value patches. **Attack:** ordering — value +entries and structural entries interleave in emission order; the live-list +change means late registrations see earlier-queued structural work. Driver +double-applies? (registerRowOps consumers are driver-internal only.) + +### A4. Dispatch windows (`applyEntries`) +Snapshot for multi-consumer lists; FIXED length window + undefined guard for +the single-consumer alias (a callback registering another patch mid-dispatch +must not run it in the same drain — it just received its initial apply). +**Attack:** entry removed mid-dispatch shifts the aliased single-entry list — +covered by the undefined guard? + +### A5. Initial list construction severs on throw (`patch-driver.ts`) +Client + hydration first-build loops now sever completed rows' registrations +AND the throwing row's partials, remove inserted/claimed DOM (including the +claimed server row under hydration), dispose the list owner, rethrow. +**Attack:** `patchCount` accounting across sever-then-rethrow; boundary +remount re-engagement. + +### A6. Failed-apply recovery is ACTIVE (`patch-driver.ts`) +`resyncNeeded` + slot ticks now trigger an immediate identity resync (deep +value-only recovery still waits for the next list event — documented). +Identity swaps register the new subject's channels BEFORE the apply. +**Attack:** resync loops when the poison row keeps throwing (flag stays set, +retried per event — bounded?). + +## B. Default flip (patch mode ON) + +- Babel `config.patchDriver: "patchDriver"`; Rust `patch_driver` resolves + `Wrapper::Default` like every other wrapper (opt out: `false`). The JS + loader already normalizes `true`/absent. +- All Babel dom fixture outputs regenerated; parity tier `dom-patch` + replaced by `dom-nopatch` (fences the explicit opt-out — plain `dom` now + covers patch grammar). Byte parity previously held on the whole corpus + with patch on (108/108, zero ratchet files). +- **Attack:** anything still assuming dormancy — treeshake/metafile tests, + size-scenario notes, docs, the `driveList` "compiler is default-on" + comments (now true), octane fixture flags (now redundant), the loader's + `patchDriver: true` normalization interacting with default-on. +- Known accepted costs (ruled by Ryan at flip-preview time): ~+1.5 kB brotli + typical apps (value tier), ~+3.6 kB store-list apps, portal-swarm ~5% + effect-fallback tax on signal-only mount churn. + +## Standing accepted trades (pre-existing, documented) +- Keyless rows: adoption pairs positionally, ops rebuild — content-correct, + retention churn (design §21a). +- Demoted LIST-ROW bodies re-drive under the list owner (per-row severing + lost for demoted rows) — §20. +- Deep value-only recovery after a failed apply waits for the next list + event (A6). + +## Test map +- `packages/signals/tests/store/patch-channel.test.ts` — channel semantics, + all rounds' regressions (31+ tests). +- `packages/web/test/for.patchlist.spec.tsx` — driver incl. exception + atomicity, severing, recovery (15+ tests). +- `packages/web/test/for.equivalence.spec.tsx` — driver ≡ classic matrix. +- `packages/compiler/__tests__/parity*` — Babel↔Oxc byte parity (dom = + patch-on, dom-nopatch = opt-out). diff --git a/packages/signals/DESIGN-PATCH-CHANNEL.md b/packages/signals/DESIGN-PATCH-CHANNEL.md index 9551d98c3..65438feab 100644 --- a/packages/signals/DESIGN-PATCH-CHANNEL.md +++ b/packages/signals/DESIGN-PATCH-CHANNEL.md @@ -86,6 +86,148 @@ Deferred from the audit's secondary list: staged exception-safe applyOps @ts-nocheck on patch-driver.ts, and a versioned internal compiler entry for the runtime primitives. +## 24. Entangle-tear consolidation, pass 1 (2026-09-01) — visibility moves to the seam + +Held #3091 out of rc.5 over an external probe: a `registerPatch` consumer +on a confirming foreign store observed the new world mid-`until()`-hold +(patch DOM showing the confirmation beside value bindings correctly +holding). A second structural review named the root pattern: the channel +re-decides visibility with private bookkeeping, so every new scheduler +behavior needs a hand-written mirror. This pass moves the decisions to +the seam. THREE mechanisms, ZERO core-scheduler changes: + +- **`nodeValue` held-truth committed arm** (store.ts): the O6 single-home + rule ("committed truth lives in the backing; `_value` is never served") + assumed backing and nodes converge at flush. The steal extends node + parking PAST the flush while the backing committed eagerly — for the + hold's duration, committed truth exists ONLY in `_value`. Untracked + readers (userland `untrack()`, the channel's visible view) fell through + to the backing and saw the future while every tracked reader held. + The held-truth refusal arm now serves `_value`: one seam, both reader + kinds agree. This was a USER-VISIBLE bug beyond the channel. +- **Parked-truth deferral, value channel** (patch.ts): an optimistic- + family delivery whose record's truth is parked under a not-done + transaction defers WITHOUT consuming (`dv` stays behind `bc`) unless a + live override justifies a lane frame (classic shows the draft; so do + we). The holder is named by the nodes' own `_transition` — the steal + re-stamps it, merges canonicalize it, so the deferral follows the hold + wherever the scheduler moves it with no steal-specific code. A redrive + stashes on the holder's `_heldPatches` and re-bumps at its commit. + Transaction stamps could NOT gate this: landings emit at microtask time + with no ambient transaction. Only `t.fam?.opt` gates the probe — plain + bumps park the delivery signal itself, so their wakes are commit-timed + by construction (the dbmon path never probes). +- **Parked-truth re-stash, structural channel** (patch.ts drain): landing + row ops rode a DIFFERENT carrier than the landing's node staging (the + fold's transition committed early; the nodes parked ambient and were + stolen later) — carrier-side mirrors (a steal stash-move, a forwarding + pointer) were built, WORKED, and were then DELETED: probing parked + truth at APPLY time subsumes them, ordering-free. Items re-stash on the + holder and release at the reveal; version chains absorb the replay. + +Cost: +36 B createStore (the seam arm), +294 B patchDriver tier, +221 B +list tier. The probes (value + structural) are pinned as REGULAR tests. +Remaining for pass 2 (before default-on re-proposes): visible-view +snapshot compare to retire the bt/bo dedup stamps, and the auditor's +node-attached structural stash evaluation. + +## 23. Fold audit 6 (2026-09-01) — dn override lifecycle + matcher key spaces + +Three findings at `c48aed8b`; the first one also indicted the gate +discipline (the suite had been EXITING NONZERO while piped summaries +showed green — exit codes are now part of every gate). + +- **INV-6: dn overrides outliving their lane (P1).** Optimistic bumps arm + an override on the delivery signal so in-flight visibility rides the + lane — but the settle-drain's revert resync (`_clearOptimisticStores` → + `emitPatchOptimistic`) arms a FRESH override after that lane already + resolved. With another flight still open (the equal-landing tests keep + the projection's fetch lane alive), the arm lands there and never + reverts in the window: an override at quiescence. Fix at the delivery + commit: dn is PURE NOTIFICATION, so a delivery CONSUMES any override on + it (drop like `resolveOptimisticNodes` — `_overrideValue`, lane, owner — + but `_transition` is left alone: a parked plain write's commit + bookkeeping keys off it). No dn override outlives its delivery, + independent of any lane's lifecycle. +- **Mixed primitive/object key collision (P1).** Fold-audit-5's primitive + lane keyed primitive rows BY VALUE into the SAME map where object rows + key through `keyFn` — whose result is typically a primitive id. `5` and + `{ id: 5 }` collided; a moved primitive could be handed an object row's + source. `buildRowOps` now holds TWO key spaces (object-keyed / + value-keyed); a row only ever matches its own kind. The + `buildIdentityRowOps` prefix scan had the same disease worse: `keyFn` + probing a primitive yields `undefined` on BOTH sides, so two DIFFERENT + primitives falsely aligned and real changes escaped the ops window. The + scan is now kind-aware: objects compare by key, primitives by value, + kind mismatch breaks. +- **`undefined` moves rebuild (P2).** `undefined` rows (and sparse holes) + were skipped by both map build and lookup. A sentinel (`UNDEF_ROW`) + makes them first-class match participants in the value space. + +Cost: +31 B store-family app, +47 B rowProof tier. Full signals suite now +exits 0 (the INV-6 violation had been failing the run since the +equal-landing tests landed). + +**6b — the driver's rebuild check (P1 follow-up).** The web driver's +refRebuild test used strict `!==` against a source the matcher had just +proven by Map equality — so a MOVED NaN row rebuilt, losing node/focus +identity classic keeps (classic's `newIndices` Map has SameValueZero for +free; nobody ever spent bytes on NaN there). One inline SameValueZero +comparison (12 B, rowProof tier). Lesson repeated from the key spaces +above: everywhere a Map proves a match, a strict recheck is a +contradiction waiting for an auditor. + +## 22. Node-delivery mount pass (2026-08-30) — pay-for-use machinery + +The node-delivery prototype's remaining dbmon gap vs the channel was mount +(+1.3 ms/1000 rows) and, apparently, unmount. Three changes, one finding: + +- **`deliveryEffect` primitive** (`core/effect.ts`): a detached + single-source render effect — `createEffectNode` + `recompute` + + initial run, no `createRoot`, no owner. The channel is shared + infrastructure and owner-less BY DESIGN (errors route per-entry to + registrant owners), so the generic path's root allocation and + NO_OWNER_EFFECT diagnostic were pure overhead. This alone recovered + little (~0.1 ms): the node/signal/ext allocations dominated, not the + root. +- **Lazy creation at first bump**: the delivery signal + effect are + built by the first consumer-visible emission (`bumpDelivery`), not at + registration. A mounted list that never updates allocates nothing. + Soundness pin: once built, machinery is NEVER torn down — a held + write bumping during an unbound consumer window must still deliver to + a consumer registering before the settle (the old dispose-on-empty + kept the signal for exactly this reason; keeping the node too closes + the same window and makes row re-binding free). Channels never built + skip bumps silently: a first-ever consumer's `entry.pv` baseline + already reflects those writes. +- **No dispose on last unbind**: `pc.p = null` is the only teardown; + the node takes the inert `p === null` return on later bumps and the + record's death releases the subgraph. Perf-over-memory ruling + (records outliving consumers retain ~200 B of dormant machinery). + +Finding (refined after tracing): the bench's unmount split (channel +0.3 ms vs node 1.5 ms) was a HARNESS ARTIFACT, and the mechanism is NOT +a collection landing in the timed window — CDP tracing shows ZERO GC +events inside 11/12 slow unmounts and `usedJSHeapSize` never moves. +Real teardown is **0.2–0.3 ms/1000 rows on both builds** (the fast +samples). The slow state (~1.6–2.3 ms, both builds, octane too) is a +CONCURRENT MAJOR-GC CYCLE in progress: repeated 1000-row mounts with +the bench's 5 ms yields leave no idle for incremental marking/sweeping +to finish, and once a background cycle is live the teardown's +pointer-heavy unlink walk pays the write-barrier tax on every store. +Proof: inserting 800 ms idles between cycles snaps samples back to +0.2–0.3 ms, then they degrade again as allocation re-accumulates. The +bench column therefore measures "was the page inside a background GC +cycle during the sample window" — which side a build lands on is +threshold luck, not disposal cost. + +dbmon after the pass (same session, quiet machine, both builds through +the identical Oxc default-on fixture): mount 6.4 (channel 6.4), tick 2.1 +(2.2), partial 0.6 (0.5), remount 4.6 (5.0), sort 2.2 (2.4). Node +delivery now dominates or ties the channel on every op. All gates green +(1,415 signals / 683 web / 352 SSR / 150 hydrate / 32 turbo tasks, all +size scenarios under limits). + ## 21. Re-audit rounds 2–3 (2026-08-27) — adoption seams, key equality, recovery Round 2 (six findings, all real): adoption seams demote accessor-bearing diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 902f71b7f..3ee6c302d 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -25,7 +25,7 @@ import { } from "./core/index.js"; import type { IQueue, Signal } from "./core/index.js"; import { emitDiagnostic } from "./core/dev.js"; -import { haltReactivity, schedule } from "./core/scheduler.js"; +import { GlobalQueue, haltReactivity, schedule } from "./core/scheduler.js"; import { accessor, type Accessor } from "./signals.js"; export interface BoundaryComputed extends Computed { @@ -377,6 +377,26 @@ export class CollectionQueue extends Queue { } } +// Boundary hold probe (round 10, P1-4): the patch channel's delivery fans +// out per registrant and must defer entries whose owner queue is holding +// its render effects — the same gate CollectionQueue.run applies, walked up +// the queue chain. Installed here so apps without boundary machinery pay +// nothing (null probe = nothing can hold). Raw `_value` reads: the probe +// runs inside a dispatching effect and must not subscribe. +GlobalQueue._queueHeld = (queue): boolean => { + let q: any = queue; + while (q != null) { + if ( + q._disabled !== undefined && + q._disabled._value === true && + (!_revealUsed || q._collapsed._value === true) + ) + return true; + q = q._parent; + } + return false; +}; + function createCollectionBoundary( type: number, fn: () => T, diff --git a/packages/signals/src/core/attribution-hooks.ts b/packages/signals/src/core/attribution-hooks.ts index 37528c988..1176a85b4 100644 --- a/packages/signals/src/core/attribution-hooks.ts +++ b/packages/signals/src/core/attribution-hooks.ts @@ -55,6 +55,59 @@ export interface AttributionHooks { * from the node's state against its asyncStart snapshot. */ asyncEnd(el: Computed, prev: unknown, value: unknown, direct: boolean): void; + /** + * A patched store record's visibility transitioned. `dn` is the record's + * delivery signal — the ONLY graph node its template consumers subscribe + * to, so this is where cause chains for patch-applied DOM updates anchor. + * Called AFTER the signal write (the engine's own `write` stamp carries a + * meaningless counter transition; this re-stamp names the record and, for + * self emissions (`withValues`), previews the record transition). `name` + * is the record's store path ("store.rows.3"). Ancestor bubbles pass + * `origin` — the ORIGINATING child's delivery signal (its fresh stamp + * becomes the cause) or its path when the child has no channel — so + * chains report the true write source, not the bubbled ancestor. + */ + patchEmit( + dn: Signal, + name: string, + prev: unknown, + next: unknown, + withValues: boolean, + origin?: Signal | string | null + ): void; + /** + * A patch-family channel is about to dispatch to `count` consumers. + * The engine applies the SAME wide-write policy (threshold, doubling + * memo, metadata) it applies to graph subscriber counts. `key` is the + * memo identity (the delivery signal for value channels, the consumer + * list for structural ones — which have no signal to stamp); `dn` when + * present provides the record-path name. + */ + patchDispatch(key: object, count: number, channel: string, dn: Signal | null): void; + /** + * A COALESCED bump (pending-dedup absorbed the signal write) with a + * different origin: append it to the pending stamp's causes so chains + * report every child that fed the delivery, not just the first. + */ + patchOrigin(dn: Signal, origin: Signal | string): void; + /** + * The channel's pending stamp was consumed by a delivery — later + * self-emissions must not inherit its accumulated child causes. + */ + patchDelivered(dn: Signal): void; + /** + * A STRUCTURAL (row-ops / slot-patch) dispatch completed. Structural + * consumers run in commit drains, not effects — no rerun event exists, + * so the engine records a synthetic one. `causeDn` is the record's + * delivery signal when value machinery exists (its stamp is the cause). + */ + patchStructural( + name: string | null, + count: number, + channel: string, + causeDn: Signal | null, + ms: number + ): void; } export let attrHooks: AttributionHooks | null = null; diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index 4fd6329ab..429478402 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -222,6 +222,26 @@ export interface WriteCost { } const scopeCosts = new Map, ScopeCost>(); const writeCosts = new Map(); +/** Wide-dispatch doubling memos for patch-family channels (keyed by the + * delivery signal or, for structural channels, the CHANNEL — emission + * snapshots slice consumer lists, so list identity is per-flush). */ +const patchDispatchWarned = new WeakMap(); +/** Delivery-consumed patch stamps: re-stamps after these carry no causes + * forward (round 10.12). */ +const consumedPatchStamps = new WeakSet(); + +/** Append a bubble origin to a stamp's causes, NAME-deduped (round 10.12: + * one child writing twice in a batch is one cause, and synthesized + * path-string origins never collide with themselves). */ +function appendPatchOrigin(rec: ChangeRecord, origin: unknown): void { + const oc = + typeof origin === "string" + ? ({ seq: rec.seq, kind: "write", name: origin } as ChangeRecord) + : (origin as AttributedNode)._devChange; + if (oc === undefined) return; + const causes = (rec.causes ??= []); + if (!causes.some(c => c.name === oc.name)) causes.push(oc); +} function rootsOf(causes: ChangeRecord[], out: Set): void { for (const c of causes) { @@ -322,7 +342,7 @@ function checkWideWrite( const message = `[WIDE_WRITE] ${verb} "${nodeName(node)}" reached ${subs} subscribers — every one ` + `re-runs this flush. If consumers ask keyed questions of this value (for example every ` + - `row comparing against one selected id), invert with createSelector or createProjection ` + + `row comparing against one selected id), invert with a per-key store or projection ` + `so only the keys whose answer flipped update.`; emitDiagnostic({ code: "WIDE_WRITE", @@ -741,6 +761,100 @@ const engineHooks: AttributionHooks = { write(el, prev, value) { stampWrite(el, "write", prev, value); }, + patchEmit(dn, name, prev, next, withValues, origin) { + // Patched records have no key nodes — the delivery signal is the chain + // anchor. Name it with the record's store path and replace the counter + // stamp the plain `write` hook just left, so "why did this run" for a + // patch delivery reads as the RECORD's transition, not `5 → 6`. + (dn as AttributedNode & Signal)._name = name; + const prevStamp = (dn as AttributedNode)._devChange; + if (withValues) stampWrite(dn, "write", prev, next); + else stampWrite(dn, "write"); + const rec = (dn as AttributedNode)._devChange!; + // A re-stamp within one PENDING window carries the accumulated child + // causes forward (round 10.12): a parent's self-emission must not + // erase the children that already fed this delivery. Consumed stamps + // (patchDelivered) carry nothing. + if ( + prevStamp !== undefined && + prevStamp.causes !== undefined && + !consumedPatchStamps.has(prevStamp) + ) { + const causes = (rec.causes ??= []); + for (const c of prevStamp.causes) if (!causes.some(x => x.name === c.name)) causes.push(c); + } + // Ancestor bubbles carry their ORIGIN (round 10.10): the chain must + // report the child whose write bubbled, not the ancestor it reached. + if (origin != null) appendPatchOrigin(rec, origin); + }, + patchDispatch(key, count, channel, dn) { + // SAME policy as graph wide-writes (round 10.10; structural channels + // 10.11): threshold from options.wideWrites, doubling memo, + // subscriber metadata — a channel consumer list IS its record's + // fan-out, invisible to `_subCount`. Structural channels have no + // signal to stamp; their memo rides the consumer-list identity. + const limit = options.wideWrites; + if (typeof limit !== "number") return; + const warned = patchDispatchWarned.get(key) ?? 0; + if (count < limit || count < warned * 2) return; + patchDispatchWarned.set(key, count); + const name = dn !== null ? `"${nodeName(dn)}"` : `a ${channel} channel`; + const message = + `[WIDE_WRITE] write to ${name} dispatched to ${count} ${channel} consumers — every ` + + `one applies this flush. If consumers ask keyed questions of this record, invert ` + + `with a per-key store or projection so only the keys whose answer flipped update.`; + emitDiagnostic({ + code: "WIDE_WRITE", + kind: "perf", + severity: "warn", + message, + nodeName: dn !== null ? nodeName(dn) : undefined, + data: { subscribers: count, write: "patch", channel } + }); + console.warn(message); + }, + patchOrigin(dn, origin) { + // Coalesced bump (round 10.11, P2): the pending stamp gains every + // origin that fed it, not just the first child's. + const rec = (dn as AttributedNode)._devChange; + if (rec === undefined) return; + appendPatchOrigin(rec, origin); + }, + patchDelivered(dn) { + const rec = (dn as AttributedNode)._devChange; + if (rec !== undefined) consumedPatchStamps.add(rec); + }, + patchStructural(name, count, channel, causeDn, ms) { + // Synthetic rerun event (round 10.12, P2): structural consumers run in + // commit drains — no effect node, no recompute frames — but a delivery + // to N list consumers is exactly the work rerun events exist to + // witness. Costs/hot-scope checks are node-keyed and skipped. + const causes: ChangeRecord[] = []; + if (causeDn !== null) { + const rec = (causeDn as AttributedNode)._devChange; + if (rec !== undefined) causes.push(rec); + } + const event: RerunEvent = { + run: ++runSeq, + nodeRuns: 0, + nodeKind: "effect", + nodeName: `${channel}(${name ?? "structural"})`, + node: null as unknown as Computed, + causes, + depCount: 0, + depsAdded: [], + depsRemoved: [], + selfMs: ms, + totalMs: ms, + changed: true, + phase: "plain", + held: false + }; + history.push(event); + if (history.length > options.historyLimit) history.shift(); + for (const listener of listeners) listener(event); + if (options.log) console.log(formatRerun(event)); + }, refreshed(el) { stampWrite(el, "refresh"); }, diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index 833ef31db..6f8fcea55 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -283,7 +283,7 @@ export function getObservers(node: Signal | Computed): Computed[] return observers; } -function shouldWarnGraphSize(count: number): boolean { +export function shouldWarnGraphSize(count: number): boolean { return count >= GRAPH_SIZE_WARN_AT && (count - GRAPH_SIZE_WARN_AT) % GRAPH_SIZE_WARN_EVERY === 0; } diff --git a/packages/signals/src/core/effect.ts b/packages/signals/src/core/effect.ts index dd82fbfc4..6096e1e39 100644 --- a/packages/signals/src/core/effect.ts +++ b/packages/signals/src/core/effect.ts @@ -78,6 +78,27 @@ export function effect( } } +/** + * Detached single-source render effect (the store patch channel's delivery + * primitive). Owner-less BY DESIGN — the channel is shared infrastructure + * across boundaries, errors route per-consumer inside `commit`, and the + * caller owns disposal via the returned node (`dispose(node)`). Skips the + * generic path's root/owner allocation and the NO_OWNER_EFFECT diagnostic, + * which is a true positive everywhere else. + */ +export function deliveryEffect(compute: () => void, commit: () => void): Computed { + const node = createEffectNode( + compute as (prev?: unknown) => unknown, + commit as (val: unknown, prev: unknown) => void, + undefined, + EFFECT_RENDER, + undefined + ) as Effect; + recompute(node, true); + runEffect(node); // initial run: dispatch dedup makes it a subscribe-only pass + return node; +} + function notifyEffectStatus(this: Effect, status?: number, error?: any): void { // Use passed values if provided, otherwise read from node const actualStatus = status !== undefined ? status : this._statusFlags; diff --git a/packages/signals/src/core/invariants.ts b/packages/signals/src/core/invariants.ts index 2c812ba7b..1905cf51e 100644 --- a/packages/signals/src/core/invariants.ts +++ b/packages/signals/src/core/invariants.ts @@ -41,7 +41,12 @@ export const InvariantHooks: { pendingProbeActive: (() => boolean) | null; /** Fresh oracle for what an isPending companion SHOULD read right now. */ computePendingState: ((node: AnyNode) => boolean) | null; -} = { pendingProbeActive: null, computePendingState: null }; + /** Patch-channel quiescence check (PINV-1..3, re-audit 7): installed by + * store/next/patch.ts when the channel first arms (pay-for-use — apps + * without patches never load it). Asserts registration accounting, + * cleared coalescing stamps, and drained apply queues. */ + patchQuiescent: (() => void) | null; +} = { pendingProbeActive: null, computePendingState: null, patchQuiescent: null }; // INV-7: nodes that received a transition-held `_pendingValue`. A node still // holding one at quiescence with no queued commit is a leak (#2827 class). @@ -268,6 +273,7 @@ function censusRecord(key: string): void { */ export function devCheckQuiescent(isQueuedForCommit: (node: AnyNode) => boolean): void { if (!__TEST__) return; + InvariantHooks.patchQuiescent?.(); for (const node of heldPendingNodes) { if (isDisposed(node) || node._pendingValue === NOT_PENDING) { heldPendingNodes.delete(node); diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 14b81e49a..41f01e7be 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -232,17 +232,13 @@ function mergeTransitionState(target: Transition, outgoing: Transition): void { const heldPatches = (outgoing as any)._heldPatches as unknown[] | undefined; if (heldPatches !== undefined) { (outgoing as any)._heldPatches = undefined; - let dest = (target as any)._heldPatches as unknown[] | undefined; - if (dest !== undefined) dest.push(...heldPatches); - else dest = (target as any)._heldPatches = heldPatches; - // Retarget the entries' coalescing stamps to the surviving stash - // (opaque backref contract with store/next/patch.ts): without this a - // post-merge emission misses the stamp and pushes a SECOND entry — - // the record's patch applies twice at commit (re-audit 5, P1-2). - for (let i = 0; i < heldPatches.length; i++) { - const pc = (heldPatches[i] as any).pc; - if (pc !== undefined && pc.qe === heldPatches[i]) pc.qa = dest; - } + const dest = (target as any)._heldPatches as any[] | undefined; + // Only STRUCTURAL payloads ride the held stash under node delivery + // (values ride per-record signals, which the scheduler merges + // natively) — a plain move suffices; the per-channel dedup/stamp + // repair of the queue era is gone with the queues. + if (dest === undefined) (target as any)._heldPatches = heldPatches; + else dest.push(...heldPatches); } // Legal transfer, not a new registration: entries move between transitions. if (__DEV__) beginAsyncReporterWrites(); @@ -581,6 +577,13 @@ export class GlobalQueue extends Queue { * apply at lane-effect timing — visible in flight, unlike the regular * effect queues an action stashes. Injected; null when unused. */ static _drainPatchOptimistic: (() => void) | null = null; + /** Boundary hold probe (boundaries.ts; round 10, P1-4): is this queue — + * or any ancestor — currently holding its render effects (pending + * Loading / collapsed reveal)? Patch delivery fans out per registrant + * and must defer entries whose owner queue is held, exactly like the + * render effect the consumer replaced. Injected by boundaries; null = + * no boundary machinery loaded = nothing can hold. */ + static _queueHeld: ((queue: IQueue) => boolean) | null = null; static _gatedRead: | ((el: Signal, owner: OptimisticNode, c: Computed) => boolean) | null = null; diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 3f9860e34..4c02cd953 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -34,9 +34,15 @@ export { registerPatch, registerRowOps, registerSlotPatchNext as registerSlotPatch, - patchableRaw + patchableRaw, + patchProxyFor } from "./next/patch.js"; -export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily } from "./next/store.js"; +export { + storeIsShallow, + storeHasFamily, + storeHasOptimisticFamily, + storeFamilyOf +} from "./next/store.js"; export { createOptimisticStoreNext as createOptimisticStore } from "./next/optimistic.js"; /** Public createStore: plain form `(init, options?)` and derived writable diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index b645a8b57..47660bc69 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -56,6 +56,7 @@ import { } from "../store.js"; import { runProjectionComputedNext } from "./projection.js"; import { + currentFoldEpoch, bumpDeep, authoritativeRead, getHasNode, @@ -118,12 +119,28 @@ function installNextBlockedHalf(): void { const overlaid = t?.fam?.overlaid as Set | undefined; if (overlaid !== undefined) { for (const ot of overlaid) { - if (ot.pc !== null && ot.pc.p !== null) patchHooks!.emitPatchOptimistic(ot, null, null); + // ONE emission (round 10.5, F7): the primitive self-gates on + // consumers/machinery and bubbles ancestors internally — + // compiled bodies reading INTO reverted children through + // nested chains are reached without a duplicating ancestor + // call on the undeduped lane path. Gating on the LOCAL + // consumer list here silenced ancestor channels (round 10, + // P1-3). + if (patchHooks !== null) patchHooks.emitPatchOptimistic(ot, null, null); // Row-ops resync (family increment 2): reverts flip node values // back engine-natively; a driven list must rebuild retention by // row identity against the post-revert view (resolved from the - // target at drain — overrides are gone by then). - if (ot.pc !== null && ot.pc.ro !== null) rowHooks!.emitRowOpsOptimistic(ot, null, null); + // target at drain — overrides are gone by then). NOT for + // targets with a pending STAGED fold (fold audit P1): the + // fold's own emission carries the reveal — a second rebuild + // here rebuilt the same rows again and lost DOM identity/focus. + if ( + ot.pc !== null && + ot.pc.ro !== null && + ot.sf !== true && + ot.rf !== currentFoldEpoch() + ) + rowHooks!.emitRowOpsOptimistic(ot, null, null); // Keyset resync (classic channel twin): the keyset node's own // revert can compare EQUAL (a landing's bump matched the // tentative bump) while the arrangement underneath changed — @@ -339,6 +356,19 @@ function runFolded(txn: Transition, op: () => void): void { } } +/** Staged descent guard (fold audit P1): recursion may only continue + * through a live draft PROXY — a raw child (a SHALLOW store's row) has no + * staging trap under it, and merging into it would mutate committed truth + * in place: visible to every reader BEFORE the reveal, with no value/slot + * notification ever. Raw children replace their slot wholesale instead — + * the staged slot write parks and reveals like any other. */ +function stagedChild(cur: any, key: PropertyKey): any | undefined { + const child = cur[key]; + return child !== null && typeof child === "object" && (child as any)[$TARGET] !== undefined + ? child + : undefined; +} + /** Keyed identity-preserving deep merge through live draft proxies — the * staged twin of the adoption walk. Reads see the pending backing (staged * view), so consecutive landings during one hold compose; key-matched rows @@ -401,7 +431,12 @@ function stagedApply(cur: any, incoming: any, keyFn: KeyFn | null): void { } if (matched !== undefined) { if (unwrapValue(cur[i]) !== matched) cur[i] = matched; - stagedApply(cur[i], nv, keyFn); + const child = stagedChild(cur, i); + if (child !== undefined) stagedApply(child, nv, keyFn); + else { + const pv = unwrapValue(cur[i]); + if (!isEqual(pv, nv) && !targetsEqual(pv, nv)) cur[i] = nv; + } } else { const pv = unwrapValue(cur[i]); if (!isEqual(pv, nv) && !targetsEqual(pv, nv)) cur[i] = nv; @@ -412,9 +447,11 @@ function stagedApply(cur: any, incoming: any, keyFn: KeyFn | null): void { const nv = incoming[i]; const pv = unwrapValue(cur[i]); if (pv === nv) continue; - if (isWrappable(nv) && isWrappable(pv) && Array.isArray(nv) === Array.isArray(pv)) - stagedApply(cur[i], nv, keyFn); - else if (!isEqual(pv, nv) && !targetsEqual(pv, nv)) cur[i] = nv; + if (isWrappable(nv) && isWrappable(pv) && Array.isArray(nv) === Array.isArray(pv)) { + const child = stagedChild(cur, i); + if (child !== undefined) stagedApply(child, nv, keyFn); + else if (!isEqual(pv, nv) && !targetsEqual(pv, nv)) cur[i] = nv; + } else if (!isEqual(pv, nv) && !targetsEqual(pv, nv)) cur[i] = nv; } } if (cur.length !== len) cur.length = len; @@ -438,7 +475,9 @@ function stagedApply(cur: any, incoming: any, keyFn: KeyFn | null): void { continue; } } - stagedApply(cur[k], nv, keyFn); + const child = stagedChild(cur, k); + if (child !== undefined) stagedApply(child, nv, keyFn); + else if (!isEqual(pv, nv) && !targetsEqual(pv, nv)) cur[k] = nv; } else if (!isEqual(pv, nv) && !targetsEqual(pv, nv)) { cur[k] = nv; } @@ -467,9 +506,11 @@ export function notifyOptimisticWrites(t: StoreNextTarget, pb: Record any + ): boolean; emitRowOpsOptimistic(t: StoreNextTarget, next: any[] | null, ops: RowOps | null): void; } +/** Raw→proxy wrap for captured structural rows (re-audit 8, P1-2). + * Installed by createTarget — patch.ts must not import wrapNext directly: + * that edge retains the whole trap/write engine in store-less bundles that + * merely compiled a rowProof list (~3.7 kB brotli). If no target was ever + * created, no raw can resolve — the null hook passes raws through. */ +export let wrapRecordHook: + | ((value: any, parent: StoreNextTarget, parentKey: PropertyKey | null, fam: any) => any) + | null = null; + +export function installWrapRecordHook(fn: NonNullable): void { + wrapRecordHook = fn; +} + export let patchHooks: PatchValueHooks | null = null; export let rowHooks: PatchRowHooks | null = null; diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 78b57166a..db1e45347 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -19,39 +19,89 @@ * Tree-shaking: core never imports this module; stores without patches * never schedule the queue. */ -import { EFFECT_RENDER, STATUS_ERROR } from "../../core/constants.js"; -import { ext } from "../../core/core.js"; +import { + CONFIG_OWNED_WRITE, + EFFECT_RENDER, + EFFECT_USER, + NOT_PENDING, + STATUS_ERROR +} from "../../core/constants.js"; +import { ext, read as readSignal, setSignal, signal } from "../../core/core.js"; import { StatusError } from "../../core/error.js"; import { haltReactivity } from "../../core/scheduler.js"; -import { getOwner, isDisposed } from "../../core/owner.js"; +import { createRoot, getOwner, isDisposed } from "../../core/owner.js"; import { activeTransition, + currentTransition, globalQueue, GlobalQueue, setPatchCommitHook, type Transition } from "../../core/scheduler.js"; import type { Owner } from "../../core/types.js"; -import { $TARGET } from "../store.js"; -import { markDescendants, ownedRaw, type StoreNextTarget } from "./target.js"; -import { installPatchHooks, installRowHooks } from "./patch-hooks.js"; +import { $TARGET, isWrappable } from "../store.js"; +import { + markDescendants, + ownedRaw, + storeNextLookup, + type PatchChannel, + type StoreNextTarget +} from "./target.js"; +import { installPatchHooks, installRowHooks, wrapRecordHook } from "./patch-hooks.js"; +import { optHooks } from "./target.js"; // One-way: reconcile emits through the hooks (never imports this module), // so pulling its setter-channel emitter here creates no cycle. import { emitSetterRowOps } from "./reconcile.js"; // Cycle with store.js is benign (established pattern above): both resolve at // call time, long after module initialization. -import { targetIsPlain } from "./store.js"; +import { + deepPathsPlain, + heldMaskView, + rootKeysCurrent, + targetIsPlain, + targetKeysPlain +} from "./store.js"; +import type { DeepNode } from "./target.js"; + +import { InvariantHooks } from "../../core/invariants.js"; +import { assertInvariant, emitDiagnostic, shouldWarnGraphSize } from "../../core/dev.js"; +import { attrHooks } from "../../core/attribution-hooks.js"; import { runWithOwner, untrack } from "../../core/core.js"; import { createRenderEffect } from "../../signals.js"; +import { deliveryEffect } from "../../core/effect.js"; // Cycle with store.js is benign: pcOf is only called at registration time, // long after both modules initialize. -import { pcOf } from "./store.js"; +import { pcOf, repairAncestorSlots } from "./store.js"; export type PatchFn = (next: any, prev: any, force?: boolean) => void; interface PatchEntry { fn: PatchFn; owner: Owner | null; + /** Unbound mark: dispatch snapshots skip severed consumers. */ + u?: boolean; + /** Demoted mark (round 10.7): severed from PATCH dispatch (the body is + * becoming an effect) but NOT user-unbound — the redrive installs it; + * `u` alone means the consumer left and cancels even a queued redrive. */ + dm?: boolean; + /** Manifest-less registration — holds a ref on the channel's `akAll` + * full-scan poison (round 10.9, P2). */ + ml?: boolean; + /** THIS entry's interned manifest (round 10.9, P1): the demotion + * fallback's compute subscribes exactly this envelope — the channel + * UNION would make every sibling read (and fail on) every other + * sibling's keys. null = manifest-less (dual-run fallback). */ + mk?: ProcessedManifest | null; + /** Fallback-effect disposer (round 10.8): the re-drive's root — unbind + * calls it so the demoted effect dies with its consumer. */ + dd?: () => void; + /** Registrant's owner queue (round 10, P1-4): dispatch defers this entry + * into it while a boundary hold is active — render-effect parity. */ + q?: unknown; + /** Deferred-into-held-queue dedup flag. */ + hq?: boolean; + /** Per-entry prev baseline (node delivery). */ + pv?: unknown; } // Per-flush apply queue. Bubbled (forced) emissions resolve `next` LAZILY at @@ -64,9 +114,28 @@ interface QueuedApply { force: boolean; /** When set, `next` resolves at drain as `t.pb ?? t.v` (bubbles). */ t: StoreNextTarget | null; - /** Coalescing backref (re-audit 3): set for stamped SELF entries so the - * drain can clear the channel's qa/qe stamps (retention). */ - pc?: { qa: unknown; qe: unknown }; + /** Channel backref: STRUCTURAL items carry it as the stable dev + * diagnostics key + naming/cause anchor (round 10.12 — emission + * snapshots slice the consumer list, so list identity is per-flush), + * and the drain consults it for the LATE-REGISTRANT resync sweep + * (round 10.13: consumers registered between emission and a held + * drain take the live rebuild instead of permanent staleness). */ + pc?: PatchChannel; + /** Structural row ops (re-audit 6): entries queue the LIVE consumer list + * plus the ops payload — cloned wrappers survived unbinding, so stale + * row callbacks fired after a subject switch. */ + ops?: RowOps | null; + /** Slot-tick payload index (same live-list rationale as `ops`). */ + si?: number; + /** Structural version at emission (see PatchChannel.sv): entries apply + * an item's ops only when their applied-version chain connects + * (`av === svAt - 1`); gaps take ONE flush-end resync. */ + svAt?: number; + /** COMMITTED resolution (revert-form resyncs): the settle loop's revert + * emission conceptually follows the override teardown — the proxy would + * still compose the dying override at drain time; committed raw is the + * post-revert truth. Every other resync reads the VISIBLE view. */ + cm?: boolean; } let queue: QueuedApply[] | null = null; let scheduled = false; @@ -87,11 +156,30 @@ function drainApplyQueue(): void { // rethrow after the drain so they still surface. let firstError: unknown = UNSET; for (let i = 0; i < q.length; i++) { - clearStamp(q[i]); - const { list, prev, force, t } = q[i]; - const next = t !== null ? (t.pb ?? t.v) : q[i].next; - firstError = applyEntries(list, next, prev, force, firstError); + const item = q[i]; + // PARKED-TRUTH re-stash (2026-09-01 tear, structural half): whichever + // carrier the ops rode (the fold's transition, the ambient batch, an + // early-committing landing), at APPLY time a record whose truth is + // still parked names its holder through the nodes' `_transition` — the + // steal re-stamped them, so this follows the hold wherever the + // scheduler moved it, ordering-free. Overrides exempt (a draft's lane + // ops are live display). Optimistic families only: plain stores' + // structural emissions are commit-timed by their stash. + const t0 = (item.pc as any)?.t as StoreNextTarget | undefined; + if (t0 !== undefined && t0.fam?.opt === true) { + scanHadOverride = false; + const holder = nodesParkedHolder(t0); + if (holder !== null && !scanHadOverride) { + (((holder as any)._heldPatches ??= []) as QueuedApply[]).push(item); + continue; + } + } + const next = drainNext(item); + if (next === UNSET) continue; + if (item.ops !== undefined || item.si !== undefined) + firstError = applyStructural(item, next, firstError); } + firstError = runResyncs(firstError); if (firstError !== UNSET) { // Unhandled patch errors HALT like unhandled effect errors (re-audit 2, // P1-4): app state is undefined past an unboundaried throw. @@ -100,6 +188,235 @@ function drainApplyQueue(): void { } } +/** Structural `next` resolution from a live target (structural audit, F2): + * what an UNTRACKED READER sees — optimistic families compose overrides + * through the proxy (a resync during an open window must not rebuild to + * committed backing and drop tentative rows); everyone else the pending + * or committed raw. Consumers canonicalize rows via patchableRaw, so + * proxy-composed rows keep identity retention. */ +function visibleStructRows(t: StoreNextTarget): any { + return t.fam?.opt === true ? t.px : (t.pb ?? t.v); +} + +/** Drain-side `next` resolution (structural audit F2): live targets read + * the VISIBLE view at drain time. (The old-contract superseded-work + * generation gate lived here; the #3164 fold ruling removed landing-time + * consumption, and with it the stale-work window the gate closed — staged + * truth now rides the retaining transaction's own queues.) */ +function drainNext(item: QueuedApply): unknown { + const { force, t } = item; + if (t === null) return item.next; + if (force) return forcedNext(t); + // COMMITTED only — never `pb`: a revert-form resync follows the override + // teardown, and a draft backing lingering at the drain (rows escaped + // into DOM bindings materialize one) is exactly the state that died. + return item.cm === true ? t.v : visibleStructRows(t); +} + +/** Forced-apply `next` resolution. Deep-path channels read through the + * PROXY (re-audit 7): eager adoption swaps a child's backing without + * rewriting ancestor raw slots (proxy readers resolve children through + * their targets), so a raw parent walk would read the PRE-ADOPTION child. + * The drain runs untracked — proxy reads resolve fresh without edges. + * Depth-1 channels keep the raw fast path. */ +function forcedNext(t: StoreNextTarget): any { + return t.pc !== null && t.pc.dp !== null ? t.px : (t.pb ?? t.v); +} + +/** Row-ops/slot-tick dispatch over the EMISSION-TIME snapshot (re-audit 7, + * P1-5): baseline-relative structural work must reach exactly the consumers + * registered when it was computed — late registrants initialized from + * current state. Unbinds between emission and drain sever through the + * shared entries' `u` marks (re-audit 6). Same per-entry isolation and + * error routing as value patches. */ +function applyStructural(item: QueuedApply, next: any, firstError: unknown): unknown { + // VERSION CHAIN (structural redesign): iterate the LIVE consumer list — + // membership questions (late registrants, held windows, cross-queue + // ordering) are answered by version arithmetic, not snapshots. An entry + // applies an item's payload only when its applied-version chain connects + // (`av === svAt - 1`): the item's baseline is then EXACTLY the state the + // entry last saw (its registration read or its previous application). + // Anything at or below `av` is already covered; any gap marks the entry + // for ONE flush-end resync (after every queue drains). + const pc = item.pc; + if (pc === undefined) return firstError; + const svAt = item.svAt as number; + const live = (item.si !== undefined ? pc.sp : pc.ro) as + | (RowOpsEntry & { hqs?: Set; av?: number; rs?: boolean })[] + | null; + if (live === null || live.length === 0) return firstError; + const dch = __DEV__ && attrHooks !== null ? (pc ?? null) : null; + const dchannel = item.si !== undefined ? "slot-patch" : "row-ops"; + let dstart = 0; + if (__DEV__ && attrHooks !== null) { + attrHooks.patchDispatch((dch as object) ?? (item.list as object), live.length, dchannel, null); + dstart = performance.now(); + } + // DELETED-SLOT gate (structural audit F3): a slot tick coalesced with a + // later shrink indexes past the live list — advance every connected + // entry's chain WITHOUT delivery (a gap here would force spurious + // resyncs; the tick is a no-op by rule, not a missed update). + let gated = false; + if (item.si !== undefined) { + const liveRows = visibleStructRows(pc.t as StoreNextTarget); + if (!Array.isArray(liveRows) || item.si >= liveRows.length) gated = true; + } + const snap = live.length > 1 ? live.slice() : live; + for (let j = 0; j < snap.length; j++) { + const entry = snap[j]; + if (entry === undefined || entry.u === true) continue; + if (entry.owner !== null && isDisposed(entry.owner)) continue; + const av = entry.av as number; + if (av >= svAt) continue; // covered by its registration read or a resync + // BOUNDARY HOLD parity (round 10.13): defer INTO the collapsed queue; + // the deferred run resyncs from live truth and fast-forwards the chain. + const oq = entry.q as any; + if (queueIsHeld(oq)) { + deferHeldStructural(entry as any, oq, item); + continue; + } + if (av !== svAt - 1) { + // Chain gap: some emission this entry needed was missed (skipped + // item, cross-queue ordering) — ONE resync at flush end covers it. + if (entry.rs !== true) { + entry.rs = true; + (rsPending ??= []).push([entry as any, pc]); + } + continue; + } + entry.av = svAt; + if (gated) continue; + try { + if (item.si !== undefined) (entry.fn as any)(item.si, next, item.prev); + else (entry.fn as any)(next, item.ops ?? null); + } catch (err) { + if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; + } + } + // Structural deliveries are attribution EVENTS (round 10.12, P2). + if (__DEV__ && attrHooks !== null && dch !== null) + attrHooks.patchStructural( + (dch as any).t !== undefined ? targetPath((dch as any).t) : null, + live.length, + dchannel, + ((dch as any).dn as any) ?? null, + performance.now() - dstart + ); + return firstError; +} + +/** Entries that observed a version gap this flush — resynced ONCE, after + * EVERY queue drains (lane first, then regular: the old per-queue sweep let + * a live resync be chased by the other queue's stale ops). */ +let rsPending: Array< + [RowOpsEntry & { av?: number; rs?: boolean; hqs?: Set }, PatchChannel] +> | null = null; + +function runResyncs(firstError: unknown): unknown { + const list = rsPending; + rsPending = null; + if (list === null) return firstError; + for (let i = 0; i < list.length; i++) { + const [entry, pc] = list[i]; + entry.rs = false; + if (entry.u === true) continue; + if (entry.owner !== null && isDisposed(entry.owner)) continue; + const isSlot = pc.sp !== null && (pc.sp as unknown[]).indexOf(entry) !== -1; + // Fast-forward the chain BEFORE delivering: the resync reads live + // truth, covering every version up to the channel's current one. + entry.av = (isSlot ? (pc as any).svs : pc.sv) as number; + const oq = entry.q as any; + if (queueIsHeld(oq)) { + deferHeldStructural(entry as any, oq, { + pc, + si: undefined, + next: null, + prev: null, + force: false, + t: pc.t as StoreNextTarget, + ops: null, + list: [] as unknown as PatchEntry[] + }); + continue; + } + try { + const rows = visibleStructRows(pc.t as StoreNextTarget); + if (isSlot) { + // Slot consumers have no whole-list form: tick every live index + // with the current value (undefined prev fires the compare). + if (Array.isArray(rows)) { + for (let si = 0; si < rows.length; si++) (entry.fn as any)(si, rows[si], undefined); + } + } else { + (entry.fn as any)(rows, null); + } + } catch (err) { + if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; + } + } + return firstError; +} + +/** The live-state RESYNC form of a structural item: row-ops consumers get + * `(rows, null)` (the driver rebuilds retention by identity), slot + * consumers get the CURRENT value at the index with the original prev (the + * compare fires for anything their initialization predates). Live state is + * the VISIBLE view (structural audit, F2): optimistic families read + * through the proxy, and a slot deleted since emission is skipped (F3). */ +function structuralResync(entry: { fn: Function }, item: QueuedApply): void { + const t = item.pc !== undefined ? (item.pc.t as StoreNextTarget) : null; + const rows = t !== null ? (item.cm === true ? t.v : visibleStructRows(t)) : item.next; + if (item.si !== undefined) { + if (t !== null && (!Array.isArray(rows) || item.si >= rows.length)) return; + entry.fn(item.si, t !== null ? rows[item.si] : item.next, item.prev); + } else { + entry.fn(rows, null); + } +} + +/** Deferred structural re-apply for a held consumer (round 10.13): runs + * FROM its owner queue at release, always in the live resync form. Row + * consumers dedup to one queued run per hold window (the resync reads + * live truth — repeats are pure waste); SLOT consumers dedup PER INDEX + * (fold audit P1): distinct indexes are distinct deliveries — a shared + * flag collapsed multi-slot batches to the first index, leaving the rest + * permanently stale. */ +function deferHeldStructural( + entry: { fn: Function; owner: Owner | null; u?: boolean; hq?: boolean; hqs?: Set }, + oq: any, + item: QueuedApply +): void { + if (item.si !== undefined) { + const si = item.si; + const set = (entry.hqs ??= new Set()); + if (set.has(si)) return; + set.add(si); + oq.enqueue(EFFECT_RENDER, () => { + set.delete(si); + if (entry.u === true) return; + if (entry.owner !== null && isDisposed(entry.owner)) return; + // Release fast-forwards the chain (fold audit 2, P2): the resync + // reads live truth — without this the next update saw a gap and + // forced a second, redundant full resync. + (entry as any).av = ((item.pc as any)?.svs as number) ?? (entry as any).av; + try { + structuralResync(entry, item); + } catch (err) { + if (!routeEntryError(entry as any, err)) deferHalt(err); + } + }); + return; + } + deferIntoQueue(entry, oq, () => { + (entry as any).av = ((item.pc as any)?.sv as number) ?? (entry as any).av; + try { + structuralResync(entry, item); + } catch (err) { + if (!routeEntryError(entry as any, err)) return err as unknown; + } + }); +} + const UNSET: unique symbol = Symbol(); /** ONE callback/error primitive for every drain (normal, transition-held, @@ -108,44 +425,104 @@ const UNSET: unique symbol = Symbol(); * OWNER's queue chain exactly like a render-effect error (§2b): an Errored * boundary above the row collects it. Unhandled errors are aggregated by the * caller (first one rethrows after its drain completes). */ -function applyEntries( - list: { fn: Function; owner: Owner | null; u?: boolean }[], - next: any, - prev: any, - force: boolean, - firstError: unknown -): unknown { +/** Deferred re-apply for a consumer whose owner queue is holding (round 10, + * P1-4): enqueued INTO that queue, so the boundary's own release timing — + * not the channel's — decides when the entry sees the update. Reads the + * visible view at RUN time (the settled state, exactly what the held render + * effect would compute). One queued run per entry per hold window. */ +/** ONE held-owner-queue probe (size pass 2): shared by value dispatch, + * structural dispatch, and demotion scheduling. */ +function queueIsHeld(oq: unknown): boolean { + const probe = GlobalQueue._queueHeld; + return probe !== null && oq != null && oq !== globalQueue && probe(oq as any); +} + +/** ONE deferred-run shape for every held consumer (size pass 2): dedup + * flag, owner-queue enqueue, liveness guards, error deferral. `run` + * re-derives from LIVE state at release by construction. */ +function deferIntoQueue( + entry: { u?: boolean; dm?: boolean; hq?: boolean; owner: Owner | null }, + oq: any, + run: () => unknown +): void { + if (entry.hq === true) return; + entry.hq = true; + oq.enqueue(EFFECT_RENDER, () => { + entry.hq = false; + if (entry.u === true || entry.dm === true) return; + if (entry.owner !== null && isDisposed(entry.owner)) return; + const err = run(); + if (err !== undefined && err !== UNSET) deferHalt(err); + }); +} + +function deferHeldEntry(entry: PatchEntry, oq: any, pc: any): void { + deferIntoQueue(entry, oq, () => applyEntries([entry], visibleView(pc.t, pc), UNSET, pc)); +} + +/** Route a consumer's throw to its registering owner's boundary. Shared by + * dispatch and demotion fanout (round 10, P1-5): the nearest COMPUTED + * ancestor is the recompute target — .reset() recomputes sources, + * and a plain owner (the list driver's listOwner) is not recomputable; the + * component/memo scope above it is, and recomputing it rebuilds the rows, + * exactly what reset means for a throwing render effect. */ +function routeEntryError(entry: PatchEntry, err: unknown): boolean { + const owner = entry.owner as any; + if (owner === null) return false; + let source = owner; + while (source !== null && source._fn === undefined) source = source._parent; + source ??= owner; + const statusErr = new StatusError(source, err); + ext(source)._error = statusErr; + source._statusFlags = (source._statusFlags ?? 0) | STATUS_ERROR; + return owner._queue.notify(source, STATUS_ERROR, STATUS_ERROR, statusErr) as boolean; +} + +/** One deferred unboundaried halt, a phase after the fanout it must not + * abort (the round-2 channel contract, shared by every dispatch shape). */ +function deferHalt(err: unknown): void { + globalQueue.enqueue(EFFECT_USER, () => { + haltReactivity(err); + throw err; + }); +} + +function applyEntries(list: PatchEntry[], next: any, firstError: unknown, pc: any): unknown { // SNAPSHOT multi-consumer lists (re-audit 5, P1-3): a callback can dispose // a sibling's owner, whose unbind SPLICES this same array mid-iteration — // index-walking the live array skips the shifted consumer. The dominant // single-consumer case pays nothing; unbound entries are marked so a // snapshot never applies a consumer severed by an earlier callback. + // FIXED WINDOW (re-audit 6, P2-4): the single-consumer fast path aliases + // the live list — a callback registering ANOTHER patch mid-dispatch must + // not run it in this same drain (it just received its initial apply). const snap = list.length > 1 ? list.slice() : list; - for (let j = 0; j < snap.length; j++) { + const len = snap.length; + for (let j = 0; j < len; j++) { const entry = snap[j]; - if (entry.u === true) continue; + if (entry === undefined || entry.u === true || entry.dm === true) continue; // Disposed owners drop their patches (the row unmounted mid-flush). if (entry.owner !== null && isDisposed(entry.owner)) continue; + // BOUNDARY HOLD parity (round 10, P1-4): a consumer registered under a + // holding queue (pending Loading / collapsed reveal) defers exactly + // like the render effect it replaced — the entry re-applies FROM ITS + // OWN QUEUE at release, reading the visible state of that moment. + const oq = entry.q as any; + if (queueIsHeld(oq)) { + deferHeldEntry(entry, oq, pc); + continue; + } try { - entry.fn(next, prev, force); + const ep = entry.pv; + // A consumer whose baseline never materialized (projection backing + // absent at registration) takes its first delivery FORCED — there is + // nothing to compare against, and compiled bodies only tolerate an + // undefined prev under force. + if (ep == null) entry.fn(next, undefined, true); + else entry.fn(next, ep, false); + entry.pv = next === pc.t?.px ? untrack(() => manifestSnapshot(pc, next)) : next; } catch (err) { - let handled = false; - const owner = entry.owner as any; - if (owner !== null) { - // Route through the nearest COMPUTED ancestor (re-audit 2, P1-4): - // .reset() recomputes its sources, and a plain owner (the - // list driver's listOwner) is not recomputable — the component/memo - // scope above it is, and recomputing it rebuilds the rows, exactly - // what reset means for a throwing render effect. - let source = owner; - while (source !== null && source._fn === undefined) source = source._parent; - source ??= owner; - const statusErr = new StatusError(source, err); - ext(source)._error = statusErr; - source._statusFlags = (source._statusFlags ?? 0) | STATUS_ERROR; - handled = owner._queue.notify(source, STATUS_ERROR, STATUS_ERROR, statusErr); - } - if (!handled && firstError === UNSET) firstError = err; + if (!routeEntryError(entry, err) && firstError === UNSET) firstError = err; } } return firstError; @@ -160,16 +537,114 @@ function applyEntries( // the ambient batch never stashes. let commitHookInstalled = false; +/** PARKED-TRUTH probe (2026-09-01 tear): a record whose node values are + * parked under a NOT-DONE transaction has truth the visible world hasn't + * revealed — a landing's fold mid-flight, fold-staged reveals, or the + * until()-flip steal's held mask (which re-stamps `_transition` to the + * awaiting transaction, so the SAME probe follows the hold wherever the + * scheduler moves it — no steal-specific code). The scan also notes + * active overrides (`scanHadOverride`): an override is a live lane + * display classic readers see NOW, so its delivery must not defer. + * Cold by construction: the caller gates on `bt`/`bo`, so + * transaction-free ticks (the dbmon shape) never reach this. */ +let scanHadOverride = false; + +function nodesParkedHolder(t: StoreNextTarget): Transition | null { + const n = t.n as Record | null | undefined; + let holder: Transition | null = null; + if (n != null) { + for (const k in n) { + const nd = n[k]; + if (hasActiveOverrideNode(nd)) scanHadOverride = true; + else if (nd._pendingValue !== NOT_PENDING && nd._transition != null) { + const tx = currentTransition(nd._transition as Transition); + if (tx != null && tx._done !== true) holder = tx; + } + } + } + return holder; +} + +function hasActiveOverrideNode(nd: any): boolean { + const ov = nd._x?._overrideValue; + return ov !== undefined && ov !== NOT_PENDING; +} + +function deepHeldHolder(node: DeepNode, raw: any, fam: any): Transition | null { + if (raw === null || (typeof raw !== "object" && typeof raw !== "function")) return null; + const ct = (fam?.map ?? storeNextLookup).get(raw) as StoreNextTarget | undefined; + if (ct !== undefined) { + const h = nodesParkedHolder(ct); + if (h !== null) return h; + } + const children = node.c; + if (children !== null) { + for (let i = 0; i < children.length; i++) { + const f = deepHeldHolder(children[i], raw[children[i].k as any], fam); + if (f !== null) return f; + } + } + return null; +} + +function parkedTruthHolder(t: StoreNextTarget, pc: any): Transition | null { + const h = nodesParkedHolder(t); + if (h !== null) return h; + const dp = pc.dp as DeepNode[] | null; + if (dp !== null && t.v != null) { + for (let i = 0; i < dp.length; i++) { + const f = deepHeldHolder(dp[i], (t.v as any)[dp[i].k as any], t.fam); + if (f !== null) return f; + } + } + return null; +} + +/** Defer the delivery WITHOUT consuming the bump (`dv` stays behind `bc`): + * the holder's commit releases a redrive that wakes the effect, and content + * then resolves through the read seam post-reveal. One redrive per holder + * (`pc.hh`); a REVERTED holder drops its stash by design — the revert + * restores the world the unconsumed bump would have re-applied, so the + * missed wake is content-free and the next genuine bump supersedes it. */ +function deferRedrive(pc: any, holder: Transition): void { + if (pc.hh != null && currentTransition(pc.hh as Transition) === holder) return; + pc.hh = holder; + (((holder as any)._heldPatches ??= []) as any[]).push({ rd: pc }); +} + function releaseBatch(batch: Transition): void { const held = (batch as any)._heldPatches as QueuedApply[] | undefined; if (held === undefined) return; (batch as any)._heldPatches = undefined; - for (let i = 0; i < held.length; i++) pushLive(held[i]); + for (let i = 0; i < held.length; i++) { + const item = held[i] as any; + if (item.rd !== undefined) { + // Parked-truth redrive: the holder committed — bump and wake (the + // bump makes redelivery unconditional even when the deferring + // delivery fell through and consumed); the seam serves the revealed + // world. A REVERTED holder drops this with its stash by design: the + // revert restored the world, the wake would be content-free. + item.rd.hh = null; + item.rd.bc++; + setSignal(item.rd.dn, (v: number) => v + 1); + continue; + } + pushLive(item); + } } +/** The VISIBLE-version bump (version-chain redesign): an emission's effect + * becomes readable exactly when its item enters the LIVE queue — commit- + * coincident emissions immediately, transition-stashed ones at their + * releaseBatch. Entries born after this point have the emission's state in + * their first read, so their `av` starts at or past it. */ function pushLive(item: QueuedApply): void { - if (queue === null) queue = []; - queue.push(item); + const pc = item.pc as any; + if (pc !== undefined && item.svAt !== undefined) { + const k = item.si !== undefined ? "svvs" : "svv"; + if (item.svAt > (pc[k] as number)) pc[k] = item.svAt; + } + (queue ??= []).push(item); if (!scheduled) { scheduled = true; globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); @@ -179,9 +654,7 @@ function pushLive(item: QueuedApply): void { function push(item: QueuedApply): void { const tx = activeTransition; if (tx !== null) { - let held = (tx as any)._heldPatches as QueuedApply[] | undefined; - if (held === undefined) (tx as any)._heldPatches = held = []; - held.push(item); + (((tx as any)._heldPatches ??= []) as QueuedApply[]).push(item); return; } pushLive(item); @@ -196,43 +669,6 @@ function push(item: QueuedApply): void { * pc.p array, so mid-batch registrants ride the single application. Forced * entries and row/slot ops never coalesce; the drain clears the stamps so a * quiet record retains nothing from its last batch. */ -function pushSelf(pc: { qa: unknown; qe: unknown }, item: QueuedApply): void { - const tx = activeTransition; - let arr: QueuedApply[]; - if (tx !== null) { - let held = (tx as any)._heldPatches as QueuedApply[] | undefined; - if (held === undefined) (tx as any)._heldPatches = held = []; - arr = held; - } else { - if (queue === null) queue = []; - arr = queue; - } - if (pc.qa === arr && pc.qe !== null) { - const qe = pc.qe as QueuedApply; - qe.next = item.next; - qe.list = item.list; // pc.p can be re-created if emptied mid-batch - return; - } - pc.qa = arr; - pc.qe = item; - (item as any).pc = pc; - arr.push(item); - if (arr === queue && !scheduled) { - scheduled = true; - globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); - } -} - -/** Drain-side stamp clear (re-audit 3, P2-6): without it a quiet long-lived - * record's channel retains its last batch's container array, entry, and both - * captured backings for the record's lifetime. */ -function clearStamp(item: QueuedApply): void { - const pc = (item as any).pc as { qa: unknown; qe: unknown } | undefined; - if (pc !== undefined && pc.qe === item) { - pc.qa = null; - pc.qe = null; - } -} /** Shallow clone for the owned-prev rule (§2c): owned backings fold values * INTO the same raw at commit, so a queued prev must be snapshotted. */ @@ -245,39 +681,55 @@ function clonePrev(prev: any): any { * `t.d` cheaply; this function re-checks and walks ancestors (§4b). */ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { - const p = (t.pc !== null ? t.pc.p : null) as PatchEntry[] | null; - if (p !== null) - pushSelf(t.pc!, { - list: p, - next, - prev: ownedRaw.has(prev) ? clonePrev(prev) : prev, - force: false, - t: null - }); - // Bubbling: ancestors force-re-apply from their LIVE backing, resolved at - // drain (privatization may clone it between now and then). + const pc = t.pc as any; + if (pc !== null) { + bumpOne(t, pc); + // RAW PAYLOAD only where raw IS visible truth (#3123 P1, equal-landing + // flash): optimistic families compose override views at read time — an + // authoritative landing's committed backing served raw would flash + // through overrides an EQUAL landing holds. Those deliveries take the + // visibleView proxy read, same as every classic reader. + if (t.fam?.opt !== true) { + pc.np = next; + pc.npb = pc.bc; + } + // Self emission knows both sides — upgrade the chain stamp with the + // record transition ("store.rows.3 {label: a…} → {label: b…}"). + if (__DEV__ && attrHooks !== null && pc.dn !== null) + attrHooks.patchEmit(pc.dn, targetPath(t), prev, next, true); + } + bumpAncestors(t); +} + +/** Ancestor bubble, standalone: for seams whose OWN record cannot patch + * (demotions, channel-less landings) but whose ancestors' compiled bodies + * read into the subtree through nested chains. Delegates to the same + * bubbling primitive every emission uses. */ +export function emitPatchAncestors(t: StoreNextTarget): void { + bumpAncestors(t); +} + +/** Tentative (optimistic) ancestor bubble (re-audit 8, P1-3): in-flight + * visibility rides the LANE queue. Standalone form for seams that handled + * (or demoted) the record itself. */ +export function emitPatchAncestorsOptimistic(t: StoreNextTarget, _tx: unknown): void { + let origin: unknown = undefined; + if (__DEV__ && attrHooks !== null) + origin = + t.pc !== null && (t.pc as any).dn !== null && (t.pc as any).p !== null + ? (t.pc as any).dn + : targetPath(t); let u = t.u; while (u !== null) { - const up = (u.pc !== null ? u.pc.p : null) as PatchEntry[] | null; - if (up !== null) push({ list: up, next: null, prev: null, force: true, t: u }); + if (u.pc !== null) bumpOneOptimistic(u, u.pc, origin); u = u.u; } } -/** Emission for sites that already stand at the record with both sides in - * hand and have already handled ancestors (the adoption walk descends — - * parents were visited first), so no bubbling walk. */ -export function emitPatchLocal(t: StoreNextTarget, next: any, prev: any): void { - const p = (t.pc !== null ? t.pc.p : null) as PatchEntry[] | null; - if (p !== null) - pushSelf(t.pc!, { - list: p, - next, - prev: ownedRaw.has(prev) ? clonePrev(prev) : prev, - force: false, - t: null - }); -} +/** Historically the "walk handled my ancestors" emission — round 10 made + * bubbling primitive-owned (pending-dedup makes the redundant walk free), + * so this IS emitPatch: no seam gets to skip ancestors. */ +export const emitPatchLocal = emitPatch; /** Optimistic-channel emission: overrides are visible THIS flush while the * transaction is in flight — that is what optimism means. These ride a @@ -296,11 +748,33 @@ function drainOptimistic(): void { // must reach the registering owner's Errored boundary. let firstError: unknown = UNSET; for (let i = 0; i < q.length; i++) { - clearStamp(q[i]); - const { list, prev, force, t } = q[i]; - const next = t !== null ? (t.pb ?? t.v) : q[i].next; - firstError = applyEntries(list, next, prev, force, firstError); + const item = q[i]; + // PARKED-TRUTH re-stash (2026-09-01 tear, structural half): whichever + // carrier the ops rode (the fold's transition, the ambient batch, an + // early-committing landing), at APPLY time a record whose truth is + // still parked names its holder through the nodes' `_transition` — the + // steal re-stamped them, so this follows the hold wherever the + // scheduler moved it, ordering-free. Overrides exempt (a draft's lane + // ops are live display). Optimistic families only: plain stores' + // structural emissions are commit-timed by their stash. + const t0 = (item.pc as any)?.t as StoreNextTarget | undefined; + if (t0 !== undefined && t0.fam?.opt === true) { + scanHadOverride = false; + const holder = nodesParkedHolder(t0); + if (holder !== null && !scanHadOverride) { + (((holder as any)._heldPatches ??= []) as QueuedApply[]).push(item); + continue; + } + } + const next = drainNext(item); + if (next === UNSET) continue; + if (item.ops !== undefined || item.si !== undefined) + firstError = applyStructural(item, next, firstError); } + // Standalone lane drains resync at their own tail; when this drain runs + // INSIDE drainApplyQueue the pending marks survive to ITS tail — after + // the regular queue — so a resync can never be chased by stale ops. + if (queue === null || queue.length === 0) firstError = runResyncs(firstError); if (firstError !== UNSET) { haltReactivity(firstError); throw firstError; @@ -308,33 +782,11 @@ function drainOptimistic(): void { } export function emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void { - const p = (t.pc !== null ? t.pc.p : null) as PatchEntry[] | null; - if (p === null) return; - if (optQueue === null) optQueue = []; - if (next === null) optQueue.push({ list: p, next: null, prev: null, force: true, t }); - else { - // Same-batch coalescing, optimistic container (re-audit 3): later - // non-forced emission updates the queued entry's next in place. - const pc = t.pc! as unknown as { qa: unknown; qe: unknown }; - if (pc.qa === optQueue && pc.qe !== null) { - const qe = pc.qe as QueuedApply; - qe.next = next; - qe.list = p; - } else { - const item: QueuedApply = { list: p, next, prev, force: false, t: null }; - pc.qa = optQueue; - pc.qe = item; - (item as any).pc = pc; - optQueue.push(item); - } - } - // Backup scheduling: the lane-slot drain covers in-flight application; a - // stashed regular drain guarantees settle-time application when no lane - // survives to the final flush (pure reverts). - if (!scheduled) { - scheduled = true; - globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); - } + // Bubbles like every emission (round 10, P1-3): a patch on an ANCESTOR + // must show a nested optimistic write in flight — the lane view already + // answers it, and ancestors ride the same lane timing. + if (t.pc !== null) bumpOneOptimistic(t, t.pc); + emitPatchAncestorsOptimistic(t, null); } /** Row-ops emission at OPTIMISTIC (lane) timing: user drafts on an @@ -354,16 +806,21 @@ export function emitRowOpsOptimistic( const list = (t.pc !== null ? t.pc.ro : null) as RowOpsEntry[] | null; if (list === null) return; if (optQueue === null) optQueue = []; + // Snapshot at emission, unbind safety via `u` marks — see emitSlotPatch. optQueue.push({ - list: list.map(e => ({ - owner: e.owner, - fn: (n: any, _p: any) => e.fn(n as any[], ops as any) - })), + list: list.slice() as unknown as PatchEntry[], next: nextRows, prev: null, force: false, - t: nextRows === null ? t : null + t: nextRows === null ? t : null, + cm: nextRows === null && ops === null, + ops, + pc: t.pc as PatchChannel, + svAt: ((t.pc as any).sv = ((t.pc as any).sv as number) + 1) }); + // Lane emissions are visible AT EMISSION (optimism is in-flight + // visibility) — bump the visible version immediately. + (t.pc as any).svv = (t.pc as any).sv; if (!scheduled) { scheduled = true; globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); @@ -387,24 +844,587 @@ export function hasPatches(): boolean { return patchCount > 0; } -export function registerPatch(record: any, fn: PatchFn): () => void { - let t: StoreNextTarget | undefined = record?.[$TARGET]; - if (t === undefined) throw new Error("registerPatch: not a store record"); - // Chained backings (§7b): register on the ULTIMATE owner — that is where - // value transitions fold and dispatch; the wrapper's identity is stable - // and would never fire (see ultimateTarget). - t = ultimateTarget(t) ?? t; +interface ProcessedManifest { + roots: PropertyKey[]; + dp: DeepNode[] | null; + /** Root-aligned deep index: dpr[i] = roots[i]'s deep subtree or null. */ + dpr: (DeepNode | null)[] | null; +} +const manifestCache = new WeakMap(); + +/** Insert a dot-split path into the prefix tree (see PatchChannel.dp). */ +function insertPath(dp: DeepNode[], segs: string[]): void { + let level = dp; + for (let d = 0; d < segs.length; d++) { + let node: DeepNode | undefined; + for (let i = 0; i < level.length; i++) { + if (level[i].k === segs[d]) { + node = level[i]; + break; + } + } + if (node === undefined) { + node = { k: segs[d], c: null }; + level.push(node); + } + if (d < segs.length - 1) level = node.c ??= []; + } +} + +function internManifest(keys: PropertyKey[]): ProcessedManifest { + let m = manifestCache.get(keys); + if (m !== undefined) return m; + const roots: PropertyKey[] = []; + let dp: DeepNode[] | null = null; + for (const k of keys) { + if (typeof k === "string" && k.indexOf(".") !== -1) { + const segs = k.split("."); + if (roots.indexOf(segs[0]) === -1) roots.push(segs[0]); + insertPath((dp ??= []), segs); + } else if (roots.indexOf(k) === -1) { + roots.push(k); + } + } + // Root-aligned deep index (round 10.11, P2): dpr[i] is roots[i]'s deep + // subtree (or null) — the envelope walk was scanning every deep root per + // manifest root (quadratic per compute). Built once per interned + // manifest. + let dpr: (DeepNode | null)[] | null = null; + if (dp !== null) { + dpr = new Array(roots.length); + for (let i = 0; i < roots.length; i++) { + dpr[i] = null; + for (let j = 0; j < dp.length; j++) + if (dp[j].k === roots[i]) { + dpr[i] = dp[j]; + break; + } + } + } + m = { roots, dp, dpr }; + manifestCache.set(keys, m); + return m; +} + +function cloneTree(dp: DeepNode[]): DeepNode[] { + return dp.map(n => ({ k: n.k, c: n.c === null ? null : cloneTree(n.c) })); +} + +/** Copy-on-write guard for interned key structures (see registerPatch). */ +function ensureOwnedKeys(pc: { ak: PropertyKey[] | null; dp: DeepNode[] | null; ks?: boolean }) { + if (pc.ks === true) { + pc.ak = pc.ak === null ? null : pc.ak.slice(); + pc.dp = pc.dp === null ? null : cloneTree(pc.dp); + pc.ks = false; + } +} + +function unionKeys( + pc: { ak: PropertyKey[] | null; dp: DeepNode[] | null; ks?: boolean }, + keys: Iterable +): void { + ensureOwnedKeys(pc); + const ak = (pc.ak ??= []); + for (const k of keys) { + if (typeof k === "string" && k.indexOf(".") !== -1) { + const segs = k.split("."); + if (ak.indexOf(segs[0]) === -1) ak.push(segs[0]); + insertPath((pc.dp ??= []), segs); + } else if (ak.indexOf(k) === -1) ak.push(k); + } +} + +/** NODE DELIVERY (the structural successor to the queue machinery): one + * plain version signal per channel, bumped at the emission seams; ONE + * render effect per channel dispatches every entry with an exact + * manifest-shaped prev snapshot. Timing — transitions, holds, lanes, + * merges, mount order — is scheduler-owned by construction. + * + * BUBBLING LIVES HERE (round 10). Every bump walks the ancestor chain — + * no emission seam decides whether ancestors need delivery, so no seam + * can forget (three of round 10's blockers were exactly that class: + * landings, optimistic writes, and adoptions each re-implementing the + * bubble and missing a case). The pending-dedup below makes the walk + * nearly free: an ancestor already carrying an undelivered bump exits in + * two reads, so an N-row reconcile bumps each ancestor once, not N times. + * + * PAY-FOR-USE CREATION (mount pass): the signal + effect are built at the + * FIRST consumer-visible emission, not at registration — a mounted list + * that never updates allocates nothing here. Once built, the machinery + * persists across consumer churn AND is never torn down with the last + * consumer: a held write bumping during an unbound window must still + * deliver to a consumer that registers before the settle. Channels whose + * machinery was never built skip silently — a first-ever consumer's + * registration baseline (`entry.pv`) already reflects those writes. + * QUIESCENT SKIP (round 10, P2): a built channel with no consumers only + * keeps bumping while a transition is in flight (the held-window pin); + * outside one, the write is immediately visible and a future registrant's + * baseline covers it — no signal write, no inert effect run. */ +function bumpOne(t: StoreNextTarget, pc: any, origin?: unknown): void { + // CANONICAL transaction identity (round 10.7, P1/P2): stamps store — + // and compares resolve — through currentTransition, so a merge between + // bumps (A absorbed into B) neither defeats the dedup (A¹B² produced + // three bumps instead of two) nor retains the merged-away object's + // generator/application state through the stamp. + const txn = activeTransition === null ? null : currentTransition(activeTransition); + if (pc.de === undefined) { + if (pc.p === null) return; + ensureDelivery(t, pc); + } else if (pc.bc !== pc.dv) { + // Already pending: the one scheduled delivery reads the LATEST visible + // state (and payload emitters re-stash after this call), so a second + // signal write adds nothing — WITHIN one transaction scope (round + // 10.5 F1, refined 10.6). A write under a DIFFERENT transition than + // the pending bump's must reach the signal: entanglement and merging + // are SCHEDULER bookkeeping keyed on writes — a skipped write left + // transition B's involvement unrecorded, and A's resolution could + // deliver B's still-pending value early. Dedup never outranks the + // scheduler; repeats inside the SAME transition add nothing to it. + if (txn === null || (pc.bt != null && currentTransition(pc.bt as Transition) === txn)) { + // Coalesced bubbles still record their origin (round 10.11, P2). + if (__DEV__ && attrHooks !== null && origin != null) + attrHooks.patchOrigin(pc.dn, origin as any); + return; + } + } else if (pc.p === null && txn === null) { + return; + } + // Synchronous dedup counter + pure-notification signal: the WRITE may be + // held by a transition (its commit IS the delivery moment), but the + // dispatch decision must never read a mid-commit signal value. + pc.bc++; + pc.bt = txn; + setSignal(pc.dn, (v: number) => v + 1); + // Cause-chain anchor (attribution parity): AFTER the write, so this + // record-path stamp replaces the engine's counter stamp. Name-only here + // (bubbles have no values); self emitters re-stamp with the transition, + // and ancestor bumps carry the ORIGINATING child (round 10.10, P2). + if (__DEV__ && attrHooks !== null) + attrHooks.patchEmit(pc.dn, targetPath(t), null, null, false, origin as any); +} + +/** Tracked read of a manifest deep-path subtree THROUGH the proxy — the + * demotion fallback's compute pass (round 10.9; corrected 10.10): the + * caller read this node's value ONCE and hands it down — a second read + * would make an unstable getter track one value and commit another. And + * FUNCTIONS descend (re-audit 9, P1-8's lesson, again): they carry + * accessor properties whose dependencies must track. */ +function readDeepChildren(node: DeepNode, v: any): void { + const children = node.c; + if (children === null || v === null || (typeof v !== "object" && typeof v !== "function")) return; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const cv = v[child.k]; // the ONE tracked read for this step + if (child.c !== null) readDeepChildren(child, cv); + } +} + +/** DEV: channel-side HUGE_FAN_OUT twin (attribution parity, round 10.10 + * covering VALUE, ROW-OPS, and SLOT channels): channel consumers are not + * graph subscribers — a record driving thousands of consumers has ONE + * delivery-signal edge, so the always-on link warning would never see the + * structure it exists to catch. Same code, same milestones. */ +function warnChannelFanOut(count: number, channel: string): void { + const message = + `[HUGE_FAN_OUT] A store record's ${channel} channel has ${count} registered ` + + `consumers. Every emission on this record dispatches all of them this flush. ` + + `If many independent consumers ask keyed questions of one record, prefer a ` + + `per-key store or projection so only the keys whose answer flipped update.`; + emitDiagnostic({ + code: "HUGE_FAN_OUT", + kind: "perf", + severity: "warn", + message, + data: { subscribers: count, channel } + }); + console.warn(message); +} + +/** DEV: the record's store path ("store.rows.3") — the name cause chains + * and rerun events use for patch machinery (matches the "store.key" naming + * key nodes get under attribution). */ +function targetPath(t: StoreNextTarget): string { + let s = ""; + let x: StoreNextTarget | null = t; + while (x !== null) { + if (x.pk != null) s = "." + String(x.pk) + s; + x = x.u; + } + return "store" + s; +} + +function bumpAncestors(t: StoreNextTarget): void { + // Origin for ancestor chain stamps (round 10.10, P2): the child's own + // delivery signal (its fresh stamp is the cause) or its path — also + // when the child's channel is DEMOTED (round 10.12): a consumer-less + // dn's last stamp is a stale pre-demotion transition, not this write. + let origin: unknown = undefined; + if (__DEV__ && attrHooks !== null) + origin = + t.pc !== null && (t.pc as any).dn !== null && (t.pc as any).p !== null + ? (t.pc as any).dn + : targetPath(t); + let u = t.u; + while (u !== null) { + if (u.pc !== null) bumpOne(u, u.pc, origin); + u = u.u; + } +} + +function bumpOneOptimistic(t: StoreNextTarget, pc: any, origin?: unknown): void { + const txn = activeTransition === null ? null : currentTransition(activeTransition); + if (pc.de === undefined) { + if (pc.p === null) return; + ensureDelivery(t, pc); + } else if ( + pc.bc !== pc.dv && + txn !== null && + pc.bo != null && + currentTransition(pc.bo as Transition) === txn + ) { + // SAME-TRANSACTION optimistic dedup (round 10.6, P2; canonicalized + // 10.7): the first bump registered the override + revert bookkeeping + // with this transaction; a repeat (tentative reconcile + its setter's + // notifyOptimisticWrites, N nested writes bubbling the same ancestors) + // adds nothing. Stamped separately from plain bumps (`bt`): a plain + // HELD write is not lane-visible — an optimistic bump after one must + // still write. + if (__DEV__ && attrHooks !== null && origin != null) + attrHooks.patchOrigin(pc.dn, origin as any); + return; + } + // Override-armed write: in-flight visibility now, re-notify on revert — + // the engine is installed by every optimistic caller of this seam. + pc.bc++; + pc.bo = txn; + const w = GlobalQueue._optimisticWrite; + if (w !== null && w !== undefined) w(pc.dn, (pc.dn._value ?? 0) + 1); + else setSignal(pc.dn, (v: number) => v + 1); + if (__DEV__ && attrHooks !== null) + attrHooks.patchEmit(pc.dn, targetPath(t), null, null, false, origin as any); +} + +/** Manifest-shaped prev snapshot: roots copied flat, deep paths rebuilt as + * nested literals — compares stay exact even when folds mutate backings in + * place, which is what let forced re-applies retire entirely. */ +function manifestSnapshot(pc: any, next: any): any { + if (next === null || typeof next !== "object") return next; + const snap: any = Array.isArray(next) ? next.slice() : { ...next }; + const dp = pc.dp as DeepNode[] | null; + if (dp !== null) for (let i = 0; i < dp.length; i++) snapNode(dp[i], next, snap); + return snap; +} + +function snapNode(node: DeepNode, src: any, dst: any): void { + if (src === null || typeof src !== "object") return; + const v = src[node.k]; + if (node.c === null) { + dst[node.k] = v; + return; + } + if (v === null || typeof v !== "object") { + dst[node.k] = v; + return; + } + const child: any = Array.isArray(v) ? [] : {}; + dst[node.k] = child; + for (let i = 0; i < node.c.length; i++) snapNode(node.c[i], v, child); +} + +/** What an untracked reader sees RIGHT NOW: optimistic families serve the + * override view, held targets the mask, everyone else committed. THE single + * visibility decision — the queue design made it at five different seams. */ +function visibleView(t: StoreNextTarget, pc?: any): any { + // THROUGH THE PROXY when raw reads can go stale: optimistic families + // (tentative values live in node overrides at any depth) and DEEP-PATH + // channels (eager child adoption swaps nested backings without rewriting + // ancestor raw slots — the queue design's forcedNext made the same + // call). Untracked proxy reads resolve both. Everyone else: the SAME + // hold resolution the store's traps use (heldMaskView checks whether the + // holding transition finished), else committed raw. + if (t.fam?.opt === true) return t.px; + if (pc !== undefined && pc.dp !== null) return t.px; + const hm = heldMaskView(t); + return hm !== null ? hm : t.v; +} + +function ensureDelivery(t: StoreNextTarget, pc: any): void { + if (pc.de !== undefined) return; + // The whole machinery persists once built (see bumpOne): a + // write-time emission held by a transition rides the signal's pending + // commit — tearing anything down with the last consumer dropped that + // delivery, permanently staleing a consumer registered before the settle. + if (pc.dn === null) { + const dn = (pc.dn = signal(0, { equals: false })); + // Arm the override slot (NOT_PENDING) WITHOUT CONFIG_OPTIMISTIC: plain + // bumps keep held-write semantics under transactions, while optimistic + // bumps route through the engine's write with correct revert + // registration (an UNARMED slot reads as an active override there — + // INV-2 caught the miss). + ext(dn)._overrideValue = NOT_PENDING; + // Internal machinery: bumps fire from walk/fold seams that may run + // under owned scopes (the queue design pushed arrays there; a signal + // write must carry the same exemption). + (dn as any)._config |= CONFIG_OWNED_WRITE; + pc.dv = 0; // last dispatched bump count — the pure-registration flush skips + pc.bc = 0; + } + const dn = pc.dn; + // OWNER-NEUTRAL delivery: the channel is shared infrastructure (multi- + // consumer across boundaries) — created under a boundary's computation, + // the effect would land in that boundary's queue and miss lane-timed + // runs (bisected: boundary-owned registrations got no in-flight + // deliveries). Errors still route per-entry to each REGISTRANT's owner. + // + // DETACHED PRIMITIVE (mount pass): deliveryEffect is a bare node with one + // static source — no root, no owner bookkeeping. The node IS pc.de: it + // is never disposed (persistence rule above); a bump with no consumers + // takes the inert `p === null` return, and the record's death releases + // the whole subgraph. The null-owner wrap keeps the queue global. + runWithOwner(null, () => { + pc.de = deliveryEffect( + () => void readSignal(dn), + () => { + if (pc.bc === pc.dv) return; // pure-registration run: baselines are per-entry + // PARKED-TRUTH DEFERRAL (2026-09-01 tear): an OPTIMISTIC-family + // record whose truth is parked under a not-done transaction — a + // landing mid-flight (landings emit at microtask time with NO + // ambient transaction, so transaction stamps can't gate this), a + // fold stage, or the until()-flip steal (the wake outruns the + // steal; the probe follows `_transition` wherever the scheduler + // re-stamps it). With no live override to display, classic emits + // NO frame now — defer without consuming (`dv` stays behind `bc`) + // and ride the holder's commit. With one (a draft mid-flight), + // deliver the lane view now AND redeliver at the reveal. Only + // optimistic families own the lane-timed early-wake rail: plain + // bumps park the delivery signal itself, so their wakes are + // commit-timed by construction — no probe on the dbmon path. + if (t.fam?.opt === true) { + scanHadOverride = false; + const holder = parkedTruthHolder(t, pc); + if (holder !== null) { + deferRedrive(pc, holder); + if (!scanHadOverride) return; + } + } + pc.dv = pc.bc; + if (pc.hh == null) pc.hh = null; + // The delivery CONSUMES any override on the notification signal + // (INV-6, fold audit 6): optimistic bumps arm dn so in-flight + // visibility rides the lane — but dn is PURE NOTIFICATION, and a + // revert-resync bump at another lane's settle can arm it on a + // still-open flight (the projection's) that never resolves in this + // window. Once delivered, the override has no residual meaning — + // drop it like resolveOptimisticNodes would. `_transition` is left + // alone on purpose: a plain bump PARKED under a real transaction + // may still be pending on this node, and its commit bookkeeping + // keys off that stamp. + const dnx = (dn as any)._x; + if (dnx != null && dnx._overrideValue !== undefined && dnx._overrideValue !== NOT_PENDING) { + dnx._overrideValue = NOT_PENDING; + dnx._optimisticLane = undefined; + dnx._overrideOwner = null; + } + // Release the transaction stamps (round 10.7, P1): a delivered + // channel has no pending bump for them to dedup against, and a + // retained stamp would pin the transition object (generators, + // application state) for the record's lifetime. + pc.bt = pc.bo = null; + // The attribution stamp is CONSUMED (round 10.12, P2): a later + // self-emission must not inherit this delivery's child causes. + if (__DEV__ && attrHooks !== null) attrHooks.patchDelivered(pc.dn); + const p = pc.p as PatchEntry[] | null; + if (p === null) { + // Inert (demoted or emptied). A deferred-demotion latch queued for + // consumers that have since left is CONSUMED here (round 10, P2): + // it described a view no one is left to demote for — a later + // plain consumer must not inherit it. + pc.dmq = false; + return; + } + // Wide-dispatch policy lives in the ENGINE (round 10.10, P2): + // same thresholds, memo field, and metadata as graph wide-writes. + if (__DEV__ && attrHooks !== null) + attrHooks.patchDispatch(pc.dn, p.length, "patch template", pc.dn); + // Deferred demotion (tentative getter views): performed HERE — the + // delivery effect is clean, lane-timed effect context, so the + // re-driven bodies subscribe correctly (creations inside a setter's + // write window never track). + if (pc.dmq === true) { + pc.dmq = false; + demoteToEffects(t, true); + return; + } + // Payload fast path (raw-read thesis): self emissions stashed + // their fresh state (bc-tagged against later bumps/reverts) — + // deliveries read it RAW. Proxy resolution only for payload-less + // dispatches (ancestor bumps, optimistic views, holds). + const npHit = pc.np !== undefined && pc.npb === pc.bc; + // PAYLOAD-LESS deliveries re-probe the deep manifest (round 10.5, + // F3): a self emission was probed at its seam, but an ancestor + // BUBBLE was probed only at the CHILD's seam against the child's + // keys — a child-subject adoption can carry a getter into a path + // only THIS channel's bodies read. Cost rides the rare path: dbmon + // ticks are all payload hits and never probe. + if (!npHit && pc.dp !== null && !deepPathsPlain(pc.dp, heldMaskView(t) ?? t.v, t)) { + demoteToEffects(t, true); + return; + } + // Direct object-valued root keys (round 10.6, P1): same currency + // rule for `dp === null` manifests — a stale alias slot serves the + // outgoing object raw; demote so the body reads through the proxy. + // akAll channels (manifest-less consumers) full-scan. + if ( + !npHit && + (pc.ak !== null || pc.akAll === true) && + !rootKeysCurrent(t, heldMaskView(t) ?? t.v, pc.akAll === true ? null : pc.ak) + ) { + demoteToEffects(t, true); + return; + } + const next = npHit ? pc.np : visibleView(t, pc); + pc.np = undefined; + const snap = p.length > 1 ? p.slice() : p; + let firstError: unknown = UNSET; + firstError = applyEntries(snap, next, firstError, pc); + if (firstError !== UNSET) { + // CHANNEL CONTRACT (round-2 pin): every healthy patch applies + // before an unboundaried error crashes the system. A raw rethrow + // here would halt sibling channels' render-phase effects — defer + // the halt one phase so the flush still throws, after siblings. + deferHalt(firstError); + } + } + ); + // Rerun events read as "patchDelivery(store.rows.3) ran ← store.rows.3 + // write" — the machinery names itself for attribution. + if (__DEV__) (pc.de as any)._name = "patchDelivery(" + targetPath(t) + ")"; + }); +} + +/** Shared registration prologue (size pass 2): resolve the record to its + * ULTIMATE backing (§7b — chained backings fold and dispatch there; the + * wrapper's identity is stable and would never fire) and arm the commit + * hooks once. Row hooks arm separately — value-only apps must not retain + * the structural walk. */ +function channelTarget(record: any, api: string): StoreNextTarget { + const t: StoreNextTarget | undefined = record?.[$TARGET]; + if (t === undefined) throw new Error(api + ": not a store record"); + // LATE-MOUNT repair (fold audit P1): adoptions before the FIRST patch + // registration skip the eager parent-slot repair (hasPatches gate) — fix + // this target's own ancestor chain now, or its currency probes read + // stale alias slots and demote the binding to the effect fallback + // forever. + repairAncestorSlots(t); if (!commitHookInstalled) { commitHookInstalled = true; armPatchHooks(); setPatchCommitHook(releaseBatch); GlobalQueue._drainPatchOptimistic = drainOptimistic; } - const entry: PatchEntry = { fn, owner: getOwner() }; + return ultimateTarget(t) ?? t; +} + +/** Shared structural unbind (size pass 2): mark-severed + splice + empty + * list release, identical for row-ops and slot-patch consumers. */ +function structuralUnbind( + entry: object & { u?: boolean }, + list: unknown[], + pc: any, + field: "ro" | "sp", + counted: boolean +): () => void { + let unbound = false; + return () => { + if (unbound) return; + unbound = true; + entry.u = true; // queued structural work skips severed consumers + if (counted) patchCount--; + const idx = list.indexOf(entry); + if (idx >= 0) list.splice(idx, 1); + if (list.length === 0 && pc[field] === list) pc[field] = null; + }; +} + +export function registerPatch(record: any, fn: PatchFn, keys?: Iterable): () => void { + const t = channelTarget(record, "registerPatch"); + const owner = getOwner(); + // Owner queue captured at registration (round 10, P1-4): dispatch defers + // into it while its boundary holds — render-effect parity. + const entry: PatchEntry = { fn, owner, q: (owner as any)?._queue ?? null }; const pc = pcOf(t); + if (__TEST__) devTrackChannel(pc); const list = (pc.p ??= []) as PatchEntry[]; + // A registration that STARTS the consumer list opens a fresh generation: + // a deferred-demotion latch can never predate its consumers (round 10, + // P2 — a stale latch permanently demoted a later plain consumer). + if (list.length === 0) pc.dmq = false; list.push(entry); + if (__DEV__ && shouldWarnGraphSize(list.length)) warnChannelFanOut(list.length, "patch"); + // Accessed-key union (prod-sound adoption demotion). Two sources: + // compiler MANIFESTS (re-audit 7, P1-1 — the static read envelope, + // complete across ternary/logical branches; dot-joined strings mark + // nested chains and split into deep paths) and recording-proxy sets from + // manifest-less callers (executed reads only; hydration registrations + // without a manifest record at their first drain apply instead). + // + // Manifests are INTERNED by array identity: compiled templates share ONE + // manifest literal across every row they bind, so the split/dedup runs + // once and each channel takes the processed arrays BY REFERENCE (list + // mounts were paying ~3 ms/1000 rows for per-row processing). Shared + // arrays are copy-on-write: any later union (a second template on the + // same record, drain-side recording) clones first via ensureOwnedKeys. + if (keys !== undefined) { + if (Array.isArray(keys)) { + const m = internManifest(keys as string[]); + entry.mk = m; // this entry's OWN envelope (demotion computes read it) + if (pc.ak === null && pc.dp === null) { + pc.ak = m.roots; + pc.dp = m.dp; + pc.ks = true; + } else if (pc.ak !== m.roots) { + unionKeys(pc, keys); + } + } else { + // One-shot iterables: materialize once — the union AND the entry's + // own envelope both need the keys. Keys stay PropertyKey (round + // 10.10, P1): stringifying a symbol tracked "Symbol(x)" instead of + // the symbol-keyed property. + const arr = Array.from(keys as Iterable); + entry.mk = internManifest(arr); + unionKeys(pc, arr); + } + } else { + // MANIFEST-LESS consumer (hand-written registerPatch; size pass): the + // read set is unknowable, so adoption gates FULL-SCAN this channel + // (`akAll` poisons the key union — a partial ak from a manifested + // sibling must not narrow probes below this consumer's reads). This + // replaced the drain-side recording proxy: compiled output always + // ships manifests, so only hand-written callers pay the wider probe. + // REF-COUNTED (round 10.9, P2): the poison leaves with the last + // manifest-less consumer — later compiled consumers get manifest- + // -narrow probes back. + entry.ml = true; + entry.mk = null; + pc.mlc = (pc.mlc ?? 0) + 1; + pc.akAll = true; + } patchCount++; + // NO delivery machinery here (mount pass): the signal + effect are built + // by the first consumer-visible bump (see bumpOne) — a list that + // mounts and never updates allocates none of it. + // PER-ENTRY prev baseline (node delivery): the state this consumer's + // initial apply saw — a consumer mounting mid-batch compares against the + // batch's outcome (no duplicate setter writes), while one mounting + // mid-transaction compares against the held view (the commit delivers). + // No counters, no skip rules: the compare IS the decision. + // ZERO-ALLOC baselines: raw backings are immutable after adoption swaps, + // so the baseline is a REFERENCE; the overlay (in-place) fold — the one + // mutator — clones just-in-time via prepareInPlaceFold, exactly where the + // queue design ran clonePrev. Optimistic views are proxies: snapshot + // (UNTRACKED — a tracked spread subscribes the registrant's computation). + (entry as any).pv = + t.fam?.opt === true ? untrack(() => manifestSnapshot(pc, t.px)) : (heldMaskView(t) ?? t.v); // Bindings are subscriptions for reachability (§6d pruning must descend // into bound records). markDescendants(t); @@ -413,6 +1433,10 @@ export function registerPatch(record: any, fn: PatchFn): () => void { if (unbound) return; unbound = true; (entry as any).u = true; // dispatch snapshots skip severed consumers + // A demoted entry's fallback EFFECT dies with its consumer (round + // 10.8, P2): queued (held) or live, unbind disposes its root — it + // neither applies at release nor stays subscribed. + (entry as any).dd?.(); // Decrement ONLY on actual removal: a demotion (demoteToEffects) may // have already pulled this entry and repaired the count — the splice // miss is how this closure learns that. @@ -420,11 +1444,39 @@ export function registerPatch(record: any, fn: PatchFn): () => void { if (idx >= 0) { list.splice(idx, 1); patchCount--; + // The full-scan poison leaves with its consumer (round 10.9, P2). + if (entry.ml === true && --pc.mlc! === 0) pc.akAll = false; + } + if (list.length === 0 && pc.p === list) { + // The delivery machinery (dn/de/bc/dv) persists — held write-time + // emissions must survive consumer churn, and a re-binding row reuses + // the node (see bumpOne's persistence rule). The demotion latch does + // NOT persist (round 10, P2): it belonged to the leaving consumers. + pc.p = null; + pc.dmq = false; } - if (list.length === 0 && pc.p === list) pc.p = null; }; } +/** Resolve a captured RAW record to its live proxy under `list`'s family + * (re-audit 8, P1-2): structural operations carry raw row arrays captured at + * emission — the driver must BIND those records, and indexing the live + * subject instead builds the wrong row once a second operation queues. + * Shallow slot values (never wrapped) resolve to themselves. */ +export function patchProxyFor(list: any, raw: any, key?: PropertyKey): any { + if (raw === null || typeof raw !== "object" || !isWrappable(raw)) return raw; + let t: StoreNextTarget | undefined = list?.[$TARGET]; + if (t === undefined) return raw; + t = ultimateTarget(t) ?? t; + if (t === undefined || t.s === true) return raw; // shallow rows are raw + // Same wrap a live `list[key]` read performs (fresh records create their + // target here — bind-time is their first touch), minus the live indexing. + // Routed through the createTarget-installed hook: the list target's very + // existence proves it is installed, and the indirection keeps the trap/ + // write engine shakeable in store-less bundles. + return wrapRecordHook!(raw, t, key ?? null, t.fam); +} + /** Resolve a target through CHAINED backings (§7b) to the ultimate owner. * A projection family wrapper's backing IS another store's proxy: value * transitions fold on the ULTIMATE target (the wrapper's identity never @@ -446,7 +1498,7 @@ function ultimateTarget(t: StoreNextTarget): StoreNextTarget | undefined { * returns undefined otherwise (driver falls back to the effect path). * Not patchable: non-records, non-proxies, accessor-bearing records * (patches read raw — getters need tracked evaluation), broken chains. */ -export function patchableRaw(record: any): Record | undefined { +export function patchableRaw(record: any, keys?: string[]): Record | undefined { let t: StoreNextTarget | undefined = record?.[$TARGET]; if (t === undefined || t.px !== record || t.a === true) return undefined; t = ultimateTarget(t); @@ -457,7 +1509,25 @@ export function patchableRaw(record: any): Record | undefined // records) never re-apply. Sticky `sc` makes this one probe pass per // record lifetime. if (t === undefined || !targetIsPlain(t)) return undefined; - return t.pb ?? t.v; + // COMMITTED-VISIBLE view (re-audits 8/9): a driver mounting + // mid-transition must render what an untracked reader sees. Adoption + // swaps t.v SPECULATIVELY under a transition (#3074) — the visible truth + // is the held mask until the hold resolves; the held entry's release + // re-applies the resolved state to the mount. + const hm = heldMaskView(t); + const raw = (hm ?? t.v) as Record; + // Manifest deep-path admission (re-audit 8, P1-1): a getter ALREADY + // nested on a declared read path rejects patch admission outright — the + // adoption gates only see FUTURE adoptions. CURRENCY-probed with `t` + // (round 10.5, F2): stale alias slots decline to classic — including + // direct object-valued ROOT keys (round 10.6, P1: `dp === null` + // manifests like ["right"] read the object itself). + if (keys !== undefined) { + const m = internManifest(keys); + if (m.dp !== null && !deepPathsPlain(m.dp, raw, t)) return undefined; + if (!rootKeysCurrent(t, raw, m.roots)) return undefined; + } + return raw; } /** Accessor demotion (design §5): a record that acquires an accessor after @@ -470,6 +1540,19 @@ export function demotePatches(t: StoreNextTarget): PatchEntry[] | null { t.pc.p = null; if (p === null) return null; patchCount -= p.length; + // SEVER as patch consumers (round 10.5, F4; split from `u` in 10.7): + // these entries become effects — any straggler dispatch holding a + // reference (a boundary-held deferred callback, a mid-flight snapshot) + // must skip them, or the body applies once from the effect and AGAIN + // from the stale callback. `dm`, not `u`: an explicit unbind AFTER + // demotion must still be able to cancel the queued redrive, and the + // redrive distinguishes "severed for conversion" from "consumer left". + // Demoted entries stop being PATCH consumers — the full-scan poison + // leaves with them (their fallback effects track their own reads). + for (let i = 0; i < p.length; i++) { + p[i].dm = true; + if (p[i].ml === true && --(t.pc as any).mlc === 0) (t.pc as any).akAll = false; + } // Drain IN PLACE: unbind closures captured this array — a late unbind must // miss its indexOf and not double-decrement the repaired count. return p.splice(0, p.length); @@ -491,29 +1574,145 @@ export function demotePatches(t: StoreNextTarget): PatchEntry[] | null { * lost for demoted rows — the effect lives until the LIST disposes. Rows * only demote when user code defines an accessor on a row record at * runtime. */ -export function demoteToEffects(t: StoreNextTarget): void { +/** In-place folds mutate the committed backing — reference baselines and + * stashed payloads pointing at it must clone/invalidate FIRST (the queue's + * clonePrev moment, now pay-per-overlay-fold instead of per-emission). */ +export function prepareInPlaceFold(t: StoreNextTarget): void { + const pc = t.pc as any; + if (pc === null) return; + const v = t.v; + const p = pc.p as PatchEntry[] | null; + if (p !== null) { + for (let i = 0; i < p.length; i++) { + if ((p[i] as any).pv === v) (p[i] as any).pv = untrack(() => manifestSnapshot(pc, v)); + } + } + if (pc.np === v) pc.np = undefined; // the post-merge bump re-stashes +} + +export function demoteToEffects(t: StoreNextTarget, immediate = false): void { + // Demotion IS a visibility event for ancestors (round 10.11, P1): their + // manifests read INTO this subtree, and the seam that demoted saw a + // change worth emitting. Bubbled HERE — primitive-owned, like every + // other emission — so no fold/landing/trap seam can forget, and the + // already-empty channel (previously demoted, machinery persistent) + // still reaches its ancestors instead of freezing them. Pending-dedup + // makes redundant bubbles free. + bumpAncestors(t); const entries = demotePatches(t); if (entries === null || entries.length === 0) return; const proxy = t.px; - globalQueue.enqueue(EFFECT_RENDER, () => { + // Lane-timed demotions run their re-drives NOW (re-audit 9, P1-4): the + // optimistic drain IS effect timing, and the global render queue is + // stashed by the in-flight action — deferring would postpone the + // tentative view (and the getter's tracked evaluation) to settle. + const redrive = () => { + // PER-ENTRY ISOLATION (round 10, P1-5): a throwing re-drive must not + // abort the loop — every healthy sibling still becomes a live effect, + // errors route to each entry's own boundary, and one unboundaried + // failure defers a single halt AFTER the fanout (the same contract the + // dispatch loop pins). + let firstError: unknown = UNSET; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; + // An explicit unbind AFTER demotion cancels the redrive (round 10.7, + // P2): the consumer left — installing its body as an effect would + // resurrect a subscription nothing owns. (`dm` marks conversion, `u` + // marks departure — only departure cancels.) + if (entry.u === true) continue; if (entry.owner !== null && isDisposed(entry.owner)) continue; const fn = entry.fn; - runWithOwner(entry.owner, () => - createRenderEffect( - () => { - fn(proxy, proxy, false); - }, - () => { - // Block body: a compiled patch body's return value must not be - // mistaken for an effect cleanup. + // HELD owners schedule their initial run through their own queue + // (round 10.6, P1): a synchronous force-apply here would write DOM + // that a collapsed boundary is holding — the same parity rule as + // dispatch's deferHeldEntry. Warm owners keep the immediate run + // (lane-timed demotions NEED it: the global render queue is stashed + // in-flight, and deferral would postpone the tentative view). + const oq = entry.q as any; + const held = queueIsHeld(oq); + // COMPUTE throws are captured PER ENTRY (round 10.8, P1) — a + // throwing getter would otherwise route through the effect's own + // error machinery and halt DURING creation/scheduling, before held + // healthy siblings release. And a FAILED compute must not commit + // (round 10.9, P1): the latch below makes the commit a no-op for + // that run — core saw "success", the entry saw its error routed, + // and recovery (the dependency changing back) re-runs cleanly. + // Manifested entries compute by READING THEIR OWN ENVELOPE (round + // 10.9, P1 — the driver's round-9 rule, shared by demotion): the + // body never runs inside the tracked pass, so NaN fields and + // unstable getters cannot fire DOM writes during compute. PER + // ENTRY, never the channel union: the union would subscribe every + // sibling to every other sibling's keys — and fail every sibling on + // one sibling's throwing getter. Manifest-less entries keep the + // documented dual-run, same as the driver's fallback. + const mk = entry.mk ?? null; + let computeFailed = false; + const compute = () => { + computeFailed = false; + try { + if (mk !== null) { + // Each root reads ONCE (round 10.10, P1): deep roots live in + // BOTH mk.roots and mk.dp — descending from the already-read + // value instead of re-reading keeps unstable getters tracking + // exactly the value the envelope observed. `dpr` is the + // root-aligned index (round 10.11, P2 — linear, not + // roots × deep-roots). + const roots = mk.roots; + const dpr = mk.dpr; + for (let k = 0; k < roots.length; k++) { + const v = (proxy as any)[roots[k]]; + const node = dpr !== null ? dpr[k] : null; + if (node !== null) readDeepChildren(node, v); + } + } else fn(proxy, proxy, false); + } catch (err) { + computeFailed = true; + if (!routeEntryError(entry, err)) deferHalt(err); + } + }; + // FIRST scheduled run is per-entry isolated (round 10.7, P1): the + // queued initial applies run back-to-back at release. Later runs + // keep classic effect error semantics. + let first = held; + const commit = () => { + if (computeFailed) return; // the tracked pass failed — no apply + if (first) { + first = false; + try { untrack(() => fn(proxy, undefined, true)); + } catch (err) { + if (!routeEntryError(entry, err)) deferHalt(err); } - ) - ); + return; + } + // Block body: a compiled patch body's return value must not be + // mistaken for an effect cleanup. + untrack(() => fn(proxy, undefined, true)); + }; + try { + // OWN ROOT per re-driven entry (round 10.8, P2): the entry's + // unbind disposes it — an explicit unbind after the fallback + // effect exists (queued OR live) cancels the effect and its + // subscriptions. TRANSPARENT (round 10.9, P2): the root shares its + // parent's id, so demotion keeps the classic fallback's + // owner/hydration-ID depth. + runWithOwner(entry.owner, () => + createRoot( + d => { + (entry as any).dd = d; + createRenderEffect(compute, commit, held ? { schedule: true } : undefined); + }, + { transparent: true } + ) + ); + } catch (err) { + if (!routeEntryError(entry, err) && firstError === UNSET) firstError = err; + } } - }); + if (firstError !== UNSET) deferHalt(firstError); + }; + if (immediate) redrive(); + else globalQueue.enqueue(EFFECT_RENDER, redrive); } // --------------------------------------------------------------------------- @@ -537,38 +1736,52 @@ export type RowOpsFn = (next: any[], ops: RowOps | null) => void; interface RowOpsEntry { fn: RowOpsFn; owner: Owner | null; + /** Unbound mark (queued structural work skips severed consumers). */ + u?: boolean; + /** Registrant's owner queue (round 10.13): structural dispatch defers + * into it while a boundary hold is active — render-effect parity. */ + q?: unknown; + /** Deferred-into-held-queue dedup flag. */ + hq?: boolean; + /** Per-index deferred-slot dedup (fold audit P1). */ + hqs?: Set; + /** APPLIED structural version (version-chain redesign): initialized to + * the channel's VISIBLE version at registration — exactly what the + * entry's first read covered. Ops apply only on an unbroken chain. */ + av?: number; + /** Marked for the flush-end resync (a version gap was observed). */ + rs?: boolean; } /** Register a structural-ops consumer on a keyed store array (the list * container's channel — what `For` consumes through the seam). */ export function registerRowOps(array: any, fn: RowOpsFn): () => void { - let t: StoreNextTarget | undefined = array?.[$TARGET]; - if (t === undefined) throw new Error("registerRowOps: not a store array"); - // Chained backings resolve to the ULTIMATE owner, same as registerPatch - // (§7b) — the walk/fold emits there (re-audit blocker 4). - t = ultimateTarget(t) ?? t; + const t = channelTarget(array, "registerRowOps"); armRowHooks(); - if (!commitHookInstalled) { - commitHookInstalled = true; - armPatchHooks(); - setPatchCommitHook(releaseBatch); - GlobalQueue._drainPatchOptimistic = drainOptimistic; - } - const entry: RowOpsEntry = { fn, owner: getOwner() }; + const rowner = getOwner(); const pc = pcOf(t); + const rq = (rowner as any)?._queue ?? null; + const entry: RowOpsEntry = { + fn, + owner: rowner, + q: rq, + // Speculative-scope init (fold audit 3, P1): a consumer rendering under + // a HOLDING boundary queue reads the speculative view — its baseline + // covers the stashed emissions too (sv). The old `activeTransition` + // probe missed PARKED windows (the flag is execution-scoped; the hold + // persists). Ambient mounts read committed truth (svv) and receive the + // stashed ops at release. + av: ((queueIsHeld(rq) ? ((pc as any).sv as number) : ((pc as any).svv as number)) ?? + 0) as number + }; + if (__TEST__) devTrackChannel(pc); const list = (pc.ro ??= []) as RowOpsEntry[]; list.push(entry); + if (__DEV__ && shouldWarnGraphSize(list.length)) + warnChannelFanOut(list.length, "row-ops (structural list)"); patchCount++; markDescendants(t); - let unbound = false; - return () => { - if (unbound) return; - unbound = true; - patchCount--; - const idx = list.indexOf(entry); - if (idx >= 0) list.splice(idx, 1); - if (list.length === 0 && pc.ro === list) pc.ro = null; - }; + return structuralUnbind(entry, list, pc, "ro", true); } /** Slot patches (shallow arrays) ride the same apply queue: the walk emits @@ -577,13 +1790,24 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { export function emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev: any): void { const sp = t.pc !== null ? t.pc.sp : null; if (sp === null) return; + // SNAPSHOT of entry references (re-audit 7, P1-5): structural work is + // baseline-relative — a consumer registering between emission and drain + // initialized from CURRENT state and must not receive it. Unbinds still + // sever queued work through the shared entries' `u` marks (re-audit 6). push({ - list: sp.map(e => ({ owner: e.owner, fn: () => e.fn(index, next, prev) })), + list: sp.slice() as unknown as PatchEntry[], next, prev, force: false, - t: null + t: null, + si: index, + pc: t.pc as PatchChannel, + svAt: ((t.pc as any).svs = ((t.pc as any).svs as number) + 1) }); + // Walk/fold state is EAGERLY visible (only notifications batch — fold + // audit 4): every reader from this moment has the tick's state in its + // init read, PARKED windows included. + (t.pc as any).svvs = (t.pc as any).svs; } /** Slot patch for shallow arrays: the reconcile walk emits (index, next, @@ -594,32 +1818,25 @@ export function registerSlotPatchNext( arr: any, fn: (index: number, next: any, prev: any) => void ): () => void { - let t: StoreNextTarget | undefined = arr?.[$TARGET]; - if (t === undefined) throw new Error("registerSlotPatchNext: not a store array"); - // Chained backings resolve to the ULTIMATE owner, same as registerPatch - // (§7b) — the walk emits slot ticks there (re-audit blocker 4). - t = ultimateTarget(t) ?? t; + const t = channelTarget(arr, "registerSlotPatchNext"); armRowHooks(); - if (!commitHookInstalled) { - commitHookInstalled = true; - armPatchHooks(); - setPatchCommitHook(releaseBatch); - GlobalQueue._drainPatchOptimistic = drainOptimistic; - } // Multi-consumer (external audit): one shallow array can drive several // lists — registrations are a list, unbinds splice their own entry. const pc = pcOf(t); - const entry = { fn, owner: getOwner() }; - (pc.sp ??= []).push(entry); - markDescendants(t); - let unbound = false; - return () => { - if (unbound || pc.sp === null) return; - unbound = true; - const idx = pc.sp.indexOf(entry); - if (idx >= 0) pc.sp.splice(idx, 1); - if (pc.sp.length === 0) pc.sp = null; + const sowner = getOwner(); + const sq = (sowner as any)?._queue ?? null; + const entry = { + fn, + owner: sowner, + q: sq, + av: (((pc as any).svvs as number) ?? 0) as number }; + const list = (pc.sp ??= []) as unknown[]; + list.push(entry); + if (__DEV__ && shouldWarnGraphSize(list.length)) + warnChannelFanOut(list.length, "slot-patch (shallow list)"); + markDescendants(t); + return structuralUnbind(entry, list, pc, "sp", false); } /** Row-ops ride the SAME apply queue/timing as record patches: transition- @@ -628,16 +1845,19 @@ export function registerSlotPatchNext( export function emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void { const list = (t.pc !== null ? t.pc.ro : null) as RowOpsEntry[] | null; if (list === null) return; + // Snapshot at emission, unbind safety via `u` marks — see emitSlotPatch. push({ - list: list.map(e => ({ - owner: e.owner, - fn: (n: any, _p: any) => e.fn(n as any[], ops) - })), + list: list.slice() as unknown as PatchEntry[], next, prev: null, force: false, - t: null + t: null, + ops, + pc: t.pc as PatchChannel, + svAt: ((t.pc as any).sv = ((t.pc as any).sv as number) + 1) }); + // Adoption commits eagerly (fold audit 4): visible at emission, always. + (t.pc as any).svv = (t.pc as any).sv; } // Pay-for-use seams: the write paths (store/reconcile/optimistic) emit @@ -654,10 +1874,52 @@ function armPatchHooks(): void { installPatchHooks({ emitPatch, emitPatchLocal, + emitPatchAncestors, + emitPatchAncestorsOptimistic, emitPatchOptimistic, hasPatches, - demoteToEffects + demoteToEffects, + prepareInPlaceFold }); + if (__TEST__) InvariantHooks.patchQuiescent = devPatchQuiescent; +} + +// --------------------------------------------------------------------------- +// Test-mode channel invariants (PINV, re-audit 7) — the audits kept finding +// accounting/retention bugs one instance at a time; these assert the ledger +// itself at every quiescence point. Pattern: core/invariants.ts. + +const devChannels = __TEST__ ? new Set() : (null as never); + +function devTrackChannel(pc: unknown): void { + if (__TEST__) devChannels.add(pc); +} + +function devPatchQuiescent(): void { + let live = 0; + for (const pc of devChannels) { + const p = pc.p as unknown[] | null; + const ro = pc.ro as unknown[] | null; + if (p === null && ro === null && pc.sp === null) { + devChannels.delete(pc); + } + live += (p?.length ?? 0) + (ro?.length ?? 0); + assertInvariant( + pc.bc === undefined || pc.dv === undefined || pc.bc === pc.dv || p === null, + "PINV-2", + "a live channel has undispatched bumps at quiescence — a delivery effect was never scheduled or lost its subscription" + ); + } + assertInvariant( + patchCount === live, + "PINV-1", + `patchCount (${patchCount}) diverged from the live registration ledger (${live}) — an unbind/demotion path double-counted or leaked` + ); + assertInvariant( + queue === null && optQueue === null, + "PINV-3", + "the patch apply queue is non-empty at quiescence — queued applications can never run (a release/schedule path lost its drain)" + ); } function armRowHooks(): void { diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 775f7c4bf..b82b9d9f0 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -44,7 +44,8 @@ import { targetsEqual, notifyKeyValue, unwrapValue, - targetIsPlain + targetIsPlain, + targetKeysPlain } from "./store.js"; import { ownedRaw, @@ -54,7 +55,7 @@ import { optHooks } from "./target.js"; import { getWriteOverride } from "../store.js"; -import { projectionWriteActive } from "../../core/scheduler.js"; +import { activeTransition, projectionWriteActive } from "../../core/scheduler.js"; type KeyFn = (item: any) => any; @@ -64,6 +65,36 @@ export function reconcileNextState( key: string | KeyFn | null | undefined, replace = false ): void { + const outcome = reconcileTop(value, state, key, replace); + if (outcome === "unchanged") return; // no divergence — nothing to bubble + const tentative = outcome === "tentative"; + // Ancestor bubble for TARGETED reconciles (re-audit 7): the walk emits + // locally for its own subtree — parents above the walk ROOT read into it + // through nested compiled chains and must force-re-apply, exactly as a + // nested setter write bubbles. One null check when no patches exist. + // TENTATIVE (optimistic) walks bubble at LANE timing plus a settle-held + // twin (re-audit 8, P1-3): in-flight ancestors show the tentative view, + // settle/revert re-applies resolved truth. + if (patchHooks !== null && patchHooks.hasPatches()) { + const t: StoreNextTarget | undefined = state?.[$TARGET]; + if (t !== undefined && t.u !== null && !tentative) { + // Tentative walks already bubbled: the tentative gate's own + // emitPatchOptimistic bubbles internally, and the lane path has no + // pending-dedup — a second walk here DUPLICATED ancestor deliveries + // (round 10.5, F7). The non-tentative walk keeps this bubble for + // changed-paths whose emissions were skipped; pending-dedup makes it + // free when the walk's own emissions already covered it. + patchHooks.emitPatchAncestors(t); + } + } +} + +function reconcileTop( + value: any, + state: any, + key: string | KeyFn | null | undefined, + replace = false +): "changed" | "unchanged" | "tentative" { 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) @@ -81,9 +112,9 @@ export function reconcileNextState( // 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 + if (prev === value) return "unchanged"; // already chained to this store adoptPB(t, value); - return; + return "changed"; } const incoming = unwrapValue(value); if (keyFn) { @@ -105,7 +136,7 @@ export function reconcileNextState( // resolving to this proxy; re-handed later it wraps fresh. (t.fam?.map ?? storeNextLookup).delete(t.pb ?? t.v); adoptPB(t, incoming); - return; + return "changed"; } } // Tentative channel (§6b, RUL-5): a user-context reconcile on an optimistic @@ -115,9 +146,28 @@ export function reconcileNextState( // existing child targets instead of overriding their parent slots. if (t.fam?.opt === true && !projectionWriteActive && !getWriteOverride()) { optHooks!.applyTentative(t, incoming, keyFn); - return; + // Tentative SELF visibility (re-audit 9, P1-4 root): engine overrides + // notify effects through nodes, but the record's own patch channel + // never heard about the walk. ACCESSOR GATE HERE (node delivery): a + // getter-bearing tentative view must demote — deliveries read raw/ + // proxy untracked, so the getter's dependencies would never re-apply. + if (patchHooks !== null && t.pc !== null && t.pc.p !== null) { + // Probe the INCOMING object: the view materializes values (getters + // already invoked), so accessors are only visible on the input. + // Getter-bearing views DEMOTE AT DELIVERY (pc.dmq): effects created + // inside this setter's write window never subscribe — the delivery + // effect performs the demotion from clean lane-timed effect context. + if (!targetKeysPlain(t, incoming)) (t.pc as any).dmq = true; + patchHooks.emitPatchOptimistic(t, null, t.v); + } + return "tentative"; } + // The sound identity skip (O7) means NO divergence — mirror it here so + // unchanged reconciles don't force ancestor re-applies (re-audit 9, P2). + const prev2 = t.pb ?? t.v; + if (incoming === prev2 && !ownedRaw.has(prev2)) return "unchanged"; applyAdopt(t, incoming, keyFn, replace); + return "changed"; } function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj = false): void { @@ -143,26 +193,22 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj // visits parents before children, so ancestors emitted already. EAGER // only — family targets' visibility moment is their fold commit // (drainFolds emits there; emitting here too would double-fire). - if (patchHooks !== null && eager && t.pc !== null && t.pc.p !== null) { - // Accessor demotion at the ADOPTION seam is DEV-ONLY (prod principle: - // explicitly-odd input must not cost correct-input prod — the - // per-adoption scan was ~12% of dbmon's tick since adoptPB resets the - // verdict every adoption). Dev demotes AND warns; prod emits directly, - // so a getter adoptee's OUTSIDE deps (signals) won't re-apply in prod — - // caught loudly during development instead. Registration-time admission - // (patchableRaw) keeps its full one-time scan in both modes. - if (__DEV__ && !targetIsPlain(t)) { - console.warn( - "A reconcile adopted an object with own getters into a record that " + - "carries compiled patches. Patches read raw values and will not " + - "track the getters' reactive dependencies — this record's patches " + - "are demoted to effects in development, but production will NOT " + - "demote. Avoid getters on patched records, or key them out of " + - "patch-eligible templates." - ); - patchHooks.demoteToEffects(t); - } else { + if (patchHooks !== null && eager && t.pc !== null && (t.pc.p !== null || t.pc.dn !== null)) { + // Accessor demotion at the ADOPTION seam, PROD-SOUND (re-audit 6 + // reversed the earlier dev-only trade; re-audit 7 made the probe + // STATELESS against `incoming` — the object the queued bodies will + // actually read, which in setter drafts is not target.v). Recorded-key + // channels pay O(|ak|); unrecorded ones a fresh scan of the adoptee. + if (targetKeysPlain(t, incoming)) { patchHooks.emitPatchLocal(t, incoming, old); + } else { + if (__DEV__) + console.warn( + "A reconcile adopted an object whose getters shadow keys read by " + + "this record's compiled patches — the patches are demoted to " + + "tracked effects so the getters' reactive dependencies apply." + ); + patchHooks.demoteToEffects(t); } } // Shallow adoption: records are slot values — sticky raw-mark the incoming @@ -494,21 +540,43 @@ const identityKey = (r: any) => unwrapValue(r); export function sameKey(a: any, b: any): boolean { return a === b || (a !== a && b !== b); } -export function emitSetterRowOps(t: StoreNextTarget, prevRows: any[], nextRows: any[]): void { - const ops = buildIdentityRowOps(prevRows, nextRows); - if (ops !== null) rowHooks!.emitRowOps(t, nextRows, ops); +export function emitSetterRowOps( + t: StoreNextTarget, + prevRows: any[], + nextRows: any[], + key?: KeyFn +): boolean { + const ops = buildIdentityRowOps(prevRows, nextRows, key); + if (ops === null) return false; + rowHooks!.emitRowOps(t, nextRows, ops); + return true; } /** Identity-keyed structural diff, returned rather than emitted: shared by * the setter channel (regular queue) and the OPTIMISTIC write channel (lane * queue) — same retention semantics, different dispatch timing. Returns * null when the lists are identity-aligned (no structure changed). */ -export function buildIdentityRowOps(prevRows: any[], nextRows: any[]): RowOps | null { +export function buildIdentityRowOps(prevRows: any[], nextRows: any[], key?: KeyFn): RowOps | null { + // Staged-reveal callers resolve identity through the FAMILY MAP (fold + // audit 2, P1): the fold re-seats retained rows' raws, and raw-keyed + // matching rebuilt rows whose proxies never changed. + const k = key ?? identityKey; let p = 0; const min = prevRows.length < nextRows.length ? prevRows.length : nextRows.length; - while (p < min && unwrapValue(prevRows[p]) === unwrapValue(nextRows[p])) p++; + // KIND-AWARE alignment (fold audit 6): primitive rows compare by VALUE — + // `keyFn` probing a primitive yields undefined on BOTH sides, falsely + // aligning different values — and an object keyed to a primitive id must + // never align with a primitive row OF that value. + while (p < min) { + const pu = unwrapValue(prevRows[p]); + const nu = unwrapValue(nextRows[p]); + const po = pu !== null && typeof pu === "object"; + if (po !== (nu !== null && typeof nu === "object")) break; + if (po ? k(prevRows[p]) !== k(nextRows[p]) : pu !== nu) break; + p++; + } if (p === prevRows.length && p === nextRows.length) return null; - return buildRowOps(prevRows, nextRows, p, identityKey); + return buildRowOps(prevRows, nextRows, p, k); } /** Shared row-ops builder (keyed deep branch + shallow/positional branch): @@ -524,6 +592,9 @@ function buildAndEmitRowOps( rowHooks!.emitRowOps(t, nextRows, buildRowOps(prevRows, nextRows, structStart, keyFn)); } +/** Sentinel: `undefined` rows (and sparse holes) as a matchable value. */ +const UNDEF_ROW = Symbol(); + function buildRowOps( prevRows: any[], nextRows: any[], @@ -537,36 +608,52 @@ function buildRowOps( // indices and each is consumed ONCE — first-wins reuse would hand the same // source (and its one DOM row) to multiple next positions. The no-dup fast // shape stays a bare number; collisions upgrade to a queue. + // TWO KEY SPACES (fold audit 6, P1): object rows key through `keyFn`, + // whose result is often a primitive id — sharing one map with + // value-keyed primitive rows collided `5` with `{ id: 5 }` and handed a + // moved primitive an object row's source (fold audit 5 introduced the + // primitive lane). `undefined` rows and sparse holes participate via a + // sentinel: a plain move of `undefined` retains its node like any value. let oldIndexByKey: Map | null = null; + let oldIndexByVal: Map | null = null; if (keyFn !== null && structStart < plen) { oldIndexByKey = new Map(); + oldIndexByVal = new Map(); for (let j = structStart; j < plen; j++) { const p = unwrapValue(prevRows[j]); + let m: Map; + let pk: any; if (p !== null && typeof p === "object") { - const pk = keyFn(p); + pk = keyFn(p); if (pk === undefined) continue; - const existing = oldIndexByKey.get(pk); - if (existing === undefined) oldIndexByKey.set(pk, j); - else if (Array.isArray(existing)) existing.push(j); - else oldIndexByKey.set(pk, [existing, j]); + m = oldIndexByKey; + } else { + pk = p === undefined ? UNDEF_ROW : p; + m = oldIndexByVal; } + const existing = m.get(pk); + if (existing === undefined) m.set(pk, j); + else if (Array.isArray(existing)) existing.push(j); + else m.set(pk, [existing, j]); } } const consumed = oldIndexByKey !== null ? new Set() : null; for (let k = structStart; k < nlen; k++) { - const nv = nextRows[k]; + const nv = unwrapValue(nextRows[k]); let oldIdx = -1; - if (nv !== null && typeof nv === "object" && oldIndexByKey !== null) { - const nk = keyFn!(nv); + if (oldIndexByKey !== null) { + const isObj = nv !== null && typeof nv === "object"; + const m0 = isObj ? oldIndexByKey : oldIndexByVal!; + const nk = isObj ? keyFn!(nextRows[k]) : nv === undefined ? UNDEF_ROW : nv; if (nk !== undefined) { - const m = oldIndexByKey.get(nk); + const m = m0.get(nk); if (m !== undefined) { if (Array.isArray(m)) { oldIdx = m.shift()!; - if (m.length === 1) oldIndexByKey.set(nk, m[0]); + if (m.length === 1) m0.set(nk, m[0]); } else { oldIdx = m; - oldIndexByKey.delete(nk); + m0.delete(nk); } consumed!.add(oldIdx); } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index b577bf09b..9d601d5af 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -85,6 +85,7 @@ import { witnessAffectsMark } from "../store.js"; import { + type DeepNode, devAssertNeverUserMutation, ingestedRaw, markDescendants, @@ -99,7 +100,7 @@ import { // channel tree-shakes out of apps that never register a patch consumer. // Every call is `t.pc`-guarded — a target only acquires `pc` through // patch.js registration, which installs the hooks first. -import { patchHooks, rowHooks } from "./patch-hooks.js"; +import { patchHooks, rowHooks, installWrapRecordHook, wrapRecordHook } from "./patch-hooks.js"; // --------------------------------------------------------------------------- // wrap / dedupe @@ -147,7 +148,35 @@ TargetShape.prototype = Object.prototype; /** Lazily allocate the patch-channel extension (one literal shape). */ export function pcOf(t: StoreNextTarget): PatchChannel { - return t.pc ?? (t.pc = { sp: null, p: null, ro: null, wk: null, qa: null, qe: null }); + return ( + t.pc ?? + (t.pc = { + sp: null, + p: null, + ro: null, + wk: null, + dn: null, + de: undefined, + dv: 0, + bc: 0, + np: undefined, + npb: 0, + dmq: false, + bt: null, + bo: null, + hh: null, + ak: null, + dp: null, + ks: false, + akAll: false, + mlc: 0, + sv: 0, + svv: 0, + svs: 0, + svvs: 0, + t + }) + ); } function createTarget( @@ -186,6 +215,7 @@ function createTarget( t.del = null; t.hv = null; t.ht = null; + if (wrapRecordHook === null) installWrapRecordHook(wrapNext); t.px = new Proxy(t, traps); // Legacy interop: shared machinery (affects walks, wrap dedupe) reads the // proxy off looked-up targets as a field. @@ -415,9 +445,125 @@ function cloneRaw(source: Record, t?: StoreNextTarget): Record /** Scanned plainness for patch admission (patchableRaw): runs the one-time * accessor scan if it hasn't happened yet — the sticky `a` flag alone is not - * trustworthy before a scan (it starts false and is discovered lazily). */ + * trustworthy before a scan (it starts false and is discovered lazily). + * Prototype gate first (re-audit 7, P1-2b): class instances are wrappable + * store input whose accessors live on the PROTOTYPE — own-key scans never + * see them, so non-plain prototypes reject patch admission wholesale (their + * records keep tracked-effect semantics). */ export function targetIsPlain(target: StoreNextTarget): boolean { - return target.sc ? !target.a : scanAccessorsOnce(target); + return isPlainProto(target.v) && (target.sc ? !target.a : scanAccessorsOnce(target)); +} + +/** Adoption-seam demotion gate, PROD-SOUND at bounded cost (re-audit 6): + * probes ONLY the keys the record's compiled bodies actually read. + * STATELESS against the emission's actual `next` object (re-audit 7, + * P1-2a): sticky scan flags describe whatever backing was scanned last — + * at adoption seams the object the bodies will read is the INCOMING one + * (in setter drafts it is not even target.v yet), so the probe takes it + * explicitly. Unrecorded channels (registered under hydration, never yet + * applied) get a full fresh scan of the same object. */ +export function targetKeysPlain(target: StoreNextTarget, next: Record): boolean { + if (!isPlainProto(next)) return false; + // akAll (size pass): a manifest-less consumer's reads are unknowable — + // the union is poisoned and every probe FULL-SCANS (replaces the + // drain-side recording proxy; compiled output always ships manifests). + const ak = target.pc !== null && target.pc.akAll !== true ? target.pc.ak : null; + if (ak === null) { + for (const key of Reflect.ownKeys(next)) { + if (lookupGetter.call(next, key) !== undefined || lookupSetter.call(next, key) !== undefined) + return false; + } + return true; + } + for (let i = 0; i < ak.length; i++) + if (lookupGetter.call(next, ak[i]) !== undefined) return false; + const dp = target.pc !== null ? target.pc.dp : null; + return dp === null || deepPathsPlain(dp, next, target); +} + +/** Flat-key alias currency (round 10.6, P1): a manifest ROOT key whose + * value is a RAW object must still be the CURRENT backing of its target — + * `["right"]` reads the object itself, so a stale alias slot (the target + * adopted a different backing; only the canonical parent chain was + * path-copied) would hand the body the outgoing state with no pending + * delivery to correct it. Deep paths get the same probe inside + * deepPathsPlain; this covers the `dp === null` direct-object manifests. + * Primitive values skip on a typeof; proxies are always current. */ +export function rootKeysCurrent( + t: StoreNextTarget, + view: any, + keys: PropertyKey[] | null +): boolean { + if (view === null || typeof view !== "object") return true; + const map = t.fam?.map ?? storeNextLookup; + const ks = keys ?? Reflect.ownKeys(view); // null = full scan (akAll channels) + for (let i = 0; i < ks.length; i++) { + const v = (view as any)[ks[i]]; + if (v !== null && typeof v === "object" && (v as any)[$TARGET] === undefined) { + const ct = map.get(v); + if (ct !== undefined && (ct.pb ?? ct.v) !== v) return false; + } + } + return true; +} + +/** Walk the manifested deep-path PREFIX TREE through `next`, probing every + * step for accessors and plain prototypes — shared prefixes probe exactly + * once. A branch that leaves objects stops probing (the body's own read + * would fault/short there, not hit a getter). Steps landing on store + * proxies probe the raw backing. Root nodes skip their own getter probe — + * `ak` (probed by the caller against the record) covers first segments. + * With `t` given, interior RAW steps are also CURRENCY-probed (round 10.5, + * F2): a raw child whose target has since adopted a different backing is a + * stale alias path — eager path-copying repairs the canonical parent + * chain, but a second parent sharing the same raw keeps the old slot, and + * a body reading through it would render the outgoing state. Diverged = + * not plain = decline (classic reads through the proxy and stays right). */ +export function deepPathsPlain(dp: DeepNode[], next: any, t?: StoreNextTarget): boolean { + const map = t !== undefined ? (t.fam?.map ?? storeNextLookup) : null; + for (let i = 0; i < dp.length; i++) { + if (!deepNodePlain(dp[i], next, true, map)) return false; + } + return true; +} + +function deepNodePlain( + node: DeepNode, + parent: any, + rootProbed: boolean, + map: { get(k: object): StoreNextTarget | undefined } | null +): boolean { + if (!rootProbed && lookupGetter.call(parent, node.k) !== undefined) return false; + const children = node.c; + if (children === null) return true; // leaf: the key probe was the work + let o: any = parent[node.k]; + // FUNCTIONS are accessor carriers too (re-audit 9, P1-8) — and their + // prototype is never plain, so descending demotes them conservatively. + if (o === null || (typeof o !== "object" && typeof o !== "function")) return true; + const inner: StoreNextTarget | undefined = o[$TARGET]; + if (inner !== undefined) o = inner.pb ?? inner.v; + else if (map !== null) { + const ct = map.get(o); + if (ct !== undefined && (ct.pb ?? ct.v) !== o) return false; // stale alias slot + } + if (!isPlainProto(o)) return false; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + // Leaves inline (they dominate real manifests — dbmon is 10 leaves per + // 6 interior nodes; the recursion frames were ~20% of the probe). + if (child.c === null) { + if (lookupGetter.call(o, child.k) !== undefined) return false; + } else if (!deepNodePlain(child, o, false, map)) return false; + } + return true; +} + +/** Patch-admission prototype gate. Distinct from the overlay path's own-key + * scan (`scanAccessorsOnce`): overlays remain VALID over class prototypes + * (reads fall through the chain), so `a` keeps meaning own accessors only. */ +function isPlainProto(o: object): boolean { + const p = Reflect.getPrototypeOf(o); + return p === Object.prototype || p === Array.prototype || p === null; } /** One-time own-accessor scan (Annex-B probes, no descriptor allocation); @@ -486,6 +632,13 @@ function ensurePB(target: StoreNextTarget): Record { pb = target.pb = null; } if (activeTransition !== null) foldBatches.set(target, activeTransition); + // STAGED-TRUTH fold marker (fold audit P1): an optimistic-family draft + // written under the AUTHORITATIVE posture is a truth landing staging into + // a retaining transaction (stageLanding / the projection channel) — its + // eventual fold commits REAL truth, and the structural channels must hear + // it (the `opt !== true` gates below exist for OVERRIDE materializations, + // which ride the lane; staged truth is not one). + if (projectionWriteActive && target.fam?.opt === true) target.sf = true; if (pb === null) { // Prototype-chain overlay (#3044): plain-data non-array containers // outside projection/optimistic families open drafts in O(1) — own keys @@ -546,7 +699,7 @@ let latestPullActive = false; * while the hold is live, and lazily clears a hold whose transition has * committed (transitions merge — resolve through currentTransition, same as * foldHeld's node stamps). */ -function heldMaskView(t: StoreNextTarget): Record | null { +export function heldMaskView(t: StoreNextTarget): Record | null { const ht = t.ht; if (ht === null) return null; if (ht !== PLAIN_HOLD && currentTransition(ht)?._done === true) return (t.ht = t.hv = null); @@ -600,9 +753,35 @@ export function adoptPB( target.sc = false; target.a = false; if (target.pc !== null) target.pc.wk = null; // adoption supersedes staged trap writes + const old = target.v; target.v = incoming; target.ch = (incoming as any)[$TARGET] !== undefined; (target.fam?.map ?? storeNextLookup).set(incoming, target); + // Eager path copying (round 10, P1-1): a child-subject adoption is + // immediately visible to every reader — including a LATER mount reading + // the ANCESTOR's committed raw. The fold drain path-copies for queued + // adoptions; the eager walk (which skips the queue by design) must do + // the same, or the ancestor's raw slot serves the outgoing backing with + // no pending delivery to correct it. + // + // GATED ON PATCHES EXISTING (perf audit): ancestor committed raws are + // only ever handed out to PATCH consumers (patchableRaw / delivery + // payloads) — classic readers resolve through proxies and nodes. In a + // patch-less app the repair's privatization cascade re-cloned every + // freshly adopted interior backing per reconcile (an extra tree copy), + // and the identity swap turned downstream equality gates into keyset/ + // deep bump storms (−11% on the listened-paths bench, zero channels). + if ( + eager && + target.u !== null && + patchHooks !== null && + patchHooks.hasPatches() && + target.u.v[target.pk!] === old + ) { + privatizeCommitted(target.u); + devAssertNeverUserMutation(target.u.v); + target.u.v[target.pk!] = incoming; + } if (__TEST__ && ingestedRaw && !ownedRaw.has(incoming)) ingestedRaw.add(incoming); } @@ -654,6 +833,35 @@ export const stagedTruthPB = new WeakMap(); /** Committed-time privatization for parent-chain slot updates (path copying). */ +/** Registration-time ancestor-slot repair (fold audit P1): the per-adoption + * eager repair is gated on patches EXISTING — sound for apps that never + * register one, but a LATE-mounted binding (first registration after + * adoptions already ran) would find stale ancestor raw slots, fail its + * currency probes, and fall PERMANENTLY onto the effect fallback. Repair + * the registered target's own ancestor chain once, at registration cost. */ +export function repairAncestorSlots(t: StoreNextTarget): void { + let c: StoreNextTarget = t; + while (c.u !== null && c.pk !== null) { + const parent = c.u; + const slot = parent.v[c.pk]; + // ONLY a stale alias of THIS SAME child (its outgoing backing left in + // the parent's raw by a pre-registration eager adoption). Never write + // when the slot holds something else — a TENTATIVE row registering + // mid-flight has no committed slot at all, and writing its backing + // here would leak optimism into committed truth (the equivalence + // matrix caught exactly that). + if (slot !== c.v && slot !== null && typeof slot === "object") { + const owner = (parent.fam?.map ?? storeNextLookup).get(slot as object); + if (owner === c) { + privatizeCommitted(parent); + devAssertNeverUserMutation(parent.v); + parent.v[c.pk] = c.v; + } + } + c = parent; + } +} + function privatizeCommitted(target: StoreNextTarget): void { if (ownedRaw.has(target.v)) return; const clone = cloneRaw(target.v, target); @@ -668,7 +876,17 @@ function privatizeCommitted(target: StoreNextTarget): void { } } +/** Fold-drain epoch (fold audit 4, P1): `rf` reveal marks are stamped with + * it and expire with the flush — a boolean lingered on descendant-retained + * roots outside `overlaid` (the settle loop never visited them to consume + * it) and suppressed a LATER revert's resync. */ +let foldEpoch = 0; +export function currentFoldEpoch(): number { + return foldEpoch; +} + function drainFolds(): void { + foldEpoch++; if (foldOlds.size === 0) return; const entries = [...foldOlds]; foldOlds.clear(); @@ -728,6 +946,9 @@ function drainFolds(): void { continue; } if (t.ovl) { + // Reference baselines clone BEFORE the in-place merge (node + // delivery — the queue's clonePrev moment). + if (t.pc !== null && patchHooks !== null) patchHooks.prepareInPlaceFold(t); // Overlay flatten (#3044): apply this batch's writes onto an OWNED // committed backing in place — O(written), not O(container). The // backing keeps its identity, so the `t.v === old` gate below skips @@ -750,6 +971,17 @@ function drainFolds(): void { (t.fam?.map ?? storeNextLookup).delete(pb); t.pb = null; t.ovl = false; + // Patch bump AT THE MERGE (node delivery): overlay flattens preserve + // identity, so the `t.v === old` gate below skips every downstream + // emission — this is the one moment in-place folds are visible. + // Post-merge, so deliveries read committed state (write-time bumps + // raced transition settles). + if (t.pc !== null && patchHooks !== null && (t.pc.p !== null || t.pc.dn !== null)) { + if (targetKeysPlain(t, t.v)) patchHooks.emitPatch(t, t.v, old); + else patchHooks.demoteToEffects(t); + } else if (t.pc !== null && patchHooks !== null) { + patchHooks.emitPatchAncestors(t); + } if (t.pc !== null) t.pc.wk = null; // written-keys window closes with the fold commit } else { // Setter-channel structural ops: a fold that changes an array's shape @@ -764,15 +996,94 @@ function drainFolds(): void { // channel: adoption folds (reconcile walk emitted ops) and // optimistic families (lane-timed override channel). Re-audit // blocker 4. - if ( - t.pc !== null && - t.pc.ro !== null && - !t.adopted && - t.fam?.opt !== true && - Array.isArray(pb) && - Array.isArray(t.v) - ) - rowHooks!.emitSetterRowOps(t, t.v as any[], pb as any[]); + // STAGED TRUTH overrides the optimistic-family gate (fold audit + // P1): the gate exists for override materializations (lane + // channel); a staged landing's fold is REAL truth committing — the + // reveal — and the driven list must hear it (the settle loop's + // resync only covers OVERLAID targets; a root array whose retention + // came from a descendant override is not one). + // ONE channel per replacement (fold audits 2+3, P1): the walk's + // split — VALUE-ALIGNED windows ride slot ticks ONLY; anything + // structural (length change OR a moved/removed wrappable reference — + // an equal-length REORDER is structure, classifying it by length + // alone rebuilt moved rows and lost identity/focus) rides row ops + // ONLY. Staged DEEP reveals key rows by TARGET identity, not raw + // identity — the fold re-seats retained rows' raws, and raw-keyed + // ops rebuilt rows whose proxies never changed. + if (t.pc !== null && Array.isArray(pb) && Array.isArray(t.v)) { + const oldArr = t.v as any[]; + const newArr = pb as any[]; + let aligned = t.sf === true && t.pc.sp !== null && oldArr.length === newArr.length; + if (aligned) { + // Value-aligned means every differing slot is a NON-wrappable + // replacement; a wrappable ref that changed slots is structure. + for (let si = 0; si < newArr.length; si++) { + const ov = oldArr[si]; + const nv = newArr[si]; + if (ov === nv) continue; + if ( + (ov !== null && typeof ov === "object") || + (nv !== null && typeof nv === "object") + ) { + aligned = false; + break; + } + } + } + if (aligned) { + // PRIMITIVE REORDERS are structure too (fold audit 4): classic + // keys primitive rows by VALUE — a permutation must MOVE rows + // (identity/focus), not rewrite slot contents in place. Same + // multiset + any moved position = reorder, not replacement. + let differs = false; + const counts = new Map(); + for (let si = 0; si < newArr.length; si++) { + if (oldArr[si] !== newArr[si]) differs = true; + counts.set(oldArr[si], (counts.get(oldArr[si]) ?? 0) + 1); + } + if (differs) { + let sameMultiset = true; + for (let si = 0; si < newArr.length; si++) { + const c = counts.get(newArr[si]); + if (c === undefined || c === 0) { + sameMultiset = false; + break; + } + counts.set(newArr[si], c - 1); + } + if (sameMultiset) aligned = false; + } + } + if (aligned) { + let ticked = false; + for (let si = 0; si < newArr.length; si++) { + if (oldArr[si] !== newArr[si]) { + ticked = true; + rowHooks!.emitSlotPatch(t, si, newArr[si], oldArr[si]); + } + } + // Slot ticks ARE the reveal — but only a PROVEN one suppresses + // the settle loop's resync (fold audit 4: a no-op fold marking + // rf suppressed the only required revert). Epoch-stamped so a + // mark on a root the loop never visits expires with the flush. + if (ticked && t.sf === true) t.rf = foldEpoch; + } else if (t.pc.ro !== null && !t.adopted && (t.fam?.opt !== true || t.sf === true)) { + const map = t.fam?.map ?? storeNextLookup; + const emitted = rowHooks!.emitSetterRowOps( + t, + oldArr, + newArr, + t.sf === true + ? (v: any) => { + const raw = unwrapValue(v); + return (raw !== null && typeof raw === "object" && map.get(raw)) || raw; + } + : undefined + ); + if (emitted === true && t.sf === true) t.rf = foldEpoch; + } + } + t.sf = false; t.v = pb; t.ch = false; // pb is always a plain clone t.pb = null; @@ -799,19 +1110,35 @@ function drainFolds(): void { // folds re-emitting would double the walk's ops) and PLAIN fold // adoptions (no walk at all). Optimistic families ride the override // channel (lane-timed ops + revert RESYNC) — never re-emit here. + // Same staged-truth exemption as the clone-branch gate above (fold + // audit P1). if ( t.pc.ro !== null && - t.fam?.opt !== true && + (t.fam?.opt !== true || t.sf === true) && (t.fam !== null ? foldedEager && !t.adopted : t.adopted) && Array.isArray(t.v) && Array.isArray(old) ) rowHooks!.emitSetterRowOps(t, old as any[], t.v as any[]); - if (t.pc.p !== null) { - // Accessor demotion at the fold-commit seam is DEV-ONLY (see the - // reconcile seam note: prod never pays per-adoption scans). - if (__DEV__ && !targetIsPlain(t)) patchHooks!.demoteToEffects(t); - else patchHooks!.emitPatchLocal(t, t.v, old); + t.sf = false; + if (t.pc.p !== null || t.pc.dn !== null) { + // Accessor demotion at the fold-commit seam: prod-sound accessed-key + // probes against the JUST-COMMITTED backing (see targetKeysPlain — + // re-audit 6 reversed the dev-only trade; re-audit 7 made the probe + // stateless against the emission object). + if (targetKeysPlain(t, t.v)) patchHooks!.emitPatchLocal(t, t.v, old); + else patchHooks!.demoteToEffects(t); + } + } else if (t.pc !== null && patchHooks !== null) { + // PLAIN setter folds (node delivery): the value bump moved here from + // the setter site — post-swap, so deliveries read committed state, + // and with ancestor bubbling (targeted nested writes reach row + // patches, §4b). + if (t.pc.p !== null || t.pc.dn !== null) { + if (targetKeysPlain(t, t.v)) patchHooks.emitPatch(t, t.v, old); + else patchHooks.demoteToEffects(t); + } else { + patchHooks.emitPatchAncestors(t); } } // Path copying (CAS: see the eager-fold twin above). @@ -975,13 +1302,9 @@ function notifyWrites(t: StoreNextTarget): void { } if (changed) setSignal(t.k, v => v + 1); } - // Patch channel (setter site): a committed write transitions this record — - // queue its patches and bubble to ancestors (targeted nested writes must - // reach the row patch, §4b). One number compare when no patches exist. - // Family targets skip this site: their visibility moment is the FOLD - // commit (drainFolds emits), not the recompute/draft write. - if (t.fam === null && patchHooks !== null && patchHooks.hasPatches()) - patchHooks.emitPatch(t, pb, old); + // Patch channel: setter writes bump at FOLD COMMIT (post-swap), not here + // — a write-time bump raced the fold at transition settle, delivering + // pre-fold state (node-delivery port). drainFolds owns the emission. // 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 @@ -1006,6 +1329,18 @@ function notifyWrites(t: StoreNextTarget): void { t.pb = null; t.v = pb; t.ch = false; + // Node delivery: post-await landings commit HERE (no fold pass) — bump + // post-swap so the delivery reads landed truth. The landed target may + // have NO channel of its own (round 10, P1-2) — ancestors' compiled + // bodies still read into it through nested chains, so the seam always + // reaches the bubbling primitive: emitPatch bubbles internally, and the + // demote/channel-less branches bubble explicitly. + if (patchHooks !== null) { + if (t.pc !== null && (t.pc.p !== null || t.pc.dn !== null)) { + if (targetKeysPlain(t, t.v)) patchHooks.emitPatch(t, t.v, oldBacking); + else patchHooks.demoteToEffects(t); // demotion bubbles internally (10.11) + } else patchHooks.emitPatchAncestors(t); + } if (t.u && t.u.v[t.pk!] === oldBacking) { privatizeCommitted(t.u); devAssertNeverUserMutation(t.u.v); @@ -1417,7 +1752,17 @@ function nodeValue(node: Signal, backing: any): any { ((inOwnerContext() || authoritativeServe()) && !(node._config & CONFIG_HELD_TRUTH && !authoritativeServe()))) ? node._pendingValue - : backing; + : node._pendingValue !== NOT_PENDING && node._config & CONFIG_HELD_TRUTH + ? // HELD truth is the O6 exception (entangle-steal tear, 2026-09-01): + // the steal extends node parking PAST the flush while the backing + // committed eagerly — for the hold's duration committed truth + // lives ONLY in `_value`. Serving the backing here handed + // untracked readers (userland untrack(), the patch channel's + // visible view) the confirming world while every tracked reader + // correctly held — same seam, two worlds. Authoritative/latest + // readers never reach this arm (served pending above). + node._value + : backing; return v === (FORCE as any) ? backing : v; } @@ -2033,6 +2378,16 @@ export function storeHasOptimisticFamily(proxy: any): boolean { return t !== undefined && t.fam?.opt === true; } +/** Family identity token for list retention (round 10, P1-7): two families + * can wrap the SAME raw rows, and retention keyed on raw identity alone + * keeps the old family's DOM and registrations across a subject swap. + * `null` = the global (family-less) namespace, where one raw maps to one + * proxy and raw-identity retention is exact. */ +export function storeFamilyOf(proxy: any): unknown { + const t: StoreNextTarget | undefined = proxy?.[$TARGET]; + return t !== undefined ? (t.fam ?? null) : null; +} + /** Tracking deep snapshot (`deep()` for next targets): subscribes to the * key-set and deep-witness node at every reachable level, then returns the * plain view. Shared references and cycles handled via the visited set. */ diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 50e892469..9bb609920 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -48,6 +48,14 @@ export interface StoreNextFamily { /** Write-side patch-channel state (stage 2), grouped off the target's named * fields — see the shape rule on `StoreNextTarget.pc`. One literal shape, * allocated by `pcOf` on first use. */ +/** One step of a manifested deep read path (see PatchChannel.dp): the key + * to probe/descend under its parent object, plus child steps (null = leaf — + * probing the key suffices, no descent needed). */ +export interface DeepNode { + k: string; + c: DeepNode[] | null; +} + export interface PatchChannel { /** Slot-patch hooks for shallow arrays — the reconcile walk emits * (i, next, prev) for key-aligned value-replaced slots through the patch @@ -57,15 +65,83 @@ export interface PatchChannel { /** Patch-channel consumers (next/patch.ts): per-record compiled patch * entries, multi-consumer. null when unpatched (the common case). */ p: object[] | null; - /** Same-batch coalescing stamp (re-audit 2/3): the container array this - * channel last pushed a non-forced SELF entry into, plus that entry. A - * later same-batch emission UPDATES the queued entry's `next` in place - * (latest state wins — adoption REPLACES the captured object, so dropping - * the later emission would apply stale state) while `prev` stays the - * batch's earliest. The drain clears both stamps so a quiet record - * retains nothing from its last batch. */ - qa: unknown; - qe: unknown; + /** Node delivery: bare per-record version signal — bumped at the + * emission seams, tracked by the channel's delivery effect. */ + dn: unknown; + /** The detached delivery-effect NODE (round 10 shape cleanup): built by + * the first consumer-visible bump, never disposed — persistence rule in + * bumpOne. `undefined` doubles as the "never built" sentinel. */ + de?: object | undefined; + /** Last dispatched bump count (the pure-registration flush skips). */ + dv?: number; + /** Synchronous bump counter (dedup; the signal is pure notification). */ + bc?: number; + /** Payload fast path: a self emission's fresh raw state, valid only + * while `npb === bc` (any later bump or revert invalidates it). */ + np?: unknown; + npb?: number; + /** Deferred-demotion latch: a tentative getter-bearing view marked the + * channel; the delivery effect consumes it in clean effect context. + * Cleared with the consumers it belonged to (round 10, P2). */ + dmq?: boolean; + /** Manifest-less consumer present (size pass): the accessed-key union is + * unknowable — adoption/delivery probes full-scan instead of trusting a + * partial `ak`. Replaces the drain-side recording proxy. Ref-counted by + * `mlc` (round 10.9): released with the last manifest-less consumer. */ + akAll?: boolean; + mlc?: number; + /** Transaction-scoped dedup stamps (round 10.6): the transition that + * last wrote the delivery signal — plain (`bt`) and optimistic (`bo`) + * tracked separately (a held plain write is not lane-visible). Repeats + * within one transaction skip the signal write; a different transaction + * always writes (scheduler owns merge bookkeeping). */ + bt?: unknown; + bo?: unknown; + /** Steal-deferral holder (2026-09-01 tear): the transition whose commit + * carries this channel's redrive while its record sits under a + * held-truth node mask — one redrive per holder. */ + hh?: unknown; + /** Structural VERSION (version-chain redesign): bumped at every + * structural emission; items stamp `svAt`. Entries apply an item only on + * an unbroken chain from their own applied version (`av === svAt - 1`) — + * membership, holds, and ordering all reduce to version arithmetic. */ + sv?: number; + /** VISIBLE structural version: the last emission whose effect an + * untracked reader can see (bumped when items enter the LIVE queue — + * commit-coincident emissions immediately, stashed ones at their + * releaseBatch; lane emissions at emission). New entries initialize + * `av` here: exactly what their first read covered. */ + svv?: number; + /** Slot-channel twin of sv/svv (rows and slots are separate consumer + * lists — one shared counter would gap every slot chain on row traffic). */ + svs?: number; + svvs?: number; + /** Accessed-key set for the channel's compiled bodies (union across + * registrations). Compiler-manifested registrations (re-audit 7, P1-1) + * hand the STATIC read envelope — complete across branches the applies + * never took; runtime-recorded sets (manifest-less callers) cover only + * executed reads. Adoption emission probes ONLY these keys for getters + * (prod-sound demotion at bounded cost); null = not yet recorded, fall + * back to the full scan. */ + ak: PropertyKey[] | null; + /** DEEP read paths from compiler manifests (nested chains like + * `row.queries.0.elapsed`) as a PREFIX TREE — shared prefixes probe once + * (dbmon-shape manifests share one array root across ten leaves; flat + * per-path probing was ~250 ms of a 20-round profile). Roots of the tree + * are the SECOND path segments (first segments ride `ak`, probed against + * the record itself). Probed against the incoming backing at adoption + * gates and the live backing at forced applies — a getter at ANY step + * demotes. Tree roots are FIRST path segments; their own getter probe + * rides `ak` (every first segment is also a root key), so root nodes only + * read + descend. null = no deep paths (the common case; one null + * check). */ + dp: DeepNode[] | null; + /** `ak`/`dp` are INTERNED manifest arrays shared across channels (copy- + * on-write: ensureOwnedKeys clones before any union/record mutation). */ + ks: boolean; + /** Owning target backref (merge coalescing resolves collided entries to + * live-at-drain form). */ + t: unknown; /** Row-ops consumers (next/patch.ts, PR-B): structural list ops — * (nextRows, { prefix, sources, removed }) at apply timing. */ ro: object[] | null; @@ -122,6 +198,19 @@ export interface StoreNextTarget { sc: boolean; /** Backing was swapped by adoption this batch (fold diff-notifies it). */ adopted: boolean; + /** Pending backing carries STAGED TRUTH (#3164 fold audit): an + * optimistic-family draft written under the authoritative posture — a + * landing staging into a retaining transaction. Its fold commits real + * truth, so the structural channels emit for it (the optimistic-family + * gates at the fold sites exist for OVERRIDE materializations, which + * ride the lane). Cleared at the fold. */ + sf?: boolean; + /** Reveal emitted by the fold, stamped with the FOLD EPOCH (fold audit + * 4): the settle drain's resync loop skips targets whose mark matches + * the current epoch. Epoch-stamped (not boolean) so marks on roots the + * loop never visits expire with their flush, and set only on PROVEN + * emission — a no-op fold must not suppress the only required revert. */ + rf?: number; /** Pending backing is a prototype-chain OVERLAY of the committed backing * (`Object.create(v)` — own keys are this batch's writes, everything else * reads through). O(written) per flush instead of O(container) clones diff --git a/packages/signals/tests/attribution-patch.test.ts b/packages/signals/tests/attribution-patch.test.ts new file mode 100644 index 000000000..84592ddb3 --- /dev/null +++ b/packages/signals/tests/attribution-patch.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRoot, createSignal, createStore, DEV, flush, registerPatch } from "../src/index.js"; + +afterEach(() => { + DEV!.attribution.disable(); + flush(); + vi.restoreAllMocks(); +}); + +describe("attribution through patch deliveries", () => { + it("a patched record write produces a named, value-carrying cause chain", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + DEV!.attribution.enable({ log: false, hotRuns: false, hotTime: false }); + const events: any[] = []; + DEV!.attribution.subscribe(e => events.push(e)); + const [state, setState] = createStore({ rows: [{ id: 1, label: "a" }] }); + createRoot(() => { + registerPatch(state.rows[0], () => {}, ["label"]); + }); + setState((s: any) => { + s.rows[0].label = "b"; + }); + flush(); + // The delivery effect's rerun event IS the "why did this run" record + // for the patch-applied DOM update: machinery names itself with the + // record's store path, and the cause stamp carries the record + // transition — not the delivery counter. + const delivery = events.find(e => String(e.nodeName).startsWith("patchDelivery(")); + expect(delivery).toBeDefined(); + expect(delivery.nodeName).toContain("store.rows.0"); + expect(delivery.causes.length).toBeGreaterThan(0); + expect(delivery.causes[0].name).toBe("store.rows.0"); + expect(delivery.causes[0].kind).toBe("write"); + // Self emission carried the record transition previews. + expect(String(delivery.causes[0].value)).toContain("b"); + }); + + it("structural dispatches record synthetic attribution events", async () => { + const { registerRowOps, reconcile } = await import("../src/index.js"); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + DEV!.attribution.enable({ log: false, hotRuns: false, hotTime: false }); + const events: any[] = []; + DEV!.attribution.subscribe(e => events.push(e)); + const [state, setState] = createStore({ + rows: [ + { id: "a", v: 1 }, + { id: "b", v: 2 } + ] + }); + createRoot(() => { + (registerRowOps as any)(state.rows, () => {}); + }); + // Keyed insert: the reconcile walk emits row ops. + setState((s: any) => { + (reconcile as any)( + [ + { id: "a", v: 1 }, + { id: "c", v: 3 }, + { id: "b", v: 2 } + ], + "id" + )(s.rows); + }); + flush(); + const structural = events.find(e => String(e.nodeName).startsWith("row-ops(")); + expect(structural).toBeDefined(); + expect(structural.nodeName).toContain("store.rows"); + }); + + it("coalesced multi-child batches list every origin once; self-emissions keep them", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + DEV!.attribution.enable({ log: false, hotRuns: false, hotTime: false }); + const events: any[] = []; + DEV!.attribution.subscribe(e => events.push(e)); + const [state, setState] = createStore({ + list: { a: { v: 1 }, b: { v: 2 }, own: 0 } + }); + createRoot(() => { + registerPatch(state.list, () => {}, ["own"]); + registerPatch(state.list.a, () => {}, ["v"]); + registerPatch(state.list.b, () => {}, ["v"]); + }); + // One batch: child A twice (one cause, not two), child B once, and a + // PARENT self write (which must not erase the children). + setState((s: any) => { + s.list.a.v = 10; + s.list.a.v = 11; + s.list.b.v = 20; + s.list.own = 1; + }); + flush(); + const parent = events.find(e => e.nodeName === "patchDelivery(store.list)"); + expect(parent).toBeDefined(); + const names = parent.causes.flatMap((c: any) => [ + c.name, + ...(c.causes?.map((x: any) => x.name) ?? []) + ]); + expect(names.filter((n: string) => n === "store.list.a").length).toBe(1); + expect(names.filter((n: string) => n === "store.list.b").length).toBe(1); + expect(names).toContain("store.list"); + }); + + it("demoted children contribute name-only origins, never stale stamps", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const [dep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ row: { meta: { id: 1, label: "x" } } }); + createRoot(() => { + registerPatch(state.row.meta, () => {}, ["label"]); + registerPatch(state.row, () => {}, ["meta.id"]); + }); + // Build the child's machinery, then demote it with a stamped write in + // its history. + setState((s: any) => { + s.row.meta.label = "stamped"; + }); + flush(); + DEV!.attribution.enable({ log: false, hotRuns: false, hotTime: false }); + const events: any[] = []; + DEV!.attribution.subscribe(e => events.push(e)); + setState((s: any) => { + Object.defineProperty(s.row.meta, "label", { + get() { + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + flush(); + events.length = 0; + // A nested write on the DEMOTED child bubbles: the ancestor's cause + // must be the child's PATH (name-only), not the stale pre-demotion + // delivery stamp. + setState((s: any) => { + s.row.meta.id = 2; + }); + flush(); + const ancestor = events.find(e => e.nodeName === "patchDelivery(store.row)"); + expect(ancestor).toBeDefined(); + const childCause = (ancestor.causes as any[]) + .flatMap((c: any) => [c, ...(c.causes ?? [])]) + .find((c: any) => c.name === "store.row.meta"); + expect(childCause).toBeDefined(); + expect(childCause.value).toBeUndefined(); // name-only, no stale previews + }); + + it("ancestor deliveries report the ORIGINATING child as the write source", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + DEV!.attribution.enable({ log: false, hotRuns: false, hotTime: false }); + const events: any[] = []; + DEV!.attribution.subscribe(e => events.push(e)); + const [state, setState] = createStore({ rows: [{ id: 1, label: "a" }] }); + createRoot(() => { + // Ancestor consumer: its deliveries come from nested-child bubbles. + registerPatch(state.rows, () => {}, ["length"]); + registerPatch(state.rows[0], () => {}, ["label"]); + }); + setState((s: any) => { + s.rows[0].label = "b"; + }); + flush(); + const ancestor = events.find(e => e.nodeName === "patchDelivery(store.rows)"); + expect(ancestor).toBeDefined(); + // The bubble's stamp names the ancestor, but its CAUSE is the child. + expect(ancestor.causes[0].name).toBe("store.rows"); + expect(ancestor.causes[0].causes?.[0]?.name).toBe("store.rows.0"); + }); +}); diff --git a/packages/signals/tests/attribution-wide-write.test.ts b/packages/signals/tests/attribution-wide-write.test.ts index ed45b3574..f1cd182b7 100644 --- a/packages/signals/tests/attribution-wide-write.test.ts +++ b/packages/signals/tests/attribution-wide-write.test.ts @@ -50,7 +50,7 @@ describe("WIDE_WRITE", () => { expect(events).toHaveLength(1); expect(events[0].nodeName).toBe("selectedId"); expect(events[0].data).toMatchObject({ subscribers: 30, write: "write" }); - expect(events[0].message).toContain("createSelector or createProjection"); + expect(events[0].message).toContain("a per-key store or projection"); }); it("re-warns only after the subscriber count doubles", () => { diff --git a/packages/signals/tests/attribution.test.ts b/packages/signals/tests/attribution.test.ts index fab939231..a81516224 100644 --- a/packages/signals/tests/attribution.test.ts +++ b/packages/signals/tests/attribution.test.ts @@ -266,7 +266,11 @@ describe("why-did-this-run attribution", () => { expect(hot[0].nodeName).toBe("hot-effect"); expect(hot[0].data).toMatchObject({ runs: 3, windowMs: 60_000 }); expect(hot[0].message).toContain('"n" (write)'); - expect(warn).toHaveBeenCalledTimes(1); + // Count THIS diagnostic's warns, not the process-global total — other + // suites in a reused worker may legitimately warn (e.g. store getter + // demotion notices), and a global count is flaky by construction. + const hotWarns = warn.mock.calls.filter(c => String(c[0]).includes('"n" (write)')); + expect(hotWarns).toHaveLength(1); }); it("warns on wide scopes and re-warns only on 50% growth", () => { diff --git a/packages/signals/tests/store/__audit-round108-lifecycle.test.ts b/packages/signals/tests/store/__audit-round108-lifecycle.test.ts new file mode 100644 index 000000000..21e560dea --- /dev/null +++ b/packages/signals/tests/store/__audit-round108-lifecycle.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it } from "vitest"; +import "../../src/boundaries.js"; +import { + createOwner, + createRoot, + createSignal, + createStore, + flush, + registerPatch, + resetErrorHalt, + runWithOwner +} from "../../src/index.js"; +import { EFFECT_RENDER } from "../../src/core/constants.js"; +import { patchCountForTests } from "../../src/store/next/patch.js"; + +class HeldQueue { + _disabled = { _value: true }; + _collapsed = { _value: true }; + _parent = null; + queues: Array void>> = [[], []]; + constructor(private handled = false) {} + enqueue(type: number, fn: (type: number) => void) { + this.queues[type - 1].push(fn); + } + run(type: number) { + const pending = this.queues[type - 1]; + this.queues[type - 1] = []; + for (let i = 0; i < pending.length; i++) pending[i](type); + } + addChild() {} + removeChild() {} + notify() { + return this.handled; + } + stashQueues() {} + restoreQueues() {} +} + +afterEach(() => resetErrorHalt()); + +function heldConsumer() { + const queue = new HeldQueue(); + const owner = createOwner() as any; + owner._queue = queue; + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ row: { value: "v1" } }); + const log: string[] = []; + let unbind!: () => void; + runWithOwner(owner, () => { + unbind = registerPatch( + state.row, + (n: any, _p: any, force?: boolean) => { + if (force) log.push(n.extra); + else void n.extra; + }, + ["extra"] + ) as () => void; + }); + const demote = () => + setState((s: any) => { + Object.defineProperty(s.row, "extra", { + get() { + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + return { demote, dep, log, owner, queue, setDep, unbind }; +} + +describe("held demotion lifecycle", () => { + it("explicit unbind before redrive prevents effect creation", () => { + const c = heldConsumer(); + c.demote(); + c.unbind(); + flush(); + expect(c.queue.queues[0]).toHaveLength(0); + c.setDep("d2"); + flush(); + expect(c.queue.queues[0]).toHaveLength(0); + expect(c.log).toEqual([]); + c.owner.dispose(); + }); + + it("explicit unbind after creation cancels the queued first run and tracking", () => { + const c = heldConsumer(); + c.demote(); + flush(); + expect(c.queue.queues[0]).toHaveLength(1); + c.unbind(); + c.queue.run(EFFECT_RENDER); + expect(c.log).toEqual([]); + c.setDep("d2"); + flush(); + expect(c.queue.queues[0]).toHaveLength(0); + c.owner.dispose(); + }); + + it("explicit unbind after first run removes the live subscription", () => { + const c = heldConsumer(); + c.demote(); + flush(); + c.queue.run(EFFECT_RENDER); + expect(c.log).toEqual(["d1"]); + c.unbind(); + c.setDep("d2"); + flush(); + c.queue.run(EFFECT_RENDER); + expect(c.log).toEqual(["d1"]); + c.owner.dispose(); + }); + + it("owner disposal cancels the queued root and restores accounting", () => { + const base = patchCountForTests(); + const c = heldConsumer(); + expect(patchCountForTests()).toBe(base + 1); + c.demote(); + flush(); + expect(patchCountForTests()).toBe(base); + expect(c.owner._firstChild).not.toBeNull(); + c.owner.dispose(); + c.queue.run(EFFECT_RENDER); + c.setDep("d2"); + flush(); + expect(c.log).toEqual([]); + expect(patchCountForTests()).toBe(base); + }); +}); + +describe("compute capture and tracking", () => { + it("unhandled held compute error creates every sibling before deferred halt", () => { + const queue = new HeldQueue(false); + const owner = createOwner() as any; + owner._queue = queue; + const [state, setState] = createStore({ row: { label: "v1", score: 0 } }); + const log: string[] = []; + runWithOwner(owner, () => { + registerPatch( + state.row, + (n: any, _p: any, force?: boolean) => { + if (force) log.push("thrower-commit"); + else void n.score; + }, + ["score"] + ); + registerPatch( + state.row, + (n: any, _p: any, force?: boolean) => { + if (force) log.push("healthy:" + n.label); + }, + ["label"] + ); + }); + setState((s: any) => { + Object.defineProperty(s.row, "score", { + get() { + throw new Error("compute boom"); + }, + configurable: true, + enumerable: true + }); + }); + expect(() => flush()).toThrow("compute boom"); + expect(queue.queues[0]).toHaveLength(2); + resetErrorHalt(); + queue.run(EFFECT_RENDER); + // Round 10.9: a FAILED compute skips its commit (the swallow-then- + // -apply this originally pinned was the audit finding); the healthy + // sibling — whose OWN envelope never touches the throwing key — + // installs and applies. + expect(log).toEqual(["healthy:v1"]); + owner.dispose(); + }); + + it("handled held compute error leaves every sibling runnable", () => { + const queue = new HeldQueue(true); + const owner = createOwner() as any; + owner._queue = queue; + const [state, setState] = createStore({ row: { label: "v1", score: 0 } }); + const log: string[] = []; + runWithOwner(owner, () => { + registerPatch(state.row, (n: any, _p: any, force?: boolean) => { + if (force) log.push("thrower-commit"); + else void n.score; + }); + registerPatch(state.row, (n: any, _p: any, force?: boolean) => { + if (force) log.push("healthy:" + n.label); + }); + }); + setState((s: any) => { + Object.defineProperty(s.row, "score", { + get() { + throw new Error("handled boom"); + }, + configurable: true, + enumerable: true + }); + }); + expect(() => flush()).not.toThrow(); + expect(queue.queues[0]).toHaveLength(2); + queue.run(EFFECT_RENDER); + // Round 10.9: handled or not, a failed compute never commits. + expect(log).toEqual(["healthy:v1"]); + owner.dispose(); + }); + + it("a nonthrowing compute tracks the introduced getter", () => { + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ row: { value: "v1" } }); + const log: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + registerPatch(state.row, (n: any, _p: any, force?: boolean) => { + if (force) log.push(n.extra); + else void n.extra; + }); + }); + setState((s: any) => { + Object.defineProperty(s.row, "extra", { + get() { + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + flush(); + expect(log).toEqual(["d1"]); + setDep("d2"); + flush(); + expect(log).toEqual(["d1", "d2"]); + dispose(); + }); + + it("reads before a throw remain tracked and successful recovery adds later dependencies", () => { + const [throws, setThrows] = createRoot(() => createSignal(true)); + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ row: { value: "v1" } }); + const log: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + registerPatch(state.row, (n: any, _p: any, force?: boolean) => { + if (force) log.push("commit"); + else void n.extra; + }); + }); + setState((s: any) => { + Object.defineProperty(s.row, "extra", { + get() { + if (throws()) throw new Error("recoverable"); + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + expect(() => flush()).toThrow("recoverable"); + // Round 10.9: the failed compute's commit is skipped… + expect(log).toEqual([]); + resetErrorHalt(); + setThrows(false); + flush(); + // …and recovery (the pre-throw read stayed tracked) commits cleanly, + // with the successful run's later reads adding their dependencies. + expect(log).toEqual(["commit"]); + setDep("d2"); + flush(); + expect(log).toEqual(["commit", "commit"]); + dispose(); + }); +}); diff --git a/packages/signals/tests/store/createOptimisticStore.test.ts b/packages/signals/tests/store/createOptimisticStore.test.ts index d8010f367..e87bded6c 100644 --- a/packages/signals/tests/store/createOptimisticStore.test.ts +++ b/packages/signals/tests/store/createOptimisticStore.test.ts @@ -3807,3 +3807,109 @@ describe("truth-author drafts read the authoritative view (#3108)", () => { dispose(); }); }); + +// --------------------------------------------------------------------------- +// OPEN upstream findings (structural-audit follow-up, 2026-08-31): the +// continuation reckoning drops a second landing that arrives in the same +// microtask chain. Channel-independent — reproduces with a bare projection, +// no actions, no consumers. Pinned as `it.fails` so the flip is visible +// when the reckoning fix lands; flip to `it` and keep. +describe("#3123 continuation reckoning — OPEN findings (it.fails pins)", () => { + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + }; + + function continuationHarness() { + type Row = { id: number }; + let notify!: { promise: Promise; resolve: (r: Row) => void }; + const reset = () => { + let rs!: (r: Row) => void; + const p = new Promise(r => (rs = r)); + notify = { promise: p, resolve: rs }; + }; + reset(); + const confirm = (r: Row) => { + const current = notify; + reset(); + current.resolve(r); + }; + let items!: any; + let setItems!: any; + const classic: string[] = []; + const dispose = createRoot(dispose => { + [items, setItems] = createOptimisticStore(async function* (store: Row[]) { + yield [] as Row[]; + while (true) { + const row = await notify.promise; + yield; + store.push(row); + } + }, [] as Row[]); + createRenderEffect( + () => Array.from(items as any[], (r: any) => String(r.id)).join(","), + (v: string) => { + classic.push(v); + } + ); + return dispose; + }); + return { + confirm, + classic, + dispose, + get items() { + return items; + }, + get setItems() { + return setItems; + } + }; + } + + it.fails("a second same-microtask continuation landing is not swallowed", async () => { + const h = continuationHarness(); + flush(); + await settle(); + h.confirm({ id: 0 }); + h.confirm({ id: 2 }); + for (let i = 0; i < 5; i++) await settle(); + const final = h.classic.at(-1); + h.dispose(); + // TODAY: "0" — the second landing's push never commits (data loss). + expect(final).toBe("0,2"); + }); + + it.fails( + "an action satisfied by a landed row settles even when its landing raced another", + async () => { + const h = continuationHarness(); + flush(); + await settle(); + let bDone = false; + action(function* () { + h.setItems((s: any[]) => { + s.push({ id: 1 }); + }); + yield until(() => (h.items as any[]).some((x: any) => x.id === 1)); + })().then( + () => { + bDone = true; + }, + () => { + bDone = true; + } + ); + flush(); + h.confirm({ id: 0 }); + h.confirm({ id: 1 }); // the echo that should satisfy the action + for (let i = 0; i < 6; i++) await settle(); + h.dispose(); + // TODAY: the swallowed echo never reaches authoritative truth, the + // until() predicate never satisfies, the action wedges forever. + expect(bDone).toBe(true); + } + ); +}); diff --git a/packages/signals/tests/store/patch-channel.test.ts b/packages/signals/tests/store/patch-channel.test.ts index 070aeda91..388855f2d 100644 --- a/packages/signals/tests/store/patch-channel.test.ts +++ b/packages/signals/tests/store/patch-channel.test.ts @@ -55,7 +55,8 @@ describe("patch channel (PR-A)", () => { }); flush(); expect(log.length).toBe(1); - expect(log[0][0]).toBe(true); // forced (ancestor bubble) + // Node delivery: ancestor re-applies ride exact prev-snapshot compares + // instead of forced re-runs — the CONTRACT is the delivered value. expect(log[0][1]).toBe("2"); }); @@ -128,9 +129,10 @@ describe("patch channel (PR-A)", () => { reject(new Error("fail")); await p; flush(); - // Revert: forced re-apply lands with committed truth visible. + // Revert: the re-apply lands with committed truth visible (node + // delivery compares against the optimistic prev snapshot — no force). const last = log[log.length - 1]; - expect(last[1]).toBe(true); + expect(last[0]).toBe("saved"); expect(state.user.name).toBe("saved"); }); @@ -464,7 +466,9 @@ describe("patch channel (re-audit hardening)", () => { }) as any; }); const p = (save() as Promise).catch(() => {}); - // Lane-timed drain: the throwing sibling must not abort b's patch. + // Lane-timed delivery: the throwing sibling must not abort b's patch — + // owner-neutral delivery effects dispatch independently, so the healthy + // channel applies before the boundary teardown (queue-contract parity). expect(() => flush()).not.toThrow(); expect(applied).toEqual(["b:1"]); expect(b()).toBe("errored"); @@ -522,9 +526,7 @@ describe("patch channel (re-audit hardening)", () => { }); }); flush(); - // Demotion repaired the count; the late unbind is inert (no negative). - expect(patchCountForTests()).toBe(base); - unbind(); + // Demotion repaired the count; the late unbind stays count-neutral. expect(patchCountForTests()).toBe(base); expect(log[log.length - 1]).toBe("b:10"); // The getter's OUTSIDE dependency now re-applies — the exact divergence @@ -532,6 +534,14 @@ describe("patch channel (re-audit hardening)", () => { setDep(11); flush(); expect(log[log.length - 1]).toBe("b:11"); + // Round 10.8: unbind DISPOSES the fallback effect (it dies with its + // consumer — the old "late unbind is inert" edge is retired). + unbind(); + expect(patchCountForTests()).toBe(base); + const settled = log.length; + setDep(12); + flush(); + expect(log.length).toBe(settled); dispose(); }); diff --git a/packages/signals/tests/store/patch-entangle.probe.test.ts b/packages/signals/tests/store/patch-entangle.probe.test.ts new file mode 100644 index 000000000..4fefe4999 --- /dev/null +++ b/packages/signals/tests/store/patch-entangle.probe.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + action, + createOptimistic, + createOptimisticStore, + createRenderEffect, + createRoot, + flush, + registerPatch, + until +} from "../../src/index.js"; + +const settle = async (n = 3) => { + for (let i = 0; i < n; i++) { + await new Promise(r => setTimeout(r, 0)); + flush(); + } +}; + +describe("probe: patch channel vs until() flip-entanglement", () => { + // PINNED OPEN (rc.6 consolidation target, 2026-09-01): the delivery reads + // its view outside the masked read seam, so the flip-entanglement steal + // (which holds the world via node masks, not boundary queues) is invisible + // to it — a patch consumer observes the confirmed world mid-hold. Held + // #3091 out of rc.5 over this. Fix shape: deliveries read through the + // SAME hold resolution the store's traps use + snapshot compare; the + // structural stash mirrors the steal like mergeTransitionState already + // mirrors merges. Audit provenance: external probe, reproduced verbatim. + it("a patch on the confirming foreign store does not apply before the joint settle", async () => { + let landV1!: () => void; + const v1 = new Promise(r => (landV1 = r)); + let finishUpload!: () => void; + const upload = new Promise(r => (finishUpload = r)); + + const patches: string[] = []; + const frames: string[] = []; + let saving!: () => boolean; + let setSaving!: (v: boolean) => void; + let stream!: { doc: { version: number; data: string } }; + let save!: () => Promise; + + createRoot(() => { + [saving, setSaving] = createOptimistic(false); + [stream] = createOptimisticStore<{ doc: { version: number; data: string } }>( + async function* () { + yield { doc: { version: 0, data: "old" } }; + await v1; + yield { doc: { version: 1, data: "new" } }; + }, + { doc: { version: 0, data: "old" } } + ); + save = action(function* () { + setSaving(true); + yield until(() => stream.doc.version >= 1); + yield upload; // hold past the flip + }); + createRenderEffect( + () => `saving=${saving()} v=${stream.doc.version} data=${stream.doc.data}`, + v => { + frames.push(v); + } + ); + }); + flush(); + await settle(); + registerPatch(stream.doc, (next: any) => { + patches.push(`v${next.version}:${next.data}:saving=${saving()}`); + }); + + const done = save(); + flush(); + await settle(); + expect(frames.at(-1)).toBe("saving=true v=0 data=old"); + const patchesBeforeConfirm = patches.length; + + // Confirming landing flips the predicate; the action keeps uploading. + landV1(); + await settle(); + await settle(); + // Value channel holds (proven elsewhere); the patch channel must too. + expect(frames.at(-1)).toBe("saving=true v=0 data=old"); + expect(patches.slice(patchesBeforeConfirm)).toEqual([]); + + finishUpload(); + await done; + await settle(); + expect(frames.at(-1)).toBe("saving=false v=1 data=new"); + // The confirmation's patch applies at (or after) the joint settle, and + // never under live optimism. + expect(patches.some(p => p.startsWith("v1:new"))).toBe(true); + expect(patches).not.toContain("v1:new:saving=true"); + }); + + it("STRUCTURAL ops on the confirming foreign store ride the steal, never the carrier's own commit", async () => { + const { registerRowOps } = await import("../../src/index.js"); + let landV1!: () => void; + const v1 = new Promise(r => (landV1 = r)); + let finishUpload!: () => void; + const upload = new Promise(r => (finishUpload = r)); + + const rowEvents: Array<{ len: number; saving: boolean }> = []; + const frames: string[] = []; + let saving!: () => boolean; + let setSaving!: (v: boolean) => void; + let stream!: { rows: { id: number }[] }; + let save!: () => Promise; + + createRoot(() => { + [saving, setSaving] = createOptimistic(false); + [stream] = (createOptimisticStore as any)( + async function* () { + yield { rows: [{ id: 1 }, { id: 2 }] }; + await v1; + yield { rows: [{ id: 1 }, { id: 2 }, { id: 3 }] }; + }, + { rows: [{ id: 1 }, { id: 2 }] } + ); + save = action(function* () { + setSaving(true); + yield until(() => stream.rows.length >= 3); + yield upload; // hold past the flip + }); + createRenderEffect( + () => `saving=${saving()} n=${stream.rows.length}`, + (v: string) => { + frames.push(v); + } + ); + }); + flush(); + await settle(); + createRoot(() => { + registerRowOps(stream.rows, (rows: any[]) => { + rowEvents.push({ len: rows.length, saving: saving() }); + }); + }); + + const done = save(); + flush(); + await settle(); + expect(frames.at(-1)).toBe("saving=true n=2"); + const mark = rowEvents.length; + + // The confirming landing adds a row — a STRUCTURAL change. Its ops + // stash on the landing transaction; the steal must carry that stash to + // the awaiting transaction (the carrier's own commit releasing them + // would rebuild the list mid-hold, rows=3 beside classic's n=2). + landV1(); + await settle(); + await settle(); + expect(frames.at(-1)).toBe("saving=true n=2"); + expect(rowEvents.slice(mark)).toEqual([]); + + finishUpload(); + await done; + await settle(); + expect(frames.at(-1)).toBe("saving=false n=3"); + const after = rowEvents.slice(mark); + expect(after.length).toBeGreaterThan(0); + expect(after.every(e => e.saving === false)).toBe(true); + expect(after.at(-1)!.len).toBe(3); + }); +}); diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts new file mode 100644 index 000000000..4556fe128 --- /dev/null +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -0,0 +1,2549 @@ +/** + * Invariant harness (re-audit 7). These tests are written from the CHANNEL'S + * CONTRACT, not from reported failure instances — each describe block states + * an invariant and drives it across the axis products where past audits + * found holes (registration mode × backing shape × lane × timing × + * consumer-list lifecycle). New emission paths and fixes must keep this file + * green; a new audit finding here means the invariant statement itself was + * wrong or missing, and the fix must extend the harness FIRST. + */ +import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; +import { + action, + createRoot, + createSignal, + createStore, + flush, + reconcile, + registerPatch, + patchableRaw, + untrack as untrackRead +} from "../../src/index.js"; + +// Getter demotions warn by design (dev notice); the assertions here are the +// demotion SEMANTICS — mute the expected console noise so reused workers +// don't leak counts into unrelated suites' global-console assertions. +let warnSpy: ReturnType; +beforeAll(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); +}); +afterAll(() => { + warnSpy.mockRestore(); +}); + +describe("INVARIANT: a patch body never reads an accessor raw", () => { + // Admission scans, adoption gates, and demotion must together guarantee + // that any getter — own or inherited, present at registration or arriving + // later through ANY adoption seam — is only ever evaluated tracked. + + it("adoption rescans even when a prior admission scan marked the record plain (sticky sc)", async () => { + const { patchCountForTests } = await import("../../src/store/next/patch.js"); + const base = patchCountForTests(); + const [dep, setDep] = createRoot(() => createSignal(1)); + const [state, setState] = createStore({ user: { id: 1, name: "a", score: 0 } }); + // A driver-style admission probe runs the one-time scan on the PLAIN + // backing — the sticky flag this invariant must not trust after adoption. + expect(patchableRaw(state.user)).toBeDefined(); + const log: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + // Hydration-style registration: no recorded key set (ak === null), + // forcing the adoption gate onto its full-scan fallback. + registerPatch(state.user, (next: any) => log.push(next.name + ":" + next.score)); + }); + setState(s => { + reconcile( + { + id: 1, + name: "b", + get score() { + return dep(); + } + }, + "id" + )(s.user); + }); + flush(); + // The getter-backed adoption must DEMOTE (count repaired), and the + // getter's outside dependency must keep re-applying — the divergence + // unsound admission silently drops. + expect(patchCountForTests()).toBe(base); + expect(log[log.length - 1]).toBe("b:1"); + setDep(2); + flush(); + expect(log[log.length - 1]).toBe("b:2"); + dispose(); + }); + + it("prototype accessors reject admission: class instances are wrappable but not patchable", () => { + class Row { + name = "a"; + get upper() { + return this.name.toUpperCase(); + } + } + const [state] = createStore({ row: new Row() }); + // Reading through the proxy works (wrappable); handing the raw backing + // to a compiled body would evaluate `upper` untracked — admission must + // refuse. + expect(state.row.upper).toBe("A"); + expect(patchableRaw(state.row)).toBeUndefined(); + }); +}); + +describe("INVARIANT: patch applications mirror effect runs (parity oracle), regardless of lane interleaving", () => { + it("normal → optimistic → normal emissions on ONE record apply like an equivalent effect", async () => { + const { createOptimisticStore, action: act, createEffect } = await import("../../src/index.js"); + const [state, setState] = (createOptimisticStore as any)({ user: { name: "n0", title: "t0" } }); + const applies: string[] = []; + const effectLog: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + // THE ORACLE: patch semantics are DEFINED as effect semantics with a + // different dispatcher — whatever sequence of states this effect + // observes is what the patch channel must apply, exactly once each. + createEffect( + () => state.user.name + "/" + state.user.title, + (v: string) => { + effectLog.push(v); + } + ); + registerPatch(state.user, (next: any) => applies.push(next.name + "/" + next.title)); + }); + flush(); + effectLog.length = 0; + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + s.user.title = "opt"; + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + // Interleave inside ONE flush window: the optimistic emission between + // the two normal emissions must not corrupt the normal channel's + // coalescing stamp (shared-stamp regression: a duplicate normal + // application queued behind the clobber). + setState((s: any) => { + s.user.name = "n1"; + }); + const p = save() as Promise; + setState((s: any) => { + s.user.name = "n2"; + }); + flush(); + const inFlight = applies.slice(); + // SETTLE BEFORE ASSERTING: an abandoned in-flight action holds every + // later write in this FILE hostage (transition state is global). + resolve(); + await p; + flush(); + // In-flight window: patch applies mirror the effect's observations + // (same states, same count — no duplicates from stamp corruption). + expect(inFlight).toEqual(effectLog.slice(0, inFlight.length)); + // Settled: identical sequences (count AND values). What the final + // state IS — reverts, write attribution to in-flight lanes — is store + // semantics owned by other suites; the channel's whole contract is + // "apply exactly what an effect would observe, exactly as often". + expect(applies).toEqual(effectLog); + expect(applies[applies.length - 1]).toBe(state.user.name + "/" + state.user.title); + dispose(); + }); +}); + +describe("INVARIANT: optimistic applies honor accessor safety and late mounts (round 9)", () => { + it("an optimistic replacement carrying a nested getter demotes instead of reading it raw", async () => { + const { createOptimisticStore, action: act, createEffect } = await import("../../src/index.js"); + const [dep, setDep] = createRoot(() => createSignal("g0")); + const [state, setState] = (createOptimisticStore as any)({ + row: { id: 1, meta: { label: "m0" } } + }); + const log: string[] = []; + const effectLog: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + // ORACLE: an equivalent effect on the same read. + createEffect( + () => String((state.row.meta as any)?.label), + (v: string) => { + effectLog.push(v); + } + ); + registerPatch(state.row, (n: any) => log.push(String(n.meta?.label)), ["meta.label"]); + }); + flush(); + effectLog.length = 0; + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + reconcile( + { + id: 1, + meta: { + get label() { + return dep(); + } + } + }, + "id" + )(s.row); + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = save() as Promise; + flush(); + const inFlight = log[log.length - 1]; + setDep("g1"); + flush(); + const afterDep = log[log.length - 1]; + // SETTLE BEFORE ASSERTING (abandoned transactions strand the file). + resolve(); + await p; + flush(); + // The getter evaluated TRACKED (demotion engaged): the tentative view + // rendered its live value in flight — an untracked raw read would + // never even show g0 through the demoted body. EFFECT PARITY bounds + // everything else (stash timing, settle ordering): the demoted body IS + // an effect now, so it must land wherever the oracle lands. + expect(inFlight).toBe("g0"); + expect(afterDep).toBe("g0"); + expect(log[log.length - 1]).toBe(effectLog[effectLog.length - 1]); + dispose(); + }); +}); + +describe("INVARIANT: optimistic visibility covers the whole read envelope, ancestors included", () => { + it("a targeted child reconcile inside an action re-applies ANCESTOR patches in flight", async () => { + const { createOptimisticStore, action: act } = await import("../../src/index.js"); + const [state, setState] = (createOptimisticStore as any)({ + row: { id: 1, meta: { label: "m0" } } + }); + const log: string[] = []; + registerPatch(state.row, (next: any) => log.push(next.meta?.label ?? "?"), ["meta.label"]); + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + reconcile({ label: "opt" }, "id")(s.row.meta); + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = save() as Promise; + flush(); + const inFlight = log[log.length - 1]; + resolve(); + await p; + flush(); + // In-flight visibility is what optimism MEANS: the ancestor's compiled + // body reads through the child — it must re-apply at the lane drain, + // not at settle; the settle then re-applies committed truth (revert). + expect(inFlight).toBe("opt"); + expect(log[log.length - 1]).toBe("m0"); + }); +}); + +describe("INVARIANT: a consumer is never left stale by the skip rule (round 9)", () => { + it("a consumer mounting mid-transaction reads the HELD view and receives the commit", async () => { + const { patchableRaw } = await import("../../src/index.js"); + const [state, setState] = createStore({ user: { id: 1, name: "a" } }); + const log: string[] = []; + registerPatch(state.user, () => {}); + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = action(function* () { + setState(s => { + reconcile({ id: 1, name: "b" }, "id")(s.user); + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = save() as Promise; + flush(); + // Driver-style mount MID-TRANSACTION: whatever visibility rule the + // store applies (held masks for family adoptions, speculative swaps + // for eager ones), the mount's initial read must MATCH what an + // untracked proxy reader sees at the same moment — and the commit must + // reach it if it read the pre-commit view. + const visible = untrackRead(() => state.user.name); + const raw = patchableRaw(state.user) as any; + log.push("init:" + raw.name); + registerPatch(state.user, (n: any) => log.push("apply:" + n.name)); + expect(log[0]).toBe("init:" + visible); + resolve(); + await p; + flush(); + expect(state.user.name).toBe("b"); + const last = log[log.length - 1]; + // Either the mount read "b" already (skip fine) or the commit applied. + expect(log[0] === "init:b" || last === "apply:b").toBe(true); + }); + + it("an ambient eager reconcile self-corrects: pre-flush mounts read the adopted state", async () => { + const { patchableRaw } = await import("../../src/index.js"); + const [state, setState] = createStore({ user: { id: 1, name: "a" } }); + registerPatch(state.user, () => {}); + setState(s => { + reconcile({ id: 1, name: "b" }, "id")(s.user); + }); + // Eager adoption swapped the committed backing at walk time — a mount + // here reads "b" already; the queued entry may skip it safely. + const raw = patchableRaw(state.user) as any; + expect(raw.name).toBe("b"); + const log: string[] = []; + registerPatch(state.user, (n: any) => log.push(n.name)); + flush(); + // Functional next event regardless of whether this flush skipped it. + setState(s => { + s.user.name = "c"; + }); + flush(); + expect(log[log.length - 1]).toBe("c"); + }); + + it("admission rejects manifested paths crossing FUNCTION intermediates (accessor carriers)", async () => { + const { patchableRaw } = await import("../../src/index.js"); + const [dep] = createRoot(() => createSignal("f0")); + const format: any = () => {}; + Object.defineProperty(format, "label", { + get() { + return dep(); + }, + enumerable: true, + configurable: true + }); + const [state] = createStore({ row: { id: 1, format } }); + expect(patchableRaw(state.row, ["format.label"])).toBeUndefined(); + }); +}); + +describe("INVARIANT: one forced ancestor application per flush (effect parity)", () => { + it("multiple nested writes in one batch coalesce their ancestor bubbles", () => { + const [state, setState] = createStore({ + row: { id: 1, q0: { elapsed: "a0" }, q1: { elapsed: "b0" } } + }); + let applies = 0; + registerPatch( + state.row, + () => { + applies++; + }, + ["q0.elapsed", "q1.elapsed"] + ); + setState(s => { + s.row.q0.elapsed = "a1"; + s.row.q1.elapsed = "b1"; + }); + flush(); + // An effect reading both chains runs ONCE for the batch; so does the + // forced ancestor re-apply. + expect(applies).toBe(1); + // Next batch applies again (stamp cleared at drain). + setState(s => { + s.row.q0.elapsed = "a2"; + }); + flush(); + expect(applies).toBe(2); + }); + + it("an UNCHANGED reconcile (same reference, no divergence) forces nothing", () => { + const [state, setState] = createStore({ + row: { id: 1, meta: { label: "m" } } + }); + let applies = 0; + registerPatch( + state.row, + () => { + applies++; + }, + ["meta.label"] + ); + const sameMeta = { label: "m" }; + setState(s => { + reconcile(sameMeta, "id")(s.row.meta); + }); + flush(); + const after = applies; + // Reconciling the CHILD with its identical adopted reference again: an + // effect reading through the ancestor would not re-run; neither may + // the ancestor bubble force a re-apply. + setState(s => { + reconcile(sameMeta, "id")(s.row.meta); + }); + flush(); + expect(applies).toBe(after); + }); + + it("forced settle twins survive lane drains, applying once at settle (effect parity)", async () => { + const { createOptimisticStore, action: act, createEffect } = await import("../../src/index.js"); + const [state, setState] = (createOptimisticStore as any)({ + row: { id: 1, meta: { label: "m0" } } + }); + const effectLog: string[] = []; + const applies: string[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + createEffect( + () => state.row.meta.label, + (v: string) => { + effectLog.push(v); + } + ); + registerPatch(state.row, (n: any) => applies.push(n.meta?.label ?? "?"), ["meta.label"]); + }); + flush(); + effectLog.length = 0; + applies.length = 0; + let r1!: () => void; + let r2!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + reconcile({ label: "opt1" }, "id")(s.row.meta); + }); + yield new Promise(r => { + r1 = r; + }); + // Second tentative walk with its own lane window: the first lane + // drain cleared the LANE stamp only — a cleared SETTLE stamp here + // would stage a duplicate settle twin. + setState((s: any) => { + reconcile({ label: "opt2" }, "id")(s.row.meta); + }); + yield new Promise(r => { + r2 = r; + }); + }) as any; + }); + const p = save() as Promise; + flush(); // lane window 1: opt1 visible + r1(); + await Promise.resolve(); + flush(); // lane window 2: opt2 visible + r2(); + await p; + flush(); // settle: revert to committed truth, ONCE + expect(applies).toEqual(effectLog); + dispose(); + }); +}); + +describe("INVARIANT: queued applications reach exactly the consumers registered at emission (values resolve live, structure never admits late registrants)", () => { + it("structural ops never reach a consumer registered between emission and dispatch", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore({ rows: [{ id: 1 }, { id: 2 }] }); + const early: any[] = []; + const late: any[] = []; + // Pre-flush registrations share the emission's baseline (committed + // state) and DO receive ops; the unsound window is registration DURING + // the flush, after the fold emitted — a real driver registering there + // (a row build inside another consumer's dispatch, a boundary remount) + // initialized from the post-write state, and replaying baseline- + // relative ops against it corrupts retention. + // + // Round 10.13 refinement: late consumers still never see the + // baseline-relative OPS — pre-drain registrants receive the RESYNC + // form (ops null, live rows). Structural-audit refinement: the window + // is FIXED — a consumer registered DURING the drain (from another + // consumer's dispatch, like a driver row build) initialized from + // current state and receives nothing until the next event. + let registeredLate = false; + registerRowOps(state.rows, (_next: any[], ops: any) => { + early.push(ops); + if (!registeredLate) { + registeredLate = true; + registerRowOps(state.rows, (_n: any[], o: any) => late.push(o)); + } + }); + setState(s => { + s.rows.splice(0, 1); + }); + flush(); + expect(early.length).toBe(1); + expect(late.length).toBe(0); // mid-drain registrant: outside the window + // The late consumer participates in the NEXT event normally (real ops). + setState(s => { + s.rows.splice(0, 1); + }); + flush(); + expect(early.length).toBe(2); + expect(late.length).toBe(1); + expect(late[0]).not.toBe(null); + }); + + it("a value entry never re-applies to a consumer that initialized FROM its state (mid-flush mount)", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore({ rows: [{ id: 1, label: "L1" }] }); + const spy: string[] = []; + let mounted = false; + // A pre-existing consumer keeps the record's channel live so the value + // write actually queues an entry. + registerPatch(state.rows[0], () => {}); + // A structural consumer that MOUNTS a value consumer during its own + // dispatch — the driver's row build, exactly: the new consumer's initial + // force-apply reads current (post-write) state. + // COMPILED-SHAPE spy (compare-gated writes + initial force apply, like + // real driver mounts): the invariant is OBSERVABLE — no stale value + // ever writes, and no value writes twice. Node delivery may dispatch + // the fresh consumer with CURRENT state; the compares make that a + // no-op, exactly like an effect's initial run. + let sp: string | undefined; + const applyRow = (n: any, p: any, f?: boolean) => { + if (f || n.label !== (p?.label ?? sp)) { + sp = n.label; + spy.push(n.label); + } + }; + registerRowOps(state.rows, () => { + if (!mounted) { + mounted = true; + applyRow(state.rows[0], undefined, true); // driver initial apply + registerPatch(state.rows[0], applyRow, ["label"]); + } + }); + setState(s => { + s.rows.push({ id: 2, label: "L2" }); + s.rows[0].label = "X1"; + }); + flush(); + // Exactly ONE observable write of X1 (the mount's initial apply) — a + // stale or duplicate delivery would push a second entry. + expect(spy).toEqual(["X1"]); + setState(s => { + s.rows[0].label = "Y1"; + }); + flush(); + expect(spy).toEqual(["X1", "Y1"]); + }); + + it("a value patch held by a transition reaches a consumer registered AFTER emission (list resolves live at drain)", async () => { + const [state, setState] = createStore({ user: { name: "a" } }); + const log: string[] = []; + let resolve!: () => void; + let save!: () => Promise | void; + let unbindOld!: () => void; + createRoot(() => { + unbindOld = registerPatch(state.user, () => {}); + save = action(function* () { + setState(s => { + s.user.name = "b"; + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = save() as Promise; + flush(); + // Consumer list recreated while the entry is held: the old consumer + // unbinds (list drops to null) and a NEW one registers (fresh array). + unbindOld(); + let dispose!: () => void; + createRoot(d => { + dispose = d; + registerPatch(state.user, (next: any) => log.push(next.name)); + }); + resolve(); + await p; + flush(); + // The commit's application must reach the live consumer — a stale + // list reference captured at emission misses it. + expect(log).toEqual(["b"]); + dispose(); + }); + + it("the same holds across a transition MERGE collision (both stashes queued the same channel)", async () => { + const { createEffect } = await import("../../src/index.js"); + const [state, setState] = createStore({ user: { name: "a" } }); + const log: string[] = []; + const effectLog: string[] = []; + let resolveA!: () => void; + let resolveB!: () => void; + let saveA!: () => Promise | void; + let saveB!: () => Promise | void; + let unbindOld!: () => void; + createRoot(() => { + // ORACLE: the patch channel applies exactly when (and with what) an + // effect on the same record runs — including across the queue passes + // two independently-settling transitions produce. + createEffect( + () => state.user.name, + (v: string) => { + effectLog.push(v); + } + ); + unbindOld = registerPatch(state.user, () => {}); + saveA = action(function* () { + setState(s => { + s.user.name = "a1"; + }); + yield new Promise(r => { + resolveA = r; + }); + }) as any; + saveB = action(function* () { + setState(s => { + s.user.name = "b1"; + }); + yield new Promise(r => { + resolveB = r; + }); + }) as any; + }); + flush(); + effectLog.length = 0; + const pa = saveA() as Promise; + flush(); + const pb = saveB() as Promise; + flush(); + // Same-channel entries now sit in BOTH transitions' stashes; the merge + // coalesces them. Recreate the consumer list before commit. + unbindOld(); + let dispose!: () => void; + createRoot(d => { + dispose = d; + registerPatch(state.user, (next: any) => log.push(next.name)); + }); + resolveA(); + resolveB(); + await pa; + await pb; + flush(); + // The recreated consumer received the merged commit — with exactly the + // application sequence the effect observed (missed commit = [], stamp + // corruption / uncoalesced releases = more applies than effect runs). + expect(log).toEqual(effectLog); + expect(log[log.length - 1]).toBe("b1"); + dispose(); + }); +}); + +describe("INVARIANT: every visibility transition reaches every registered ancestor (round 10)", () => { + // The proxy and the patch channel are two readers of ONE visibility + // stream. Any seam that changes what the proxy answers for a nested path + // must deliver to ancestor channels whose compiled bodies read into it — + // regardless of whether the WRITTEN target has consumers of its own. + + it("post-await projection landing on a channel-less child delivers to the patched ancestor", async () => { + const { createProjection } = await import("../../src/index.js"); + const tick = (ms: number) => new Promise(r => setTimeout(r, ms)); + let proj!: any; + let disposeRoot!: () => void; + const log: string[] = []; + createRoot(d => { + disposeRoot = d; + proj = createProjection( + async function* (state: any) { + yield; // settle pass 1 — the projection is readable at seed + await tick(5); + // Post-await authoritative write: write-override, immediate landing. + state.row.meta.label = "landed"; + yield; + }, + { row: { meta: { label: "seed" } } } + ); + }); + flush(); + const { createEffect } = await import("../../src/index.js"); + const effectSeen: string[] = []; + createRoot(() => { + // A tracked subscriber pulls the generator (real templates always + // have one); the patch consumer must observe the same landings. + createEffect( + () => proj.row.meta.label, + (v: string) => { + effectSeen.push(v); + } + ); + // The ancestor is patched; the written child (meta) never gets a channel. + const row = untrackRead(() => proj.row); + registerPatch(row, (next: any) => log.push(next.meta.label)); + }); + flush(); + await tick(20); + flush(); + await tick(5); + flush(); + expect(effectSeen[effectSeen.length - 1]).toBe("landed"); + // Proxy sees landed truth… + expect(untrackRead(() => proj.row.meta.label)).toBe("landed"); + // …and so must the ancestor's patch consumer. + expect(log[log.length - 1]).toBe("landed"); + disposeRoot(); + }); + + it("ordinary nested optimistic write delivers in-flight to the patched ancestor", async () => { + const { createOptimisticStore, action: act } = await import("../../src/index.js"); + const [state, setState] = (createOptimisticStore as any)({ row: { meta: { label: "saved" } } }); + const log: string[] = []; + createRoot(() => { + registerPatch(state.row, (next: any) => log.push(next.meta.label)); + }); + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + s.row.meta.label = "optimistic"; + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = save() as Promise; + flush(); + // The lane view answers "optimistic" for row.meta.label — the row's + // patch consumer must see the same in-flight state. + expect(untrackRead(() => state.row.meta.label)).toBe("optimistic"); + expect(log[log.length - 1]).toBe("optimistic"); + resolve(); + await p; + flush(); + }); +}); + +describe("INVARIANT: an ancestor's deep manifest is honored on bubbled deliveries (round 10.5)", () => { + it("a child-subject adoption carrying a getter into an ancestor's read path demotes the ancestor", async () => { + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ row: { meta: { id: 1, label: "x" } } }); + const log: string[] = []; + createRoot(() => { + // Ancestor consumer with a DEEP manifest — its body reads INTO meta. + registerPatch(state.row, (next: any) => log.push(next.meta.label), ["meta.label"]); + }); + // Child-subject reconcile adopts a getter-bearing object at meta. The + // child's own seam probes the CHILD's keys; only the ancestor's + // manifest knows meta.label is read — the bubbled delivery must probe + // it and DEMOTE, so the getter evaluates tracked. + setState((s: any) => { + reconcile( + { + id: 1, + get label() { + return dep(); + } + }, + "id" + )(s.row.meta); + }); + flush(); + expect(log[log.length - 1]).toBe("d1"); + // The demoted body is a live tracked effect: dependency changes flow. + setDep("d2"); + flush(); + expect(log[log.length - 1]).toBe("d2"); + }); +}); + +describe("INVARIANT: demotion fanout is per-entry isolated (round 10)", () => { + it("a throwing demoted body neither blocks siblings nor loses them", async () => { + const { resetErrorHalt } = await import("../../src/core/scheduler.js"); + const [state, setState] = createStore({ user: { id: 1, name: "a" } }); + const log: string[] = []; + let phase = "mount"; + let dispose!: () => void; + createRoot(d => { + dispose = d; + // Entry A: throws once demotion re-drives it post-accessor. + registerPatch(state.user, (next: any) => { + if (phase === "demoted") throw new Error("body A blew up"); + log.push("A:" + next.name); + }); + // Entry B: healthy sibling. + registerPatch(state.user, (next: any) => log.push("B:" + next.name)); + }); + phase = "demoted"; + // Accessor arrives through the trap — the whole channel demotes and + // every entry re-drives as a tracked effect. + setState((s: any) => { + Object.defineProperty(s.user, "flair", { + get() { + return s.user.name + "!"; + }, + configurable: true, + enumerable: true + }); + }); + try { + flush(); + } catch { + /* A's unboundaried throw surfaces at flush — expected */ + } + resetErrorHalt(); + const bCount = log.filter(l => l.startsWith("B:")).length; + // B was re-driven despite A's throw… + expect(bCount).toBeGreaterThan(0); + // …and stays LIVE: a later write still updates it. + setState((s: any) => { + s.user.name = "later"; + }); + try { + flush(); + } catch { + /* A throws again as a live effect — isolation, not silence */ + } + resetErrorHalt(); + expect(log).toContain("B:later"); + dispose(); + }); +}); + +describe("INVARIANT: structure honors holds and reaches held-window registrants (round 10.13)", () => { + it("a structural consumer under a held queue defers and resyncs at release", async () => { + const { registerRowOps, reconcile, runWithOwner } = await import("../../src/index.js"); + const { createOwner } = await import("../../src/core/owner.js"); + const { GlobalQueue } = await import("../../src/core/scheduler.js"); + // Boundary machinery installs the held probe. + await import("../../src/boundaries.js"); + expect(GlobalQueue._queueHeld).not.toBe(null); + const heldQueue: any = { + _disabled: { _value: true }, + _collapsed: { _value: true }, + _parent: null, + pending: [] as Array<(type: number) => void>, + enqueue(type: number, fn: (type: number) => void) { + this.pending.push(fn); + }, + run() { + const fns = this.pending.splice(0); + for (const fn of fns) fn(1); + }, + addChild() {}, + removeChild() {}, + notify() { + return true; + } + }; + const owner = createOwner() as any; + owner._queue = heldQueue; + const [state, setState] = createStore({ + rows: [ + { id: "a", v: 1 }, + { id: "b", v: 2 } + ] + }); + const calls: any[] = []; + runWithOwner(owner, () => { + (registerRowOps as any)(state.rows, (rows: any[], ops: any) => + calls.push([rows.map((r: any) => r.id), ops === null]) + ); + }); + setState((s: any) => { + (reconcile as any)( + [ + { id: "b", v: 2 }, + { id: "a", v: 1 } + ], + "id" + )(s.rows); + }); + flush(); + // Held: nothing dispatched before the queue releases. + expect(calls.length).toBe(0); + heldQueue.run(); + // Released: the LIVE resync form (row ops are baseline-relative — the + // original ops would be stale by release). + expect(calls.length).toBe(1); + expect(calls[0][1]).toBe(true); + expect(calls[0][0]).toEqual(["b", "a"]); + }); + + it("a consumer registered during a held structural commit receives the settle resync", async () => { + const { registerRowOps, reconcile, action: act } = await import("../../src/index.js"); + const [state, setState] = createStore({ + rows: [ + { id: "a", v: 1 }, + { id: "b", v: 2 } + ] + }); + const early: any[] = []; + createRoot(() => { + (registerRowOps as any)(state.rows, (_r: any[], ops: any) => early.push(ops)); + }); + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = act(function* () { + setState((s: any) => { + (reconcile as any)( + [ + { id: "b", v: 2 }, + { id: "a", v: 1 } + ], + "id" + )(s.rows); + }); + yield new Promise(r => { + resolve = r; + }); + }) as any; + }); + const p = save() as Promise; + flush(); + // The structural emission is HELD by the transaction; a consumer + // registers during the hold (a list mounting mid-transition). + const late: any[] = []; + createRoot(() => { + (registerRowOps as any)(state.rows, (rows: any[], ops: any) => + late.push([rows.map((r: any) => r.id), ops === null]) + ); + }); + resolve(); + await p; + flush(); + // Fold audit 4 refinement: adoption commits EAGERLY — the held-window + // registrant's init read already contained the reordered view, so it + // is owed NOTHING at release (a delivery would replay ops over state + // it already rendered — the parked-window corruption). It participates + // in the NEXT event normally. + expect(late.length).toBe(0); + setState((s: any) => { + reconcile([{ id: "a", v: 1 }], "id")(s.rows); + }); + flush(); + expect(late.length).toBe(1); + expect(late[late.length - 1][0]).toEqual(["a"]); + }); +}); + +describe("INVARIANT: demotion never silences ancestors (round 10.11)", () => { + it("a fold on a previously-demoted child still bubbles to patched ancestors", async () => { + const [state, setState] = createStore({ row: { meta: { id: 1, label: "x" } } }); + const metaLog: string[] = []; + createRoot(() => { + registerPatch(state.row.meta, (n: any) => metaLog.push(n.label), ["label"]); + }); + // Build the child's delivery machinery with a plain delivered write… + setState((s: any) => { + s.row.meta.label = "y"; + }); + flush(); + expect(metaLog[metaLog.length - 1]).toBe("y"); + // …then demote it with an accessor ON A DECLARED KEY (the manifest- + // -scoped probe only sees declared keys — an off-envelope getter is + // correctly ignored): consumers pulled, machinery persists (dn + // survives churn by design), and every later fold probe fails. + const [dep] = createRoot(() => createSignal("d1")); + setState((s: any) => { + Object.defineProperty(s.row.meta, "label", { + get() { + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + flush(); + // An ancestor registers with a deep manifest INTO the child. + const rowLog: string[] = []; + createRoot(() => { + registerPatch(state.row, (n: any) => rowLog.push(String(n.meta.id)), ["meta.id"]); + }); + // A leaf fold on the accessor-bearing child hits the demote branch + // with an ALREADY-EMPTY channel — it must still bubble, or the + // ancestor freezes at its baseline forever. + setState((s: any) => { + s.row.meta.id = 2; + }); + flush(); + expect(rowLog[rowLog.length - 1]).toBe("2"); + }); +}); + +describe("INVARIANT: demotion envelopes read each step once and probe true keys (round 10.10)", () => { + it("an unstable root getter is invoked once per tracked pass, not twice", async () => { + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ row: { meta: { id: 1, label: "x" } } }); + const log: string[] = []; + createRoot(() => { + registerPatch(state.row, (n: any) => log.push(n.meta.label), ["meta.label"]); + }); + let reads = 0; + setState((s: any) => { + Object.defineProperty(s.row, "meta", { + get() { + reads++; + return { id: 1, label: dep() }; // unstable: fresh object per read + }, + configurable: true, + enumerable: true + }); + }); + flush(); + const base = reads; + setDep("d2"); + flush(); + expect(log[log.length - 1]).toBe("d2"); + // One envelope read (tracked pass) + one body read (commit) — the + // double root read made unstable getters track one value and commit + // another. + expect(reads - base).toBe(2); + }); + + it("symbol keys in iterable manifests probe the symbol property, not its string form", async () => { + const sym = Symbol("flag"); + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ box: { [sym]: "s1", other: 0 } }); + const log: string[] = []; + createRoot(() => { + registerPatch(state.box, (n: any) => log.push(n[sym]), new Set([sym])); + }); + // A getter arriving ON THE SYMBOL KEY demotes; the re-driven envelope + // must track the symbol itself — the stringified form read a + // nonexistent "Symbol(flag)" property, so the getter's dependency + // never subscribed and later changes went stale. + setState((s: any) => { + Object.defineProperty(s.box, sym, { + get() { + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + flush(); + expect(log[log.length - 1]).toBe("d1"); + setDep("d2"); + flush(); + expect(log[log.length - 1]).toBe("d2"); + }); +}); + +describe("INVARIANT: the full-scan poison lives exactly as long as its consumers (round 10.9)", () => { + it("akAll releases with the last manifest-less consumer", async () => { + const { $TARGET } = await import("../../src/store/store.js"); + const [state] = createStore({ user: { name: "a" } }); + let u1!: () => void; + let u2!: () => void; + createRoot(() => { + u1 = registerPatch(state.user, () => {}) as () => void; // manifest-less + u2 = registerPatch(state.user, () => {}, ["name"]) as () => void; // compiled + }); + const pc = (state.user as any)[$TARGET].pc; + expect(pc.akAll).toBe(true); + u1(); + // The compiled consumer gets manifest-narrow probes back. + expect(pc.akAll).toBe(false); + u2(); + }); +}); + +describe("INVARIANT: channel fan-out stays diagnosable (attribution parity)", () => { + it("mass registration and wide dispatch fire the graph-size diagnostics", async () => { + const [state, setState] = createStore({ cfg: { theme: "a" } }); + warnSpy.mockClear(); + const unbinds: (() => void)[] = []; + createRoot(() => { + for (let i = 0; i < 2000; i++) { + unbinds.push(registerPatch(state.cfg, () => {}) as () => void); + } + }); + // Registration-side HUGE_FAN_OUT twin (patch consumers are invisible + // to the graph's _subCount — the channel must witness its own shape). + expect(warnSpy.mock.calls.some(c => String(c[0]).includes("[HUGE_FAN_OUT]"))).toBe(true); + // Dispatch-side WIDE_WRITE twin: ENGINE policy (round 10.10) — the + // same threshold option, memo field, and metadata as graph + // wide-writes, so it only fires with attribution enabled. + const { DEV } = await import("../../src/index.js"); + DEV!.attribution.enable({ log: false, hotRuns: false, hotTime: false, wideWrites: 250 }); + setState((s: any) => { + s.cfg.theme = "b"; + }); + flush(); + expect(warnSpy.mock.calls.some(c => String(c[0]).includes("[WIDE_WRITE]"))).toBe(true); + DEV!.attribution.disable(); + for (const u of unbinds) u(); + }); +}); + +describe("INVARIANT: the demoted fallback effect lives and dies with its consumer (round 10.8)", () => { + it("unbind after demotion disposes the live fallback effect", async () => { + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ user: { name: "a" } }); + const log: string[] = []; + let unbind!: () => void; + createRoot(() => { + unbind = registerPatch(state.user, (n: any) => log.push(n.flair ?? n.name)) as () => void; + }); + setState((s: any) => { + Object.defineProperty(s.user, "flair", { + get() { + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + flush(); + const before = log.length; + setDep("d2"); + flush(); + // The fallback is a LIVE tracked effect… + expect(log.length).toBeGreaterThan(before); + expect(log[log.length - 1]).toBe("d2"); + unbind(); + const after = log.length; + setDep("d3"); + flush(); + // …and unbind DISPOSES it: no application, no surviving subscription. + expect(log.length).toBe(after); + }); + + it("a compute-phase throw routes per-entry — sibling installation is never halted", async () => { + const { resetErrorHalt } = await import("../../src/core/scheduler.js"); + const [state, setState] = createStore({ user: { id: 1, name: "a" } }); + const log: string[] = []; + let phase = "mount"; + createRoot(() => { + // A throws ONLY in the tracked compute pass (force !== true) — the + // path that previously escaped per-entry capture and halted through + // the effect's own error machinery during creation/scheduling. + registerPatch(state.user, (next: any, _p: any, force?: boolean) => { + if (phase === "demoted" && force !== true) throw new Error("compute boom"); + log.push("A:" + next.name); + }); + registerPatch(state.user, (next: any) => log.push("B:" + next.name)); + }); + phase = "demoted"; + setState((s: any) => { + Object.defineProperty(s.user, "extra", { + get() { + return 1; + }, + configurable: true, + enumerable: true + }); + }); + try { + flush(); + } catch { + /* deferred unboundaried halt — expected */ + } + resetErrorHalt(); + expect(log.filter(l => l.startsWith("B:")).length).toBeGreaterThan(0); + setState((s: any) => { + s.user.name = "later"; + }); + try { + flush(); + } catch { + /* A's compute throws again — isolation, not silence */ + } + resetErrorHalt(); + expect(log).toContain("B:later"); + }); +}); + +describe("INVARIANT: the deferred-demotion latch cannot outlive its consumers (round 10)", () => { + it("unbinding the last consumer clears the latch; a stale latch never demotes a later plain consumer", async () => { + const { $TARGET } = await import("../../src/store/store.js"); + const [state, setState] = createStore({ user: { name: "a" } }); + let unbind!: () => void; + createRoot(() => { + unbind = registerPatch(state.user, () => {}) as () => void; + }); + const pc = (state.user as any)[$TARGET].pc; + // Simulate the tentative gate marking the channel for its CURRENT + // consumers (the getter-bearing optimistic view path). + pc.dmq = true; + // (1) The latch leaves WITH the consumers. + unbind(); + expect(pc.dmq).toBe(false); + // (2) A latch re-armed during the consumer-less window (any residue + // path) cannot be inherited: a registration that STARTS a consumer + // list opens a fresh generation. + pc.dmq = true; + const log: Array<[string, boolean | undefined]> = []; + createRoot(() => { + registerPatch(state.user, (next: any, _p: any, force?: boolean) => + log.push([next.name, force]) + ); + }); + expect(pc.dmq).toBe(false); + setState((s: any) => { + s.user.name = "updated"; + }); + flush(); + // A demoted channel would null pc.p and re-drive through effects; a + // live patch delivers the plain update to a populated consumer list. + expect(log.some(([v]) => v === "updated")).toBe(true); + expect(pc.p).not.toBe(null); + const { patchCountForTests } = await import("../../src/store/next/patch.js"); + expect(patchCountForTests()).toBeGreaterThan(0); + }); +}); + +describe("INVARIANT: structural resyncs honor holds, fix their window, and serve the VISIBLE view", () => { + // The 10.13 late-registrant rule, refined by the structural audit: a + // resync is owed ONLY to consumers whose initialization predates the + // item's visibility commit — i.e. registrants inside a TRANSITION-HELD + // window (ambient same-flush registrants initialized from post-commit + // state and are owed nothing). The resync defers into held owner queues + // like every other application, never admits mid-drain registrants, and + // always serves the view an untracked reader sees at that moment. + + it("the window is FIXED at both edges: pre-flush registrants ride the fold's snapshot, mid-drain registrants get nothing", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore({ rows: [{ id: 1 }, { id: 2 }] }); + const early: any[] = []; + const late: any[] = []; + const midDrain: any[] = []; + createRoot(() => { + registerRowOps(state.rows, (_n: any[], ops: any) => early.push(ops)); + }); + setState((s: any) => { + s.rows.splice(0, 1); + }); + // Between setState and flush: the plain-store emission happens at the + // FOLD (flush time), so this consumer is IN the snapshot — it gets the + // real ops, and they are baseline-correct for it (its init read + // pre-dates the commit exactly like the early consumer's). + createRoot(() => { + registerRowOps(state.rows, (_n: any[], ops: any) => { + late.push(ops); + if (midDrain.length === 0 && late.length === 1) { + // Registered FROM a dispatch callback: initialized from current + // state mid-drain — the fixed window excludes it entirely. + registerRowOps(state.rows, (_nn: any[], o: any) => midDrain.push(o)); + } + }); + }); + flush(); + expect(early.length).toBe(1); + expect(late.length).toBe(1); + expect(late[0]).not.toBe(null); // in the fold snapshot: real, sound ops + expect(midDrain.length).toBe(0); // outside the window + // All participate in the next event normally. + setState((s: any) => { + s.rows.splice(0, 1); + }); + flush(); + expect(early.length).toBe(2); + expect(late.length).toBe(2); + expect(midDrain.length).toBe(1); + }); + + it("a late registrant under a HELD owner queue defers into it instead of resyncing through the hold", async () => { + const { registerRowOps, getOwner, action: act } = await import("../../src/index.js"); + const { GlobalQueue } = await import("../../src/core/scheduler.js"); + const [state, setState] = createStore({ rows: [{ id: 1 }, { id: 2 }] }); + createRoot(() => { + registerRowOps(state.rows, () => {}); + }); + const releases: Array<() => void> = []; + const fakeQ: any = { enqueue: (_t: number, fn: () => void) => releases.push(fn) }; + const prevProbe = (GlobalQueue as any)._queueHeld; + (GlobalQueue as any)._queueHeld = (q: any) => q === fakeQ || prevProbe?.(q) === true; + try { + let confirm!: () => void; + const run = act(function* () { + setState((s: any) => { + s.rows.splice(0, 1); + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + const held: any[] = []; + // Held-window registrant whose OWNER QUEUE is itself collapsed. + createRoot(() => { + (getOwner() as any)._queue = fakeQ; + registerRowOps(state.rows, (_n: any[], ops: any) => held.push(ops)); + }); + confirm(); + await run; + flush(); + // The resync deferred INTO the collapsed queue — nothing ran through + // the hold; the boundary's own release timing delivers it. + expect(held.length).toBe(0); + expect(releases.length).toBe(1); + releases[0]!(); + expect(held).toEqual([null]); + } finally { + (GlobalQueue as any)._queueHeld = prevProbe; + } + }); + + it("a held-release resync serves the visible optimistic view, not committed backing", async () => { + const { + createOptimisticStore, + registerRowOps, + getOwner, + action: act + } = await import("../../src/index.js"); + const { GlobalQueue } = await import("../../src/core/scheduler.js"); + const [items, setItems] = (createOptimisticStore as any)([{ id: 1 }] as any[]); + const releases: Array<() => void> = []; + const fakeQ: any = { enqueue: (_t: number, fn: () => void) => releases.push(fn) }; + const prevProbe = (GlobalQueue as any)._queueHeld; + (GlobalQueue as any)._queueHeld = (q: any) => q === fakeQ || prevProbe?.(q) === true; + try { + const seen: any[][] = []; + createRoot(() => { + (getOwner() as any)._queue = fakeQ; + registerRowOps(items, (rows: any[], _ops: any) => + seen.push(Array.from(rows, (r: any) => r.id)) + ); + }); + let confirm!: () => void; + const run = act(function* () { + setItems((draft: any[]) => { + draft.push({ id: 2 }); + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + // The lane dispatch deferred into the held queue. Release it WHILE + // the optimistic window is still open: the rebuild must read what an + // untracked reader sees — the override view [1, 2] — never the + // committed backing [1]. + expect(releases.length).toBe(1); + releases[0]!(); + expect(seen.at(-1)).toEqual([1, 2]); + confirm(); + await run; + flush(); + } finally { + (GlobalQueue as any)._queueHeld = prevProbe; + } + }); + + it("a resync never emits a slot tick for a deleted slot", async () => { + const { registerSlotPatchNext } = await import("../../src/store/next/patch.js"); + const { action: act } = await import("../../src/index.js"); + const [state, setState] = createStore({ list: ["a", "b", "c"] }); + createRoot(() => { + registerSlotPatchNext(state.list, () => {}); + }); + // EARLY consumer: registered before the write — its chain is behind the + // stashed ticks and receives them at release, where the deleted-slot + // gate must drop index 2. + const ticks: Array<[number, any]> = []; + createRoot(() => { + registerSlotPatchNext(state.list, (i: number, v: any) => ticks.push([i, v])); + }); + let confirm!: () => void; + const run = act(function* () { + setState((s: any) => { + // Slot ticks emit from the RECONCILE walk (aligned value-replaced + // slots): tick index 1 (survives) and index 2 (deleted right after + // by the shrinking reconcile) — both stashed by the transition. + reconcile(["a", "y", "x"], null)(s.list); + reconcile(["a", "y"], null)(s.list); + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + // Late-window registrant (fold audit 4): adoption is EAGER — its init + // read already holds ["a","y"], so it is owed nothing at release. + const late: Array<[number, any]> = []; + createRoot(() => { + registerSlotPatchNext(state.list, (i: number, v: any) => late.push([i, v])); + }); + confirm(); + await run; + flush(); + // NON-VACUOUS: the early consumer's surviving-slot tick MUST arrive. + expect(ticks.some(([i, v]) => i === 1 && v === "y")).toBe(true); + // The deleted slot's tick is invalid against the live 2-length list — + // skipped, never delivered as (2, undefined). And the late registrant + // received nothing (its read covered the stash). + expect(ticks.every(([i]) => i < 2)).toBe(true); + expect(late.length).toBe(0); + }); + + it("an aborted retainer's re-derivation keeps the survivor's rows in the driven list (fifth posture)", async () => { + // Entangled retainers settle TOGETHER (fourth posture) — but an ABORTED + // retainer dies alone: rederiveAtSettle wipes and replays the survivor. + // Replay frames are suppressed (one-reckoning rule), so the settle + // drain's resync is the LAST word — it must read the VISIBLE view + // (survivor's re-armed overrides composed), never the bare committed + // backing, or the survivor's row vanishes from the DOM until its own + // settle. Classic-parity oracle rides alongside. + const { + createOptimisticStore, + registerRowOps, + createRenderEffect, + action: act + } = await import("../../src/index.js"); + const [items, setItems] = (createOptimisticStore as any)([{ id: 1 }] as any[]); + const frames: number[][] = []; + const classic: number[][] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + registerRowOps(items, (rows: any[], _ops: any) => + frames.push(Array.from(rows, (r: any) => r.id)) + ); + createRenderEffect( + () => items.map((r: any) => r.id), + (v: number[]) => { + classic.push(v); + } + ); + }); + flush(); + let failA!: (e: Error) => void; + let confirmB!: () => void; + const runA = act(function* () { + setItems((draft: any[]) => { + draft.push({ id: 2 }); + }); + yield new Promise((_r, reject) => { + failA = reject; + }); + })().catch(() => {}); + flush(); + const runB = act(function* () { + setItems((draft: any[]) => { + draft.push({ id: 3 }); + }); + yield new Promise(resolve => { + confirmB = resolve; + }); + })(); + flush(); + expect(frames.at(-1)).toEqual([1, 2, 3]); + + // A ABORTS: it dies alone, B's edit survives the re-derivation — the + // channel must land where classic lands, with B's row intact. + failA(new Error("aborted")); + await runA; + flush(); + expect(frames.at(-1)).toEqual(classic.at(-1) as number[]); + + confirmB(); + await runB; + flush(); + expect(frames.at(-1)).toEqual([1]); + expect(classic.at(-1)).toEqual([1]); + dispose(); + }); + + it("back-to-back continuation landings keep the channel AT classic parity — never ahead of it", async () => { + // Audit follow-up P1: with two landings arriving while the actions stay + // open, the channel exposed the newest topology while classic effects + // held the previous one until action settlement. Whatever the correct + // visibility ruling is, the channel's contract is CLASSIC PARITY — + // delivery for delivery, at every step. + const { + createOptimisticStore, + registerRowOps, + createRenderEffect, + action: act + } = await import("../../src/index.js"); + type Row = { id: number }; + let notify!: { promise: Promise; resolve: (row: Row) => void }; + const reset = () => { + let resolve!: (row: Row) => void; + const promise = new Promise(r => (resolve = r)); + notify = { promise, resolve }; + }; + reset(); + const confirm = (row: Row) => { + const current = notify; + reset(); + current.resolve(row); + }; + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + }; + let items!: any; + let setItems!: any; + // Full-fidelity frames (id:pending pairs): the echo-mask ruling is about + // VALUES (the replayed edit's value masks the landed echo until settle), + // so topology-only frames hide the divergence. + const view = (rows: any[]) => Array.from(rows, (r: any) => r.id + ":" + r.pending); + const channel: string[][] = []; + const classic: string[][] = []; + const dispose = createRoot(dispose => { + [items, setItems] = (createOptimisticStore as any)(async function* (store: Row[]) { + yield [] as Row[]; + while (true) { + const row = await notify.promise; + yield; + store.push(row); + } + }, [] as Row[]); + registerRowOps(items, (rows: any[], _ops: any) => channel.push(view(rows))); + createRenderEffect( + () => view(items as any[]), + (v: string[]) => { + classic.push(v); + } + ); + return dispose; + }); + flush(); + await settle(); + + // Two retained adds on OPEN actions (blind — they outlive both landings). + let doneA!: () => void; + let doneB!: () => void; + act(function* () { + setItems((s: any[]) => { + s.push({ id: 10, pending: true }); + }); + yield new Promise(r => { + doneA = r; + }); + })(); + flush(); + act(function* () { + setItems((s: any[]) => { + s.push({ id: 11, pending: true }); + }); + yield new Promise(r => { + doneB = r; + }); + })(); + flush(); + expect(classic.at(-1)).toEqual(["10:true", "11:true"]); + expect(channel.at(-1)).toEqual(["10:true", "11:true"]); + + // TWO continuation landings BACK-TO-BACK, each ECHOING one retained add + // (the d813a96f ruling: the echoed row takes the landed slot, the + // replayed edit's VALUE masks it until settle). Actions stay open. The + // channel must land WHERE CLASSIC LANDS at every observation point. + confirm({ id: 10, pending: false } as any); + await settle(); + await settle(); + expect(channel.at(-1)).toEqual(classic.at(-1) as string[]); + + confirm({ id: 11, pending: false } as any); + await settle(); + await settle(); + expect(channel.at(-1)).toEqual(classic.at(-1) as string[]); + + doneA(); + doneB(); + await settle(); + await settle(); + expect(channel.at(-1)).toEqual(classic.at(-1) as string[]); + expect(classic.at(-1)).toEqual(["10:false", "11:false"]); + dispose(); + }); + + it("a continuation landing delivers ONE coherent topology — never the landed base without replayed edits", async () => { + const { + createOptimisticStore, + registerRowOps, + until, + action: act + } = await import("../../src/index.js"); + type Row = { id: number }; + let notify!: { promise: Promise; resolve: (row: Row) => void }; + const reset = () => { + let resolve!: (row: Row) => void; + const promise = new Promise(r => (resolve = r)); + notify = { promise, resolve }; + }; + reset(); + const confirm = (row: Row) => { + const current = notify; + reset(); + current.resolve(row); + }; + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + }; + let items!: any; + let setItems!: any; + const frames: number[][] = []; + const dispose = createRoot(dispose => { + [items, setItems] = (createOptimisticStore as any)(async function* (store: Row[]) { + yield [] as Row[]; + while (true) { + const row = await notify.promise; + yield; + store.push(row); + } + }, [] as Row[]); + registerRowOps(items, (rows: any[], _ops: any) => + frames.push(Array.from(rows, (r: any) => r.id)) + ); + return dispose; + }); + flush(); + await settle(); + + const add = act(function* (row: Row) { + setItems((store: Row[]) => { + store.push(row); + }); + yield until(() => items.some((x: any) => x.id === row.id)); + }); + const addA = add({ id: 0 }); + flush(); + const addB = add({ id: 1 }); + flush(); + expect(frames.at(-1)).toEqual([0, 1]); + const watermark = frames.length; + + // A's confirmation: a CONTINUATION landing carrying A's row contradicts + // the base — wipe, replay of B's still-open edit, resync. The driven + // list must see ONE coherent [0, 1]: an intermediate [0] frame is the + // DOM identity/focus loss (row B rebuilt for nothing). + confirm({ id: 0 }); + await settle(); + await settle(); + for (const f of frames.slice(watermark)) expect(f).toEqual([0, 1]); + + confirm({ id: 1 }); + await settle(); + await Promise.all([addA, addB]); + await settle(); + // …and NO stale pre-landing structural work replays at owner settle. + for (const f of frames.slice(watermark)) expect(f).toEqual([0, 1]); + dispose(); + }); +}); + +describe("INVARIANT: structural channels under fold/holds — per-index slots, no double-applies, reveal coverage", () => { + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + }; + + it("held slot deliveries defer PER INDEX — later indexes are not collapsed away", async () => { + const { getOwner } = await import("../../src/index.js"); + const { registerSlotPatchNext } = await import("../../src/store/next/patch.js"); + const { GlobalQueue } = await import("../../src/core/scheduler.js"); + const [state, setState] = createStore({ list: ["a", "b", "c"] }); + const releases: Array<() => void> = []; + const fakeQ: any = { enqueue: (_t: number, fn: () => void) => releases.push(fn) }; + const prevProbe = (GlobalQueue as any)._queueHeld; + (GlobalQueue as any)._queueHeld = (q: any) => q === fakeQ || prevProbe?.(q) === true; + try { + const ticks: Array<[number, any]> = []; + createRoot(() => { + (getOwner() as any)._queue = fakeQ; + registerSlotPatchNext(state.list, (i: number, v: any) => ticks.push([i, v])); + }); + // Two aligned value-replaced slots in one batch: two slot items. + setState((s: any) => { + reconcile(["x", "y", "c"], null)(s.list); + }); + flush(); + expect(ticks.length).toBe(0); // held + // BOTH indexes must have deferred into the queue — a shared dedup + // flag collapsing them leaves index 1 permanently stale. + for (const r of releases.splice(0)) r(); + expect(ticks.some(([i, v]) => i === 0 && v === "x")).toBe(true); + expect(ticks.some(([i, v]) => i === 1 && v === "y")).toBe(true); + } finally { + (GlobalQueue as any)._queueHeld = prevProbe; + } + }); + + it("a late row consumer never receives a resync FOLLOWED by stale ops (no double-build)", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore({ rows: [{ id: 1 }, { id: 2 }, { id: 3 }] }); + createRoot(() => { + registerRowOps(state.rows, () => {}); + }); + // Emission 1: reconcile walk emits its ops DURING the setter. + setState((s: any) => { + reconcile([{ id: 2 }, { id: 3 }], "id")(s.rows); + }); + // Late consumer registers BETWEEN the two emissions. + const events: Array<"resync" | "ops"> = []; + createRoot(() => { + registerRowOps(state.rows, (_n: any[], ops: any) => + events.push(ops === null ? "resync" : "ops") + ); + }); + // Emission 2: setter fold emits at flush — the late consumer IS in this + // snapshot (registered before the fold), with baseline-correct ops. + setState((s: any) => { + s.rows.splice(0, 1); + }); + flush(); + // The consumer may get the resync (live view, includes emission 2's + // effect) OR emission 2's ops — never resync THEN ops: the ops would + // re-apply against the already-final rebuild, duplicating the row. + const resyncAt = events.indexOf("resync"); + const opsAt = events.indexOf("ops"); + if (resyncAt !== -1 && opsAt !== -1) expect(opsAt).toBeLessThan(resyncAt); + // And it participates normally afterwards. + const mark = events.length; + setState((s: any) => { + s.rows.splice(0, 1); + }); + flush(); + expect(events.length).toBe(mark + 1); + }); + + it("staged truth never mutates raw shallow rows before the reveal, and slots notify at it", async () => { + const { + createOptimisticStore, + registerRowOps, + action: act + } = await import("../../src/index.js"); + const { registerSlotPatchNext } = await import("../../src/store/next/patch.js"); + // Shallow list: primitive rows — slot channel territory. + const [items, setItems] = (createOptimisticStore as any)(["a", "b"] as any[]); + const ticks: Array<[number, any]> = []; + const rowsSeen: string[][] = []; + createRoot(() => { + registerSlotPatchNext(items, (i: number, v: any) => ticks.push([i, v])); + registerRowOps(items, (rows: any[]) => rowsSeen.push(Array.from(rows, String))); + }); + let confirm!: () => void; + const run = act(function* () { + setItems((draft: any[]) => { + draft.push("c"); // retain optimism on the family + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + // Landing while retained: STAGES. Slot 0's committed value must stay + // "a" for every ordinary reader until the reveal. + // Simulate the projection landing channel: authoritative write of fresh + // truth (what a poll/refresh continuation does). + const { storeSetterNext, runAuthoritative } = await import("../../src/store/next/store.js"); + runAuthoritative(() => { + storeSetterNext(items, (draft: any[]) => { + draft[0] = "A2"; + }); + }); + flush(); + const { snapshot } = await import("../../src/index.js"); + // Ordinary readers: still the optimistic view over OLD committed truth. + expect((items as any)[0]).toBe("a"); + confirm(); + await run; + await settle(); + // Reveal: slot 0 flips to A2 — the slot channel must be told. + expect((items as any)[0]).toBe("A2"); + expect(ticks.some(([i, v]) => i === 0 && v === "A2")).toBe(true); + void snapshot; + void rowsSeen; + }); + + it("a consumer mounted INSIDE a writing transition never replays its stashed ops (bac stays bac)", async () => { + // Fold audit 2, P1: the mid-transition mount reads the SPECULATIVE view + // (boundary content renders from it) — its version baseline must cover + // the stashed emissions, or the release replays ops it already saw: + // store/classic end "bac", driven DOM ends "abc". + const { registerRowOps, action: act } = await import("../../src/index.js"); + const [state, setState] = createStore({ + rows: [{ id: "a" }, { id: "b" }, { id: "c" }] + }); + createRoot(() => { + registerRowOps(state.rows, () => {}); + }); + let confirm!: () => void; + const run = act(function* () { + setState((s: any) => { + reconcile([{ id: "b" }, { id: "a" }, { id: "c" }], "id")(s.rows); + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + // Mount DURING the transition window, reading the speculative view. + const frames: string[][] = []; + let lastOps: any = "none"; + createRoot(() => { + registerRowOps(state.rows, (rows: any[], ops: any) => { + frames.push(Array.from(rows, (r: any) => r.id)); + lastOps = ops; + }); + }); + flush(); + confirm(); + await run; + flush(); + // The release must NOT deliver the stashed reorder ops to this entry — + // its baseline already contained "bac". Applying them re-reorders a + // list that is already reordered. + for (const f of frames) expect(f).toEqual(["b", "a", "c"]); + void lastOps; + }); + + it("av init keys off the HOLDING QUEUE, not the execution-time transition flag (parked windows)", async () => { + // Fold audit 3, P1: `activeTransition` is null in a PARKED action's + // window while speculative state is still what held-boundary content + // reads — the flag-based init handed those registrants a committed + // baseline and replayed stashed ops over speculative DOM. The + // discriminator is the registrant's owner queue HOLDING. + const { registerRowOps, getOwner, action: act } = await import("../../src/index.js"); + const { $TARGET } = await import("../../src/store/store.js"); + const { GlobalQueue } = await import("../../src/core/scheduler.js"); + const [state, setState] = createStore({ rows: [{ id: "a" }, { id: "b" }] }); + createRoot(() => { + registerRowOps(state.rows, () => {}); + }); + let confirm!: () => void; + const run = act(function* () { + setState((s: any) => { + reconcile([{ id: "b" }, { id: "a" }], "id")(s.rows); + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); // action is now PARKED: activeTransition is null here + const pc = (state.rows as any)[$TARGET].pc; + const fakeQ: any = { enqueue: () => {} }; + const prevProbe = (GlobalQueue as any)._queueHeld; + (GlobalQueue as any)._queueHeld = (q: any) => q === fakeQ || prevProbe?.(q) === true; + try { + // Fold audit 4: adoption commits EAGERLY, so EVERY parked-window + // mount — held boundary or ambient — has the walk's state in its + // init read; both initialize at the full emitted version and neither + // may replay the stash at release. + createRoot(() => { + (getOwner() as any)._queue = fakeQ; + registerRowOps(state.rows, () => {}); + }); + const held = pc.ro[pc.ro.length - 1]; + expect(held.av).toBe(pc.sv); + createRoot(() => { + registerRowOps(state.rows, () => {}); + }); + const ambient = pc.ro[pc.ro.length - 1]; + expect(ambient.av).toBe(pc.sv); + expect(pc.svv).toBe(pc.sv); // walk visibility IS emission visibility + } finally { + (GlobalQueue as any)._queueHeld = prevProbe; + } + confirm(); + await run; + flush(); + }); + + it("a NO-OP fold never suppresses the revert resync (rf only on proven emission)", async () => { + // Fold audit 4, P1: rf was set before proving the reveal emitted — + // an identity-aligned (no-op) staged fold marked the channel and the + // settle loop skipped the ONLY resync the revert needed. + const { + createOptimisticStore, + registerRowOps, + action: act + } = await import("../../src/index.js"); + const { storeSetterNext, runAuthoritative } = await import("../../src/store/next/store.js"); + const a = { id: 1 }; + const [items, setItems] = (createOptimisticStore as any)([a] as any[]); + const frames: number[][] = []; + createRoot(() => { + registerRowOps(items, (rows: any[]) => frames.push(Array.from(rows, (r: any) => r.id))); + }); + let confirm!: () => void; + const run = act(function* () { + setItems((d: any[]) => { + d.push({ id: 2 }); + }); + yield new Promise(r => { + confirm = r; + }); + })(); + flush(); + expect(frames.at(-1)).toEqual([1, 2]); + // A staged landing that restates the SAME truth: identity-aligned, + // emits nothing — and must not mark the reveal as delivered. + runAuthoritative(() => { + storeSetterNext(items, (d: any[]) => { + void d.length; // open the draft; write nothing new + }); + }); + flush(); + confirm(); + await run; + await Promise.resolve(); + flush(); + // The revert's resync is the only notification that removes row 2 — + // a lingering rf would leave the driven list on [1, 2] forever. + expect(frames.at(-1)).toEqual([1]); + }); + + it("an equal-length PRIMITIVE reorder is structure (classic value-identity), not slot rewrites", async () => { + const { + createOptimisticStore, + registerRowOps, + action: act + } = await import("../../src/index.js"); + const { registerSlotPatchNext } = await import("../../src/store/next/patch.js"); + const { storeSetterNext, runAuthoritative } = await import("../../src/store/next/store.js"); + const [items, setItems] = (createOptimisticStore as any)(["a", "b", "c"] as any[]); + const rowEvents: any[] = []; + const ticks: any[] = []; + createRoot(() => { + registerRowOps(items, (_r: any[], ops: any) => rowEvents.push(ops)); + registerSlotPatchNext(items, (i: number, v: any) => ticks.push([i, v])); + }); + let confirm!: () => void; + const run = act(function* () { + setItems((d: any[]) => { + d.push("z"); + }); + yield new Promise(r => { + confirm = r; + }); + })(); + flush(); + const rowMark = rowEvents.length; + const tickMark = ticks.length; + // Staged PERMUTATION of primitives: classic keys primitive rows by + // VALUE — rows must MOVE (row ops), not have contents rewritten. + runAuthoritative(() => { + storeSetterNext(items, (d: any[]) => { + const t0 = d[0]; + d[0] = d[2]; + d[2] = t0; + }); + }); + flush(); + confirm(); + await run; + await Promise.resolve(); + flush(); + expect(ticks.slice(tickMark)).toEqual([]); + const permOps = rowEvents.slice(rowMark).filter(o => o !== undefined && o !== null) as Array<{ + sources: number[]; + }>; + expect(permOps.length).toBeGreaterThan(0); + // RETENTION (fold audit 5): a pure permutation must MATCH every moved + // value to its old index — sources:[-1,…] rebuilt every row and lost + // the retained DOM nodes primitives key by value. + for (const o of permOps) expect(o.sources.every(sc => sc >= 0)).toBe(true); + }); + + it("mixed primitive/object identities never collide across key spaces", async () => { + const { buildIdentityRowOps } = await import("../../src/store/next/reconcile.js"); + const byId = (r: any) => r?.id; + // ONE key map collided `{ id: 1 }` (keyed to 1) with the primitive row + // `1` (valued 1) — a moved primitive was handed the OBJECT row's source + // (fold audit 6, P1): stale DOM wearing the wrong identity. + const obj = { id: 1 }; + const ops = buildIdentityRowOps([obj, 1, 2], [2, obj, 1], byId)!; + expect(ops.prefix).toBe(0); + expect(ops.sources).toEqual([2, 0, 1]); + expect(ops.removed).toEqual([]); + // PREFIX SCAN, same disease: `keyFn` probing a primitive yields + // undefined on both sides — two DIFFERENT primitives falsely aligned + // and the real change at index 0 escaped the ops window entirely. + const rep = buildIdentityRowOps([5, 6], [7, 6], byId)!; + expect(rep.prefix).toBe(0); + expect(rep.sources).toEqual([-1, 1]); + // An object keyed to a primitive id never aligns with that primitive. + const cross = buildIdentityRowOps([{ id: 5 }, "x"], [5, "x"], byId)!; + expect(cross.prefix).toBe(0); + expect(cross.sources[0]).toBe(-1); + }); + + it("a plain move of `undefined` (and a sparse hole) retains its row", async () => { + const { buildIdentityRowOps } = await import("../../src/store/next/reconcile.js"); + // `undefined` rows were skipped by both map build and lookup — a pure + // move rebuilt the row (fold audit 6, P2). The sentinel makes them + // first-class match participants; sparse holes read as the same value. + const ops = buildIdentityRowOps(["a", undefined, "b"], [undefined, "a", "b"])!; + expect(ops.prefix).toBe(0); + expect(ops.sources).toEqual([1, 0, 2]); + expect(ops.removed).toEqual([]); + const sparse = new Array(3); + sparse[0] = "a"; + sparse[2] = "b"; + const holes = buildIdentityRowOps(sparse, [undefined, "a", "b"])!; + expect(holes.sources).toEqual([1, 0, 2]); + }); + + it("a shallow staged reveal rides ONE channel — slot ticks for aligned replacement, never row ops too", async () => { + const { + createOptimisticStore, + registerRowOps, + action: act + } = await import("../../src/index.js"); + const { registerSlotPatchNext } = await import("../../src/store/next/patch.js"); + const { storeSetterNext, runAuthoritative } = await import("../../src/store/next/store.js"); + const [items, setItems] = (createOptimisticStore as any)(["a", "b"] as any[]); + const rowEvents: any[] = []; + const ticks: Array<[number, any]> = []; + createRoot(() => { + registerRowOps(items, (_r: any[], ops: any) => rowEvents.push(ops)); + registerSlotPatchNext(items, (i: number, v: any) => ticks.push([i, v])); + }); + let confirm!: () => void; + const run = act(function* () { + setItems((d: any[]) => { + d.push("c"); + }); + yield new Promise(r => { + confirm = r; + }); + })(); + flush(); + const rowMark = rowEvents.length; + // ALIGNED staged replacement (same length): slot territory. + runAuthoritative(() => { + storeSetterNext(items, (d: any[]) => { + d[0] = "A2"; + }); + }); + flush(); + confirm(); + await run; + await Promise.resolve(); + flush(); + // The reveal delivers the replacement ONCE: a slot tick — ANY row + // event (ops OR the null resync the old filtered assertion hid) would + // rebuild the row a second time (lifecycle/focus). + expect(ticks.some(([i, v]) => i === 0 && v === "A2")).toBe(true); + expect(rowEvents.slice(rowMark)).toEqual([]); + }); + + it("an equal-length staged REORDER is structure: one row-ops event, zero slot ticks", async () => { + // Fold audit 3, P1: classifying aligned windows by LENGTH alone called + // reorders value replacements — moved rows rebuilt via slot ticks and + // lost identity/focus. Moved wrappable references are STRUCTURE. + const { + createOptimisticStore, + registerRowOps, + action: act + } = await import("../../src/index.js"); + const { registerSlotPatchNext } = await import("../../src/store/next/patch.js"); + const { storeSetterNext, runAuthoritative } = await import("../../src/store/next/store.js"); + const a = { id: "a" }; + const b = { id: "b" }; + const [items, setItems] = (createOptimisticStore as any)([a, b] as any[]); + const rowEvents: any[] = []; + const ticks: any[] = []; + createRoot(() => { + registerRowOps(items, (_r: any[], ops: any) => rowEvents.push(ops)); + registerSlotPatchNext(items, (i: number, v: any) => ticks.push([i, v])); + }); + let confirm!: () => void; + const run = act(function* () { + setItems((d: any[]) => { + d.push({ id: "c" }); // retain optimism + }); + yield new Promise(r => { + confirm = r; + }); + })(); + flush(); + const rowMark = rowEvents.length; + const tickMark = ticks.length; + // Staged equal-length REORDER: same refs, swapped slots. + runAuthoritative(() => { + storeSetterNext(items, (d: any[]) => { + const t0 = d[0]; + d[0] = d[1]; + d[1] = t0; + }); + }); + flush(); + confirm(); + await run; + await Promise.resolve(); + flush(); + // Structure rode row ops; the slot channel stayed silent. + expect(ticks.slice(tickMark)).toEqual([]); + const revealOps = rowEvents.slice(rowMark).filter(o => o !== null && o !== undefined); + expect(revealOps.length).toBeGreaterThan(0); + }); + + it("a staged ROOT structural change reveals WITH row ops when only a descendant holds the override", async () => { + const { + createOptimisticStore, + registerRowOps, + action: act + } = await import("../../src/index.js"); + const harnessFetches: Array<() => void> = []; + let serverData: any[] = [{ id: 1, label: "a" }]; + let items!: any; + let setItems!: any; + let setVersion!: (v: (p: number) => number) => number; + createRoot(() => { + const [version, setV] = createSignal(0); + setVersion = setV; + [items, setItems] = (createOptimisticStore as any)( + () => + new Promise(resolve => { + version(); + harnessFetches.push(() => resolve(serverData.map(r => ({ ...r })))); + }), + [] as any[] + ); + }); + flush(); + harnessFetches.shift()!(); + await settle(); + const frames: number[][] = []; + createRoot(() => { + registerRowOps(items, (rows: any[]) => frames.push(Array.from(rows, (r: any) => r.id))); + }); + // DESCENDANT-only override: a value edit on row 0 — the ROOT ARRAY + // itself carries no override. + let confirm!: () => void; + const run = act(function* () { + setItems((draft: any[]) => { + draft[0].label = "opt"; + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + // STRUCTURAL landing while retained: stages into the transaction. + serverData = [ + { id: 1, label: "a" }, + { id: 2, label: "b" } + ]; + setVersion(v => v + 1); + flush(); + harnessFetches.shift()!(); + await settle(); + // Reveal at settle: the root array's staged structural change commits — + // the driven list MUST receive ops/resync for the new row. + const revealMark = frames.length; + confirm(); + await run; + await settle(); + await settle(); + expect((items as any[]).length).toBe(2); + expect(frames.at(-1)).toEqual([1, 2]); + // ONE coherent notification per reveal (fold audit P1): overlapping + // resync + row-op + slot work rebuilt the same rows repeatedly and + // lost DOM identity/focus. At most one [1] frame may precede the + // final [1,2] (the revert half), never repeated [1,2] rebuilds. + const since = frames.slice(revealMark); + const finals = since.filter(f => f.length === 2 && f[0] === 1 && f[1] === 2); + expect(finals.length).toBe(1); + }); +}); + +describe("INVARIANT: landings integrate with the patch channel at classic-effect parity (#3123)", () => { + // RUL-2 as re-ruled: an EQUAL landing (membership/arrangement unchanged) + // holds live overrides — classic effects keep showing the optimistic view. + // A CONTRADICTING landing consumes them authoritatively — classic + // reversion effects ride the regular queues of the landing's commit. + // The patch channel must match both, delivery for delivery. + const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + flush(); + }; + + function landingHarness(initialRows: any[]) { + let serverData = initialRows.map(r => ({ ...r })); + const fetches: Array<() => void> = []; + let items: any; + let setItems: any; + let setVersion!: (v: (p: number) => number) => number; + let dispose!: () => void; + let store: { createOptimisticStore: any; action: any }; + const build = async () => { + const mod = await import("../../src/index.js"); + store = { createOptimisticStore: mod.createOptimisticStore, action: mod.action }; + createRoot(d => { + dispose = d; + const [version, setV] = createSignal(0); + setVersion = setV; + [items, setItems] = (store.createOptimisticStore as any)( + () => + new Promise(resolve => { + version(); + fetches.push(() => resolve(serverData.map(r => ({ ...r })))); + }), + [] as any[] + ); + }); + flush(); + fetches.shift()!(); // initial landing + await settle(); + }; + return { + build, + get items() { + return items; + }, + get setItems() { + return setItems; + }, + get action() { + return store.action; + }, + get dispose() { + return dispose; + }, + setServer(data: any[]) { + serverData = data.map(r => ({ ...r })); + }, + poll() { + setVersion(v => v + 1); + flush(); + fetches.shift()!(); + } + }; + } + + it("an equal landing never flashes committed state through value patches", async () => { + const h = landingHarness([{ id: 1, label: "a", count: 1 }]); + await h.build(); + expect(h.items[0].label).toBe("a"); + + const { createRenderEffect } = await import("../../src/index.js"); + const patched: string[] = []; + const classic: string[] = []; + let disposeConsumers!: () => void; + createRoot(d => { + disposeConsumers = d; + registerPatch(h.items[0], (next: any) => patched.push(next.label + ":" + next.count), [ + "label", + "count" + ]); + createRenderEffect( + () => h.items[0].label + ":" + h.items[0].count, + (v: string) => { + classic.push(v); + } + ); + }); + flush(); + + // Optimistic edit on `label`, held in flight. + let confirm!: () => void; + const run = h.action(function* (this: any) { + h.setItems((draft: any[]) => { + draft[0].label = "x"; + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + expect(classic.at(-1)).toBe("x:1"); + const watermark = patched.length; + + // Interim landing under FOLD semantics (#3164 re-ruling): the family + // retains optimism (the action is open), so fresh truth STAGES into the + // retaining transaction — classic readers keep the optimistic view + // exactly as it was (count still 1: staged truth is invisible until the + // reveal). The channel must deliver NOTHING newer than classic sees — + // no "a:" flash, no early count. + h.setServer([{ id: 1, label: "a", count: 2 }]); + h.poll(); + await settle(); + expect(classic.at(-1)).toBe("x:1"); + const sinceLanding = patched.slice(watermark); + expect(sinceLanding.some(v => v.startsWith("a:"))).toBe(false); + expect(sinceLanding.some(v => v.endsWith(":2"))).toBe(false); + + // ATOMIC REVEAL at settle: override dies, staged truth lands — both + // channels flip together to committed "a:2". + confirm(); + await run; + await settle(); + await settle(); + expect(classic.at(-1)).toBe("a:2"); + expect(patched.at(-1)).toBe("a:2"); + disposeConsumers(); + h.dispose(); + }); + + it("a contradicting landing is one authoritative delivery with a structural resync", async () => { + const h = landingHarness([{ id: 1, label: "a" }]); + await h.build(); + const { registerRowOps } = await import("../../src/index.js"); + + const patched: string[] = []; + const rowEvents: Array<{ ids: any[]; resync: boolean }> = []; + let disposeConsumers!: () => void; + createRoot(d => { + disposeConsumers = d; + registerPatch(h.items[0], (next: any) => patched.push(next.label), ["label"]); + registerRowOps(h.items, (next: any[], ops: any) => + rowEvents.push({ ids: next.map((r: any) => r.id), resync: ops === null }) + ); + }); + flush(); + + // Optimistic structural add, held in flight. + let confirm!: () => void; + const run = h.action(function* (this: any) { + h.setItems((draft: any[]) => { + draft.push({ id: 2, label: "b" }); + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + expect(rowEvents.at(-1)?.ids).toEqual([1, 2]); + const patchMark = patched.length; + const rowMark = rowEvents.length; + + // CONTRADICTING landing under FOLD semantics (#3164 re-ruling): the + // family retains optimism (the action is open), so the landing STAGES — + // classic readers keep the optimistic arrangement [1, 2], and the + // channel must stay exactly there with them: no early flip, no early + // value. + h.setServer([ + { id: 1, label: "a2" }, + { id: 3, label: "c" } + ]); + h.poll(); + await settle(); + expect(rowEvents.slice(rowMark)).toEqual([]); + expect(patched.slice(patchMark)).toEqual([]); + + // ATOMIC REVEAL at settle: override dies, staged truth lands — the + // driven list flips to [1, 3] and the value channel delivers the + // authoritative "a2", both on the settle drain. + confirm(); + await run; + await settle(); + await settle(); + expect(rowEvents.at(-1)!.ids).toEqual([1, 3]); + expect(patched.at(-1)).toBe("a2"); + disposeConsumers(); + h.dispose(); + }); + + it("a continuation echo replay keeps channel/classic parity (no flash, no duplicate, value masks until settle)", async () => { + // The third landing posture (d813a96f): a CONTINUATION landing that + // echoes an open transaction's keyed add. Wipe + replay re-derives the + // optimistic view — the echoed row keeps the landed slot, the replayed + // edit's value masks it until its transaction settles, the other open + // add re-bases without a flash. The channel must tell the driven list + // the same story classic effects see, frame for frame. + const mod: any = await import("../../src/index.js"); + const { createOptimisticStore, createRenderEffect, registerRowOps, until } = mod; + type Row = { id: number; pending: boolean }; + let notify!: { promise: Promise; resolve: (row: Row) => void }; + const reset = () => { + let resolve!: (row: Row) => void; + const promise = new Promise(r => (resolve = r)); + notify = { promise, resolve }; + }; + reset(); + const confirm = (row: Row) => { + const current = notify; + reset(); + current.resolve(row); + }; + let items!: any; + let setItems!: (fn: (rows: Row[]) => void) => void; + const classic: string[][] = []; + const dispose = createRoot((d: () => void) => { + [items, setItems] = createOptimisticStore(async function* (store: Row[]) { + yield [] as Row[]; + while (true) { + const row = await notify.promise; + yield; + store.push({ ...row, pending: false }); + } + }, [] as Row[]); + createRenderEffect( + () => items.map((r: Row) => r.id + (r.pending ? "p" : "")), + (v: string[]) => { + classic.push(v); + } + ); + return d; + }); + flush(); + await settle(); + + const rowFrames: string[][] = []; + let disposeConsumers!: () => void; + createRoot(d => { + disposeConsumers = d; + registerRowOps(items, (next: Row[]) => { + rowFrames.push(next.map((r: Row) => r.id + (r.pending ? "p" : ""))); + }); + }); + flush(); + + // Two blind keyed adds; both actions hold past their own confirmations. + const holds: (() => void)[] = []; + const add = action(function* (row: Row) { + setItems(store => { + store.push({ ...row, pending: true }); + }); + yield until(() => items.some((x: Row) => x.id === row.id)); + yield new Promise(resolve => holds.push(resolve)); + }); + const addA = add({ id: 0, pending: true }); + flush(); + const addB = add({ id: 1, pending: true }); + flush(); + expect(classic.at(-1)).toEqual(["0p", "1p"]); + expect(rowFrames.at(-1)).toEqual(["0p", "1p"]); + const classicMark = classic.length; + const rowMark = rowFrames.length; + + // A's confirmation: continuation landing echoing row 0 (pending:false). + confirm({ id: 0, pending: false }); + await settle(); + await settle(); + expect(classic.at(-1)).toEqual(["0p", "1p"]); + expect(rowFrames.at(-1)).toEqual(["0p", "1p"]); + + confirm({ id: 1, pending: false }); + await settle(); + await settle(); + expect(rowFrames.at(-1)).toEqual(["0p", "1p"]); + + // Settle is the only reckoning: edits die with their transactions and + // the landed truth (pending:false) stands, in BOTH consumers. + for (const release of holds) release(); + await Promise.all([addA, addB]); + await settle(); + expect(classic.at(-1)).toEqual(["0", "1"]); + expect(rowFrames.at(-1)).toEqual(["0", "1"]); + + // After both rows were visible, no channel frame ever lost a row + // (flash) or carried a duplicate key (echo double-count) — and classic + // held the same line. + for (const frame of rowFrames.slice(rowMark)) { + expect(frame).toHaveLength(2); + expect(new Set(frame.map(s => s[0])).size).toBe(2); + } + for (const frame of classic.slice(classicMark)) expect(frame).toHaveLength(2); + + disposeConsumers(); + dispose(); + }); + + it("the settle-drain reckoning (entangled retainers die together) keeps channel/classic parity", async () => { + // The fourth posture: settle-time re-derivation. Actions writing one + // optimistic store ENTANGLE through the shared writes and settle + // together — one action completing early does not strip its mask + // (edits live exactly as long as their transaction, and the entangled + // transaction is still open). At the joint settle the reckoning wipes + // and replays (nothing survives here) — the channel must land on the + // committed truth without serving the wipe's half-states. + const mod: any = await import("../../src/index.js"); + const { createOptimisticStore, createRenderEffect, registerRowOps, until } = mod; + type Row = { id: number; pending: boolean }; + let notify!: { promise: Promise; resolve: (row: Row) => void }; + const reset = () => { + let resolve!: (row: Row) => void; + const promise = new Promise(r => (resolve = r)); + notify = { promise, resolve }; + }; + reset(); + const confirm = (row: Row) => { + const current = notify; + reset(); + current.resolve(row); + }; + let items!: any; + let setItems!: (fn: (rows: Row[]) => void) => void; + const classic: string[][] = []; + const dispose = createRoot((d: () => void) => { + [items, setItems] = createOptimisticStore(async function* (store: Row[]) { + yield [] as Row[]; + while (true) { + const row = await notify.promise; + yield; + store.push({ ...row, pending: false }); + } + }, [] as Row[]); + createRenderEffect( + () => items.map((r: Row) => r.id + (r.pending ? "p" : "")), + (v: string[]) => { + classic.push(v); + } + ); + return d; + }); + flush(); + await settle(); + + const rowFrames: string[][] = []; + let disposeConsumers!: () => void; + createRoot(d => { + disposeConsumers = d; + registerRowOps(items, (next: Row[]) => { + rowFrames.push(next.map((r: Row) => r.id + (r.pending ? "p" : ""))); + }); + }); + flush(); + + // A completes at its own confirmation (dies at settle); B holds open. + const add = action(function* (row: Row) { + setItems(store => { + store.push({ ...row, pending: true }); + }); + yield until(() => items.some((x: Row) => x.id === row.id)); + }); + let holdB!: () => void; + const addHeld = action(function* (row: Row) { + setItems(store => { + store.push({ ...row, pending: true }); + }); + yield new Promise(resolve => { + holdB = resolve; + }); + }); + const addA = add({ id: 0, pending: true }); + flush(); + const addB = addHeld({ id: 1, pending: true }); + flush(); + expect(classic.at(-1)).toEqual(["0p", "1p"]); + expect(rowFrames.at(-1)).toEqual(["0p", "1p"]); + + // A's confirmation lands its row and completes A — but A's transaction + // entangled with B's through the shared store, so BOTH masks hold (an + // edit lives as long as its transaction; the joint transaction is open). + confirm({ id: 0, pending: false }); + await addA; + await settle(); + await settle(); + expect(classic.at(-1)).toEqual(["0p", "1p"]); + expect(rowFrames.at(-1)).toEqual(["0p", "1p"]); + + // Joint settle: every retainer dies, landed truth stands — row 0 as the + // server confirmed it, row 1 (never landed) reverted. Channel included. + holdB(); + await addB; + await settle(); + expect(classic.at(-1)).toEqual(["0"]); + expect(rowFrames.at(-1)).toEqual(["0"]); + + disposeConsumers(); + dispose(); + }); +}); diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 4378f4e8b..a4041cb2d 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -26,12 +26,14 @@ export { latest, // Patch-channel compiler contract (undocumented as application API) patchableRaw, + patchProxyFor, registerPatch, registerRowOps, registerSlotPatch, storeIsShallow, storeHasFamily, storeHasOptimisticFamily, + storeFamilyOf, reconcile, refresh, repeat, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 656216245..61fd0a3d7 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -39,12 +39,14 @@ export { // Patch-channel compiler contract (parity with the client entry; the // channel is inert on the server — SSR renders once, hydration claims) patchableRaw, + patchProxyFor, registerPatch, registerRowOps, registerSlotPatch, storeIsShallow, storeHasFamily, storeHasOptimisticFamily, + storeFamilyOf, reconcile, refresh, repeat, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index c27c438a2..abef969c4 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -2836,6 +2836,10 @@ export function registerPatch( export function registerRowOps(_array: any, _fn: (next: any[], ops: any) => void): () => void { return noopUnbind; } +export function patchProxyFor(_list: any, raw: any): any { + return raw; // SSR renders once from whatever it is handed +} + export function patchableRaw(_record: any): undefined { return undefined; } @@ -2854,4 +2858,7 @@ export function storeHasFamily(_proxy: any): boolean { export function storeHasOptimisticFamily(_proxy: any): boolean { return false; } +export function storeFamilyOf(_proxy: any): unknown { + return null; +} const noopUnbind = () => {}; diff --git a/packages/universal/README.md b/packages/universal/README.md index 14573e905..6739d79a2 100644 --- a/packages/universal/README.md +++ b/packages/universal/README.md @@ -65,7 +65,13 @@ export const { setProp, mergeProps, applyRef, - ref + ref, + // Required since patch mode became the compiler default: compiled + // templates with pure member-read bindings import `patchDriver`, and + // compiled pure list rows import `rowProof`, from your renderer module. + // createRenderer provides both — just re-export. + patchDriver, + rowProof } = createRenderer({ createElement(string) { return document.createElement(string); diff --git a/packages/universal/src/universal.ts b/packages/universal/src/universal.ts index 7909f4bc2..54ae50802 100644 --- a/packages/universal/src/universal.ts +++ b/packages/universal/src/universal.ts @@ -46,6 +46,20 @@ export interface Renderer { fn: () => ((element: NodeType) => void) | ((element: NodeType) => void)[], element: NodeType ): void; + /** Patch-mode dual driver (compiled output imports this under the + * DEFAULT-ON patch compiler): runs the compiled body as a dual-phase + * effect. `createRenderer` synthesizes it — custom renderers just + * re-export it like every other member. The optional third argument is + * the compiler's static read manifest (unused by the universal flavor). */ + patchDriver( + subject: unknown, + body: (next: any, prev: any, force?: boolean) => void, + keys?: string[] + ): void; + /** Pure-row stamp for compiled `` output (default-on patch + * compiler). The universal flavor keeps classic list semantics — this is + * an identity function that exists so compiled imports link. */ + rowProof(fn: F): F; } const transparentOptions = { transparent: true, sync: true }; @@ -418,12 +432,45 @@ export function createRenderer({ ref, // Patch-mode dual driver, universal flavor: no store/record seams here, // so every compiled body runs through the classic dual-phase effect - // (compute pass reads with next === prev; commit pass force-applies). - patchDriver(subject, body) { - effect( - () => body(subject, subject, false), - () => body(subject, undefined, true) - ); + // (compute pass reads with next === prev; commit pass force-applies — + // untracked, matching the web fallback: force short-circuits compares, + // not reads, and dev strict-read would flag the re-reads otherwise). + patchDriver(subject, body, keys) { + if (keys !== undefined) { + // Manifest compute: exact tracked reads, write-free by construction + // (the dual-run form fires setters during compute for NaN/unstable + // fields — re-audit 9, P1-3). + const paths = keys.map(k => (k.indexOf(".") === -1 ? k : k.split("."))); + effect( + () => { + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + if (typeof p === "string") subject?.[p]; + else { + let o = subject; + for (let d = 0; d < p.length && o != null; d++) o = o[p[d]]; + } + } + }, + () => { + untrack(() => body(subject, undefined, true)); + } + ); + } else { + effect( + () => body(subject, subject, false), + () => { + untrack(() => body(subject, undefined, true)); + } + ); + } + }, + // Compiler-proven pure list rows arrive wrapped in `rowProof` under the + // default-on patch compiler. The universal flavor has no list driver — + // the stamp is meaningless here — but the export must exist for the + // compiled import to link; identity keeps classic list semantics. + rowProof(fn) { + return fn; } }; } diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 176138d33..2bdd8d8c1 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -11,14 +11,17 @@ import { createOwner, onCleanup, patchableRaw, + patchProxyFor, registerPatch, registerRowOps, registerSlotPatch, runWithOwner, sharedConfig, + storeFamilyOf, storeHasOptimisticFamily, storeIsShallow, - untrack + untrack, + createRoot } from "solid-js"; import { effect } from "./render.js"; import { installListDriver } from "./client.js"; @@ -180,9 +183,9 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? const shallow = storeIsShallow(subject); let lastBodies: any[] | null = null; let lastUnbinds: (() => void)[] | null = null; - const collectBind = (abs: number, build: () => Node): Node => { + const collectBind = (rec: any, build: () => Node): Node => { const prevC = rowCollector; - rowCollector = { row: shallow ? subject[abs] : undefined, bodies: [], unbinds: [] }; + rowCollector = { row: shallow ? rec : undefined, bodies: [], unbinds: [] }; try { return build(); } finally { @@ -197,7 +200,12 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // stay aligned on both the engage and (pre-owner) decline paths. const listOwner = createOwner(); let declined = false; - const bindRow = (abs: number, claimId?: string): Node => { + // Rows bind THEIR OPERATION'S captured record (re-audit 8, P1-2): queued + // structural work must not index the live subject — a second operation + // queued before the drain shifts it, binding the wrong record and + // corrupting every later operation's baseline. Captured raws resolve to + // their proxies through the list's family lookup. + const bindRow = (rec: any, claimId?: string): Node => { if ("_SOLID_DEV_") { // Ownership assertion: a stamped row must attach NOTHING to the list // owner — the compiler proved the template, but handler/attribute @@ -207,13 +215,13 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? const o = listOwner as any; const prevChild = o._firstChild; const prevDisposal = o._disposal; - const node = collectBind(abs, () => + const node = collectBind(rec, () => runWithOwner(listOwner, () => claimId !== undefined ? (runWithOwner(createOwner({ id: claimId }) as any, () => - untrack(() => rowFn(subject[abs])) + untrack(() => rowFn(rec)) ) as Node) - : (untrack(() => rowFn(subject[abs])) as Node) + : (untrack(() => rowFn(rec)) as Node) ) ) as Node; if (o._firstChild !== prevChild || o._disposal !== prevDisposal) { @@ -227,13 +235,13 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? } return node; } - return collectBind(abs, () => + return collectBind(rec, () => runWithOwner(listOwner, () => claimId !== undefined ? (runWithOwner(createOwner({ id: claimId }) as any, () => - untrack(() => rowFn(subject[abs])) + untrack(() => rowFn(rec)) ) as Node) - : (untrack(() => rowFn(subject[abs])) as Node) + : (untrack(() => rowFn(rec)) as Node) ) ) as Node; }; @@ -252,27 +260,32 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? rowUnbinds = []; }; let prevRaws: any[] = raw.slice(); + // The family the CURRENT rows were bound under (round 10.5, F5) — + // updated only by a fully successful apply, so a throwing swap build + // leaves it on the old family and the recovery resync rebuilds. + let boundFam: unknown = storeFamilyOf(subject); // Initial construction severs on throw like update-time builds (re-audit // 5, P1-4): without this, rows registered before a throwing row leak // their registrations under the never-mounted list — keeping patchCount // elevated GLOBALLY (every store's setter-site gate stays hot) long after // an error boundary recovers the region. + let initIdx = 0; try { if (hydrating) { // Claim pass: each bind claims its server row through the row-scoped // id (getNextElement resolves the `_hk` registry entry); patchDriver // skips the initial apply. - for (let i = 0; i < raw.length; i++) { - entries[i] = bindRow(i, rowIds![i]); - if (rowBodies !== null) rowBodies[i] = lastBodies!; - rowUnbinds[i] = lastUnbinds!; + for (; initIdx < raw.length; initIdx++) { + entries[initIdx] = bindRow(patchProxyFor(subject, raw[initIdx], initIdx), rowIds![initIdx]); + if (rowBodies !== null) rowBodies[initIdx] = lastBodies!; + rowUnbinds[initIdx] = lastUnbinds!; } } else { - for (let i = 0; i < raw.length; i++) { - const node = bindRow(i); - entries[i] = node; - if (rowBodies !== null) rowBodies[i] = lastBodies!; - rowUnbinds[i] = lastUnbinds!; + for (; initIdx < raw.length; initIdx++) { + const node = bindRow(patchProxyFor(subject, raw[initIdx], initIdx)); + entries[initIdx] = node; + if (rowBodies !== null) rowBodies[initIdx] = lastBodies!; + rowUnbinds[initIdx] = lastUnbinds!; parent.insertBefore(node, endAnchor); } } @@ -283,6 +296,16 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? const n = entries[j] as ChildNode | undefined; if (n !== undefined && n.parentNode === parent) n.remove(); } + // Surrender the list's ENTIRE server region (re-audit 7, P2-2): the + // throwing row's claimed element AND every trailing unclaimed server + // row belong to this list — a boundary fallback rendering into the + // region must not sit beside stale server rows. + if (hydrating && domRows !== undefined) { + for (let j = initIdx; j < domRows.length; j++) { + const server = domRows[j] as ChildNode; + if (server.parentNode === parent) server.remove(); + } + } (listOwner as any).dispose(); throw err; } @@ -295,6 +318,14 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // ingest stores them verbatim — matching without unwrapping rebuilds // every row (JFB keyed-reorder identity gate). const identityOps = (nextArr: any[]): { prefix: number; sources: number[] } => { + // FAMILY guard on resyncs (round 10.5, F5): a failed swap apply leaves + // retained rows bound under the OLD family while `subject` already + // moved — raw-identity retention here would resurrect exactly the + // cross-family staleness the swap path rebuilds against. Shallow rows + // carry no family-bound registrations (values ride the ARRAY's slot + // channel), so raw retention stays exact for them (F6). + if (!shallow && storeFamilyOf(subject) !== boundFam) + return { prefix: 0, sources: nextArr.map(() => -1) }; const keyOf = (r: any) => { const w = r != null ? patchableRaw(r) : undefined; return w !== undefined ? w : r; @@ -351,8 +382,19 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? for (; j < sources.length; j++) { const abs = prefix + j; const src = sources[j]; - if (src === -1 || (refRebuild && src >= 0 && next[abs] !== prevRaws[src])) { - built[j] = bindRow(abs); + // SameValueZero, matching the matcher's Map (fold audit 6, P1): the + // ops proved this source by value — a strict `!==` here re-litigated + // NaN and rebuilt a MOVED row, losing node/focus identity classic + // rendering keeps. + const nraw = next[abs]; + if ( + src === -1 || + (refRebuild && + src >= 0 && + nraw !== prevRaws[src] && + (nraw === nraw || prevRaws[src] === prevRaws[src])) + ) { + built[j] = bindRow(patchProxyFor(subject, nraw, abs)); if (builtBodies !== null) builtBodies[j] = lastBodies!; builtUnbinds[j] = lastUnbinds!; } @@ -412,6 +454,7 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? rowUnbinds = newUnbinds; prevRaws = next.slice(); resyncNeeded = false; // a full successful apply restores the baseline + boundFam = storeFamilyOf(subject); // rows now bound under this family (F5) }; let unbindOps = runWithOwner(listOwner, () => registerRowOps(subject, applyOps)) as () => void; @@ -427,10 +470,24 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // - shallow + `keyed={fn}`: replacement under a matching key is a value // tick — patch the row in place (the declared semantics). const refRebuild = shallow && typeof meta.keyed !== "function"; - const rebuildSlot = (i: number): void => { - runUnbinds(rowUnbinds[i]); + // BUILD BEFORE DESTROY (re-audit 7, P1-6): the old row must stay mounted + // AND registered until the replacement exists — unbinding first left a + // throwing factory's slot severed-but-visible (silent staleness, the + // worst failure shape) plus the partial build's registrations leaked. + const rebuildSlot = (i: number, rec: any): void => { const old = entries[i] as ChildNode; - const node = bindRow(i); + let node: Node; + try { + node = bindRow(rec); + } catch (err) { + // Sever the failed build's own partial registrations (collectBind's + // finally published them); the old row keeps patching. The armed + // resync retries through the next event, same as applyOps. + runUnbinds(lastUnbinds ?? undefined); + resyncNeeded = true; + throw err; + } + runUnbinds(rowUnbinds[i]); rowBodies![i] = lastBodies!; rowUnbinds[i] = lastUnbinds!; parent.insertBefore(node, old); @@ -455,7 +512,7 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? return; } if (refRebuild) { - rebuildSlot(i); + rebuildSlot(i, next); prevRaws[i] = next; return; } @@ -511,7 +568,24 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? lateClassic?.(); return; } - const swapOps = identityOps(nextRaw); + // The swap builds from the VISIBLE array (round 10, P1-6): an + // optimistic family's committed raw lags in-flight overrides — the + // same proxy read initial engagement uses. And retention requires + // FAMILY identity (round 10, P1-7): two families can wrap the same + // raws, but the retained rows' registrations belong to the OLD + // family — matching raw identity across families keeps DOM bound to + // channels the new subject never emits on. A family change rebuilds + // every row. + const nextVisible = storeHasOptimisticFamily(value) + ? (untrack(() => Array.from(value as any)) as any[]) + : nextRaw; + // SHALLOW rows carry no family-bound registrations (round 10.5, + // F6): raw retention is exact across families — classic + // retains them, and rebuilding would change DOM identity/focus. + const sameFamily = shallow || storeFamilyOf(value) === storeFamilyOf(subject); + const swapOps = sameFamily + ? identityOps(nextVisible) + : { prefix: 0, sources: nextVisible.map(() => -1) }; subject = value; // Register the NEW subject's channels BEFORE applying (re-audit 5, // P2-6): a throwing row build mid-swap must leave the list @@ -522,7 +596,7 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? unbindSlots = runWithOwner(listOwner, () => registerSlotPatch(subject, applySlot) ) as () => void; - applyOps(nextRaw, swapOps); + applyOps(nextVisible, swapOps); } ) ); @@ -552,15 +626,41 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // next === prev so every compare fails and it becomes a pure tracked // read; the commit pass force-applies, keeping DOM writes in the effect // phase where transitions and batching expect them. -export const patchDriver = (subject, body) => { - const raw = patchableRaw(subject); +export const patchDriver = (subject, body, keys?: string[]) => { + const raw = patchableRaw(subject, keys); if (raw !== undefined) { // Hydration is claim + register ONLY (DESIGN-PATCH-CHANNEL §5): the // server HTML already carries current values, so the initial force-apply - // is skipped — no writes, no graph edges. The registration alone arms - // the record for post-hydration transitions. - if (!sharedConfig.hydrating) body(raw, undefined, true); - const unbind = registerPatch(subject, body); + // is skipped — no writes, no graph edges. + let unbind: () => void; + if (keys !== undefined) { + // COMPILER MANIFEST (re-audit 7, P1-1): the static read envelope — + // complete across ternary/logical branches and nested chains, which + // runtime recording can never guarantee (untaken branches read + // nothing). No recording proxy; hydration registrations get the + // envelope up front instead of waiting for a first drain apply. + // + // Initial applies read the VISIBLE view (re-audit 9, P1-2): for + // optimistic-family records the committed raw lags live overrides — + // a mount after the lane drain must match its siblings, so it reads + // through the PROXY (untracked; the raw fast path stays for plain + // records). Deep-path staleness (round 10, P1-1) is repaired at the + // SOURCE: eager child adoptions path-copy the ancestor chain, so the + // committed raw a mount reads is always current — a proxy read here + // wrapped every nested object per row (+8 ms dbmon mount). + if (!sharedConfig.hydrating) { + const src = storeHasOptimisticFamily(subject) ? subject : raw; + untrack(() => body(src, undefined, true)); + } + unbind = registerPatch(subject, body, keys); + } else { + // Manifest-less callers (hand-written registrations; size pass): no + // read-set recording — registration poisons the channel's key union + // (`akAll`) and adoption probes full-scan. Compiled output always + // ships the manifest, so only hand-written callers pay wider probes. + if (!sharedConfig.hydrating) body(raw, undefined, true); + unbind = registerPatch(subject, body); + } if (rowCollector !== null) rowCollector.unbinds.push(unbind); // Ordinary (non-list-row) templates: the registration dies with the // registering owner. Drains only SKIP disposed owners — without this, @@ -571,14 +671,31 @@ export const patchDriver = (subject, body) => { } else if (rowCollector !== null && subject === rowCollector.row) { rowCollector.bodies.push(body); if (!sharedConfig.hydrating) body(subject, undefined, true); + } else if (keys !== undefined) { + // Effect fallback, MANIFEST form (re-audit 9, P1-3): the compute pass + // reads the declared envelope directly — running the body with + // next === prev is NOT reliably read-only (NaN fields and unstable + // getters make compares true, firing DOM/custom setters inside a + // tracked computation and again at commit). The manifest IS the read + // set, so the compute is exact and pure by construction. + const paths = keys.map(k => (k.indexOf(".") === -1 ? k : k.split("."))); + effect( + () => { + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + if (typeof p === "string") { + subject?.[p]; + } else { + let o: any = subject; + for (let d = 0; d < p.length && o != null; d++) o = o[p[d]]; + } + } + }, + () => untrack(() => body(subject, undefined, true)) + ); } else { - // Effect fallback with correct WRITE TIMING: the compute pass calls the - // body with next === prev, so every compare fails and it becomes a pure - // TRACKED READ of each binding expression (eligible expressions are pure - // member chains — double evaluation is free of side effects); the commit - // pass force-applies, putting DOM writes in the effect phase where - // transitions and batching expect them — same split as classic compiled - // effects, same single compiled body. + // Manifest-less fallback (hand-written callers): dual-run compute. + // Bodies with NaN/unstable reads should pass a manifest instead. effect( () => body(subject, subject, false), // untrack: the commit pass re-evaluates binding expressions by design diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index c8ac875b0..16e39499d 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -4766,9 +4766,22 @@ export { notSup as runHydrationEvents, notSup as ref, notSup as setStyleProperty, - notSup as acquireAsset + notSup as acquireAsset, + // patchDriver executes only when a DOM template runs — same class as + // `template` above (re-audit 9: dom-compiled modules must LINK under + // Node; SSR renders through the ssr() pipeline instead). + notSup as patchDriver }; +/** Server identity: rowProof wraps row functions at DEFINITION sites in + * isomorphic modules — it must be callable, not just linkable. The stamp + * is meaningless without the client list driver. */ +export function rowProof(fn: F): F; + +export function rowProof(fn) { + return fn; +} + function notSup() { throw new Error( "Client-only API called on the server side. Run client-only code in onMount, or conditionally run client-only component with ." diff --git a/packages/web/test/__audit-round108-public.spec.tsx b/packages/web/test/__audit-round108-public.spec.tsx new file mode 100644 index 000000000..13e48fab1 --- /dev/null +++ b/packages/web/test/__audit-round108-public.spec.tsx @@ -0,0 +1,198 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { afterEach, expect, test } from "vitest"; +import { + createMemo, + createRevealOrder, + createRoot, + createSignal, + createStore, + Errored, + flush, + For, + Loading, + reconcile, + resetErrorHalt +} from "solid-js"; +import { patchDriver, render, rowProof } from "@solidjs/web"; + +afterEach(() => resetErrorHalt()); + +function mountHeld(handled: boolean) { + const [state, setState] = createStore({ row: { label: "v1", score: 0 } }); + let release!: (value: string) => void; + const healthy: string[] = []; + const div = document.createElement("div"); + const Frontier = () => { + const data = createMemo(() => new Promise(resolve => (release = resolve))); + return {data()}; + }; + const Thrower = () => { + patchDriver( + state.row, + (n: any, _p: any, force?: boolean) => { + if (!force) void n.score; + }, + ["score"] + ); + return thrower; + }; + const Healthy = () => { + patchDriver( + state.row, + (n: any, _p: any, force?: boolean) => { + if (force) healthy.push(n.label); + }, + ["label"] + ); + return healthy; + }; + const Tail = () => ( + <> + {handled ? ( + caught}> + + + ) : ( + + )} + + + ); + const dispose = render( + () => + createRevealOrder( + () => ( + <> + + + + + + + + ), + { collapsed: () => true } + ), + div + ); + flush(); + healthy.length = 0; + const demote = () => + setState((s: any) => { + Object.defineProperty(s.row, "score", { + get() { + throw new Error("compute boom"); + }, + configurable: true, + enumerable: true + }); + }); + return { demote, dispose, div, healthy, release }; +} + +test("handled compute throw preserves healthy fanout through a real collapsed hold", async () => { + const c = mountHeld(true); + c.demote(); + expect(() => flush()).not.toThrow(); + expect(c.healthy).toEqual([]); + c.release("ready"); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(c.healthy).toEqual(["v1"]); + expect(c.div.textContent).toContain("caught"); + c.dispose(); +}); + +test("unhandled compute throw installs held healthy fanout before the deferred halt", async () => { + const c = mountHeld(false); + c.demote(); + expect(() => flush()).toThrow("compute boom"); + expect(c.healthy).toEqual([]); + // Test-only recovery from the intentional application halt proves that + // the healthy effect was installed before the deferred error surfaced. + resetErrorHalt(); + c.release("ready"); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(c.healthy).toEqual(["v1"]); + c.dispose(); +}); + +test("removing a demoted For row severs its fallback effect", () => { + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ + rows: [{ id: 1, extra: "plain" }] + }); + const log: string[] = []; + const Row = rowProof((row: any) => { + const text = document.createTextNode(""); + patchDriver( + row, + (n: any, _p: any, force?: boolean) => { + if (force) { + text.data = n.extra; + log.push(n.extra); + } else void n.extra; + }, + ["extra"] + ); + return text as any; + }); + const div = document.createElement("div"); + const dispose = render(() => {Row}, div); + flush(); + setState((s: any) => { + Object.defineProperty(s.rows[0], "extra", { + get() { + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + flush(); + expect(log[log.length - 1]).toBe("d1"); + setState((s: any) => reconcile([], "id")(s.rows)); + flush(); + const removedAt = log.length; + expect(div.textContent).toBe(""); + setDep("d2"); + flush(); + expect(log).toHaveLength(removedAt); + dispose(); +}); + +test("render-root disposal severs a live demoted fallback", () => { + const [dep, setDep] = createRoot(() => createSignal("d1")); + const [state, setState] = createStore({ row: { extra: "plain" } }); + const log: string[] = []; + const div = document.createElement("div"); + const dispose = render(() => { + patchDriver(state.row, (n: any, _p: any, force?: boolean) => { + if (force) log.push(n.extra); + else void n.extra; + }); + return ; + }, div); + setState((s: any) => { + Object.defineProperty(s.row, "extra", { + get() { + return dep(); + }, + configurable: true, + enumerable: true + }); + }); + flush(); + expect(log[log.length - 1]).toBe("d1"); + dispose(); + const disposedAt = log.length; + setDep("d2"); + flush(); + expect(log).toHaveLength(disposedAt); +}); diff --git a/packages/web/test/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx new file mode 100644 index 000000000..d3c862b46 --- /dev/null +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -0,0 +1,722 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +/** + * Driver invariant harness (re-audit 7). Written from the DRIVER'S CONTRACT, + * not from failure instances: each block states an invariant and drives it + * across every code path that must uphold it. New driver entry points must + * be added to these matrices in the same commit that introduces them. + */ +import { describe, expect, test, beforeEach, afterEach } from "vitest"; +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + createStore, + flush, + For, + Loading, + reconcile, + resetErrorHalt +} from "solid-js"; +import { patchDriver, render, rowProof } from "@solidjs/web"; + +interface Row { + id: number; + label: string; +} + +const buildRow = (db: Row) => { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + const text = document.createTextNode(""); + td.appendChild(text); + tr.appendChild(td); + patchDriver(db, (n: Row, p: Row, f?: boolean) => { + if (f || n.label !== p.label) (text as Text).data = n.label; + }); + return tr as unknown as any; +}; + +const rows = (div: HTMLElement) => Array.from(div.querySelectorAll("tr")); +const labels = (div: HTMLElement) => + rows(div) + .map(tr => tr.textContent) + .join(","); +const make = (...ids: number[]): Row[] => ids.map(id => ({ id, label: `L${id}` })); + +// Poison factory shared by the atomicity matrix: rows labelled BOOM register +// a live-probe patch FIRST (real compiled output registers before later +// template statements can throw), then throw. `applies` counts leaked +// dispatches — a severed registration never grows it. +function makePoison(applies: { n: number }) { + return rowProof((db: Row) => { + if (db.label.startsWith("BOOM")) { + patchDriver(db, () => { + applies.n++; + }); + throw new Error("row build boom"); + } + return buildRow(db); + }); +} + +describe("INVARIANT: a throwing row build leaves DOM, bookkeeping, and sibling registrations atomic — at EVERY build entry point", () => { + test("entry point: initial client construction", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const applies = { n: 0 }; + const poison = makePoison(applies); + const [state, setState] = createStore({ + rows: [make(1)[0], { id: 9, label: "BOOM" }, make(3)[0]] + }); + expect(() => ( +
+ {poison} +
+ )).toThrow("row build boom"); + resetErrorHalt(); + // Nothing mounted, nothing left half-built. + expect(rows(div).length).toBe(0); + // The completed row 1 and the poison's own partial registration are + // severed: later writes reach nobody. + const before = applies.n; + setState(s => { + s.rows[1].label = "BOOM2"; + }); + flush(); + expect(applies.n).toBe(before); + dispose(); + }); + }); + + test("entry point: staged update build (ops application)", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const applies = { n: 0 }; + const poison = makePoison(applies); + const [state, setState] = createStore({ rows: make(1, 2, 3) }); +
+ {poison} +
; + const [tr1, tr2, tr3] = rows(div); + setState(s => { + reconcile([make(1)[0], { id: 9, label: "BOOM" }, make(3)[0]], "id")(s.rows); + }); + expect(() => flush()).toThrow("row build boom"); + resetErrorHalt(); + expect(labels(div)).toBe("L1,L2,L3"); + expect(rows(div)[0]).toBe(tr1); + expect(rows(div)[1]).toBe(tr2); + expect(rows(div)[2]).toBe(tr3); + const before = applies.n; + setState(s => { + s.rows[1].label = "BOOM2"; + }); + flush(); + expect(applies.n).toBe(before); + dispose(); + }); + }); + + test("entry point: shallow slot rebuild (reference replacement)", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const applies = { n: 0 }; + const poison = makePoison(applies); + // Shallow LIST of deep-store records: slot values are patchable + // records, so row builds register real channels — the leak surface. + const [recs, setRecs] = createStore<{ all: Row[] }>({ all: make(1, 2, 3) }); + const [shRows, setState] = createStore([recs.all[0], recs.all[1], recs.all[2]], { + shallow: true + } as any); +
+ {poison} +
; + expect(labels(div)).toBe("L1,L2,L3"); + const [tr1, tr2, tr3] = rows(div); + + // Key-aligned reference replacement whose replacement build throws. + const boom = { id: 2, label: "BOOM" }; + setState(s => { + reconcile([recs.all[0], boom, recs.all[2]], "id")(s); + }); + expect(() => flush()).toThrow("row build boom"); + resetErrorHalt(); + + // Atomic: the old row is still mounted, still REGISTERED (its record's + // value ticks must keep applying — a severed-but-mounted row is silent + // staleness, the worst failure shape), and the poison's partial + // registration is severed. + expect(labels(div)).toBe("L1,L2,L3"); + expect(rows(div)[1]).toBe(tr2); + const before = applies.n; + setRecs(s => { + s.all[1].label = "LIVE2"; + }); + flush(); + expect(applies.n).toBe(before); + expect(rows(div)[1].textContent).toBe("LIVE2"); + + // Recovery: a healthy replacement rebuilds the slot. + setState(s => { + reconcile([recs.all[0], { id: 2, label: "H2" }, recs.all[2]], "id")(s); + }); + flush(); + expect(labels(div)).toBe("L1,H2,L3"); + expect(rows(div)[0]).toBe(tr1); + expect(rows(div)[2]).toBe(tr3); + dispose(); + }); + }); + + test("entry point: identity resync after a failed apply", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const applies = { n: 0 }; + const poison = makePoison(applies); + const [state, setState] = createStore({ rows: make(1, 2, 3) }); +
+ {poison} +
; + const [tr1, tr2, tr3] = rows(div); + // First failure arms resyncNeeded. + setState(s => { + reconcile([make(1)[0], { id: 9, label: "BOOM" }, make(3)[0]], "id")(s.rows); + }); + expect(() => flush()).toThrow("row build boom"); + resetErrorHalt(); + // The next LIST event triggers the identity resync (deep contract: + // value-only recovery waits for structure); the resync build throws + // too (the poison row is still in the store) — the resync itself must + // be atomic, same contract as any build. + setState(s => { + reconcile([make(3)[0], { id: 9, label: "BOOM" }, make(1)[0]], "id")(s.rows); + }); + expect(() => flush()).toThrow("row build boom"); + resetErrorHalt(); + expect(rows(div)[0]).toBe(tr1); + expect(rows(div)[1]).toBe(tr2); + expect(rows(div)[2]).toBe(tr3); + // Healthy state recovers content through the retried resync. + setState(s => { + reconcile(make(1, 3), "id")(s.rows); + }); + flush(); + expect(labels(div)).toBe("L1,L3"); + dispose(); + }); + }); +}); + +describe("INVARIANT: driver initial applies render the VISIBLE view (round 9)", () => { + test("a patch template mounting after a lane drain shows the optimistic override", async () => { + const { createOptimisticStore, action } = await import("solid-js"); + const [state, setState] = (createOptimisticStore as any)({ + row: { id: 1, label: "committed" } + }); + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = (action as any)(function* () { + setState((s: any) => { + s.row.label = "optimistic"; + }); + yield new Promise(r => { + resolve = r; + }); + }); + }); + const p = save() as Promise; + flush(); // lane drain done — override visible to every reader + const text = document.createTextNode(""); + let dispose!: () => void; + createRoot(d => { + dispose = d; + patchDriver( + state.row, + (n: any, p2: any, f?: boolean) => { + if (f || n.label !== p2.label) text.data = n.label; + }, + ["label"] + ); + }); + // Siblings mounted before the action show "optimistic"; a late mount + // must not render the committed value beside them. + expect(text.data).toBe("optimistic"); + resolve(); + await p; + flush(); + expect(text.data).toBe("committed"); + dispose(); + }); + + test("the effect fallback never writes during its tracked compute pass (NaN fields)", () => { + // A NON-record subject takes the effect fallback; a NaN field makes + // `n.x !== p.x` true even with next === prev — the compute pass must + // stay read-only regardless. + const subject = { x: NaN, label: "L" }; + const text = document.createTextNode(""); + let writes = 0; + let dispose!: () => void; + createRoot(d => { + dispose = d; + patchDriver( + subject, + (n: any, p: any, f?: boolean) => { + if (f || n.x !== p.x || n.label !== p.label) { + writes++; + text.data = n.label + ":" + n.x; + } + }, + ["x", "label"] + ); + }); + flush(); + // Exactly ONE write — the commit-phase force apply. A compute-phase + // write means DOM/custom setters run inside a tracked computation + // (and twice per update). + expect(writes).toBe(1); + expect(text.data).toBe("L:NaN"); + dispose(); + }); + + test("Map rows pass through raw — no incompatible-receiver proxy wrap", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const m1 = new Map([["k", 1]]); + const m2 = new Map([ + ["k", 1], + ["j", 2] + ]); + const mapRow = rowProof((db: Map) => { + const tr = document.createElement("tr"); + // `.size` is a prototype ACCESSOR with a Map brand check — a + // wrapped receiver throws. + tr.textContent = "size:" + db.size; + return tr as unknown as any; + }); + const [state, setState] = createStore({ rows: [m1] }); +
+ {mapRow} +
; + expect(labels(div)).toBe("size:1"); + setState((s: any) => { + s.rows.push(m2); + }); + flush(); + expect(labels(div)).toBe("size:1,size:2"); + dispose(); + }); + }); +}); + +describe("INVARIANT: structural operations build rows from THEIR OWN captured state", () => { + test("two structural updates queued in one flush each bind their operation's records", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const [state, setState] = createStore({ rows: make(1, 2, 3) }); + const pure = rowProof(buildRow); +
+ {pure} +
; + expect(labels(div)).toBe("L1,L2,L3"); + // ONE flush, TWO structural emissions: a keyed reconcile adding a row + // (walk-site ops) followed by a head splice (setter-site ops). The + // first operation's new-row build must bind ITS captured record — a + // live `subject[abs]` read sees the post-splice list and binds the + // wrong record, corrupting every later operation's baseline. + setState(s => { + reconcile(make(1, 2, 3, 4), "id")(s.rows); + }); + setState(s => { + s.rows.splice(0, 1); + }); + flush(); + expect(labels(div)).toBe("L2,L3,L4"); + // Retention/baseline intact: a follow-up keyed move retains nodes. + const [tr2, tr3, tr4] = rows(div); + setState(s => { + reconcile(make(4, 3, 2), "id")(s.rows); + }); + flush(); + expect(labels(div)).toBe("L4,L3,L2"); + expect(rows(div)[0]).toBe(tr4); + expect(rows(div)[1]).toBe(tr3); + expect(rows(div)[2]).toBe(tr2); + dispose(); + }); + }); +}); + +describe("INVARIANT: the rebuild check agrees with the matcher's equality (SameValueZero)", () => { + test("a moved NaN row retains its node — classic's Map-based diff keeps it, so must we", () => { + createRoot(dispose => { + let div!: HTMLDivElement; + const prim = rowProof((v: any) => { + const tr = document.createElement("tr"); + tr.textContent = String(v); + return tr as unknown as any; + }); + const [state, setState] = createStore([1, NaN, 2], { shallow: true } as any); +
+ {prim} +
; + expect(labels(div)).toBe("1,NaN,2"); + const [tr1, trN, tr2] = rows(div); + // Pure move THROUGH THE ROW-OPS PATH (reconcile): the matcher (Map, + // SameValueZero) proves NaN's source — the driver's strict `!==` + // rebuild check re-litigated it and built a fresh row, losing node + // identity classic keeps. + setState((s: any[]) => { + reconcile([NaN, 1, 2])(s); + }); + flush(); + expect(labels(div)).toBe("NaN,1,2"); + expect(rows(div)[0]).toBe(trN); + expect(rows(div)[1]).toBe(tr1); + expect(rows(div)[2]).toBe(tr2); + dispose(); + }); + }); +}); + +describe("INVARIANT: a body's declared read envelope is honored at EVERY depth and branch", () => { + test("a nested getter PRESENT AT REGISTRATION takes the tracked fallback from the start", () => { + const [dep, setDep] = createRoot(() => createSignal("s0")); + const [state] = createStore({ + row: { + id: 1, + meta: { + get label() { + return dep(); + } + } + } + }); + const text = document.createTextNode(""); + let dispose!: () => void; + createRoot(d => { + dispose = d; + patchDriver( + state.row, + (n: any, p: any, f?: boolean) => { + if (f || n.meta.label !== p.meta.label) text.data = n.meta.label; + }, + ["meta.label"] + ); + }); + // The initial render works either way — the DIVERGENCE is the getter's + // outside dependency: admission must have chosen the tracked fallback. + expect(text.data).toBe("s0"); + setDep("s1"); + flush(); + expect(text.data).toBe("s1"); + dispose(); + }); + + test("a nested-chain body keeps applying when the nested value changes through a targeted reconcile", () => { + const [state, setState] = createStore({ + row: { id: 1, queries: [{ elapsed: "1" }] } + }); + const text = document.createTextNode(""); + let dispose!: () => void; + createRoot(d => { + dispose = d; + // Compiled shape for `textContent={row.queries[0].elapsed}` (depth-2 + // chain with a numeric-literal step — the dbmon cell shape). + patchDriver( + state.row, + (n: any, p: any, f?: boolean) => { + if (f || n.queries[0].elapsed !== p.queries[0].elapsed) text.data = n.queries[0].elapsed; + }, + ["queries.0.elapsed"] + ); + }); + expect(text.data).toBe("1"); + // Reconcile TARGETED at the nested record — the ancestor's patch must + // re-apply (effect parity: an effect tracking the chain re-runs). + setState((s: any) => { + reconcile({ elapsed: "2" }, "id")(s.row.queries[0]); + }); + flush(); + expect(text.data).toBe("2"); + dispose(); + }); + + test("a getter arriving at a nested step of a read path demotes the ancestor's patch", () => { + const [dep, setDep] = createRoot(() => createSignal("g1")); + const [state, setState] = createStore({ + row: { id: 1, meta: { label: "m1" } } + }); + const text = document.createTextNode(""); + let dispose!: () => void; + createRoot(d => { + dispose = d; + patchDriver( + state.row, + (n: any, p: any, f?: boolean) => { + if (f || n.meta.label !== p.meta.label) text.data = n.meta.label; + }, + ["meta.label"] + ); + }); + expect(text.data).toBe("m1"); + // Root-level adoption whose NESTED object carries the getter: the + // declared path row.meta.label crosses it — must demote, and the + // getter's dependency must keep applying through the fallback. + setState((s: any) => { + reconcile( + { + id: 1, + meta: { + get label() { + return dep(); + } + } + }, + "id" + )(s.row); + }); + flush(); + expect(text.data).toBe("g1"); + setDep("g2"); + flush(); + expect(text.data).toBe("g2"); + dispose(); + }); + + test("a ternary body's untaken branch still demotes when that key becomes a getter", () => { + const [dep, setDep] = createRoot(() => createSignal("sig-b")); + const [state, setState] = createStore({ + cell: { flag: true, a: "A", b: "B" } + }); + const text = document.createTextNode(""); + let dispose!: () => void; + createRoot(d => { + dispose = d; + // Hand-written mirror of Tier-2 compiled output for + // `textContent={cell.flag ? cell.a : cell.b}` — under the initial + // force-apply only ONE branch's key is read. + patchDriver( + state.cell, + (n: any, p: any, f?: boolean) => { + if (f || n.flag !== p.flag || (n.flag ? n.a : n.b) !== (p.flag ? p.a : p.b)) + text.data = n.flag ? n.a : n.b; + }, + ["flag", "a", "b"] + ); + }); + expect(text.data).toBe("A"); + // `b` — never read by any apply so far — becomes getter-backed while + // the flag flips. The channel must treat the body's FULL read + // envelope as recorded: this adoption demotes, and the getter's + // outside dependency keeps re-applying through the tracked fallback. + setState((s: any) => { + reconcile( + { + flag: false, + a: "A", + get b() { + return dep(); + } + }, + "id" + )(s.cell); + }); + flush(); + expect(text.data).toBe("sig-b"); + setDep("sig-b2"); + flush(); + expect(text.data).toBe("sig-b2"); + dispose(); + }); +}); + +describe("INVARIANT: mount, delivery, and swap all answer ONE visibility question (round 10)", () => { + test("a deep-path template mounting after a child-subject adoption shows the adopted state", () => { + const [state, setState] = createStore({ row: { meta: { id: 1, label: "A" } } }); + // Child-subject reconcile: the walk swaps meta's backing (adoption) while + // the ancestor row has NO consumer — its committed raw slot may lag. + setState((s: any) => { + reconcile({ id: 1, label: "B" }, "id")(s.row.meta); + }); + flush(); + const text = document.createTextNode(""); + let dispose!: () => void; + createRoot(d => { + dispose = d; + patchDriver( + state.row, + (n: any, p: any, f?: boolean) => { + if (f || n.meta.label !== p.meta.label) text.data = n.meta.label; + }, + ["meta.label"] + ); + }); + // The proxy answers "B" — the mount source must agree, with no pending + // bump left to paper over a stale initial render. + expect(text.data).toBe("B"); + dispose(); + }); + + test("value deliveries honor a collapsed reveal-order hold exactly like render effects", async () => { + // The auditor's hold composition (round 10.7): two sibling Loading + // boundaries under SEQUENTIAL reveal order with collapsed reveals — + // the first stays pending (the frontier), so the SECOND is a + // genuinely pending collapsed CollectionQueue holding its effects. + const { createRevealOrder } = await import("solid-js"); + const [state, setState] = createStore({ row: { label: "v1" } }); + const resolvers: Array<(v: string) => void> = []; + const classic = document.createTextNode(""); + const patched = document.createTextNode(""); + const div = document.createElement("div"); + const disposer = render(() => { + const A = () => { + const data = createMemo(() => new Promise(r => resolvers.push(r))); + return {data()}; + }; + const B = () => { + createRenderEffect( + () => state.row.label, + (v: string) => { + classic.data = v; + } + ); + patchDriver( + state.row, + (n: any, p: any, f?: boolean) => { + if (f || n.label !== p.label) patched.data = n.label; + }, + ["label"] + ); + return b; + }; + return (createRevealOrder as any)( + () => ( + <> + {
} + {} + + ), + { collapsed: () => true } + ); + }, div); + flush(); + // B sits collapsed behind the pending frontier; both sinks carry the + // mount-time state (initial passes run at creation). + expect(classic.data).toBe("v1"); + expect(patched.data).toBe("v1"); + // A write DURING the hold: the classic sink stays held — the patch + // sink must defer into the same queue, not race ahead (round 10, + // P1-4). + setState((s: any) => { + s.row.label = "v2"; + }); + flush(); + expect(classic.data).toBe("v1"); + expect(patched.data).toBe("v1"); + // Release the frontier: both sinks apply the latest state. + resolvers.pop()!("d1"); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(classic.data).toBe("v2"); + expect(patched.data).toBe("v2"); + disposer(); + }); + + test("swapping to an already-optimistic list family shows in-flight rows", async () => { + const { createOptimisticStore, action } = await import("solid-js"); + const [a] = (createOptimisticStore as any)({ rows: make(1, 2) }); + const [b, setB] = (createOptimisticStore as any)({ rows: make(10, 11) }); + let resolve!: () => void; + let save!: () => Promise | void; + createRoot(() => { + save = (action as any)(function* () { + setB((s: any) => { + s.rows.push({ id: 12, label: "L12" }); + }); + yield new Promise(r => { + resolve = r; + }); + }); + }); + const p = save() as Promise; + flush(); // append is in flight on family B + const [sel, setSel] = createRoot(() => createSignal(false)); + let div!: HTMLDivElement; + const dispose = createRoot(d => { + const proofed = rowProof(buildRow); +
+ {proofed} +
; + return d; + }); + expect(labels(div)).toBe("L1,L2"); + setSel(true); // swap to the family with an in-flight optimistic append + flush(); + // The optimistic view of B is 10,11,12 — the swap must render it, not + // the committed backing. + expect(labels(div)).toBe("L10,L11,L12"); + dispose(); + resolve(); + await p; + flush(); + }); + + test("swapping between families sharing raw rows rebinds to the new family", async () => { + const { createOptimisticStore, untrack: ut } = await import("solid-js"); + const raws = make(1, 2); + const [a] = (createOptimisticStore as any)({ rows: raws }); + // Family B ingests A's ROW PROXIES verbatim (deep ingest stores them as + // given) — raw-identity retention collapses both families to the same + // raws, which is exactly the round-10 aliasing case. + const [b, setB] = (createOptimisticStore as any)({ + rows: ut(() => (a as any).rows.map((r: any) => r)) + }); + const [sel, setSel] = createRoot(() => createSignal(false)); + let div!: HTMLDivElement; + const dispose = createRoot(d => { + const proofed = rowProof(buildRow); +
+ {proofed} +
; + return d; + }); + expect(labels(div)).toBe("L1,L2"); + const beforeRows = rows(div); + setSel(true); // same underlying rows, DIFFERENT family + flush(); + // Family changed: retention by raw identity would keep family-A rows + // (and their A-channel registrations) under the B subject — the swap + // must REBUILD, binding rows to the family that will actually emit. + // (Direct setter writes on optimistic stores outside an action revert + // by design, so the emission oracle below rides an action.) + expect(rows(div)[0]).not.toBe(beforeRows[0]); + expect(labels(div)).toBe("L1,L2"); + // Structure through family B reaches the swapped list in flight. + const { action } = await import("solid-js"); + let resolveB!: () => void; + let push!: () => Promise | void; + createRoot(() => { + push = (action as any)(function* () { + setB((s: any) => { + s.rows.push({ id: 3, label: "L3" }); + }); + yield new Promise(r => { + resolveB = r; + }); + }); + }); + const pb = push() as Promise; + flush(); + expect(labels(div)).toBe("L1,L2,L3"); + resolveB(); + await pb; + flush(); + dispose(); + }); +}); diff --git a/packages/web/test/harness/__artifacts__/for-then-siblings-driven.json b/packages/web/test/harness/__artifacts__/for-then-siblings-driven.json new file mode 100644 index 000000000..9338fb447 --- /dev/null +++ b/packages/web/test/harness/__artifacts__/for-then-siblings-driven.json @@ -0,0 +1,5 @@ +{ + "name": "for-then-siblings-driven", + "shell": "
r
r
count: 0
", + "rest": "" +} \ No newline at end of file diff --git a/packages/web/test/harness/scenarios.tsx b/packages/web/test/harness/scenarios.tsx index 043449019..0ffa69239 100644 --- a/packages/web/test/harness/scenarios.tsx +++ b/packages/web/test/harness/scenarios.tsx @@ -216,6 +216,49 @@ function ForThenSiblings() { ); } +// Driven twin (structural-audit review ask, #3161 follow-through): a STORE- +// backed stamped-row list ENGAGES the patch-mode list driver under hydration +// — driveList claims rows positionally by _hk and mints its own listOwner +// child scope, a different road than the classic seam the static scenario +// above takes (post-flip, non-store rows decline at runtime). Siblings AFTER +// the driven list must keep their server ids on BOTH roads. The row is +// attribute-only ON PURPOSE: text holes disqualify the purity proof +// (rowProof verified emitted for this exact shape) — static text keeps rows +// visible to the textContent assertion. The update exercises both halves: +// a driven STRUCTURAL change (label write + row push through the engaged +// driver) and the sibling count (id alignment past the list; siblings' +// node identity pinned by stableSelector). +let bumpAfterDrivenFor!: () => void; +function ForThenSiblingsDriven() { + const [state, setState] = createStore<{ rows: { id: number; label: string }[] }>({ + rows: [ + { id: 1, label: "one" }, + { id: 2, label: "two" } + ] + }); + const [count, setCount] = createSignal(0); + bumpAfterDrivenFor = () => { + setCount(c => c + 1); + setState(s => { + s.rows[0].label = "one!"; + s.rows.push({ id: 3, label: "three" }); + }); + }; + return ( + <> + + {row => ( +
+ r +
+ )} +
+ +
count: {count()}
+ + ); +} + // --------------------------------------------------------------------------- // 9. Spread with children in the spread object function SpreadChildren() { @@ -1584,6 +1627,16 @@ export const scenarios: Scenario[] = [ expectedTextAfterUpdate: "row 1row 2bumpcount: 1", stableSelector: "button, pre" }, + { + name: "for-then-siblings-driven", + App: ForThenSiblingsDriven, + expectedText: "rrbumpcount: 0", + update: () => bumpAfterDrivenFor(), + // The push adds a third "r" — a driven structural update; the siblings + // (button/pre) must keep node identity across it (stableSelector). + expectedTextAfterUpdate: "rrrbumpcount: 1", + stableSelector: "button, pre" + }, { name: "spread-children", App: SpreadChildren, diff --git a/packages/web/test/hydration/patchlist-throw.spec.tsx b/packages/web/test/hydration/patchlist-throw.spec.tsx new file mode 100644 index 000000000..9505d647c --- /dev/null +++ b/packages/web/test/hydration/patchlist-throw.spec.tsx @@ -0,0 +1,73 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +/** Hydration slice of the re-audit-7 driver invariant harness — lives under + * test/hydration/ because these specs compile hydratable through their own + * vitest config. */ +import { describe, expect, test, beforeEach, afterEach } from "vitest"; +import { createStore, flush, For, resetErrorHalt, enableHydration } from "solid-js"; +import { getNextElement, hydrate, patchDriver, rowProof, template } from "@solidjs/web"; + +interface Row { + id: number; + label: string; +} + +describe("INVARIANT: hydration failure surrenders the list's ENTIRE server DOM region", () => { + enableHydration(); + const rowTmpl = template("
  • "); + const container = document.createElement("div"); + document.body.appendChild(container); + let dispose: (() => void) | undefined; + + beforeEach(() => { + if (dispose) dispose(); + dispose = undefined; + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {} }; + container.innerHTML = ""; + }); + afterEach(() => { + if (dispose) { + dispose(); + dispose = undefined; + } + }); + + test("a throwing claim removes completed, claimed, AND trailing server rows", () => { + container.innerHTML = + "
    • L1
    • L2
    • L3
    • L4
    "; + const hydratingPoison = rowProof((r: Row) => { + const li = getNextElement(rowTmpl) as HTMLElement; + if (r.label === "BOOM") throw new Error("hydration claim boom"); + const text = li.firstChild as Text; + patchDriver(r, (n: Row, p: Row, f?: boolean) => { + if (f || n.label !== p.label) text.data = n.label; + }); + return li as unknown as any; + }); + const [state] = createStore({ + rows: [ + { id: 1, label: "L1" }, + { id: 2, label: "BOOM" }, + { id: 3, label: "L3" }, + { id: 4, label: "L4" } + ] as Row[] + }); + expect(() => { + dispose = hydrate( + () => ( +
      + {hydratingPoison} +
    + ), + container + ); + }).toThrow("hydration claim boom"); + dispose = undefined; + resetErrorHalt(); + // No orphaned server rows: a boundary fallback rendering into this + // region must not sit beside stale rows 3 and 4. + expect(container.querySelectorAll("li").length).toBe(0); + }); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 1a3e70c09..14dbbc6ab 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -71,24 +71,18 @@ module.exports = [ // stash move + stamp retarget and the dispatch snapshot marks are // core-retained — a few dozen brotli bytes on every scenario. // - // #3122 eager iterator teardown (2026-08-31): 7.9 -> 7.91 KB, measured - // at 7.903. The _flightTeardown release sits on recompute's supersede - // path, which the core loop always retains. Conscious bump — see the - // in-package treeshake budget note. - // - // #3164 fold ruling (2026-08-31): 7.91 -> 7.95 KB, measured at 7.94. - // read()'s A17-for-held-truth arm (fold-staged truth masked from - // ordinary readers under a live optimism-retaining transition) plus the - // GlobalQueue._heldTruthMasked hook slot. The mask's ledger and the - // transition-optimism probe live in the optimistic module behind the - // hook — the floor pays only the guarded call site. - // - // Fold relocation pass (2026-09-01): 7.95 -> 7.94 KB, measured at 7.93. - // heldTruthNodes + transitionHoldsOptimism moved from scheduler.ts into - // the optimistic module, and read()'s latest()/authoritative-read - // exemptions moved inside the hook (which now takes the observer) — - // the floor keeps only `config-gate && hook?.(el, c)`. - limit: "7.94 KB", + // Re-audit-6 (2026-08-28): same-channel merge coalescing in + // mergeTransitionState (both stashes holding the same record's entry + // now collapse to one live-resolving entry). Core-retained; measured + // 7.91. + // + // Re-audit-9 (2026-08-29): forced-entry dedup + stamp retargeting in + // the merge path. Measured 7.92. + // + // #3122 eager iterator teardown (upstream, 2026-08-31): the + // _flightTeardown release sits on recompute's supersede path, which the + // core loop always retains. Measured 7.88 post-rebase. + limit: "8 KB", modifyEsbuildConfig }, { @@ -174,23 +168,49 @@ module.exports = [ // the drain's defer check — ~40 B measured on the pre-stage-2 base. All // load-bearing correctness on paths createStore always retains. // - // #3122/#3123 correctness batch (2026-08-31): 14.45 -> 14.51 KB, - // measured at 14.503. The #3122 teardown core bytes plus the store-walk - // exports (arrayStructureChanged/membershipChanged) the landing- - // contradiction gate reads; the replay machinery itself stays in the - // optimistic module (see the store-family app scenario). - // - // #3164 fold ruling (2026-08-31): 14.51 -> 14.56 KB, measured at 14.55. - // The core-floor arm (see that note) plus the held-truth mask SEAMS on - // always-retained store paths: nodeValue's guarded _heldTruthMasked - // call, readSource's optHooks.retainsOptimism dispatch, and the - // tentativePBs draft-session guard in ensurePB. The mask bodies - // themselves ride the optimistic module (see the store-family app - // scenario). - // - // Fold relocation pass (2026-09-01): 14.56 -> 14.55 KB, measured at - // 14.54 — the core-floor relocation (see that note). - limit: "14.55 KB", + // Re-audit-6 (2026-08-28): merge coalescing (core, see the core-floor + // note) plus the prod-sound getter-demotion seams — accessed-key union + // on the channel (pc.ak) and the targetKeysPlain bounded probe at both + // adoption emission sites, replacing the dev-only check. Measured 14.46. + // + // Re-audit-7 (2026-08-28): stateless adoption probes (gates take the + // incoming backing + prototype check), deep-path probe machinery + // (deepPathsPlain), split normal/optimistic stamps, and the reconcile + // root ancestor bubble — all on store paths createStore retains. + // Measured 14.58. + // + // Re-audit-9 (2026-08-29): held-view admission, committed-visible skip + // markers, tentative self-emission, unchanged-reconcile gate, function- + // intermediate probes. Measured 14.80. + // Rebase onto next (2026-08-31): upstream rc.5 drift stacks with the + // branch bytes. Measured 14.99. + // + // #3122/#3123 correctness batch (upstream, 2026-08-31): the #3122 + // teardown core bytes plus the store-walk exports + // (arrayStructureChanged/membershipChanged) the landing-contradiction + // gate reads; the replay machinery itself stays in the optimistic + // module (see the store-family app scenario). Held at 14.99 post-rebase + // — the earlier replay landing was already absorbed here. + // + // #3164 fold ruling (upstream, 2026-09-01): heldTruthNodes ledger + + // retainsOptimism seams ride paths createStore retains. Measured 15.12 + // post-rebase. + // Fold-audit round (2026-09-01): staged-truth fold marker + fold-site + // row/slot emissions (reveal coverage), per-index held-slot defers, and + // the drain-end late sweep (no resync-then-ops double-builds). Measured 15.19. + // Fold audit 2 (2026-09-01): transition-aware version init, reveal + // channel split, target-keyed staged identity, release fast-forward. + // Measured 15.22. + // Fold audit 3 (2026-09-01): held-queue av init, reorder classification, + // reveal single-notification mark. Measured 15.27. + // Fold audit 4 (2026-09-01): eager visible-version, epoch-stamped + // proven reveal marks, primitive-multiset reorder classification. + // Measured 15.38. + // Entanglement rebase drift (2026-09-01, until-flip + CONFIG_HELD_TRUTH). + // Entangle-tear consolidation (2026-09-01, rc.6 pass 1): nodeValue's + // held-truth committed arm (untracked readers hold like tracked ones) + // + the channel's hh field. +36 B. + limit: "15.5 KB", modifyEsbuildConfig }, { @@ -218,27 +238,16 @@ module.exports = [ // companion mid-transition backfill, which lives in the optimistic // module this scenario retains via latest(). // - // rc.5 signals drift (2026-08-30): 9.85 -> 9.9 KB, measured at 9.87. - // The #3108 truth-author authoritative-read fix (88fa9d64) lives in the - // optimistic module this scenario retains via latest(), and the - // refresh() quiescence promise (51ffcb9a) leaves marks on the settle - // walk. Drift, not a regression. - // - // #3104/#3122 correctness batch (2026-08-31): 9.9 -> 9.94 KB, measured - // at 9.932. The latest()/collectPending probe-suspension symmetry - // (#3104) lives in the verdict layer this scenario exists to measure; - // the rest is the #3122 teardown core bytes. - // - // #3164/#3166 batch (2026-08-31): 9.94 -> 9.99 KB, measured at 9.98. - // The core-floor fold arm (see that note), asyncWrite's authoritative- - // observer wake (#3164 signal path: a landing staged under an active - // override must wake until()'s predicate or it deadlocks), and the - // mid-flight latest(isPending()) probe fix (#3166) in the verdict - // layer this scenario retains. - // - // Fold relocation pass (2026-09-01): 9.99 -> 9.98 KB, measured at 9.97 - // — the core-floor relocation (see that note). - limit: "9.98 KB", + // Re-audit-6 (2026-08-28): merge coalescing (core) — this scenario had + // ~no headroom left after the audit-5 ripple. Measured 9.93. + // + // #3104/#3122 correctness batch (upstream, 2026-08-31): the + // latest()/collectPending probe-suspension symmetry (#3104) lives in + // the verdict layer this scenario exists to measure; the rest is the + // #3122 teardown core bytes. Measured 9.89 post-rebase. + // Entanglement rebase drift (2026-09-01, canonical push): boundary + // wobble headroom. + limit: "10.05 KB", modifyEsbuildConfig }, { @@ -265,17 +274,13 @@ module.exports = [ // branch's insert seam plus next's post-cap drift summing in the same // floor. // - // rc.5 signals drift (2026-08-30): 10.65 -> 10.7 KB, measured at 10.66. - // The refresh() quiescence promise's settle-walk bytes (51ffcb9a) are - // core-retained, so every app floor pays them. Drift, not a regression. - // - // #3164 fold ruling (2026-08-31): 10.7 -> 10.73 KB, measured at 10.72 - // — the signals core-floor arm + asyncWrite wake (see those notes). - // - // Fold relocation pass (2026-09-01): 10.73 -> 10.72 KB, measured at - // 10.71 — the core-floor relocation (see that note). + // Re-audit-6 (2026-08-28): merge coalescing (core). Measured 10.70. path: "minimal-app.js", - limit: "10.72 KB", + // Rebase drift (2026-09-01, #3169-#3176 + fold-ledger relocation). + // Measured 10.76. + // Entanglement rebase drift (2026-09-01, canonical push): boundary + // wobble headroom. + limit: "10.85 KB", modifyEsbuildConfig }, { @@ -319,13 +324,8 @@ module.exports = [ // useHead prelude relocation (#3081, ~120 B in hydrate(), see its note) // arriving from next on top of the drift-ratcheted floor. // - // #3164 fold ruling (2026-08-31): 17.55 -> 17.6 KB, measured at 17.59 - // — the signals core-floor arm + asyncWrite wake (see those notes). - // - // Fold relocation pass (2026-09-01): 17.6 -> 17.56 KB, measured at - // 17.54 — this bundle's import graph retained the scheduler-resident - // ledger; the relocation lets it shake. - limit: "17.56 KB", + // Re-audit-6 (2026-08-28): merge coalescing (core). Measured 17.56. + limit: "17.65 KB", modifyEsbuildConfig }, { @@ -371,32 +371,53 @@ module.exports = [ // Fold scheduling (#3089, merged from next): 25.9 -> 26 KB — the same // bytes as the createStore note (this scenario retains all of it). // - // rc.5 signals drift (2026-08-30): 26 -> 26.1 KB, measured at 26.07. - // The #3108 truth-author fix (88fa9d64, optimistic module) plus the - // refresh() quiescence promise (51ffcb9a, settle walk) — this scenario - // retains every store family, so it pays both. Drift, not a regression. - // - // Transaction-lifecycle fixes (2026-08-31): 26.1 -> 26.15 KB, measured at - // 26.12. #3141 (initTransition guarantees a flush) and #3140 (commit - // clears _transition stamps; initTransition refuses a done transaction) - // — ~25 B of scheduler prod code for an ambient-capture fix and a - // prod-hang fix. The other nine budgets absorbed it within headroom. - // - // #3123/#3164 fold ruling (2026-08-31): 26.15 -> 26.71 KB, measured at - // 26.70. The optimistic-store reckoning, re-ruled from replay to FOLD - // after GabbeV's union-tear report (#3164): the interim #3123 replay - // machinery (retained-setter replay, echo dedupe, settle re-derivation, - // ~26.535 measured) was backed out and replaced by landing folds — - // truth landings stage into the retaining transaction - // (runAsTransitionBatch), held-truth masks keep ordinary readers on - // committed until the atomic reveal (heldTruthNodes ledger + - // transitionHoldsOptimism, dispatched through _heldTruthMasked / - // optHooks.retainsOptimism), until()/latest() tunnel through, and the - // revert path resyncs overlaid keysets for mapArray. This scenario - // retains every store family, so it pays the whole module. Ruled - // correctness-over-size in the #3164 thread; conscious bump. + // Re-audit-6 (2026-08-28): merge coalescing (core) + the getter- + // demotion recording/probe seams (see the createStore note; this + // scenario retains the store engine). Measured 26.13. + // + // Re-audit-7 perf pass: manifest interning (WeakMap cache + prefix-tree + // builder) so list mounts stopped re-processing per row. Measured 26.28. + // + // Re-audit-8 (2026-08-28): committed-view admission, generation-stamped + // drains, forced-bubble coalescing stamps, tentative ancestor bubbling. + // Measured 26.35. + // Round-10 (2026-08-31): primitive-owned ancestor bubbling, boundary + // hold routing (per-entry queue defer), demotion fanout isolation, + // family retention token. Measured 26.47. path: "hydrating-store-app.js", - limit: "26.71 KB", + // Round-10.9 (2026-08-31): demotion-lifecycle bytes (per-entry + // envelopes, commit skip, akAll refcount). Measured 26.56. + // Rebase onto next (2026-08-31): upstream drift + lifecycle fixes + // stack with the branch bytes. Measured 27.06. + // #3123 function-of-truth replay (upstream, 2026-08-31): retained + // setter replay, flight-gate threading, keyed echo dedupe, and settle- + // time re-derivation — this scenario retains every store family, so it + // pays the whole optimistic module. Ruled correctness-over-size in the + // #3123 thread. Measured 27.10 post-rebase (the earlier replay landing + // was already absorbed in this budget). + // Structural audit (2026-08-31): one-reckoning landing notification + // (review commit 3e12ffdb) + superseded-work generation stamps and the + // rebuilt late-registrant sweep. Measured 27.18. + // #3164 fold ruling (upstream, 2026-09-01): the fold/reveal machinery + // replaces replay wholesale; net near-wash here after the branch's + // landing hook + generation stamps were deleted with the contract they + // served. Measured 27.21 post-rebase. + // Fold-audit round (2026-09-01): staged-truth fold marker + fold-site + // row/slot emissions (reveal coverage), per-index held-slot defers, and + // the drain-end late sweep (no resync-then-ops double-builds). Measured 27.30. + // Version-chain redesign (2026-09-01): snapshot/watermark/sweep + // machinery replaced by per-entry applied-version chains + ONE + // flush-end resync + registration-time ancestor repair. Flat cost — + // the per-finding mechanism accretion this class caused stops here. Measured 27.37. + // Fold audit 4 (2026-09-01): eager visible-version, epoch-stamped + // proven reveal marks, primitive-multiset reorder classification. + // Measured 27.52. + // Rebase drift (2026-09-01, #3169-#3176 + fold-ledger relocation). + // Measured 27.56. + // Fold audit 6 (2026-09-01): delivery-consumed dn overrides (INV-6) + + // two-key-space matcher (mixed identities, undefined moves). Measured + // 27.63. + limit: "27.65 KB", modifyEsbuildConfig }, { @@ -415,22 +436,23 @@ module.exports = [ // Stage-3 batch (pre-release ratchet): 12.3 -> 12.8 KB, measured at // 12.53 — the signals-core bytes (see the core-floor note). // - // #3122 eager iterator teardown (2026-08-31): 12.9 -> 12.92 KB, - // measured at 12.911 — the core-floor teardown bytes (see that note). - // - // #3164 fold ruling (2026-08-31): 12.92 -> 12.94 KB, measured at 12.93 - // — the signals core-floor arm + asyncWrite wake (see those notes). + // Re-audit-9 (2026-08-29): the merge-path core bytes (see core floor). + // Measured 12.90. // - // Fold relocation pass (2026-09-01): 12.94 -> 12.95 KB, measured at - // 12.948. The one counter-mover: this bundle never retained the - // scheduler-resident ledger (nothing to shake), so it pays only the - // hook call site's second argument plus brotli layout drift. + // #3122 eager iterator teardown (upstream, 2026-08-31): the core-floor + // teardown bytes (see that note). Measured 12.93 post-rebase. path: "csr-app.js", - limit: "12.95 KB", + // Entanglement rebase drift (2026-09-01, canonical push): boundary + // wobble headroom. + limit: "13.05 KB", modifyEsbuildConfig }, { - name: "app: CSR flip preview — + patchDriver (non-list patch templates)", + name: "app: CSR default-on — + patchDriver (non-list patch templates)", + // FLIP LANDED (2026-08-28): patch mode is the compiler default in both + // Babel and Oxc; this is no longer a preview, it's what ~every app + // ships. Opt out: patchDriver: false. + // // What patch-mode DEFAULT-ON adds to ~every app: nearly any real // template has one eligible pure member-read binding, so the compiler // emits at least one patchDriver call — retaining the dual driver and @@ -440,22 +462,61 @@ module.exports = [ // the insert seam) and the row-ops emitters + reconcile diff builders // (row hooks arm only from list registrations). // - // rc.5 signals drift (2026-08-30): 14.6 -> 14.65 KB, measured at 14.61 - // — the same core-retained quiescence bytes as the simple-app floor. - // - // #3164 fold ruling (2026-08-31): 14.65 -> 14.69 KB, measured at 14.68 - // — the core-floor arm + asyncWrite wake plus the store-seam bytes - // (see the createStore note; the value-tier machinery this scenario - // retains carries the nodeValue mask seam). - // - // Fold relocation pass (2026-09-01): 14.69 -> 14.68 KB, measured at - // 14.67 — the core-floor relocation (see that note). + // Re-audit-6 (2026-08-28): the value-tier share of the hardening — + // key recording at registration (the recording proxy in patchDriver's + // initial apply + first-drain recording), applyStructural's live-list + // dispatch, and the merge coalescing core bytes. Measured 14.91. + // + // Re-audit-7 (2026-08-28): static read manifests — compiled templates + // now carry their key/path arrays (bytes IN the compiled fixture) and + // the driver/channel gained the manifest branch, deep-path probes, and + // per-drain stamp split. Buys prod-sound demotion across ternary + // branches and nested chains. Measured 15.30; 15.51 after the perf pass + // (manifest interning + hoisted _mf$ arrays in compiled output). + // + // Re-audit-8 (2026-08-28): manifest deep-probe at admission, generation + // skip, forced coalescing, lane-timed ancestor bubbles. Measured 15.69. + // + // Re-audit-9 (2026-08-29): manifest-read effect fallback (write-free + // compute), optimistic-view initial applies, committed-visible skip + // markers, optimistic drain probes. Measured 15.99. + // Round-10.6 (2026-08-31): alias currency probes (root keys + deep), + // transaction-scoped dedup stamps, hold-aware demotion scheduling. + // Measured 16.15. path: "csr-app-patch.js", - limit: "14.68 KB", + // Round-10.7: canonical txn stamps + once-guarded held redrives. 16.22. + // Size pass (2026-08-31): recording proxy deleted (akAll full-scan), + // applyEntries single-mode, deferHalt/routeEntryError consolidation. + // Measured 16.05 — ratchet tightened. + // Round-10.9 (2026-08-31): per-entry manifest envelopes (write-free + // demotion computes), failed-compute commit skip, akAll refcount, + // transparent redrive roots. Measured 16.25. + // Rebase onto next (2026-08-31): upstream drift stacks. Measured 16.36. + // Round-10.13: structural hold parity + late-registrant resync + // (queue capture, deferHeldStructural, live-resync sweep). Measured + // 16.52. + // Structural-audit follow-up (2026-08-31): slot registration stamps, + // drain-side generation gate with live slot re-resolution, per-drain + // resync dedup, landing resync hook. Measured 16.66. + // Fold-audit round (2026-09-01): staged-truth fold marker + fold-site + // row/slot emissions (reveal coverage), per-index held-slot defers, and + // the drain-end late sweep (no resync-then-ops double-builds). Measured 16.88. + // Version-chain redesign (2026-09-01): snapshot/watermark/sweep + // machinery replaced by per-entry applied-version chains + ONE + // flush-end resync + registration-time ancestor repair. Flat cost — + // the per-finding mechanism accretion this class caused stops here. Measured 17.11. + // Entanglement rebase drift (2026-09-01, until-flip + CONFIG_HELD_TRUTH). + // Entangle-tear consolidation (2026-09-01, rc.6 pass 1): parked-truth + // deferral — deliveries and structural applies re-stash on the holder + // named by the nodes' own `_transition` (follows merges AND the steal + // with zero carrier-specific code; the scheduler diff is zero). +294 B. + limit: "17.5 KB", modifyEsbuildConfig }, { - name: "app: CSR flip preview — + rowProof (patch-mode list driver)", + name: "app: CSR default-on — + rowProof (patch-mode list driver)", + // FLIP LANDED (2026-08-28) — see the patchDriver scenario note. + // // The full flip cost: a compiled patch-mode list row (rowProof) arms // the insert seam and retains the list driver plus the row-hooks tier // (row-ops/slot emitters + reconcile's keyed/identity diff builders) — @@ -467,16 +528,62 @@ module.exports = [ // driver's failed-apply resync flag + partial-registration severing and // the coalescing entry updates ride this tier. // - // #3122 eager iterator teardown (2026-08-31): 16.9 -> 16.92 KB, - // measured at 16.901 — the core-floor teardown bytes (see that note). - // - // #3164 fold ruling (2026-08-31): 16.92 -> 16.94 KB, measured at 16.93 - // — the same bytes as the patchDriver scenario (see that note). - // - // Fold relocation pass (2026-09-01): 16.94 -> 16.91 KB, measured at - // 16.90 — retained-ledger shake, same as the hydrating no-store note. + // Re-audit-6 (2026-08-28): the list-tier share of the hardening — + // initial-construction sever-on-throw (client + hydration), ACTIVE + // failed-apply resync on slot ticks, structural queue u-mark dispatch, + // occurrence-aware identityOps — plus the value-tier bytes above. + // Measured 17.20. + // + // Re-audit-7 (2026-08-28): the value-tier manifest bytes above plus + // build-before-destroy slot rebuilds, hydration full-region surrender, + // and emission-snapshot structural queues. Measured 17.56; 17.80 after + // the perf pass (interning + prefix-tree probe + hoisted manifests). + // + // Re-audit-8 (2026-08-28): the value-tier bytes above plus captured- + // record row binds (patchProxyFor riding the createTarget-installed + // wrap hook — the direct wrapNext edge would have retained the whole + // trap engine here, +3.7 kB, caught at this gate). Measured 18.09. + // + // Re-audit-9 (2026-08-29): the value-tier bytes above plus isWrappable + // row-bind guards and immediate lane demotion. Measured 18.47. + // Round-10.6 (2026-08-31): same value-tier bytes as csr-app-patch. + // Measured 18.66. path: "csr-app-patch-lists.js", - limit: "16.91 KB", + // Round-10.7: same bytes as the value tier. 18.79. + // Size pass (2026-08-31): same trims. Measured 18.60 — tightened. + // Round-10.9 (2026-08-31): demotion-lifecycle bytes (see value tier). + // Measured 18.72. + // Rebase onto next (2026-08-31): upstream drift stacks. Measured 18.82. + // Round-10.13: structural hold parity + late-registrant resync. + // Measured 19.03. + // #3122 teardown (upstream, 2026-08-31): core-floor bytes ride this + // tier too. Measured 19.02 post-rebase. + // Structural audit (2026-08-31): registration-sequence window (fixed + // both edges, suffix scan), hold-deferred late resyncs, visible-view + // resolution, deleted-slot gate, superseded-work stamps. Measured + // 19.14. + // Follow-up (2026-08-31): the value-tier bytes above plus the slot sq + // stamp and landing resync hook. Measured 19.26. + // Fold-audit round (2026-09-01): staged-truth fold marker + fold-site + // row/slot emissions (reveal coverage), per-index held-slot defers, and + // the drain-end late sweep (no resync-then-ops double-builds). Measured 19.41. + // Version-chain redesign (2026-09-01): snapshot/watermark/sweep + // machinery replaced by per-entry applied-version chains + ONE + // flush-end resync + registration-time ancestor repair. Flat cost — + // the per-finding mechanism accretion this class caused stops here. Measured 19.56. + // Fold audit 2 (2026-09-01): transition-aware version init, reveal + // channel split, target-keyed staged identity, release fast-forward. + // Measured 19.62. + // Entanglement rebase drift (2026-09-01, until-flip + CONFIG_HELD_TRUTH). + // Fold audit 6 (2026-09-01): two key spaces + undefined sentinel + + // kind-aware prefix scan in the row matcher (mixed-identity P1, + // undefined-move P2). Measured 19.74. + // Fold audit 6b (2026-09-01): the driver's rebuild check agrees with + // the matcher's SameValueZero (moved NaN row kept its node — parity + // with classic's Map-based diff, which has this for free). 12 B. + // Entangle-tear consolidation (2026-09-01, rc.6 pass 1): the list + // tier's share of parked-truth deferral (see patchDriver note). +221 B. + limit: "20.05 KB", modifyEsbuildConfig }, {