From 161a8d96037ec2cf46098f09cc25b677b29026db Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 28 Aug 2026 12:48:00 -0700 Subject: [PATCH 01/56] fix(signals,web): patch-channel round-6 hardening Prod-sound getter demotion (accessed-key recording + bounded adoption probes), transition-merge same-channel coalescing, unbind-safe structural queue dispatch, fixed dispatch windows, initial-construction severing on throw, and active failed-apply resync. Co-authored-by: Cursor --- .../fix-patch-channel-round6-hardening.md | 13 ++ packages/signals/src/core/scheduler.ts | 45 +++++-- packages/signals/src/store/next/patch.ts | 127 ++++++++++++++---- packages/signals/src/store/next/reconcile.ts | 23 ++-- packages/signals/src/store/next/store.ts | 26 +++- packages/signals/src/store/next/target.ts | 10 ++ packages/web/src/patch-driver.ts | 49 +++++-- 7 files changed, 231 insertions(+), 62 deletions(-) create mode 100644 .changeset/fix-patch-channel-round6-hardening.md 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/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 14b81e49a..7446c9980 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -232,16 +232,41 @@ 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; + let dest = (target as any)._heldPatches as any[] | undefined; + if (dest === undefined) { + dest = (target as any)._heldPatches = heldPatches; + 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; + } + } else { + // COALESCE same-channel collisions (re-audit 6, P1-2): a record that + // emitted in BOTH transactions must apply ONCE at the merged commit — + // the surviving entry resolves `next` LIVE at drain (via the channel's + // target backref) and keeps the destination's earlier prev; the moved + // duplicate is dropped. Opaque backref contract with + // store/next/patch.ts (entry.pc, pc.t/qa/qe). + const byPc = new Map(); + for (let i = 0; i < dest.length; i++) { + const pc = (dest[i] as any).pc; + if (pc !== undefined) byPc.set(pc, dest[i]); + } + for (let i = 0; i < heldPatches.length; i++) { + const entry: any = heldPatches[i]; + const pc = entry.pc; + const dup = pc !== undefined ? byPc.get(pc) : undefined; + if (dup !== undefined) { + dup.t = pc.t; // drain resolves next live: t.pb ?? t.v + pc.qa = dest; + pc.qe = dup; + } else { + dest.push(entry); + if (pc !== undefined) { + byPc.set(pc, entry); + if (pc.qe === entry) pc.qa = dest; + } + } + } } } // Legal transfer, not a new registration: entries move between transitions. diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 78b57166a..630feb1fc 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -52,6 +52,11 @@ 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; + /** Keys recorded (adoption demotion probes); undefined = record at the + * next drain apply. */ + k?: boolean; } // Per-flush apply queue. Bubbled (forced) emissions resolve `next` LAZILY at @@ -64,9 +69,16 @@ 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 }; + /** Coalescing + recording backref (re-audits 3/6): set for stamped SELF + * entries so the drain can clear the channel's qa/qe stamps (retention) + * and record first-apply read sets (ak). */ + pc?: { qa: unknown; qe: unknown; ak: PropertyKey[] | null }; + /** 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; } let queue: QueuedApply[] | null = null; let scheduled = false; @@ -90,7 +102,9 @@ function drainApplyQueue(): void { clearStamp(q[i]); const { list, prev, force, t } = q[i]; const next = t !== null ? (t.pb ?? t.v) : q[i].next; - firstError = applyEntries(list, next, prev, force, firstError); + if (q[i].ops !== undefined || q[i].si !== undefined) + firstError = applyStructural(q[i], next, firstError); + else firstError = applyEntries(list, next, prev, force, firstError, q[i].pc); } if (firstError !== UNSET) { // Unhandled patch errors HALT like unhandled effect errors (re-audit 2, @@ -100,6 +114,39 @@ function drainApplyQueue(): void { } } +/** Row-ops/slot-tick dispatch over the LIVE registration list (re-audit 6): + * queued clones survived unbinding — a subject switch between emission and + * drain fired stale structural callbacks against the new list state. Same + * per-entry isolation and error routing as value patches. */ +function applyStructural(item: QueuedApply, next: any, firstError: unknown): unknown { + const list = item.list as unknown as { fn: Function; owner: Owner | null; u?: boolean }[]; + const snap = list.length > 1 ? list.slice() : list; + const len = snap.length; + for (let j = 0; j < len; j++) { + const entry = snap[j]; + if (entry === undefined || entry.u === true) continue; + if (entry.owner !== null && isDisposed(entry.owner)) continue; + try { + if (item.si !== undefined) entry.fn(item.si, next, item.prev); + else entry.fn(next, item.ops); + } catch (err) { + let handled = false; + const owner = entry.owner as any; + if (owner !== null) { + let source = owner; + while (source !== null && source._fn === undefined) source = source._parent; + source ??= owner; + const statusErr = new StatusError(source, err); + ext(source)._error = statusErr; + source._statusFlags = (source._statusFlags ?? 0) | STATUS_ERROR; + handled = owner._queue.notify(source, STATUS_ERROR, STATUS_ERROR, statusErr); + } + if (!handled && firstError === UNSET) firstError = err; + } + } + return firstError; +} + const UNSET: unique symbol = Symbol(); /** ONE callback/error primitive for every drain (normal, transition-held, @@ -109,11 +156,12 @@ const UNSET: unique symbol = Symbol(); * 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 }[], + list: PatchEntry[], next: any, prev: any, force: boolean, - firstError: unknown + firstError: unknown, + pc?: { ak: PropertyKey[] | null } ): 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 — @@ -121,13 +169,33 @@ function applyEntries( // single-consumer case pays nothing; unbound entries are marked so a // snapshot never applies a consumer severed by an earlier callback. const snap = list.length > 1 ? list.slice() : list; - for (let j = 0; j < snap.length; j++) { + // 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 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) continue; // Disposed owners drop their patches (the row unmounted mid-flush). if (entry.owner !== null && isDisposed(entry.owner)) continue; try { - entry.fn(next, prev, force); + // First-apply key recording (re-audit 6): entries registered without a + // recorded read set (hydration skips the initial apply) record here — + // one proxied apply per entry lifetime keeps the adoption demotion + // gate prod-sound for them too. + if (pc !== undefined && entry.k !== true && next !== null && typeof next === "object") { + entry.k = true; + const ak = (pc.ak ??= []); + const rec = new Proxy(next as object, { + get(o, key, r) { + if (ak.indexOf(key) === -1) ak.push(key); + return Reflect.get(o, key, r); + } + }); + entry.fn(rec, prev, force); + } else { + entry.fn(next, prev, force); + } } catch (err) { let handled = false; const owner = entry.owner as any; @@ -299,7 +367,9 @@ function drainOptimistic(): void { clearStamp(q[i]); const { list, prev, force, t } = q[i]; const next = t !== null ? (t.pb ?? t.v) : q[i].next; - firstError = applyEntries(list, next, prev, force, firstError); + if (q[i].ops !== undefined || q[i].si !== undefined) + firstError = applyStructural(q[i], next, firstError); + else firstError = applyEntries(list, next, prev, force, firstError, q[i].pc); } if (firstError !== UNSET) { haltReactivity(firstError); @@ -354,15 +424,14 @@ export function emitRowOpsOptimistic( const list = (t.pc !== null ? t.pc.ro : null) as RowOpsEntry[] | null; if (list === null) return; if (optQueue === null) optQueue = []; + // LIVE list + ops payload (re-audit 6): see emitRowOps. optQueue.push({ - list: list.map(e => ({ - owner: e.owner, - fn: (n: any, _p: any) => e.fn(n as any[], ops as any) - })), + list: list as unknown as PatchEntry[], next: nextRows, prev: null, force: false, - t: nextRows === null ? t : null + t: nextRows === null ? t : null, + ops }); if (!scheduled) { scheduled = true; @@ -387,7 +456,7 @@ export function hasPatches(): boolean { return patchCount > 0; } -export function registerPatch(record: any, fn: PatchFn): () => void { +export function registerPatch(record: any, fn: PatchFn, keys?: Iterable): () => 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 @@ -404,6 +473,14 @@ export function registerPatch(record: any, fn: PatchFn): () => void { const pc = pcOf(t); const list = (pc.p ??= []) as PatchEntry[]; list.push(entry); + // Accessed-key union (prod-sound adoption demotion): callers that ran the + // body against a recording proxy hand the read set here; hydration-time + // registrations record at their first drain apply instead. + if (keys !== undefined) { + const ak = (pc.ak ??= []); + for (const k of keys) if (ak.indexOf(k) === -1) ak.push(k); + entry.k = true; // recorded at the caller's initial apply + } patchCount++; // Bindings are subscriptions for reachability (§6d pruning must descend // into bound records). @@ -564,6 +641,7 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { return () => { if (unbound) return; unbound = true; + (entry as any).u = true; // queued structural work skips severed consumers patchCount--; const idx = list.indexOf(entry); if (idx >= 0) list.splice(idx, 1); @@ -577,12 +655,15 @@ 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; + // LIVE list, payload on the entry (re-audit 6): an unbind between + // emission and drain must sever the queued work too. push({ - list: sp.map(e => ({ owner: e.owner, fn: () => e.fn(index, next, prev) })), + list: sp as unknown as PatchEntry[], next, prev, force: false, - t: null + t: null, + si: index }); } @@ -616,6 +697,7 @@ export function registerSlotPatchNext( return () => { if (unbound || pc.sp === null) return; unbound = true; + (entry as any).u = true; // queued structural work skips severed consumers const idx = pc.sp.indexOf(entry); if (idx >= 0) pc.sp.splice(idx, 1); if (pc.sp.length === 0) pc.sp = null; @@ -628,15 +710,14 @@ 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; + // LIVE list, ops on the entry (re-audit 6): see emitSlotPatch. push({ - list: list.map(e => ({ - owner: e.owner, - fn: (n: any, _p: any) => e.fn(n as any[], ops) - })), + list: list as unknown as PatchEntry[], next, prev: null, force: false, - t: null + t: null, + ops }); } diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 775f7c4bf..05c46d170 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, @@ -151,18 +152,16 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj // 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 (targetKeysPlain(t)) { 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 diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index b577bf09b..10c7dce40 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -147,7 +147,9 @@ 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, qa: null, qe: null, ak: null, t }) + ); } function createTarget( @@ -420,6 +422,19 @@ export function targetIsPlain(target: StoreNextTarget): boolean { return 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 (recorded + * from real applies — patch grammar guarantees unconditional member reads, + * so the set is complete). Unrecorded channels (registered under hydration, + * never yet applied) fall back to the full one-time scan. */ +export function targetKeysPlain(target: StoreNextTarget): boolean { + const ak = target.pc !== null ? target.pc.ak : null; + if (ak === null) return targetIsPlain(target); + const v = target.v; + for (let i = 0; i < ak.length; i++) if (lookupGetter.call(v, ak[i]) !== undefined) return false; + return true; +} + /** One-time own-accessor scan (Annex-B probes, no descriptor allocation); * returns true when the container is plain data (overlay-safe). */ function scanAccessorsOnce(target: StoreNextTarget): boolean { @@ -808,10 +823,11 @@ function drainFolds(): void { ) 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); + // Accessor demotion at the fold-commit seam: prod-sound accessed-key + // probes (see targetKeysPlain — re-audit 6 reversed the dev-only + // trade: own getters are supported store input). + if (targetKeysPlain(t)) patchHooks!.emitPatchLocal(t, t.v, old); + else patchHooks!.demoteToEffects(t); } } // Path copying (CAS: see the eager-fold twin above). diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 50e892469..b59738b27 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -66,6 +66,16 @@ export interface PatchChannel { * retains nothing from its last batch. */ qa: unknown; qe: unknown; + /** Accessed-key set for the channel's compiled bodies (union across + * registrations): recorded from real applies — patch grammar guarantees + * unconditional member reads, so one recorded apply captures a body's + * complete read set. Adoption emission probes ONLY these keys for own + * getters (prod-sound demotion at bounded cost); null = not yet recorded, + * fall back to the full scan. */ + ak: PropertyKey[] | null; + /** 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; diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 176138d33..064f11879 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -257,22 +257,23 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // 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(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(initIdx); + entries[initIdx] = node; + if (rowBodies !== null) rowBodies[initIdx] = lastBodies!; + rowUnbinds[initIdx] = lastUnbinds!; parent.insertBefore(node, endAnchor); } } @@ -283,6 +284,13 @@ 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(); } + // The THROWING row's server DOM was already claimed but never assigned + // to entries — remove it too (re-audit 6, P2-5), so a boundary fallback + // doesn't render beside a stale server row. + if (hydrating && domRows !== undefined && initIdx < domRows.length) { + const claimed = domRows[initIdx] as ChildNode; + if (claimed.parentNode === parent) claimed.remove(); + } (listOwner as any).dispose(); throw err; } @@ -558,9 +566,26 @@ export const patchDriver = (subject, body) => { // 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); + // the record for post-hydration transitions (its read set records at + // the first drain apply instead). + let unbind: () => void; + if (!sharedConfig.hydrating) { + // Record the body's read set through the initial force-apply (patch + // grammar reads every bound key unconditionally, so one apply captures + // the complete set) — the store's adoption demotion gate probes ONLY + // these keys, keeping getter semantics prod-sound at bounded cost. + const keys = new Set(); + const rec = new Proxy(raw, { + get(o, k, r) { + keys.add(k); + return Reflect.get(o, k, r); + } + }); + body(rec, undefined, true); + unbind = registerPatch(subject, body, keys); + } else { + 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, From ec506d3bbce8f8676c0094ce614438fb3c284de3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 28 Aug 2026 12:48:01 -0700 Subject: [PATCH 02/56] feat(compiler,babel-plugin): patch mode default-on Both compilers now emit patchDriver/rowProof templates by default (opt out: patchDriver: false). Regenerated fixture outputs and Oxc expectations; parity tier dom-nopatch fences the opt-out. Size scenarios renamed from flip-preview to default-on and ratcheted with round-6 notes. Includes AUDIT-BRIEF-R6.md for the pre-merge audit. Co-authored-by: Cursor --- .changeset/flip-patch-driver-default-on.md | 8 + packages/babel-plugin/src/config.ts | 16 +- .../attributeExpressions/output.js | 41 ++--- .../attributeExpressions/output.js | 41 ++--- .../attributeExpressions/output.js | 41 ++--- .../attributeExpressions/output.js | 41 ++--- .../attributeExpressions/output.js | 21 ++- .../dom/attributeExpressions/output.js | 21 ++- .../dynamic/attributeExpressions/output.js | 21 ++- ...r_hydratable_fixtures--insertChildren.diff | 0 packages/compiler/__tests__/parity/harness.js | 17 +- packages/compiler/src/compiler.rs | 11 +- packages/signals/AUDIT-BRIEF-R6.md | 104 +++++++++++ scripts/size/.size-limit.js | 171 ++++-------------- 14 files changed, 279 insertions(+), 275 deletions(-) create mode 100644 .changeset/flip-patch-driver-default-on.md rename packages/compiler/__tests__/parity/expected-cross/{dom-patch => dom-nopatch}/ssr_hydratable_fixtures--insertChildren.diff (100%) create mode 100644 packages/signals/AUDIT-BRIEF-R6.md 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/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/test/__dom_compatible_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dom_compatible_fixtures__/attributeExpressions/output.js index 4f717ccca..fec9c13d7 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"; @@ -381,32 +382,26 @@ 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$); + }); 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$); + }); 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$); + }); return _el$45; })(), (() => { @@ -654,12 +649,10 @@ 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$); +}); const template86 = _el$101; const style = { background: "red", diff --git a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js index 78d9c4c56..aa32ba699 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"; @@ -376,32 +377,26 @@ 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$); + }); 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$); + }); 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$); + }); return _el$45; })(), (() => { @@ -655,12 +650,10 @@ 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$); +}); const template86 = _el$101; const style = { background: "red", 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..ddf126b20 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"; @@ -393,32 +394,26 @@ 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$); + }); 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$); + }); 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$); + }); return _el$49; })(), (() => { @@ -677,12 +672,10 @@ 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$); +}); const template86 = _el$105; const style = { background: "red", diff --git a/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js index a76e693c9..b8444c91b 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"; @@ -366,32 +367,26 @@ 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$); + }); 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$); + }); 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$); + }); return _el$45; })(), (() => { @@ -657,12 +652,10 @@ 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$); +}); const template86 = _el$106; const style = { background: "red", diff --git a/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js b/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js index da0880255..41202cef6 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"; @@ -307,22 +308,25 @@ 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$); }); 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$); }); 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$); }); return _el$53; })(), @@ -550,8 +554,9 @@ _$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$); }); const template86 = _el$109; const style = { diff --git a/packages/compiler/__tests__/fixtures/dom/attributeExpressions/output.js b/packages/compiler/__tests__/fixtures/dom/attributeExpressions/output.js index a9cb1bbbe..aba6cea57 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"; @@ -288,22 +289,25 @@ 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$); }); 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$); }); 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$); }); return _el$47; })(), @@ -526,8 +530,9 @@ _$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$); }); const template86 = _el$103; const style = { diff --git a/packages/compiler/__tests__/fixtures/dynamic/attributeExpressions/output.js b/packages/compiler/__tests__/fixtures/dynamic/attributeExpressions/output.js index 351331f58..7b55b26a5 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"; @@ -280,22 +281,25 @@ 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$); }); 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$); }); 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$); }); return _el$47; })(), @@ -528,8 +532,9 @@ _$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$); }); const template86 = _el$106; const style = { 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/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/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md new file mode 100644 index 000000000..adf5d4764 --- /dev/null +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -0,0 +1,104 @@ +# Audit brief — round 6 + patch-mode 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/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 1a3e70c09..21e9c5558 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -71,24 +71,11 @@ 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. + limit: "7.95 KB", modifyEsbuildConfig }, { @@ -174,22 +161,10 @@ 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). + // 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. limit: "14.55 KB", modifyEsbuildConfig }, @@ -218,27 +193,9 @@ 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. + limit: "10 KB", modifyEsbuildConfig }, { @@ -265,17 +222,9 @@ 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", + limit: "10.75 KB", modifyEsbuildConfig }, { @@ -319,13 +268,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 +315,11 @@ 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. path: "hydrating-store-app.js", - limit: "26.71 KB", + limit: "26.25 KB", modifyEsbuildConfig }, { @@ -414,23 +337,16 @@ 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). - // - // 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. path: "csr-app.js", - limit: "12.95 KB", + limit: "12.9 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 +356,18 @@ 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. path: "csr-app-patch.js", - limit: "14.68 KB", + limit: "15 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 +379,13 @@ 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. path: "csr-app-patch-lists.js", - limit: "16.91 KB", + limit: "17.35 KB", modifyEsbuildConfig }, { From db84cc6e59b5447ad0748900e59118f896da45d2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 28 Aug 2026 14:00:19 -0700 Subject: [PATCH 03/56] =?UTF-8?q?test:=20invariant=20harness=20for=20re-au?= =?UTF-8?q?dit=207=20(RED=20=E2=80=94=20fixes=20follow)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract-derived tests, not instance reproductions: accessor-safety across admission/adoption axes, one-apply-per-batch under lane interleaving, consumer-list lifecycle at emission/drain/merge boundaries, throw-atomicity at every row-build entry point, hydration region surrender, Tier-2 read-set completeness, and compiled-imports ⊆ documented-runtime-surface contracts. 10 red / 8 green against the round-6 branch; each red is a confirmed audit-7 finding, each green documents an invariant that already holds. Co-authored-by: Cursor --- .../__tests__/renderer-contract.test.js | 122 +++++++ .../tests/store/patch-invariants.test.ts | 229 +++++++++++++ .../web/test/for.patchinvariants.spec.tsx | 312 ++++++++++++++++++ 3 files changed, 663 insertions(+) create mode 100644 packages/compiler/__tests__/renderer-contract.test.js create mode 100644 packages/signals/tests/store/patch-invariants.test.ts create mode 100644 packages/web/test/for.patchinvariants.spec.tsx diff --git a/packages/compiler/__tests__/renderer-contract.test.js b/packages/compiler/__tests__/renderer-contract.test.js new file mode 100644 index 000000000..e24f5874f --- /dev/null +++ b/packages/compiler/__tests__/renderer-contract.test.js @@ -0,0 +1,122 @@ +/** + * 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([]); + }); +}); 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..10b7f800a --- /dev/null +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -0,0 +1,229 @@ +/** + * 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 { describe, expect, it } from "vitest"; +import { + action, + createRoot, + createSignal, + createStore, + flush, + reconcile, + registerPatch, + patchableRaw +} from "../../src/index.js"; + +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: { 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( + { + name: "b", + get score() { + return dep(); + } + }, + "name" + )(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: one application per channel per batch, regardless of lane interleaving", () => { + it("normal → optimistic → normal emissions on ONE record: normal applies once, with final state", async () => { + const { createOptimisticStore, action: act } = await import("../../src/index.js"); + const [state, setState] = (createOptimisticStore as any)({ user: { name: "n0", title: "t0" } }); + const applies: Array<[string, string]> = []; + registerPatch(state.user, (next: any) => applies.push([next.name, next.title])); + 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 destroy the normal channel's + // coalescing stamp (shared-stamp regression: the second normal write + // queued a DUPLICATE application). + setState((s: any) => { + s.user.name = "n1"; + }); + const p = save() as Promise; + setState((s: any) => { + s.user.name = "n2"; + }); + flush(); + // Exactly two applications: the coalesced normal apply (final committed + // name) and the optimistic apply (override visible). Not three. + expect(applies.length).toBe(2); + for (const [name] of applies) expect(name).toBe("n2"); + resolve(); + await p; + flush(); + dispose: { + // settle: title lands committed; no duplicate normal application + // may have queued behind the stamp corruption. + expect(state.user.title).toBe("opt"); + } + }); +}); + +describe("INVARIANT: queued applications reach exactly the consumers registered at emission (values resolve live, structure never admits late registrants)", () => { + it("row ops emitted before a late registration never reach it (it initialized from current state)", async () => { + const { registerRowOps } = await import("../../src/index.js"); + const [state, setState] = createStore({ rows: [{ id: 1 }, { id: 2 }] }); + const early: any[] = []; + const late: any[] = []; + registerRowOps(state.rows, (_next: any[], ops: any) => early.push(ops)); + setState(s => { + s.rows.splice(0, 1); + }); + // Registered AFTER the structural emission, BEFORE the drain: a real + // driver has already built rows from the post-splice state — replaying + // the baseline-relative ops would corrupt its retention. + registerRowOps(state.rows, (_next: any[], ops: any) => late.push(ops)); + flush(); + expect(early.length).toBe(1); + expect(late.length).toBe(0); + }); + + 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 [state, setState] = createStore({ user: { name: "a" } }); + const log: string[] = []; + let resolveA!: () => void; + let resolveB!: () => void; + let saveA!: () => Promise | void; + let saveB!: () => Promise | void; + let unbindOld!: () => void; + createRoot(() => { + 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; + }); + 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(); + expect(log).toEqual(["b1"]); + dispose(); + }); +}); diff --git a/packages/web/test/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx new file mode 100644 index 000000000..d265649b7 --- /dev/null +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -0,0 +1,312 @@ +/** + * @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 { + createRoot, + createSignal, + createStore, + enableHydration, + flush, + For, + reconcile, + resetErrorHalt +} from "solid-js"; +import { getNextElement, hydrate, patchDriver, rowProof, template } 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: 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); + }); +}); + +describe("INVARIANT: recorded read sets cover every key a Tier-2 body CAN read (branches included)", () => { + 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; + }); + }); + 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(); + }); +}); From d85a502fcee5e5c93c142c27d9eb3281da643801 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 28 Aug 2026 14:34:14 -0700 Subject: [PATCH 04/56] fix(signals,web): re-audit-7 runtime findings (P1-2/3/5/6, P2-2/3/9) Each fix flips a red invariant-harness test green: stateless adoption probes against the incoming backing, prototype-plainness admission, split normal/optimistic stamps, emission-snapshot structural queues + drain-live value lists (with cross-release coalescing), build-before- destroy slot rebuilds, and full server-region surrender on hydration throw. Store+core suites 465 green; driver suites green. Co-authored-by: Cursor --- .../fix-patch-channel-audit7-runtime.md | 16 +++ packages/signals/src/store/next/patch.ts | 113 +++++++++++++----- packages/signals/src/store/next/reconcile.ts | 14 +-- packages/signals/src/store/next/store.ts | 63 +++++++--- packages/signals/src/store/next/target.ts | 5 + .../tests/store/patch-invariants.test.ts | 106 ++++++++++++---- packages/web/src/patch-driver.ts | 33 +++-- .../web/test/for.patchinvariants.spec.tsx | 61 +--------- .../test/hydration/patchlist-throw.spec.tsx | 73 +++++++++++ 9 files changed, 337 insertions(+), 147 deletions(-) create mode 100644 .changeset/fix-patch-channel-audit7-runtime.md create mode 100644 packages/web/test/hydration/patchlist-throw.spec.tsx 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/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 630feb1fc..1e8881231 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -70,9 +70,17 @@ interface QueuedApply { /** When set, `next` resolves at drain as `t.pb ?? t.v` (bubbles). */ t: StoreNextTarget | null; /** Coalescing + recording backref (re-audits 3/6): set for stamped SELF - * entries so the drain can clear the channel's qa/qe stamps (retention) - * and record first-apply read sets (ak). */ - pc?: { qa: unknown; qe: unknown; ak: PropertyKey[] | null }; + * entries so the drain can clear the channel's stamps (retention), record + * first-apply read sets (ak), and resolve the VALUE consumer list LIVE + * (re-audit 7, P2-9/P1-5 dual: value applications are absolute, so they + * go to whoever is registered at drain — a list recreated while the entry + * was held or merged must not be missed). */ + pc?: { + qa: unknown; + qe: unknown; + ak: PropertyKey[] | null; + p: object[] | null; + }; /** 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. */ @@ -100,11 +108,11 @@ function drainApplyQueue(): void { let firstError: unknown = UNSET; for (let i = 0; i < q.length; i++) { clearStamp(q[i]); - const { list, prev, force, t } = q[i]; + const { prev, force, t } = q[i]; const next = t !== null ? (t.pb ?? t.v) : q[i].next; if (q[i].ops !== undefined || q[i].si !== undefined) firstError = applyStructural(q[i], next, firstError); - else firstError = applyEntries(list, next, prev, force, firstError, q[i].pc); + else firstError = applyEntries(liveValueList(q[i]), next, prev, force, firstError, q[i].pc); } if (firstError !== UNSET) { // Unhandled patch errors HALT like unhandled effect errors (re-audit 2, @@ -114,13 +122,29 @@ function drainApplyQueue(): void { } } -/** Row-ops/slot-tick dispatch over the LIVE registration list (re-audit 6): - * queued clones survived unbinding — a subject switch between emission and - * drain fired stale structural callbacks against the new list state. Same - * per-entry isolation and error routing as value patches. */ +const EMPTY_LIST: PatchEntry[] = []; + +/** VALUE entries dispatch to the channel's CURRENT consumer list (re-audit + * 7, P2-9): applications are absolute (latest state), so they belong to + * whoever is registered at drain time — a consumer list recreated while the + * entry was transition-held (or coalesced across a merge) must receive the + * commit, and a fully-unbound channel receives nothing. Entries without a + * channel backref (none exists today) keep their captured list. Structural + * entries are the DUAL — baseline-relative, snapshotted at emission. */ +function liveValueList(item: QueuedApply): PatchEntry[] { + const pc = item.pc ?? (item.t !== null ? (item.t.pc as QueuedApply["pc"]) : undefined); + if (pc == null) return item.list; + return (pc.p as PatchEntry[] | null) ?? EMPTY_LIST; +} + +/** 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 { - const list = item.list as unknown as { fn: Function; owner: Owner | null; u?: boolean }[]; - const snap = list.length > 1 ? list.slice() : list; + const snap = item.list as unknown as { fn: Function; owner: Owner | null; u?: boolean }[]; const len = snap.length; for (let j = 0; j < len; j++) { const entry = snap[j]; @@ -237,6 +261,23 @@ function releaseBatch(batch: Transition): void { function pushLive(item: QueuedApply): void { if (queue === null) queue = []; + // Same-drain coalescing for RELEASED entries (re-audit 7): two + // transitions settling in one flush each release a held entry for the + // same channel — an effect on that record runs ONCE for the flush, so + // the channel applies once (earliest prev, latest/live next). pushSelf + // handles same-batch writes; this is its cross-release twin. + const pc = (item as any).pc as { qa: unknown; qe: QueuedApply | null } | undefined; + if (pc !== undefined && !item.force) { + if (pc.qa === queue && pc.qe !== null) { + const qe = pc.qe; + qe.next = item.next; + qe.list = item.list; + if (item.t !== null) qe.t = item.t; + return; + } + pc.qa = queue; + pc.qe = item; + } queue.push(item); if (!scheduled) { scheduled = true; @@ -293,12 +334,19 @@ function pushSelf(pc: { qa: unknown; qe: unknown }, item: QueuedApply): void { /** 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. */ + * captured backings for the record's lifetime. Clears whichever stamp pair + * (normal or optimistic) this entry holds. */ function clearStamp(item: QueuedApply): void { - const pc = (item as any).pc as { qa: unknown; qe: unknown } | undefined; - if (pc !== undefined && pc.qe === item) { + const pc = (item as any).pc as + | { qa: unknown; qe: unknown; qo: unknown; qeo: unknown } + | undefined; + if (pc === undefined) return; + if (pc.qe === item) { pc.qa = null; pc.qe = null; + } else if (pc.qeo === item) { + pc.qo = null; + pc.qeo = null; } } @@ -365,11 +413,11 @@ function drainOptimistic(): void { let firstError: unknown = UNSET; for (let i = 0; i < q.length; i++) { clearStamp(q[i]); - const { list, prev, force, t } = q[i]; + const { prev, force, t } = q[i]; const next = t !== null ? (t.pb ?? t.v) : q[i].next; if (q[i].ops !== undefined || q[i].si !== undefined) firstError = applyStructural(q[i], next, firstError); - else firstError = applyEntries(list, next, prev, force, firstError, q[i].pc); + else firstError = applyEntries(liveValueList(q[i]), next, prev, force, firstError, q[i].pc); } if (firstError !== UNSET) { haltReactivity(firstError); @@ -383,18 +431,19 @@ export function emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): v 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; + // Same-batch coalescing, optimistic container (re-audit 3) — on the + // DEDICATED optimistic stamp pair (re-audit 7, P2-3): the lane queue + // must not clobber the normal channel's qa/qe mid-batch. + const pc = t.pc! as unknown as { qo: unknown; qeo: unknown }; + if (pc.qo === optQueue && pc.qeo !== null) { + const qe = pc.qeo 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; + pc.qo = optQueue; + pc.qeo = item; + (item as any).pc = t.pc; optQueue.push(item); } } @@ -424,9 +473,9 @@ export function emitRowOpsOptimistic( const list = (t.pc !== null ? t.pc.ro : null) as RowOpsEntry[] | null; if (list === null) return; if (optQueue === null) optQueue = []; - // LIVE list + ops payload (re-audit 6): see emitRowOps. + // Snapshot at emission, unbind safety via `u` marks — see emitSlotPatch. optQueue.push({ - list: list as unknown as PatchEntry[], + list: list.slice() as unknown as PatchEntry[], next: nextRows, prev: null, force: false, @@ -655,10 +704,12 @@ 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; - // LIVE list, payload on the entry (re-audit 6): an unbind between - // emission and drain must sever the queued work too. + // 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 as unknown as PatchEntry[], + list: sp.slice() as unknown as PatchEntry[], next, prev, force: false, @@ -710,9 +761,9 @@ 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; - // LIVE list, ops on the entry (re-audit 6): see emitSlotPatch. + // Snapshot at emission, unbind safety via `u` marks — see emitSlotPatch. push({ - list: list as unknown as PatchEntry[], + list: list.slice() as unknown as PatchEntry[], next, prev: null, force: false, diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 05c46d170..8d2cbec71 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -145,14 +145,12 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj // 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 (targetKeysPlain(t)) { + // 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__) diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 10c7dce40..46453ab36 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -148,7 +148,19 @@ 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, ak: null, t }) + t.pc ?? + (t.pc = { + sp: null, + p: null, + ro: null, + wk: null, + qa: null, + qe: null, + qo: null, + qeo: null, + ak: null, + t + }) ); } @@ -417,24 +429,46 @@ 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 (recorded - * from real applies — patch grammar guarantees unconditional member reads, - * so the set is complete). Unrecorded channels (registered under hydration, - * never yet applied) fall back to the full one-time scan. */ -export function targetKeysPlain(target: StoreNextTarget): boolean { + * 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; const ak = target.pc !== null ? target.pc.ak : null; - if (ak === null) return targetIsPlain(target); - const v = target.v; - for (let i = 0; i < ak.length; i++) if (lookupGetter.call(v, ak[i]) !== undefined) return false; + 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; 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); * returns true when the container is plain data (overlay-safe). */ function scanAccessorsOnce(target: StoreNextTarget): boolean { @@ -824,9 +858,10 @@ function drainFolds(): void { rowHooks!.emitSetterRowOps(t, old as any[], t.v as any[]); if (t.pc.p !== null) { // Accessor demotion at the fold-commit seam: prod-sound accessed-key - // probes (see targetKeysPlain — re-audit 6 reversed the dev-only - // trade: own getters are supported store input). - if (targetKeysPlain(t)) patchHooks!.emitPatchLocal(t, t.v, old); + // 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); } } diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index b59738b27..544fb5412 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -66,6 +66,11 @@ export interface PatchChannel { * retains nothing from its last batch. */ qa: unknown; qe: unknown; + /** Optimistic-container stamp pair (re-audit 7, P2-3): the lane queue + * coalesces independently — sharing qa/qe let an interleaved optimistic + * emission destroy the normal stamp and queue a duplicate application. */ + qo: unknown; + qeo: unknown; /** Accessed-key set for the channel's compiled bodies (union across * registrations): recorded from real applies — patch grammar guarantees * unconditional member reads, so one recorded apply captures a body's diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 10b7f800a..defa24adc 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -28,7 +28,7 @@ describe("INVARIANT: a patch body never reads an accessor raw", () => { const { patchCountForTests } = await import("../../src/store/next/patch.js"); const base = patchCountForTests(); const [dep, setDep] = createRoot(() => createSignal(1)); - const [state, setState] = createStore({ user: { name: "a", score: 0 } }); + 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(); @@ -43,12 +43,13 @@ describe("INVARIANT: a patch body never reads an accessor raw", () => { setState(s => { reconcile( { + id: 1, name: "b", get score() { return dep(); } }, - "name" + "id" )(s.user); }); flush(); @@ -79,12 +80,28 @@ describe("INVARIANT: a patch body never reads an accessor raw", () => { }); }); -describe("INVARIANT: one application per channel per batch, regardless of lane interleaving", () => { - it("normal → optimistic → normal emissions on ONE record: normal applies once, with final state", async () => { - const { createOptimisticStore, action: act } = await import("../../src/index.js"); +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: Array<[string, string]> = []; - registerPatch(state.user, (next: any) => applies.push([next.name, next.title])); + 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(() => { @@ -98,9 +115,9 @@ describe("INVARIANT: one application per channel per batch, regardless of lane i }) as any; }); // Interleave inside ONE flush window: the optimistic emission between - // the two normal emissions must not destroy the normal channel's - // coalescing stamp (shared-stamp regression: the second normal write - // queued a DUPLICATE application). + // 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"; }); @@ -109,38 +126,58 @@ describe("INVARIANT: one application per channel per batch, regardless of lane i s.user.name = "n2"; }); flush(); - // Exactly two applications: the coalesced normal apply (final committed - // name) and the optimistic apply (override visible). Not three. - expect(applies.length).toBe(2); - for (const [name] of applies) expect(name).toBe("n2"); + 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(); - dispose: { - // settle: title lands committed; no duplicate normal application - // may have queued behind the stamp corruption. - expect(state.user.title).toBe("opt"); - } + // 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: queued applications reach exactly the consumers registered at emission (values resolve live, structure never admits late registrants)", () => { - it("row ops emitted before a late registration never reach it (it initialized from current state)", async () => { + 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[] = []; - registerRowOps(state.rows, (_next: any[], ops: any) => early.push(ops)); + // 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. + 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); }); - // Registered AFTER the structural emission, BEFORE the drain: a real - // driver has already built rows from the post-splice state — replaying - // the baseline-relative ops would corrupt its retention. - registerRowOps(state.rows, (_next: any[], ops: any) => late.push(ops)); flush(); expect(early.length).toBe(1); expect(late.length).toBe(0); + // The late consumer participates in the NEXT event normally. + setState(s => { + s.rows.splice(0, 1); + }); + flush(); + expect(early.length).toBe(2); + expect(late.length).toBe(1); }); it("a value patch held by a transition reaches a consumer registered AFTER emission (list resolves live at drain)", async () => { @@ -180,14 +217,25 @@ describe("INVARIANT: queued applications reach exactly the consumers registered }); 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 => { @@ -206,6 +254,8 @@ describe("INVARIANT: queued applications reach exactly the consumers registered }); }) as any; }); + flush(); + effectLog.length = 0; const pa = saveA() as Promise; flush(); const pb = saveB() as Promise; @@ -223,7 +273,11 @@ describe("INVARIANT: queued applications reach exactly the consumers registered await pa; await pb; flush(); - expect(log).toEqual(["b1"]); + // 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(); }); }); diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 064f11879..e37e662d4 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -284,12 +284,15 @@ 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(); } - // The THROWING row's server DOM was already claimed but never assigned - // to entries — remove it too (re-audit 6, P2-5), so a boundary fallback - // doesn't render beside a stale server row. - if (hydrating && domRows !== undefined && initIdx < domRows.length) { - const claimed = domRows[initIdx] as ChildNode; - if (claimed.parentNode === parent) claimed.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; @@ -435,10 +438,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"; + // 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): void => { - runUnbinds(rowUnbinds[i]); const old = entries[i] as ChildNode; - const node = bindRow(i); + let node: Node; + try { + node = bindRow(i); + } 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); diff --git a/packages/web/test/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx index d265649b7..d05ada7a2 100644 --- a/packages/web/test/for.patchinvariants.spec.tsx +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -13,13 +13,12 @@ import { createRoot, createSignal, createStore, - enableHydration, flush, For, reconcile, resetErrorHalt } from "solid-js"; -import { getNextElement, hydrate, patchDriver, rowProof, template } from "@solidjs/web"; +import { patchDriver, rowProof } from "@solidjs/web"; interface Row { id: number; @@ -209,64 +208,6 @@ describe("INVARIANT: a throwing row build leaves DOM, bookkeeping, and sibling r }); }); -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); - }); -}); - describe("INVARIANT: recorded read sets cover every key a Tier-2 body CAN read (branches included)", () => { test("a ternary body's untaken branch still demotes when that key becomes a getter", () => { const [dep, setDep] = createRoot(() => createSignal("sig-b")); 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); + }); +}); From 77711e535169df192022b8235da5bd150f440f33 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 28 Aug 2026 18:27:39 -0700 Subject: [PATCH 05/56] fix(compilers,signals,web,universal): static read manifests close P1-1; renderer surface documents patchDriver (P1-4) Both compilers emit the body's full static read envelope (hoisted _mf$ arrays, deduped per module); the runtime interns manifests by identity, probes deep paths as a prefix tree at adoption gates and forced applies, bubbles ancestors on targeted reconciles, and resolves forced next through the proxy for deep-path channels. Eligibility: bare-subject reads and dotted string keys compile classic. Renderer type/README/contract tests pin patchDriver into the documented surface. PINV per-flush ledger checks wired into the __TEST__ invariant infra. dbmon: tick 2.1ms quiet-machine (was 1.9 unsound / 2.5 first sound cut), mount intern misses eliminated. Co-authored-by: Cursor --- .changeset/patch-manifest-emission.md | 18 ++ packages/babel-plugin/src/dom/template.ts | 22 +- packages/babel-plugin/src/shared/patch.ts | 65 +++++- .../babel-plugin/src/shared/postprocess.ts | 16 ++ packages/babel-plugin/src/types.ts | 4 + .../attributeExpressions/output.js | 52 +++-- .../attributeExpressions/output.js | 52 +++-- .../attributeExpressions/output.js | 52 +++-- .../attributeExpressions/output.js | 52 +++-- .../attributeExpressions/output.js | 9 +- .../dom/attributeExpressions/output.js | 9 +- .../dynamic/attributeExpressions/output.js | 9 +- packages/compiler/src/dom/dynamics.rs | 8 +- packages/compiler/src/dom/template.rs | 67 +++++++ packages/compiler/src/shared/patch.rs | 139 +++++++++++-- packages/compiler/src/shared/utils.rs | 23 +++ packages/compiler/types.d.ts | 15 +- packages/signals/AUDIT-BRIEF-R6.md | 56 +++++- packages/signals/src/core/invariants.ts | 8 +- .../signals/src/store/next/patch-hooks.ts | 4 + packages/signals/src/store/next/patch.ts | 188 +++++++++++++++++- packages/signals/src/store/next/reconcile.ts | 17 ++ packages/signals/src/store/next/store.ts | 37 ++++ packages/signals/src/store/next/target.ts | 34 +++- packages/universal/README.md | 6 +- packages/universal/src/universal.ts | 10 + packages/web/src/patch-driver.ts | 30 +-- .../web/test/for.patchinvariants.spec.tsx | 85 +++++++- scripts/size/.size-limit.js | 29 ++- 29 files changed, 971 insertions(+), 145 deletions(-) create mode 100644 .changeset/patch-manifest-emission.md 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/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..4d94abd61 100644 --- a/packages/babel-plugin/src/shared/patch.ts +++ b/packages/babel-plugin/src/shared/patch.ts @@ -27,18 +27,28 @@ 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 no "." inside string keys, which would + // collide with the manifest's path separator (re-audit 7). + if (t.isStringLiteral(m.property)) { + if (m.property.value.indexOf(".") !== -1) return false; + } else if (!t.isNumericLiteral(m.property)) { + 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 +146,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 fec9c13d7..17c359c52 100644 --- a/packages/babel-plugin/test/__dom_compatible_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dom_compatible_fixtures__/attributeExpressions/output.js @@ -15,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(``), _tmpl$2 = /*#__PURE__*/ _$template(`
    `), _tmpl$3 = /*#__PURE__*/ _$template(`
    `), @@ -382,26 +386,38 @@ const template32 = _tmpl$4(); const template33 = [ (() => { var _el$43 = _tmpl$19(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$.button; - if (_f$ || _v$ !== _p$.button) _$className(_el$43, _v$); - }); + _$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(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$["foo--bar"]; - if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$44, _v$); - }); + _$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(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$.foo.bar; - if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$45, _v$); - }); + _$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; })(), (() => { @@ -649,10 +665,14 @@ var _el$100 = _tmpl$4(); _$style(_el$100, /* @static */ styleProp.style); const template85 = _el$100; var _el$101 = _tmpl$4(); -_$patchDriver(styleProp, (_n$, _p$, _f$) => { - const _v$ = _n$.style; - if (_f$ || _v$ !== _p$.style) _$style(_el$101, _v$); -}); +_$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 = { background: "red", diff --git a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js index aa32ba699..bcd6401da 100644 --- a/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dom_fixtures__/attributeExpressions/output.js @@ -15,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(`
    `), @@ -377,26 +381,38 @@ const template32 = _tmpl$4(); const template33 = [ (() => { var _el$43 = _tmpl$19(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$.button; - if (_f$ || _v$ !== _p$.button) _$className(_el$43, _v$); - }); + _$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(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$["foo--bar"]; - if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$44, _v$); - }); + _$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(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$.foo.bar; - if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$45, _v$); - }); + _$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; })(), (() => { @@ -650,10 +666,14 @@ var _el$100 = _tmpl$4(); _$style(_el$100, /* @static */ styleProp.style); const template85 = _el$100; var _el$101 = _tmpl$4(); -_$patchDriver(styleProp, (_n$, _p$, _f$) => { - const _v$ = _n$.style; - if (_f$ || _v$ !== _p$.style) _$style(_el$101, _v$); -}); +_$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 = { background: "red", 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 ddf126b20..28ab62169 100644 --- a/packages/babel-plugin/test/__dom_hydratable_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dom_hydratable_fixtures__/attributeExpressions/output.js @@ -18,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(`
    `), @@ -394,26 +398,38 @@ const template32 = _$getNextElement(_tmpl$4); const template33 = [ (() => { var _el$47 = _$getNextElement(_tmpl$19); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$.button; - if (_f$ || _v$ !== _p$.button) _$className(_el$47, _v$); - }); + _$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); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$["foo--bar"]; - if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$48, _v$); - }); + _$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); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$.foo.bar; - if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$49, _v$); - }); + _$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; })(), (() => { @@ -672,10 +688,14 @@ var _el$104 = _$getNextElement(_tmpl$4); _$style(_el$104, /* @static */ styleProp.style); const template85 = _el$104; var _el$105 = _$getNextElement(_tmpl$4); -_$patchDriver(styleProp, (_n$, _p$, _f$) => { - const _v$ = _n$.style; - if (_f$ || _v$ !== _p$.style) _$style(_el$105, _v$); -}); +_$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 = { background: "red", diff --git a/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js b/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js index b8444c91b..59e4748d7 100644 --- a/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js +++ b/packages/babel-plugin/test/__dynamic_fixtures__/attributeExpressions/output.js @@ -16,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(`
    `), @@ -367,26 +371,38 @@ const template32 = _tmpl$4(); const template33 = [ (() => { var _el$43 = _tmpl$19(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$.button; - if (_f$ || _v$ !== _p$.button) _$className(_el$43, _v$); - }); + _$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(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$["foo--bar"]; - if (_f$ || _v$ !== _p$["foo--bar"]) _$className(_el$44, _v$); - }); + _$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(); - _$patchDriver(styles, (_n$, _p$, _f$) => { - const _v$ = _n$.foo.bar; - if (_f$ || _v$ !== _p$.foo.bar) _$className(_el$45, _v$); - }); + _$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; })(), (() => { @@ -652,10 +668,14 @@ var _el$105 = _tmpl$4(); _$style(_el$105, /* @static */ styleProp.style); const template85 = _el$105; var _el$106 = _tmpl$4(); -_$patchDriver(styleProp, (_n$, _p$, _f$) => { - const _v$ = _n$.style; - if (_f$ || _v$ !== _p$.style) _$style(_el$106, _v$); -}); +_$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__/fixtures/dom-hydratable/attributeExpressions/output.js b/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js index 41202cef6..d053dde84 100644 --- a/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js +++ b/packages/compiler/__tests__/fixtures/dom-hydratable/attributeExpressions/output.js @@ -18,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(`
    `); @@ -311,7 +312,7 @@ const template33 = [ _$patchDriver(styles, (_n$, _p$, _f$) => { const _v$ = _n$.button; if (_f$ || _v$ !== _p$.button) _$className(_el$51, _v$); - }); + }, _mf$); return _el$51; })(), (() => { @@ -319,7 +320,7 @@ const template33 = [ _$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; })(), (() => { @@ -327,7 +328,7 @@ const template33 = [ _$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; })(), (() => { @@ -557,7 +558,7 @@ var _el$109 = _$getNextElement(_tmpl$4); _$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 aba6cea57..22b0be5c1 100644 --- a/packages/compiler/__tests__/fixtures/dom/attributeExpressions/output.js +++ b/packages/compiler/__tests__/fixtures/dom/attributeExpressions/output.js @@ -15,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(`
    `); @@ -292,7 +293,7 @@ const template33 = [ _$patchDriver(styles, (_n$, _p$, _f$) => { const _v$ = _n$.button; if (_f$ || _v$ !== _p$.button) _$className(_el$45, _v$); - }); + }, _mf$); return _el$45; })(), (() => { @@ -300,7 +301,7 @@ const template33 = [ _$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; })(), (() => { @@ -308,7 +309,7 @@ const template33 = [ _$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; })(), (() => { @@ -533,7 +534,7 @@ var _el$103 = _tmpl$4(); _$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 7b55b26a5..3b5117d10 100644 --- a/packages/compiler/__tests__/fixtures/dynamic/attributeExpressions/output.js +++ b/packages/compiler/__tests__/fixtures/dynamic/attributeExpressions/output.js @@ -16,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(`
    `); @@ -284,7 +285,7 @@ const template33 = [ _$patchDriver(styles, (_n$, _p$, _f$) => { const _v$ = _n$.button; if (_f$ || _v$ !== _p$.button) _$className(_el$45, _v$); - }); + }, _mf$); return _el$45; })(), (() => { @@ -292,7 +293,7 @@ const template33 = [ _$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; })(), (() => { @@ -300,7 +301,7 @@ const template33 = [ _$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; })(), (() => { @@ -535,7 +536,7 @@ var _el$106 = _tmpl$4(); _$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/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..211ccefb0 100644 --- a/packages/compiler/src/shared/patch.rs +++ b/packages/compiler/src/shared/patch.rs @@ -14,23 +14,34 @@ 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 no "." inside string keys, which would + // collide with the manifest's path separator (re-audit 7). + match &member.expression { + Expression::StringLiteral(lit) => { + if lit.value.contains('.') { + return false; + } + } + Expression::NumericLiteral(_) => {} + _ => return false, } - is_eligible_expr(&member.object, subject) + is_eligible_expr(&member.object, subject, true) } Expression::StringLiteral(_) | Expression::NumericLiteral(_) @@ -38,9 +49,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 +60,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 +130,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) => { + // Match JS String(n) for the literal keys the + // grammar admits (integer/decimal indices). + #[allow(clippy::cast_possible_truncation)] + let text = if lit.value.fract() == 0.0 && lit.value.abs() < 1e15 { + (lit.value as i64).to_string() + } else { + lit.value.to_string() + }; + segs.push(text); + } + _ => 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 index adf5d4764..7175e7933 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,4 +1,58 @@ -# Audit brief — round 6 + patch-mode default flip +# Audit brief — rounds 6–7 + patch-mode default flip + +## 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 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/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts index 80104cad5..b0d2ef81a 100644 --- a/packages/signals/src/store/next/patch-hooks.ts +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -26,6 +26,10 @@ import type { RowOps } from "./patch.js"; export interface PatchValueHooks { emitPatch(t: StoreNextTarget, next: any, prev: any): void; emitPatchLocal(t: StoreNextTarget, next: any, prev: any): void; + /** Forced ancestor bubble alone (re-audit 7): targeted reconciles cover + * the walked subtree locally, but ancestor bodies read INTO it through + * nested chains — the walk root bubbles like a nested setter write. */ + emitPatchAncestors(t: StoreNextTarget): void; emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void; hasPatches(): boolean; demoteToEffects(t: StoreNextTarget): void; diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 1e8881231..702d8ae99 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -40,7 +40,10 @@ import { installPatchHooks, installRowHooks } from "./patch-hooks.js"; 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, targetIsPlain } from "./store.js"; +import type { DeepNode } from "./target.js"; +import { InvariantHooks } from "../../core/invariants.js"; +import { assertInvariant } from "../../core/dev.js"; import { runWithOwner, untrack } from "../../core/core.js"; import { createRenderEffect } from "../../signals.js"; // Cycle with store.js is benign: pcOf is only called at registration time, @@ -109,9 +112,10 @@ function drainApplyQueue(): void { for (let i = 0; i < q.length; i++) { clearStamp(q[i]); const { prev, force, t } = q[i]; - const next = t !== null ? (t.pb ?? t.v) : q[i].next; + const next = t !== null ? (force ? forcedNext(t) : (t.pb ?? t.v)) : q[i].next; if (q[i].ops !== undefined || q[i].si !== undefined) firstError = applyStructural(q[i], next, firstError); + else if (force && t !== null && deepProbeFails(t, next)) demoteToEffects(t); else firstError = applyEntries(liveValueList(q[i]), next, prev, force, firstError, q[i].pc); } if (firstError !== UNSET) { @@ -122,6 +126,26 @@ function drainApplyQueue(): void { } } +/** Deep-path demotion at FORCED applies (re-audit 7, P1-1 nested half): + * ancestor bubbles re-read whole chains from the live backing — a getter + * that arrived at a nested step through a TARGETED child adoption has no + * root adoption gate to catch it, so the forced apply is the seam. Costs + * one null check for channels without deep paths. */ +function deepProbeFails(t: StoreNextTarget, next: any): boolean { + const dp = t.pc !== null ? t.pc.dp : null; + return dp !== null && next !== null && typeof next === "object" && !deepPathsPlain(dp, next); +} + +/** 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); +} + const EMPTY_LIST: PatchEntry[] = []; /** VALUE entries dispatch to the channel's CURRENT consumer list (re-audit @@ -209,6 +233,7 @@ function applyEntries( // gate prod-sound for them too. if (pc !== undefined && entry.k !== true && next !== null && typeof next === "object") { entry.k = true; + ensureOwnedKeys(pc as any); // interned manifests are copy-on-write const ak = (pc.ak ??= []); const rec = new Proxy(next as object, { get(o, key, r) { @@ -372,6 +397,14 @@ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { }); // Bubbling: ancestors force-re-apply from their LIVE backing, resolved at // drain (privatization may clone it between now and then). + emitPatchAncestors(t); +} + +/** Ancestor bubble, standalone (re-audit 7): targeted reconciles emit + * walk-locally for the walked subtree but ancestors' compiled bodies can + * read INTO it through nested chains — the walk root must bubble exactly + * like a nested setter write does. */ +export function emitPatchAncestors(t: StoreNextTarget): void { let u = t.u; while (u !== null) { const up = (u.pc !== null ? u.pc.p : null) as PatchEntry[] | null; @@ -414,9 +447,10 @@ function drainOptimistic(): void { for (let i = 0; i < q.length; i++) { clearStamp(q[i]); const { prev, force, t } = q[i]; - const next = t !== null ? (t.pb ?? t.v) : q[i].next; + const next = t !== null ? (force ? forcedNext(t) : (t.pb ?? t.v)) : q[i].next; if (q[i].ops !== undefined || q[i].si !== undefined) firstError = applyStructural(q[i], next, firstError); + else if (force && t !== null && deepProbeFails(t, next)) demoteToEffects(t); else firstError = applyEntries(liveValueList(q[i]), next, prev, force, firstError, q[i].pc); } if (firstError !== UNSET) { @@ -505,6 +539,78 @@ export function hasPatches(): boolean { return patchCount > 0; } +interface ProcessedManifest { + roots: PropertyKey[]; + dp: DeepNode[] | 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: string[]): 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); + } + } + m = { roots, dp }; + 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); + } +} + export function registerPatch(record: any, fn: PatchFn, keys?: Iterable): () => void { let t: StoreNextTarget | undefined = record?.[$TARGET]; if (t === undefined) throw new Error("registerPatch: not a store record"); @@ -520,15 +626,36 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable void { } const entry: RowOpsEntry = { fn, owner: getOwner() }; const pc = pcOf(t); + if (__TEST__) devTrackChannel(pc); const list = (pc.ro ??= []) as RowOpsEntry[]; list.push(entry); patchCount++; @@ -786,10 +914,52 @@ function armPatchHooks(): void { installPatchHooks({ emitPatch, emitPatchLocal, + emitPatchAncestors, emitPatchOptimistic, hasPatches, demoteToEffects }); + 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) { + if (pc.qa === null && pc.qe === null && pc.qo === null && pc.qeo === null) + devChannels.delete(pc); + // fall through: a dead channel with live stamps is still a PINV-2 hit + } + live += (p?.length ?? 0) + (ro?.length ?? 0); + assertInvariant( + pc.qa === null && pc.qe === null && pc.qo === null && pc.qeo === null, + "PINV-2", + "a patch channel holds coalescing stamps at quiescence — a drain path skipped clearStamp (retention: the stamped entry pins both captured backings)" + ); + } + 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 8d2cbec71..46dc8216b 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -64,6 +64,23 @@ export function reconcileNextState( state: any, key: string | KeyFn | null | undefined, replace = false +): void { + reconcileTop(value, state, key, replace); + // 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. + if (patchHooks !== null && patchHooks.hasPatches()) { + const t: StoreNextTarget | undefined = state?.[$TARGET]; + if (t !== undefined && t.u !== null) patchHooks.emitPatchAncestors(t); + } +} + +function reconcileTop( + value: any, + state: any, + key: string | KeyFn | null | undefined, + replace = false ): void { if (state == null) throw new Error(__DEV__ ? "Cannot reconcile null or undefined state" : ""); const t: StoreNextTarget | undefined = state?.[$TARGET]; diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 46453ab36..0e6167841 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, @@ -159,6 +160,8 @@ export function pcOf(t: StoreNextTarget): PatchChannel { qo: null, qeo: null, ak: null, + dp: null, + ks: false, t }) ); @@ -458,6 +461,40 @@ export function targetKeysPlain(target: StoreNextTarget, next: Record { 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; } const transparentOptions = { transparent: true, sync: true }; diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index e37e662d4..5d59cd4f4 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -577,29 +577,35 @@ 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) => { +export const patchDriver = (subject, body, keys?: string[]) => { const raw = patchableRaw(subject); if (raw !== undefined) { // Hydration is claim + register ONLY (DESIGN-PATCH-CHANNEL §5): the // server HTML already carries current values, so the initial force-apply - // is skipped — no writes, no graph edges. The registration alone arms - // the record for post-hydration transitions (its read set records at - // the first drain apply instead). + // is skipped — no writes, no graph edges. let unbind: () => void; - if (!sharedConfig.hydrating) { - // Record the body's read set through the initial force-apply (patch - // grammar reads every bound key unconditionally, so one apply captures - // the complete set) — the store's adoption demotion gate probes ONLY - // these keys, keeping getter semantics prod-sound at bounded cost. - const keys = new Set(); + 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. + if (!sharedConfig.hydrating) body(raw, undefined, true); + unbind = registerPatch(subject, body, keys); + } else if (!sharedConfig.hydrating) { + // Manifest-less callers (hand-written registrations): record the + // EXECUTED read set through the initial force-apply. Incomplete for + // branch-reading bodies by construction — compiled output always + // ships the manifest. + const rkeys = new Set(); const rec = new Proxy(raw, { get(o, k, r) { - keys.add(k); + rkeys.add(k); return Reflect.get(o, k, r); } }); body(rec, undefined, true); - unbind = registerPatch(subject, body, keys); + unbind = registerPatch(subject, body, rkeys); } else { unbind = registerPatch(subject, body); } diff --git a/packages/web/test/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx index d05ada7a2..658f44bfb 100644 --- a/packages/web/test/for.patchinvariants.spec.tsx +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -208,7 +208,78 @@ describe("INVARIANT: a throwing row build leaves DOM, bookkeeping, and sibling r }); }); -describe("INVARIANT: recorded read sets cover every key a Tier-2 body CAN read (branches included)", () => { +describe("INVARIANT: a body's declared read envelope is honored at EVERY depth and branch", () => { + 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({ @@ -221,10 +292,14 @@ describe("INVARIANT: recorded read sets cover every key a Tier-2 body CAN read ( // 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; - }); + 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 diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 21e9c5558..73aadf519 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -165,7 +165,13 @@ module.exports = [ // 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. - limit: "14.55 KB", + // + // 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. + limit: "14.7 KB", modifyEsbuildConfig }, { @@ -318,8 +324,11 @@ module.exports = [ // 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. path: "hydrating-store-app.js", - limit: "26.25 KB", + limit: "26.35 KB", modifyEsbuildConfig }, { @@ -360,8 +369,15 @@ module.exports = [ // 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). path: "csr-app-patch.js", - limit: "15 KB", + limit: "15.55 KB", modifyEsbuildConfig }, { @@ -384,8 +400,13 @@ module.exports = [ // 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). path: "csr-app-patch-lists.js", - limit: "17.35 KB", + limit: "17.85 KB", modifyEsbuildConfig }, { From ee1d411dc9f0113db59236cf38260394af478fbd Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 28 Aug 2026 21:39:00 -0700 Subject: [PATCH 06/56] fix: re-audit-8 findings (8/8), harness-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admission deep-probes manifests + committed-view reads, captured-record structural binds (patchProxyFor via createTarget-installed wrap hook — keeps the trap engine shakeable), lane-timed tentative ancestor bubbles with settle twins, generation-stamped drains, forced-bubble coalescing, decimal-key ineligibility (both compilers), rowProof on the createRenderer surface, untracked universal commit phase. dbmon unchanged (6.4/2.0/0.5). Co-authored-by: Cursor --- .changeset/fix-patch-channel-audit8.md | 17 ++ packages/babel-plugin/src/shared/patch.ts | 8 +- .../__tests__/renderer-contract.test.js | 30 ++++ packages/compiler/src/shared/patch.rs | 23 +-- packages/signals/AUDIT-BRIEF-R6.md | 38 ++++- packages/signals/src/store/index.ts | 3 +- .../signals/src/store/next/patch-hooks.ts | 16 ++ packages/signals/src/store/next/patch.ts | 156 +++++++++++++++++- packages/signals/src/store/next/reconcile.ts | 23 ++- packages/signals/src/store/next/store.ts | 5 +- packages/signals/src/store/next/target.ts | 5 + .../tests/store/patch-invariants.test.ts | 97 +++++++++++ packages/solid/src/index.ts | 1 + packages/solid/src/server/index.ts | 1 + packages/solid/src/server/signals.ts | 4 + packages/universal/README.md | 8 +- packages/universal/src/universal.ts | 19 ++- packages/web/src/patch-driver.ts | 38 +++-- .../web/test/for.patchinvariants.spec.tsx | 71 ++++++++ scripts/size/.size-limit.js | 18 +- 20 files changed, 523 insertions(+), 58 deletions(-) create mode 100644 .changeset/fix-patch-channel-audit8.md 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/packages/babel-plugin/src/shared/patch.ts b/packages/babel-plugin/src/shared/patch.ts index 4d94abd61..4ffcc2f25 100644 --- a/packages/babel-plugin/src/shared/patch.ts +++ b/packages/babel-plugin/src/shared/patch.ts @@ -38,11 +38,13 @@ function isEligibleExpr(node: t.Node, subject: string, asMemberBase = false): bo case "MemberExpression": { const m = node as t.MemberExpression; if (m.computed) { - // Literal keys only — and no "." inside string keys, which would - // collide with the manifest's path separator (re-audit 7). + // 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)) { + } else if (!t.isNumericLiteral(m.property) || !Number.isInteger(m.property.value)) { return false; } } else if (!t.isIdentifier(m.property)) { diff --git a/packages/compiler/__tests__/renderer-contract.test.js b/packages/compiler/__tests__/renderer-contract.test.js index e24f5874f..4b3b550cc 100644 --- a/packages/compiler/__tests__/renderer-contract.test.js +++ b/packages/compiler/__tests__/renderer-contract.test.js @@ -119,4 +119,34 @@ describe("compiled imports ⊆ documented runtime surface", () => { // 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("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/shared/patch.rs b/packages/compiler/src/shared/patch.rs index 211ccefb0..70308658d 100644 --- a/packages/compiler/src/shared/patch.rs +++ b/packages/compiler/src/shared/patch.rs @@ -30,15 +30,21 @@ fn is_eligible_expr(node: &Expression<'_>, subject: &str, as_member_base: bool) if member.optional { return false; } - // Literal keys only — and no "." inside string keys, which would - // collide with the manifest's path separator (re-audit 7). + // 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(_) => {} + Expression::NumericLiteral(lit) => { + if lit.value.fract() != 0.0 { + return false; + } + } _ => return false, } is_eligible_expr(&member.object, subject, true) @@ -190,15 +196,10 @@ pub(crate) fn collect_subject_paths(values: &[&Expression<'_>], subject: &str) - match &member.expression { Expression::StringLiteral(lit) => segs.push(lit.value.to_string()), Expression::NumericLiteral(lit) => { - // Match JS String(n) for the literal keys the - // grammar admits (integer/decimal indices). + // Eligibility admits INTEGER keys only (re-audit + // 8); match JS String(n) for them. #[allow(clippy::cast_possible_truncation)] - let text = if lit.value.fract() == 0.0 && lit.value.abs() < 1e15 { - (lit.value as i64).to_string() - } else { - lit.value.to_string() - }; - segs.push(text); + segs.push((lit.value as i64).to_string()); } _ => return None, } diff --git a/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 7175e7933..328cf540a 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,4 +1,40 @@ -# Audit brief — rounds 6–7 + patch-mode default flip +# Audit brief — rounds 6–8 + patch-mode default flip + +## 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) diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 3f9860e34..4d0472704 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -34,7 +34,8 @@ export { registerPatch, registerRowOps, registerSlotPatchNext as registerSlotPatch, - patchableRaw + patchableRaw, + patchProxyFor } from "./next/patch.js"; export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily } from "./next/store.js"; export { createOptimisticStoreNext as createOptimisticStore } from "./next/optimistic.js"; diff --git a/packages/signals/src/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts index b0d2ef81a..00c140e2a 100644 --- a/packages/signals/src/store/next/patch-hooks.ts +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -30,6 +30,9 @@ export interface PatchValueHooks { * the walked subtree locally, but ancestor bodies read INTO it through * nested chains — the walk root bubbles like a nested setter write. */ emitPatchAncestors(t: StoreNextTarget): void; + /** Lane-timed twin + settle-held staging for TENTATIVE walks (re-audit + * 8, P1-3): pass the active transaction so revert/landing re-applies. */ + emitPatchAncestorsOptimistic(t: StoreNextTarget, tx: unknown): void; emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void; hasPatches(): boolean; demoteToEffects(t: StoreNextTarget): void; @@ -42,6 +45,19 @@ export interface PatchRowHooks { 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 702d8ae99..d226739c9 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -34,7 +34,7 @@ import { 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 { installPatchHooks, installRowHooks, wrapRecordHook } from "./patch-hooks.js"; // One-way: reconcile emits through the hooks (never imports this module), // so pulling its setter-channel emitter here creates no cycle. import { emitSetterRowOps } from "./reconcile.js"; @@ -42,6 +42,7 @@ import { emitSetterRowOps } from "./reconcile.js"; // call time, long after module initialization. import { deepPathsPlain, targetIsPlain } from "./store.js"; import type { DeepNode } from "./target.js"; + import { InvariantHooks } from "../../core/invariants.js"; import { assertInvariant } from "../../core/dev.js"; import { runWithOwner, untrack } from "../../core/core.js"; @@ -60,6 +61,8 @@ interface PatchEntry { /** Keys recorded (adoption demotion probes); undefined = record at the * next drain apply. */ k?: boolean; + /** Registration generation (re-audit 8, P2-6). */ + gen?: number; } // Per-flush apply queue. Bubbled (forced) emissions resolve `next` LAZILY at @@ -67,6 +70,12 @@ interface PatchEntry { // backing between emission and drain, so a captured reference goes stale. interface QueuedApply { list: PatchEntry[]; + /** Registration-generation watermark (re-audit 8, P2-6): captured at + * emission; consumers registered LATER (their initial apply read this or + * newer state) are skipped unless the entry crossed a transition release + * (`rl` — those consumers initialized from the PRE-commit view). */ + g?: number; + rl?: boolean; next: any; prev: any; force: boolean; @@ -116,7 +125,16 @@ function drainApplyQueue(): void { if (q[i].ops !== undefined || q[i].si !== undefined) firstError = applyStructural(q[i], next, firstError); else if (force && t !== null && deepProbeFails(t, next)) demoteToEffects(t); - else firstError = applyEntries(liveValueList(q[i]), next, prev, force, firstError, q[i].pc); + else + firstError = applyEntries( + liveValueList(q[i]), + next, + prev, + force, + firstError, + q[i].pc, + q[i].rl === true ? undefined : q[i].g + ); } if (firstError !== UNSET) { // Unhandled patch errors HALT like unhandled effect errors (re-audit 2, @@ -209,7 +227,8 @@ function applyEntries( prev: any, force: boolean, firstError: unknown, - pc?: { ak: PropertyKey[] | null } + pc?: { ak: PropertyKey[] | null }, + gen?: number ): 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 — @@ -224,6 +243,11 @@ function applyEntries( for (let j = 0; j < len; j++) { const entry = snap[j]; if (entry === undefined || entry.u === true) continue; + // Generation skip (re-audit 8, P2-6): a consumer registered AFTER this + // entry's emission initialized from its state (or newer) — re-applying + // is an observable duplicate setter call. Transition releases exempt + // themselves (`rl`): their late consumers saw the PRE-commit view. + if (gen !== undefined && entry.gen !== undefined && entry.gen > gen) continue; // Disposed owners drop their patches (the row unmounted mid-flush). if (entry.owner !== null && isDisposed(entry.owner)) continue; try { @@ -281,7 +305,10 @@ 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++) { + held[i].rl = true; // post-release: late consumers saw the PRE-commit view + pushLive(held[i]); + } } function pushLive(item: QueuedApply): void { @@ -311,6 +338,7 @@ function pushLive(item: QueuedApply): void { } function push(item: QueuedApply): void { + item.g = regGen; const tx = activeTransition; if (tx !== null) { let held = (tx as any)._heldPatches as QueuedApply[] | undefined; @@ -331,6 +359,7 @@ function push(item: QueuedApply): void { * 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 { + item.g = regGen; const tx = activeTransition; let arr: QueuedApply[]; if (tx !== null) { @@ -373,6 +402,10 @@ function clearStamp(item: QueuedApply): void { pc.qo = null; pc.qeo = null; } + if (item.force === true) { + (pc as any).qf = null; + (pc as any).qfo = null; + } } /** Shallow clone for the owned-prev rule (§2c): owned backings fold values @@ -403,12 +436,81 @@ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { /** Ancestor bubble, standalone (re-audit 7): targeted reconciles emit * walk-locally for the walked subtree but ancestors' compiled bodies can * read INTO it through nested chains — the walk root must bubble exactly - * like a nested setter write does. */ + * like a nested setter write does. Forced entries COALESCE per container + * (re-audit 8, P2-7): N nested writes in one batch force ONE ancestor + * re-apply, effect parity; the drain clears the stamp. */ export function emitPatchAncestors(t: StoreNextTarget): void { 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 (up !== null) pushForced(u); + u = u.u; + } +} + +function pushForced(u: StoreNextTarget): void { + const pc = u.pc! as unknown as { qf: unknown }; + const tx = activeTransition; + const arr = tx !== null ? (((tx as any)._heldPatches ??= []) as QueuedApply[]) : (queue ??= []); + if (pc.qf === arr) return; // already forced into this container this batch + pc.qf = arr; + const item: QueuedApply = { list: EMPTY_LIST, next: null, prev: null, force: true, t: u }; + item.g = regGen; + (item as any).pc = u.pc; + arr.push(item); + if (arr === queue && !scheduled) { + scheduled = true; + globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); + } +} + +/** Tentative (optimistic) ancestor bubble (re-audit 8, P1-3): in-flight + * visibility rides the LANE queue — and the SAME forced entries are staged + * on the transaction for settle (revert restores committed truth to + * ancestor expressions; landings show the landed state). Both resolve + * live at their drains. */ +export function emitPatchAncestorsOptimistic(t: StoreNextTarget, tx: unknown): void { + let u = t.u; + while (u !== null) { + const up = (u.pc !== null ? u.pc.p : null) as PatchEntry[] | null; + if (up !== null) { + const pc = u.pc! as unknown as { qfo: unknown }; + if (pc.qfo !== optQueue || optQueue === null) { + if (optQueue === null) optQueue = []; + pc.qfo = optQueue; + const item: QueuedApply = { + list: EMPTY_LIST, + next: null, + prev: null, + force: true, + t: u + }; + item.g = regGen; + (item as any).pc = u.pc; + optQueue.push(item); + if (!scheduled) { + scheduled = true; + globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); + } + } + if (tx !== null) { + const held = ((tx as any)._heldPatches ??= []) as QueuedApply[]; + const pcH = u.pc! as unknown as { qf: unknown }; + if (pcH.qf !== held) { + pcH.qf = held; + const settle: QueuedApply = { + list: EMPTY_LIST, + next: null, + prev: null, + force: true, + t: u + }; + settle.g = regGen; + (settle as any).pc = u.pc; + held.push(settle); + } + } + } u = u.u; } } @@ -529,6 +631,10 @@ export function emitRowOpsOptimistic( // Global registration count: the cheap gate emission sites check before any // per-record work (unpatched apps pay one number compare per transition). let patchCount = 0; +// Registration generation (re-audit 8, P2-6): monotonic; queued entries +// capture the counter at emission so drains can skip consumers that +// initialized from state at-or-after the emission. +let regGen = 0; /** Test-only accounting probe: the live registration count must return to * baseline across register/unbind/demote cycles. @internal */ export function patchCountForTests(): number { @@ -624,7 +730,7 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable | 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); @@ -710,7 +835,19 @@ 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 view (re-audit 8, P2-6 root cause): a driver mounting + // mid-transition must render what an untracked reader sees — the + // committed backing — not a transition's deferred draft; the held + // entry's release re-applies the commit to it. + const raw = t.v; + // 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. + if (keys !== undefined) { + const m = internManifest(keys); + if (m.dp !== null && !deepPathsPlain(m.dp, raw)) return undefined; + } + return raw; } /** Accessor demotion (design §5): a record that acquires an accessor after @@ -915,6 +1052,7 @@ function armPatchHooks(): void { emitPatch, emitPatchLocal, emitPatchAncestors, + emitPatchAncestorsOptimistic, emitPatchOptimistic, hasPatches, demoteToEffects diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 46dc8216b..dcc251d00 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -55,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; @@ -65,14 +65,20 @@ export function reconcileNextState( key: string | KeyFn | null | undefined, replace = false ): void { - reconcileTop(value, state, key, replace); + const tentative = reconcileTop(value, state, key, replace); // 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) patchHooks.emitPatchAncestors(t); + if (t !== undefined && t.u !== null) { + if (tentative) patchHooks.emitPatchAncestorsOptimistic(t, activeTransition); + else patchHooks.emitPatchAncestors(t); + } } } @@ -81,7 +87,7 @@ function reconcileTop( state: any, key: string | KeyFn | null | undefined, replace = false -): void { +): boolean { 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) @@ -99,9 +105,9 @@ function reconcileTop( // 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 false; // already chained to this store adoptPB(t, value); - return; + return false; } const incoming = unwrapValue(value); if (keyFn) { @@ -123,7 +129,7 @@ function reconcileTop( // resolving to this proxy; re-handed later it wraps fresh. (t.fam?.map ?? storeNextLookup).delete(t.pb ?? t.v); adoptPB(t, incoming); - return; + return false; } } // Tentative channel (§6b, RUL-5): a user-context reconcile on an optimistic @@ -133,9 +139,10 @@ function reconcileTop( // existing child targets instead of overriding their parent slots. if (t.fam?.opt === true && !projectionWriteActive && !getWriteOverride()) { optHooks!.applyTentative(t, incoming, keyFn); - return; + return true; } applyAdopt(t, incoming, keyFn, replace); + return false; } function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj = false): void { diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 0e6167841..f8062ad05 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -100,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 @@ -159,6 +159,8 @@ export function pcOf(t: StoreNextTarget): PatchChannel { qe: null, qo: null, qeo: null, + qf: null, + qfo: null, ak: null, dp: null, ks: false, @@ -203,6 +205,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. diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index d9d5dc6fd..0553caf8c 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -79,6 +79,11 @@ export interface PatchChannel { * emission destroy the normal stamp and queue a duplicate application. */ qo: unknown; qeo: unknown; + /** Forced-bubble coalescing stamps (re-audit 8, P2-7): the container this + * channel last pushed a FORCED (ancestor) entry into — normal/held (qf) + * and optimistic (qfo). One forced re-apply per container per batch. */ + qf: unknown; + qfo: unknown; /** 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 diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index defa24adc..35291272b 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -145,6 +145,68 @@ describe("INVARIANT: patch applications mirror effect runs (parity oracle), rega }); }); +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(); + // In-flight visibility is what optimism MEANS: the ancestor's compiled + // body reads through the child — it must re-apply now, not at settle. + expect(log[log.length - 1]).toBe("opt"); + resolve(); + await p; + flush(); + expect(log[log.length - 1]).toBe("m0"); // revert re-applies committed + }); +}); + +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); + }); +}); + 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"); @@ -180,6 +242,41 @@ describe("INVARIANT: queued applications reach exactly the consumers registered expect(late.length).toBe(1); }); + 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. + registerRowOps(state.rows, () => { + if (!mounted) { + mounted = true; + registerPatch(state.rows[0], (n: any) => spy.push(n.label)); + } + }); + // ONE flush: structural change queues row ops FIRST, then the value + // write queues the record's entry — the drain mounts the consumer, then + // must NOT hand it the value entry (it initialized from that state; a + // re-apply is an observable duplicate setter call). + setState(s => { + s.rows.push({ id: 2, label: "L2" }); + s.rows[0].label = "X1"; + }); + flush(); + expect(spy).toEqual([]); + // Functional from the NEXT event on. + setState(s => { + s.rows[0].label = "Y1"; + }); + flush(); + expect(spy).toEqual(["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[] = []; diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 4378f4e8b..17a06b9a6 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -26,6 +26,7 @@ export { latest, // Patch-channel compiler contract (undocumented as application API) patchableRaw, + patchProxyFor, registerPatch, registerRowOps, registerSlotPatch, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 656216245..49d9acd9b 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -39,6 +39,7 @@ 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, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index c27c438a2..1d90b7d93 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; } diff --git a/packages/universal/README.md b/packages/universal/README.md index ec5d1e97e..6739d79a2 100644 --- a/packages/universal/README.md +++ b/packages/universal/README.md @@ -67,9 +67,11 @@ export const { applyRef, ref, // Required since patch mode became the compiler default: compiled - // templates with pure member-read bindings import `patchDriver` from your - // renderer module. createRenderer provides it — just re-export. - patchDriver + // 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 bfdcbc3bd..3abd941d3 100644 --- a/packages/universal/src/universal.ts +++ b/packages/universal/src/universal.ts @@ -56,6 +56,10 @@ export interface Renderer { 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 }; @@ -428,12 +432,23 @@ 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). + // (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) { effect( () => body(subject, subject, false), - () => body(subject, undefined, true) + () => { + 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 5d59cd4f4..cd0260d43 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -11,6 +11,7 @@ import { createOwner, onCleanup, patchableRaw, + patchProxyFor, registerPatch, registerRowOps, registerSlotPatch, @@ -180,9 +181,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 +198,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 +213,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 +233,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; }; @@ -264,13 +270,13 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // id (getNextElement resolves the `_hk` registry entry); patchDriver // skips the initial apply. for (; initIdx < raw.length; initIdx++) { - entries[initIdx] = bindRow(initIdx, rowIds![initIdx]); + entries[initIdx] = bindRow(patchProxyFor(subject, raw[initIdx], initIdx), rowIds![initIdx]); if (rowBodies !== null) rowBodies[initIdx] = lastBodies!; rowUnbinds[initIdx] = lastUnbinds!; } } else { for (; initIdx < raw.length; initIdx++) { - const node = bindRow(initIdx); + const node = bindRow(patchProxyFor(subject, raw[initIdx], initIdx)); entries[initIdx] = node; if (rowBodies !== null) rowBodies[initIdx] = lastBodies!; rowUnbinds[initIdx] = lastUnbinds!; @@ -363,7 +369,7 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? const abs = prefix + j; const src = sources[j]; if (src === -1 || (refRebuild && src >= 0 && next[abs] !== prevRaws[src])) { - built[j] = bindRow(abs); + built[j] = bindRow(patchProxyFor(subject, next[abs], abs)); if (builtBodies !== null) builtBodies[j] = lastBodies!; builtUnbinds[j] = lastUnbinds!; } @@ -442,11 +448,11 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // 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): void => { + const rebuildSlot = (i: number, rec: any): void => { const old = entries[i] as ChildNode; let node: Node; try { - node = bindRow(i); + 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 @@ -480,7 +486,7 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? return; } if (refRebuild) { - rebuildSlot(i); + rebuildSlot(i, next); prevRaws[i] = next; return; } @@ -578,7 +584,7 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // read; the commit pass force-applies, keeping DOM writes in the effect // phase where transitions and batching expect them. export const patchDriver = (subject, body, keys?: string[]) => { - const raw = patchableRaw(subject); + 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 diff --git a/packages/web/test/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx index 658f44bfb..87df11c63 100644 --- a/packages/web/test/for.patchinvariants.spec.tsx +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -208,7 +208,78 @@ describe("INVARIANT: a throwing row build leaves DOM, bookkeeping, and sibling r }); }); +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: 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" }] } diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 73aadf519..8636338d6 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -327,8 +327,12 @@ module.exports = [ // // 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. path: "hydrating-store-app.js", - limit: "26.35 KB", + limit: "26.45 KB", modifyEsbuildConfig }, { @@ -376,8 +380,11 @@ module.exports = [ // 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. path: "csr-app-patch.js", - limit: "15.55 KB", + limit: "15.8 KB", modifyEsbuildConfig }, { @@ -405,8 +412,13 @@ module.exports = [ // 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. path: "csr-app-patch-lists.js", - limit: "17.85 KB", + limit: "18.2 KB", modifyEsbuildConfig }, { From f04c2805b4b6ab9cf40841e27a8ee5ebe0ed1acd Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 00:01:57 -0700 Subject: [PATCH 07/56] fix: re-audit-9 findings (11/11), harness-first Committed-visible skip markers + held-view admission, optimistic-view mounts, manifest-read fallback compute (write-free), tentative self emission + immediate lane demotion, per-queue forced stamps + merge repair, isWrappable binds, server patch-tier exports, function-carrier probes, safe-int keys, unchanged-reconcile bubble gate. Attribution test warn-count flake fixed two-sided. dbmon 6.3/2.1/0.6 (parity with r7/r8). Co-authored-by: Cursor --- .changeset/fix-patch-channel-audit9.md | 17 ++ packages/babel-plugin/src/shared/patch.ts | 5 +- .../__tests__/renderer-contract.test.js | 14 ++ packages/compiler/src/shared/patch.rs | 5 +- packages/signals/AUDIT-BRIEF-R6.md | 53 +++- packages/signals/src/core/scheduler.ts | 18 +- packages/signals/src/store/next/patch.ts | 79 ++++-- packages/signals/src/store/next/reconcile.ts | 29 ++- packages/signals/src/store/next/store.ts | 4 +- packages/signals/tests/attribution.test.ts | 6 +- .../tests/store/patch-invariants.test.ts | 238 +++++++++++++++++- packages/universal/src/universal.ts | 36 ++- packages/web/src/patch-driver.ts | 42 +++- packages/web/src/server.ts | 15 +- .../web/test/for.patchinvariants.spec.tsx | 102 ++++++++ scripts/size/.size-limit.js | 27 +- 16 files changed, 627 insertions(+), 63 deletions(-) create mode 100644 .changeset/fix-patch-channel-audit9.md 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/packages/babel-plugin/src/shared/patch.ts b/packages/babel-plugin/src/shared/patch.ts index 4ffcc2f25..598d97fff 100644 --- a/packages/babel-plugin/src/shared/patch.ts +++ b/packages/babel-plugin/src/shared/patch.ts @@ -44,7 +44,10 @@ function isEligibleExpr(node: t.Node, subject: string, asMemberBase = false): bo // (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.isInteger(m.property.value)) { + } 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)) { diff --git a/packages/compiler/__tests__/renderer-contract.test.js b/packages/compiler/__tests__/renderer-contract.test.js index 4b3b550cc..9c3894b79 100644 --- a/packages/compiler/__tests__/renderer-contract.test.js +++ b/packages/compiler/__tests__/renderer-contract.test.js @@ -133,6 +133,20 @@ describe("compiled imports ⊆ documented runtime surface", () => { 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. diff --git a/packages/compiler/src/shared/patch.rs b/packages/compiler/src/shared/patch.rs index 70308658d..29c3eaf6a 100644 --- a/packages/compiler/src/shared/patch.rs +++ b/packages/compiler/src/shared/patch.rs @@ -41,7 +41,10 @@ fn is_eligible_expr(node: &Expression<'_>, subject: &str, as_member_base: bool) } } Expression::NumericLiteral(lit) => { - if lit.value.fract() != 0.0 { + // 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; } } diff --git a/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 328cf540a..e71896549 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,4 +1,55 @@ -# Audit brief — rounds 6–8 + patch-mode default flip +# Audit brief — rounds 6–9 + patch-mode default flip + +## 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) diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 7446c9980..7a313728e 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -237,7 +237,13 @@ function mergeTransitionState(target: Transition, outgoing: Transition): void { dest = (target as any)._heldPatches = heldPatches; 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; + if (pc !== undefined) { + if (pc.qe === heldPatches[i]) pc.qa = dest; + // Forced stamps follow their container too (re-audit 9, P2): + // a stale qf lets the next bubble stage a duplicate twin. + if ((heldPatches[i] as any).force === true && (heldPatches[i] as any).fq !== "o") + pc.qf = dest; + } } } else { // COALESCE same-channel collisions (re-audit 6, P1-2): a record that @@ -254,6 +260,16 @@ function mergeTransitionState(target: Transition, outgoing: Transition): void { for (let i = 0; i < heldPatches.length; i++) { const entry: any = heldPatches[i]; const pc = entry.pc; + if (entry.force === true) { + // Forced entries dedupe per channel across the merge and retarget + // their stamp (re-audit 9, P2). + if (pc !== undefined && entry.fq !== "o") { + if (pc.qf === dest) continue; // destination already staged one + pc.qf = dest; + } + dest.push(entry); + continue; + } const dup = pc !== undefined ? byPc.get(pc) : undefined; if (dup !== undefined) { dup.t = pc.t; // drain resolves next live: t.pb ?? t.v diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index d226739c9..640f4f4d4 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -32,7 +32,7 @@ import { type Transition } from "../../core/scheduler.js"; import type { Owner } from "../../core/types.js"; -import { $TARGET } from "../store.js"; +import { $TARGET, isWrappable } from "../store.js"; import { markDescendants, ownedRaw, type StoreNextTarget } from "./target.js"; import { installPatchHooks, installRowHooks, wrapRecordHook } from "./patch-hooks.js"; // One-way: reconcile emits through the hooks (never imports this module), @@ -40,7 +40,7 @@ import { installPatchHooks, installRowHooks, wrapRecordHook } from "./patch-hook import { emitSetterRowOps } from "./reconcile.js"; // Cycle with store.js is benign (established pattern above): both resolve at // call time, long after module initialization. -import { deepPathsPlain, targetIsPlain } from "./store.js"; +import { deepPathsPlain, targetIsPlain, targetKeysPlain } from "./store.js"; import type { DeepNode } from "./target.js"; import { InvariantHooks } from "../../core/invariants.js"; @@ -71,11 +71,13 @@ interface PatchEntry { interface QueuedApply { list: PatchEntry[]; /** Registration-generation watermark (re-audit 8, P2-6): captured at - * emission; consumers registered LATER (their initial apply read this or - * newer state) are skipped unless the entry crossed a transition release - * (`rl` — those consumers initialized from the PRE-commit view). */ + * emission; consumers registered LATER are skipped ONLY when the entry + * was emitted from ALREADY-COMMITTED state (`cm` — re-audit 9, P1-1: + * walk-in-setter and transition-held emissions carry uncommitted + * payloads, so late consumers read the OLD committed view and must + * receive the apply). */ g?: number; - rl?: boolean; + cm?: boolean; next: any; prev: any; force: boolean; @@ -133,7 +135,7 @@ function drainApplyQueue(): void { force, firstError, q[i].pc, - q[i].rl === true ? undefined : q[i].g + q[i].cm === true ? q[i].g : undefined ); } if (firstError !== UNSET) { @@ -305,10 +307,7 @@ 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++) { - held[i].rl = true; // post-release: late consumers saw the PRE-commit view - pushLive(held[i]); - } + for (let i = 0; i < held.length; i++) pushLive(held[i]); } function pushLive(item: QueuedApply): void { @@ -360,6 +359,8 @@ function push(item: QueuedApply): void { * quiet record retains nothing from its last batch. */ function pushSelf(pc: { qa: unknown; qe: unknown }, item: QueuedApply): void { item.g = regGen; + // Uncommitted payloads never skip late consumers (re-audit 9, P1-1). + if (item.cm === undefined) item.cm = activeTransition === null; const tx = activeTransition; let arr: QueuedApply[]; if (tx !== null) { @@ -403,8 +404,11 @@ function clearStamp(item: QueuedApply): void { pc.qeo = null; } if (item.force === true) { - (pc as any).qf = null; - (pc as any).qfo = null; + // Clear ONLY the stamp this entry holds (re-audit 9, P1-5): a lane + // drain clearing the SETTLE stamp let a second tentative walk stage a + // duplicate settle twin. + if ((item as any).fq === "o") (pc as any).qfo = null; + else (pc as any).qf = null; } } @@ -426,7 +430,8 @@ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { next, prev: ownedRaw.has(prev) ? clonePrev(prev) : prev, force: false, - t: null + t: null, + cm: next === t.v && t.ht === null }); // Bubbling: ancestors force-re-apply from their LIVE backing, resolved at // drain (privatization may clone it between now and then). @@ -486,6 +491,7 @@ export function emitPatchAncestorsOptimistic(t: StoreNextTarget, tx: unknown): v t: u }; item.g = regGen; + (item as any).fq = "o"; (item as any).pc = u.pc; optQueue.push(item); if (!scheduled) { @@ -506,6 +512,8 @@ export function emitPatchAncestorsOptimistic(t: StoreNextTarget, tx: unknown): v t: u }; settle.g = regGen; + settle.cm = false; // settle payload resolves live at commit + (settle as any).fq = "n"; (settle as any).pc = u.pc; held.push(settle); } @@ -526,7 +534,12 @@ export function emitPatchLocal(t: StoreNextTarget, next: any, prev: any): void { next, prev: ownedRaw.has(prev) ? clonePrev(prev) : prev, force: false, - t: null + t: null, + // Committed-VISIBLE payloads only (re-audit 9, P1-1): setter drafts + // (next !== t.v) and speculative adoptions under a hold (t.ht) are + // invisible to a mounting consumer's initial read — skipping it + // would strand it stale forever. + cm: next === t.v && t.ht === null }); } @@ -553,6 +566,8 @@ function drainOptimistic(): void { if (q[i].ops !== undefined || q[i].si !== undefined) firstError = applyStructural(q[i], next, firstError); else if (force && t !== null && deepProbeFails(t, next)) demoteToEffects(t); + else if (optProbeFails(q[i], next)) + demoteToEffects((q[i].pc as any).t as StoreNextTarget, true); else firstError = applyEntries(liveValueList(q[i]), next, prev, force, firstError, q[i].pc); } if (firstError !== UNSET) { @@ -561,6 +576,15 @@ function drainOptimistic(): void { } } +/** Optimistic non-forced payloads probe before applying (re-audit 9, + * P1-4): tentative replacements never pass an adoption gate — a nested + * getter in the tentative view would be read raw, untracked. */ +function optProbeFails(item: QueuedApply, next: any): boolean { + const pc = item.pc as unknown as { t: StoreNextTarget; ak: PropertyKey[] | null } | undefined; + if (pc === undefined || next === null || typeof next !== "object") return false; + return !targetKeysPlain(pc.t, next); +} + 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; @@ -790,7 +814,7 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable) : t.v; // 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. @@ -881,11 +906,15 @@ 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 { +export function demoteToEffects(t: StoreNextTarget, immediate = false): void { 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 = () => { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (entry.owner !== null && isDisposed(entry.owner)) continue; @@ -903,7 +932,9 @@ export function demoteToEffects(t: StoreNextTarget): void { ) ); } - }); + }; + if (immediate) redrive(); + else globalQueue.enqueue(EFFECT_RENDER, redrive); } // --------------------------------------------------------------------------- diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index dcc251d00..175ab71c0 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -65,7 +65,9 @@ export function reconcileNextState( key: string | KeyFn | null | undefined, replace = false ): void { - const tentative = reconcileTop(value, state, key, replace); + 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 @@ -87,7 +89,7 @@ function reconcileTop( state: any, key: string | KeyFn | null | undefined, replace = false -): boolean { +): "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) @@ -105,9 +107,9 @@ function reconcileTop( // store's existing subscribers of the swap. if (replace && value !== state && value?.[$TARGET] !== undefined) { const prev = t.pb ?? t.v; - if (prev === value) return false; // already chained to this store + if (prev === value) return "unchanged"; // already chained to this store adoptPB(t, value); - return false; + return "changed"; } const incoming = unwrapValue(value); if (keyFn) { @@ -129,7 +131,7 @@ function reconcileTop( // resolving to this proxy; re-handed later it wraps fresh. (t.fam?.map ?? storeNextLookup).delete(t.pb ?? t.v); adoptPB(t, incoming); - return false; + return "changed"; } } // Tentative channel (§6b, RUL-5): a user-context reconcile on an optimistic @@ -139,10 +141,23 @@ function reconcileTop( // existing child targets instead of overriding their parent slots. if (t.fam?.opt === true && !projectionWriteActive && !getWriteOverride()) { optHooks!.applyTentative(t, incoming, keyFn); - return true; + // 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 — emit the TENTATIVE VIEW at lane timing + // (the optimistic drain's accessor probe demotes getter-bearing views + // instead of reading them raw). + if (patchHooks !== null && t.pc !== null && t.pc.p !== null) { + const view = optHooks!.optimisticView(t, t.pb ?? t.v); + patchHooks.emitPatchOptimistic(t, view, 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 false; + return "changed"; } function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj = false): void { diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index f8062ad05..4984fb2be 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -486,7 +486,9 @@ function deepNodePlain(node: DeepNode, parent: any, rootProbed: boolean): boolea const children = node.c; if (children === null) return true; // leaf: the key probe was the work let o: any = parent[node.k]; - if (o === null || typeof o !== "object") return true; + // 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; if (!isPlainProto(o)) return false; 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/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 35291272b..f9c60aed6 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -7,7 +7,7 @@ * green; a new audit finding here means the invariant statement itself was * wrong or missing, and the fix must extend the harness FIRST. */ -import { describe, expect, it } from "vitest"; +import { beforeAll, afterAll, describe, expect, it, vi } from "vitest"; import { action, createRoot, @@ -16,9 +16,21 @@ import { flush, reconcile, registerPatch, - patchableRaw + 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 @@ -145,6 +157,60 @@ describe("INVARIANT: patch applications mirror effect runs (parity oracle), rega }); }); +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 } = 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[] = []; + let dispose!: () => void; + createRoot(d => { + dispose = d; + registerPatch(state.row, (n: any) => log.push(String(n.meta?.label)), ["meta.label"]); + }); + 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's outside dependency must keep applying while the + // tentative view was live — an untracked raw read renders once and + // goes silently stale. + expect(inFlight).toBe("g0"); + expect(afterDep).toBe("g1"); + 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"); @@ -167,13 +233,92 @@ describe("INVARIANT: optimistic visibility covers the whole read envelope, ances }); 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 now, not at settle. - expect(log[log.length - 1]).toBe("opt"); + // 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(log[log.length - 1]).toBe("m0"); // revert re-applies committed + 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(); }); }); @@ -205,6 +350,89 @@ describe("INVARIANT: one forced ancestor application per flush (effect parity)", 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)", () => { diff --git a/packages/universal/src/universal.ts b/packages/universal/src/universal.ts index 3abd941d3..54ae50802 100644 --- a/packages/universal/src/universal.ts +++ b/packages/universal/src/universal.ts @@ -435,13 +435,35 @@ export function createRenderer({ // (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) { - effect( - () => body(subject, subject, false), - () => { - untrack(() => body(subject, undefined, true)); - } - ); + 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 — diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index cd0260d43..defd6b719 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -596,7 +596,16 @@ export const patchDriver = (subject, body, keys?: string[]) => { // 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. - if (!sharedConfig.hydrating) body(raw, undefined, true); + // + // 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). + if (!sharedConfig.hydrating) { + const src = storeHasOptimisticFamily(subject) ? subject : raw; + untrack(() => body(src, undefined, true)); + } unbind = registerPatch(subject, body, keys); } else if (!sharedConfig.hydrating) { // Manifest-less callers (hand-written registrations): record the @@ -625,14 +634,31 @@ export const patchDriver = (subject, body, keys?: string[]) => { } 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/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx index 87df11c63..42874fd1f 100644 --- a/packages/web/test/for.patchinvariants.spec.tsx +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -208,6 +208,108 @@ describe("INVARIANT: a throwing row build leaves DOM, bookkeeping, and sibling r }); }); +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 => { diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 8636338d6..ab7a3aaf4 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -75,7 +75,10 @@ module.exports = [ // mergeTransitionState (both stashes holding the same record's entry // now collapse to one live-resolving entry). Core-retained; measured // 7.91. - limit: "7.95 KB", + // + // Re-audit-9 (2026-08-29): forced-entry dedup + stamp retargeting in + // the merge path. Measured 7.92. + limit: "8 KB", modifyEsbuildConfig }, { @@ -171,7 +174,11 @@ module.exports = [ // (deepPathsPlain), split normal/optimistic stamps, and the reconcile // root ancestor bubble — all on store paths createStore retains. // Measured 14.58. - limit: "14.7 KB", + // + // 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. + limit: "14.9 KB", modifyEsbuildConfig }, { @@ -350,8 +357,11 @@ 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). + // + // Re-audit-9 (2026-08-29): the merge-path core bytes (see core floor). + // Measured 12.90. path: "csr-app.js", - limit: "12.9 KB", + limit: "13 KB", modifyEsbuildConfig }, { @@ -383,8 +393,12 @@ module.exports = [ // // 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. path: "csr-app-patch.js", - limit: "15.8 KB", + limit: "16.1 KB", modifyEsbuildConfig }, { @@ -417,8 +431,11 @@ module.exports = [ // 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. path: "csr-app-patch-lists.js", - limit: "18.2 KB", + limit: "18.6 KB", modifyEsbuildConfig }, { From 996768738e291716c9bb1965f8457a2369cbd658 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 00:58:34 -0700 Subject: [PATCH 08/56] experiment: node-driven patch delivery prototype (flag-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One bare version signal per record (pc.dn) bumped at emission seams; the driver's delivery effect applies compiled bodies with post-apply prev snapshots and scan-free committed reads. dbmon: tick/partial parity with the channel (2.1-2.2 / 0.6 both orders), mount +0.5-1ms (per-row root creation, optimizable). The queue/stamp/skip machinery is untouched but unused under the flag — candidacy evidence for deleting it. Co-authored-by: Cursor --- .changeset/node-delivery-prototype.md | 9 +++++ packages/signals/src/store/index.ts | 4 +- packages/signals/src/store/next/patch.ts | 41 +++++++++++++++++++- packages/signals/src/store/next/reconcile.ts | 2 +- packages/signals/src/store/next/store.ts | 3 +- packages/signals/src/store/next/target.ts | 4 ++ packages/solid/src/index.ts | 2 + packages/solid/src/server/index.ts | 2 + packages/solid/src/server/signals.ts | 6 +++ packages/web/src/patch-driver.ts | 41 +++++++++++++++++++- 10 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 .changeset/node-delivery-prototype.md 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/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 4d0472704..718b7551e 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -35,7 +35,9 @@ export { registerRowOps, registerSlotPatchNext as registerSlotPatch, patchableRaw, - patchProxyFor + patchCommittedRaw, + patchProxyFor, + patchVersion } from "./next/patch.js"; export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily } from "./next/store.js"; export { createOptimisticStoreNext as createOptimisticStore } from "./next/optimistic.js"; diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 640f4f4d4..b6fe401d1 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -20,7 +20,7 @@ * never schedule the queue. */ import { EFFECT_RENDER, STATUS_ERROR } from "../../core/constants.js"; -import { ext } from "../../core/core.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"; @@ -423,6 +423,7 @@ 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 { + if (t.pc !== null) bumpDelivery(t.pc as any); const p = (t.pc !== null ? t.pc.p : null) as PatchEntry[] | null; if (p !== null) pushSelf(t.pc!, { @@ -447,6 +448,7 @@ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { export function emitPatchAncestors(t: StoreNextTarget): void { let u = t.u; while (u !== null) { + if (u.pc !== null) bumpDelivery(u.pc as any); const up = (u.pc !== null ? u.pc.p : null) as PatchEntry[] | null; if (up !== null) pushForced(u); u = u.u; @@ -527,6 +529,7 @@ export function emitPatchAncestorsOptimistic(t: StoreNextTarget, tx: unknown): v * 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 { + if (t.pc !== null) bumpDelivery(t.pc as any); const p = (t.pc !== null ? t.pc.p : null) as PatchEntry[] | null; if (p !== null) pushSelf(t.pc!, { @@ -741,6 +744,42 @@ function unionKeys( } } +/** NODE-DELIVERY PROTOTYPE: tracked read of the record's version signal. + * Creating it counts toward hasPatches() so write-path gates arm. */ +export function patchVersion(record: any): void { + let t: StoreNextTarget | undefined = record?.[$TARGET]; + if (t === undefined) return; + t = ultimateTarget(t) ?? t; + const pc = pcOf(t); + if (pc.dn === null) { + pc.dn = signal(0, { equals: false }); + patchCount++; + markDescendants(t); + if (!commitHookInstalled) { + commitHookInstalled = true; + armPatchHooks(); + setPatchCommitHook(releaseBatch); + GlobalQueue._drainPatchOptimistic = drainOptimistic; + } + } + readSignal(pc.dn as any); +} + +function bumpDelivery(pc: { dn: unknown }): void { + if (pc.dn !== null) setSignal(pc.dn as any, (v: number) => v + 1); +} + +/** NODE-DELIVERY PROTOTYPE: held-aware committed backing WITHOUT admission + * scans — the emission-seam gates own accessor soundness; per-delivery + * re-probing doubled the probe bill. */ +export function patchCommittedRaw(record: any): Record | undefined { + let t: StoreNextTarget | undefined = record?.[$TARGET]; + if (t === undefined) return undefined; + t = ultimateTarget(t) ?? t; + if (t === undefined) return undefined; + return t.ht !== null ? ((t.hv ?? t.v) as Record) : t.v; +} + export function registerPatch(record: any, fn: PatchFn, keys?: Iterable): () => void { let t: StoreNextTarget | undefined = record?.[$TARGET]; if (t === undefined) throw new Error("registerPatch: not a store record"); diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 175ab71c0..725fb85ba 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -183,7 +183,7 @@ 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) { + 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 diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 4984fb2be..584b51765 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -161,6 +161,7 @@ export function pcOf(t: StoreNextTarget): PatchChannel { qeo: null, qf: null, qfo: null, + dn: null, ak: null, dp: null, ks: false, @@ -898,7 +899,7 @@ function drainFolds(): void { Array.isArray(old) ) rowHooks!.emitSetterRowOps(t, old as any[], t.v as any[]); - if (t.pc.p !== null) { + 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 diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 0553caf8c..342aaa2f9 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -84,6 +84,10 @@ export interface PatchChannel { * and optimistic (qfo). One forced re-apply per container per batch. */ qf: unknown; qfo: unknown; + /** NODE-DELIVERY PROTOTYPE: bare per-record version signal — bumped at + * the same emission seams, read (tracked) by the driver's delivery + * effect. No walk, no payloads; timing rides the scheduler. */ + dn: unknown; /** 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 diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 17a06b9a6..68c6c24c1 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -26,7 +26,9 @@ export { latest, // Patch-channel compiler contract (undocumented as application API) patchableRaw, + patchCommittedRaw, patchProxyFor, + patchVersion, registerPatch, registerRowOps, registerSlotPatch, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 49d9acd9b..f2418bde2 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -39,7 +39,9 @@ export { // Patch-channel compiler contract (parity with the client entry; the // channel is inert on the server — SSR renders once, hydration claims) patchableRaw, + patchCommittedRaw, patchProxyFor, + patchVersion, registerPatch, registerRowOps, registerSlotPatch, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 1d90b7d93..6ee5011c9 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -2840,6 +2840,12 @@ export function patchProxyFor(_list: any, raw: any): any { return raw; // SSR renders once from whatever it is handed } +export function patchVersion(_record: any): void {} + +export function patchCommittedRaw(_record: any): undefined { + return undefined; +} + export function patchableRaw(_record: any): undefined { return undefined; } diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index defd6b719..2f91b44e4 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -19,7 +19,10 @@ import { sharedConfig, storeHasOptimisticFamily, storeIsShallow, - untrack + untrack, + createRoot, + patchVersion, + patchCommittedRaw } from "solid-js"; import { effect } from "./render.js"; import { installListDriver } from "./client.js"; @@ -585,6 +588,42 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // phase where transitions and batching expect them. export const patchDriver = (subject, body, keys?: string[]) => { const raw = patchableRaw(subject, keys); + // ── NODE-DELIVERY PROTOTYPE (design experiment, flag-gated) ── + // Delivery rides ONE hidden node per record: the store's own deep- + // tracking signal (`deep()` — bumped by every write path with scheduler + // semantics the store already owns: transitions, holds, lanes, merges). + // The compiled body is unchanged; `prev` is a post-apply snapshot. No + // queues, no stamps, no skip rules — timing is effect timing by + // construction. Admission (accessor safety via the manifest) unchanged. + if ((globalThis as any).__PATCH_NODE__ === true && raw !== undefined) { + let prev: any; + let first = true; + // Own root: row severing must dispose THIS record's delivery effect + // individually (the structural cost of node delivery — one disposable + // owner + effect per registration; a leaner disposable-effect + // primitive is the optimization if this design wins). + let disposer!: () => void; + createRoot(d => { + disposer = d; + effect( + () => { + patchVersion(subject); // ONE tracked edge: bare version signal + }, + () => { + const next = patchCommittedRaw(subject) ?? raw; + untrack(() => body(next, prev, first)); + first = false; + // Post-apply snapshot: in-place folds reuse the backing object, + // so compares need a stable prev. (Optimization headroom: + // manifest-key-only snapshots.) + prev = Array.isArray(next) ? (next as any[]).slice() : { ...(next as object) }; + } + ); + }); + if (rowCollector !== null) rowCollector.unbinds.push(disposer); + else onCleanup(disposer); + return; + } if (raw !== undefined) { // Hydration is claim + register ONLY (DESIGN-PATCH-CHANNEL §5): the // server HTML already carries current values, so the initial force-apply From 8e1801f79dc9ac68415276cc1f2f27f237dd650f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 01:56:57 -0700 Subject: [PATCH 09/56] =?UTF-8?q?experiment(WIP):=20node-delivery=20port?= =?UTF-8?q?=20=E2=80=94=20delivery=20effect=20in=20registerPatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Value tier fully node-driven: per-channel render effect (createRoot-owned, disposed with last entry) dispatches applyEntries with manifest-shaped prev snapshots and a single visibleView() decision (optimistic view / held mask / committed). Value queues, stamps, cm/gen skip rules deleted; structural queue kept slim. Types clean, builds green. HARNESS TRIAGE PENDING (~24 red): known classes — (a) tests asserting queue-era mechanisms (force flags on ancestor re-applies — semantics hold, compares are exact now); (b) REAL defects to diagnose: structural tests red (row-ops path or reconcile gates), same-batch coalescing red, error routing through the detached delivery root (drain used haltReactivity; effect throw surfaces differently), optimistic in-flight timing. NOT MERGEABLE — checkpoint for fresh-eyes triage. Co-authored-by: Cursor --- .changeset/node-delivery-port-wip.md | 8 + packages/signals/src/store/next/patch.ts | 358 +++++++--------------- packages/signals/src/store/next/target.ts | 12 +- packages/web/src/patch-driver.ts | 36 --- 4 files changed, 120 insertions(+), 294 deletions(-) create mode 100644 .changeset/node-delivery-port-wip.md 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/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index b6fe401d1..06a4e9cfe 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -35,6 +35,7 @@ import type { Owner } from "../../core/types.js"; import { $TARGET, isWrappable } from "../store.js"; import { markDescendants, ownedRaw, 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"; @@ -47,6 +48,7 @@ import { InvariantHooks } from "../../core/invariants.js"; import { assertInvariant } from "../../core/dev.js"; import { runWithOwner, untrack } from "../../core/core.js"; import { createRenderEffect } from "../../signals.js"; +import { createRoot } from "../../core/owner.js"; // Cycle with store.js is benign: pcOf is only called at registration time, // long after both modules initialize. import { pcOf } from "./store.js"; @@ -126,17 +128,6 @@ function drainApplyQueue(): void { const next = t !== null ? (force ? forcedNext(t) : (t.pb ?? t.v)) : q[i].next; if (q[i].ops !== undefined || q[i].si !== undefined) firstError = applyStructural(q[i], next, firstError); - else if (force && t !== null && deepProbeFails(t, next)) demoteToEffects(t); - else - firstError = applyEntries( - liveValueList(q[i]), - next, - prev, - force, - firstError, - q[i].pc, - q[i].cm === true ? q[i].g : undefined - ); } if (firstError !== UNSET) { // Unhandled patch errors HALT like unhandled effect errors (re-audit 2, @@ -146,16 +137,6 @@ function drainApplyQueue(): void { } } -/** Deep-path demotion at FORCED applies (re-audit 7, P1-1 nested half): - * ancestor bubbles re-read whole chains from the live backing — a getter - * that arrived at a nested step through a TARGETED child adoption has no - * root adoption gate to catch it, so the forced apply is the seam. Costs - * one null check for channels without deep paths. */ -function deepProbeFails(t: StoreNextTarget, next: any): boolean { - const dp = t.pc !== null ? t.pc.dp : null; - return dp !== null && next !== null && typeof next === "object" && !deepPathsPlain(dp, next); -} - /** 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 @@ -166,21 +147,6 @@ function forcedNext(t: StoreNextTarget): any { return t.pc !== null && t.pc.dp !== null ? t.px : (t.pb ?? t.v); } -const EMPTY_LIST: PatchEntry[] = []; - -/** VALUE entries dispatch to the channel's CURRENT consumer list (re-audit - * 7, P2-9): applications are absolute (latest state), so they belong to - * whoever is registered at drain time — a consumer list recreated while the - * entry was transition-held (or coalesced across a merge) must receive the - * commit, and a fully-unbound channel receives nothing. Entries without a - * channel backref (none exists today) keep their captured list. Structural - * entries are the DUAL — baseline-relative, snapshotted at emission. */ -function liveValueList(item: QueuedApply): PatchEntry[] { - const pc = item.pc ?? (item.t !== null ? (item.t.pc as QueuedApply["pc"]) : undefined); - if (pc == null) return item.list; - return (pc.p as PatchEntry[] | null) ?? EMPTY_LIST; -} - /** 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 @@ -229,8 +195,7 @@ function applyEntries( prev: any, force: boolean, firstError: unknown, - pc?: { ak: PropertyKey[] | null }, - gen?: number + pc?: { ak: PropertyKey[] | null } ): 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 — @@ -245,11 +210,6 @@ function applyEntries( for (let j = 0; j < len; j++) { const entry = snap[j]; if (entry === undefined || entry.u === true) continue; - // Generation skip (re-audit 8, P2-6): a consumer registered AFTER this - // entry's emission initialized from its state (or newer) — re-applying - // is an observable duplicate setter call. Transition releases exempt - // themselves (`rl`): their late consumers saw the PRE-commit view. - if (gen !== undefined && entry.gen !== undefined && entry.gen > gen) continue; // Disposed owners drop their patches (the row unmounted mid-flush). if (entry.owner !== null && isDisposed(entry.owner)) continue; try { @@ -311,25 +271,7 @@ function releaseBatch(batch: Transition): void { } function pushLive(item: QueuedApply): void { - if (queue === null) queue = []; - // Same-drain coalescing for RELEASED entries (re-audit 7): two - // transitions settling in one flush each release a held entry for the - // same channel — an effect on that record runs ONCE for the flush, so - // the channel applies once (earliest prev, latest/live next). pushSelf - // handles same-batch writes; this is its cross-release twin. - const pc = (item as any).pc as { qa: unknown; qe: QueuedApply | null } | undefined; - if (pc !== undefined && !item.force) { - if (pc.qa === queue && pc.qe !== null) { - const qe = pc.qe; - qe.next = item.next; - qe.list = item.list; - if (item.t !== null) qe.t = item.t; - return; - } - pc.qa = queue; - pc.qe = item; - } - queue.push(item); + (queue ??= []).push(item); if (!scheduled) { scheduled = true; globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); @@ -337,12 +279,9 @@ function pushLive(item: QueuedApply): void { } function push(item: QueuedApply): void { - item.g = regGen; 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); @@ -357,60 +296,8 @@ 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 { - item.g = regGen; - // Uncommitted payloads never skip late consumers (re-audit 9, P1-1). - if (item.cm === undefined) item.cm = activeTransition === null; - 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. Clears whichever stamp pair - * (normal or optimistic) this entry holds. */ -function clearStamp(item: QueuedApply): void { - const pc = (item as any).pc as - | { qa: unknown; qe: unknown; qo: unknown; qeo: unknown } - | undefined; - if (pc === undefined) return; - if (pc.qe === item) { - pc.qa = null; - pc.qe = null; - } else if (pc.qeo === item) { - pc.qo = null; - pc.qeo = null; - } - if (item.force === true) { - // Clear ONLY the stamp this entry holds (re-audit 9, P1-5): a lane - // drain clearing the SETTLE stamp let a second tentative walk stage a - // duplicate settle twin. - if ((item as any).fq === "o") (pc as any).qfo = null; - else (pc as any).qf = null; - } -} +function clearStamp(_item: QueuedApply): void {} /** 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. */ @@ -423,19 +310,7 @@ 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 { - if (t.pc !== null) bumpDelivery(t.pc as any); - 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, - cm: next === t.v && t.ht === null - }); - // Bubbling: ancestors force-re-apply from their LIVE backing, resolved at - // drain (privatization may clone it between now and then). + if (t.pc !== null) bumpDelivery(t.pc); emitPatchAncestors(t); } @@ -448,79 +323,20 @@ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { export function emitPatchAncestors(t: StoreNextTarget): void { let u = t.u; while (u !== null) { - if (u.pc !== null) bumpDelivery(u.pc as any); - const up = (u.pc !== null ? u.pc.p : null) as PatchEntry[] | null; - if (up !== null) pushForced(u); + if (u.pc !== null) bumpDelivery(u.pc); u = u.u; } } -function pushForced(u: StoreNextTarget): void { - const pc = u.pc! as unknown as { qf: unknown }; - const tx = activeTransition; - const arr = tx !== null ? (((tx as any)._heldPatches ??= []) as QueuedApply[]) : (queue ??= []); - if (pc.qf === arr) return; // already forced into this container this batch - pc.qf = arr; - const item: QueuedApply = { list: EMPTY_LIST, next: null, prev: null, force: true, t: u }; - item.g = regGen; - (item as any).pc = u.pc; - arr.push(item); - if (arr === queue && !scheduled) { - scheduled = true; - globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); - } -} - /** Tentative (optimistic) ancestor bubble (re-audit 8, P1-3): in-flight * visibility rides the LANE queue — and the SAME forced entries are staged * on the transaction for settle (revert restores committed truth to * ancestor expressions; landings show the landed state). Both resolve * live at their drains. */ -export function emitPatchAncestorsOptimistic(t: StoreNextTarget, tx: unknown): void { +export function emitPatchAncestorsOptimistic(t: StoreNextTarget, _tx: unknown): void { let u = t.u; while (u !== null) { - const up = (u.pc !== null ? u.pc.p : null) as PatchEntry[] | null; - if (up !== null) { - const pc = u.pc! as unknown as { qfo: unknown }; - if (pc.qfo !== optQueue || optQueue === null) { - if (optQueue === null) optQueue = []; - pc.qfo = optQueue; - const item: QueuedApply = { - list: EMPTY_LIST, - next: null, - prev: null, - force: true, - t: u - }; - item.g = regGen; - (item as any).fq = "o"; - (item as any).pc = u.pc; - optQueue.push(item); - if (!scheduled) { - scheduled = true; - globalQueue.enqueue(EFFECT_RENDER, drainApplyQueue); - } - } - if (tx !== null) { - const held = ((tx as any)._heldPatches ??= []) as QueuedApply[]; - const pcH = u.pc! as unknown as { qf: unknown }; - if (pcH.qf !== held) { - pcH.qf = held; - const settle: QueuedApply = { - list: EMPTY_LIST, - next: null, - prev: null, - force: true, - t: u - }; - settle.g = regGen; - settle.cm = false; // settle payload resolves live at commit - (settle as any).fq = "n"; - (settle as any).pc = u.pc; - held.push(settle); - } - } - } + if (u.pc !== null) bumpDeliveryOptimistic(u.pc); u = u.u; } } @@ -529,21 +345,7 @@ export function emitPatchAncestorsOptimistic(t: StoreNextTarget, tx: unknown): v * 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 { - if (t.pc !== null) bumpDelivery(t.pc as any); - 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, - // Committed-VISIBLE payloads only (re-audit 9, P1-1): setter drafts - // (next !== t.v) and speculative adoptions under a hold (t.ht) are - // invisible to a mounting consumer's initial read — skipping it - // would strand it stale forever. - cm: next === t.v && t.ht === null - }); + if (t.pc !== null) bumpDelivery(t.pc); } /** Optimistic-channel emission: overrides are visible THIS flush while the @@ -568,10 +370,6 @@ function drainOptimistic(): void { const next = t !== null ? (force ? forcedNext(t) : (t.pb ?? t.v)) : q[i].next; if (q[i].ops !== undefined || q[i].si !== undefined) firstError = applyStructural(q[i], next, firstError); - else if (force && t !== null && deepProbeFails(t, next)) demoteToEffects(t); - else if (optProbeFails(q[i], next)) - demoteToEffects((q[i].pc as any).t as StoreNextTarget, true); - else firstError = applyEntries(liveValueList(q[i]), next, prev, force, firstError, q[i].pc); } if (firstError !== UNSET) { haltReactivity(firstError); @@ -579,44 +377,8 @@ function drainOptimistic(): void { } } -/** Optimistic non-forced payloads probe before applying (re-audit 9, - * P1-4): tentative replacements never pass an adoption gate — a nested - * getter in the tentative view would be read raw, untracked. */ -function optProbeFails(item: QueuedApply, next: any): boolean { - const pc = item.pc as unknown as { t: StoreNextTarget; ak: PropertyKey[] | null } | undefined; - if (pc === undefined || next === null || typeof next !== "object") return false; - return !targetKeysPlain(pc.t, next); -} - 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) — on the - // DEDICATED optimistic stamp pair (re-audit 7, P2-3): the lane queue - // must not clobber the normal channel's qa/qe mid-batch. - const pc = t.pc! as unknown as { qo: unknown; qeo: unknown }; - if (pc.qo === optQueue && pc.qeo !== null) { - const qe = pc.qeo as QueuedApply; - qe.next = next; - qe.list = p; - } else { - const item: QueuedApply = { list: p, next, prev, force: false, t: null }; - pc.qo = optQueue; - pc.qeo = item; - (item as any).pc = t.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); - } + if (t.pc !== null) bumpDeliveryOptimistic(t.pc); } /** Row-ops emission at OPTIMISTIC (lane) timing: user drafts on an @@ -765,10 +527,6 @@ export function patchVersion(record: any): void { readSignal(pc.dn as any); } -function bumpDelivery(pc: { dn: unknown }): void { - if (pc.dn !== null) setSignal(pc.dn as any, (v: number) => v + 1); -} - /** NODE-DELIVERY PROTOTYPE: held-aware committed backing WITHOUT admission * scans — the emission-seam gates own accessor soundness; per-delivery * re-probing doubled the probe bill. */ @@ -780,6 +538,88 @@ export function patchCommittedRaw(record: any): Record | undef return t.ht !== null ? ((t.hv ?? t.v) as Record) : t.v; } +/** 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. */ +function bumpDelivery(pc: any): void { + if (pc.dn !== null) setSignal(pc.dn, (v: number) => v + 1); +} + +function bumpDeliveryOptimistic(pc: any): void { + if (pc.dn === null) return; + // Override-armed write: in-flight visibility now, re-notify on revert — + // the engine is installed by every optimistic caller of this seam. + 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); +} + +/** 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): any { + if (t.fam?.opt === true && optHooks !== null) return optHooks.optimisticView(t, t.pb ?? t.v); + return t.ht !== null ? (t.hv ?? t.v) : t.v; +} + +function ensureDelivery(t: StoreNextTarget, pc: any): void { + if (pc.dn !== null) return; + const dn = (pc.dn = signal(0, { equals: false })); + pc.dv = 0; // last dispatched version — the pure-registration flush skips + pc.pv = manifestSnapshot(pc, visibleView(t)); + createRoot(d => { + pc.de = d; + createRenderEffect( + () => readSignal(dn) as number, + (v: number) => { + if (v === pc.dv) { + pc.pv = manifestSnapshot(pc, visibleView(t)); + return; + } + pc.dv = v; + const p = pc.p as PatchEntry[] | null; + if (p === null) return; // demoted or emptied — inert + const next = visibleView(t); + const prev = pc.pv; + const snap = p.length > 1 ? p.slice() : p; + let firstError: unknown = UNSET; + firstError = applyEntries(snap, next, prev, false, firstError, pc); + pc.pv = manifestSnapshot(pc, next); + if (firstError !== UNSET) throw firstError; + } + ); + }); +} + export function registerPatch(record: any, fn: PatchFn, keys?: Iterable): () => void { let t: StoreNextTarget | undefined = record?.[$TARGET]; if (t === undefined) throw new Error("registerPatch: not a store record"); @@ -827,6 +667,7 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable void)(); + pc.de = undefined; + pc.dn = null; + } + } }; } diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 342aaa2f9..9b29d7deb 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -84,10 +84,16 @@ export interface PatchChannel { * and optimistic (qfo). One forced re-apply per container per batch. */ qf: unknown; qfo: unknown; - /** NODE-DELIVERY PROTOTYPE: bare per-record version signal — bumped at - * the same emission seams, read (tracked) by the driver's delivery - * effect. No walk, no payloads; timing rides the scheduler. */ + /** Node delivery: bare per-record version signal — bumped at the + * emission seams, tracked by the channel's delivery effect. */ dn: unknown; + /** Delivery-effect root disposer (created with the first entry, disposed + * with the last). */ + de?: (() => void) | undefined; + /** Last dispatched version (the pure-registration flush skips). */ + dv?: number; + /** Manifest-shaped prev snapshot for exact compares. */ + pv?: unknown; /** 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 diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 2f91b44e4..33ce4e194 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -588,42 +588,6 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? // phase where transitions and batching expect them. export const patchDriver = (subject, body, keys?: string[]) => { const raw = patchableRaw(subject, keys); - // ── NODE-DELIVERY PROTOTYPE (design experiment, flag-gated) ── - // Delivery rides ONE hidden node per record: the store's own deep- - // tracking signal (`deep()` — bumped by every write path with scheduler - // semantics the store already owns: transitions, holds, lanes, merges). - // The compiled body is unchanged; `prev` is a post-apply snapshot. No - // queues, no stamps, no skip rules — timing is effect timing by - // construction. Admission (accessor safety via the manifest) unchanged. - if ((globalThis as any).__PATCH_NODE__ === true && raw !== undefined) { - let prev: any; - let first = true; - // Own root: row severing must dispose THIS record's delivery effect - // individually (the structural cost of node delivery — one disposable - // owner + effect per registration; a leaner disposable-effect - // primitive is the optimization if this design wins). - let disposer!: () => void; - createRoot(d => { - disposer = d; - effect( - () => { - patchVersion(subject); // ONE tracked edge: bare version signal - }, - () => { - const next = patchCommittedRaw(subject) ?? raw; - untrack(() => body(next, prev, first)); - first = false; - // Post-apply snapshot: in-place folds reuse the backing object, - // so compares need a stable prev. (Optimization headroom: - // manifest-key-only snapshots.) - prev = Array.isArray(next) ? (next as any[]).slice() : { ...(next as object) }; - } - ); - }); - if (rowCollector !== null) rowCollector.unbinds.push(disposer); - else onCleanup(disposer); - return; - } if (raw !== undefined) { // Hydration is claim + register ONLY (DESIGN-PATCH-CHANNEL §5): the // server HTML already carries current values, so the initial force-apply From df615ff878373c90845ed28cf2211d7e4853cd1a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 02:21:33 -0700 Subject: [PATCH 10/56] =?UTF-8?q?experiment(WIP):=20node-delivery=20triage?= =?UTF-8?q?=20=E2=80=94=2024=20red=20=E2=86=92=2011,=20four=20root=20cause?= =?UTF-8?q?s=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed tonight, each traced to first principles: - INV-2 violation: the delivery signal's override slot must be armed (NOT_PENDING) without CONFIG_OPTIMISTIC — plain bumps stay held-write, optimistic bumps ride the engine with correct revert registration. - Channel error contract restored: unhandled patch errors defer the halt one queue phase so sibling channels' deliveries complete first (the round-2 pin); flush still throws. - visibleView reads optimistic families THROUGH THE PROXY — tentative values live in node overrides at any depth; root-level view merges miss children. - The delivery signal + bump/dispatch bookkeeping PERSIST across consumer churn (only the effect root lives with consumers): held write-time emissions ride the signal's pending commit and died with disposal. - Value bumps moved to FOLD COMMIT (post-swap) — write-time bumps raced transition settles, delivering pre-fold state. REMAINING (11): the fold-commit move exposed the deepest discrimination — in-place folds are identity-invisible at drainFolds (t.v === old continue), which is WHY the queue design emitted at write time. Correct fix sketch: wk (written-keys) evidence at the identity-continue + ambient/transition bump gating at the setter site. Plus: force-flag test modernizations, optimistic in-flight set, projection refetch. NOT MERGEABLE. Co-authored-by: Cursor --- packages/signals/src/store/next/patch.ts | 59 ++++++++++++++++++----- packages/signals/src/store/next/store.ts | 21 +++++--- packages/signals/src/store/next/target.ts | 4 +- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 06a4e9cfe..553e2ecb7 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -19,7 +19,7 @@ * Tree-shaking: core never imports this module; stores without patches * never schedule the queue. */ -import { EFFECT_RENDER, STATUS_ERROR } from "../../core/constants.js"; +import { 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"; @@ -544,13 +544,19 @@ export function patchCommittedRaw(record: any): Record | undef * manifest-shaped prev snapshot. Timing — transitions, holds, lanes, * merges, mount order — is scheduler-owned by construction. */ function bumpDelivery(pc: any): void { - if (pc.dn !== null) setSignal(pc.dn, (v: number) => v + 1); + if (pc.dn === 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.bc ?? 0) + 1; + setSignal(pc.dn, (v: number) => v + 1); } function bumpDeliveryOptimistic(pc: any): void { if (pc.dn === null) 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.bc ?? 0) + 1; 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); @@ -587,25 +593,43 @@ function snapNode(node: DeepNode, src: any, dst: any): void { * 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): any { - if (t.fam?.opt === true && optHooks !== null) return optHooks.optimisticView(t, t.pb ?? t.v); + // Optimistic families read THROUGH THE PROXY: tentative values live in + // node overrides at ANY depth (a root-level view merge misses children), + // and untracked proxy reads resolve them all. Everyone else reads raw. + if (t.fam?.opt === true) return t.px; return t.ht !== null ? (t.hv ?? t.v) : t.v; } function ensureDelivery(t: StoreNextTarget, pc: any): void { - if (pc.dn !== null) return; - const dn = (pc.dn = signal(0, { equals: false })); - pc.dv = 0; // last dispatched version — the pure-registration flush skips - pc.pv = manifestSnapshot(pc, visibleView(t)); + if (pc.de !== undefined) return; + // The delivery SIGNAL and its bookkeeping persist across consumer churn + // (only the effect root lives with consumers): a write-time emission held + // by a transition rides the signal's pending commit — disposing the + // signal 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; + pc.dv = 0; // last dispatched bump count — the pure-registration flush skips + pc.bc = 0; + pc.pv = manifestSnapshot(pc, visibleView(t)); + } + const dn = pc.dn; createRoot(d => { pc.de = d; createRenderEffect( () => readSignal(dn) as number, - (v: number) => { - if (v === pc.dv) { + () => { + if (pc.bc === pc.dv) { pc.pv = manifestSnapshot(pc, visibleView(t)); return; } - pc.dv = v; + pc.dv = pc.bc; const p = pc.p as PatchEntry[] | null; if (p === null) return; // demoted or emptied — inert const next = visibleView(t); @@ -614,7 +638,17 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { let firstError: unknown = UNSET; firstError = applyEntries(snap, next, prev, false, firstError, pc); pc.pv = manifestSnapshot(pc, next); - if (firstError !== UNSET) throw firstError; + 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. + const err = firstError; + globalQueue.enqueue(EFFECT_USER, () => { + haltReactivity(err); + throw err; + }); + } } ); }); @@ -689,7 +723,8 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable void)(); pc.de = undefined; - pc.dn = null; + // dn/bc/dv/pv persist: held write-time emissions must survive + // consumer churn (see ensureDelivery). } } }; diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 584b51765..ab4fa4a06 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -907,6 +907,17 @@ function drainFolds(): void { 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). if (t.u && t.u.v[t.pk!] === old) { @@ -1069,13 +1080,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 diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 9b29d7deb..c90c088f6 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -90,8 +90,10 @@ export interface PatchChannel { /** Delivery-effect root disposer (created with the first entry, disposed * with the last). */ de?: (() => void) | undefined; - /** Last dispatched version (the pure-registration flush skips). */ + /** Last dispatched bump count (the pure-registration flush skips). */ dv?: number; + /** Synchronous bump counter (dedup; the signal is pure notification). */ + bc?: number; /** Manifest-shaped prev snapshot for exact compares. */ pv?: unknown; /** Accessed-key set for the channel's compiled bodies (union across From 53311df7a704320fefac72666163ec1a3c0ab724 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 10:50:27 -0700 Subject: [PATCH 11/56] experiment: node-delivery port GREEN on the full channel harness (46/46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session fixes, each traced: armed override slot without config reroute (INV-2), deferred halt preserves the round-2 sibling contract, proxy visibleView for optimistic families, persistent delivery signal across consumer churn, fold-commit bumps (write-time raced settles) + overlay- merge bumps (in-place folds are identity-invisible downstream — the undocumented reason the queue emitted at write time), write-override landing bumps, per-entry prev baselines (replaces gen/cm skip rules entirely — the compare IS the decision), untracked snapshots (tracked proxy spreads subscribed registrants' boundaries), owner-neutral delivery roots (boundary-queued effects missed lane runs), demote-at-delivery (effects created in setter write windows never subscribe), owned-write exemption on the delivery node. Full signals suite 1,415 green including PINV ledger. Web driver at 5 red (projection structure ×2, shallow retention, optimistic push visibility, nested-chain targeted reconcile) — diagnosis next. Test modernizations (asserting queue mechanisms → semantics): force flags ×2, mid-flush mount (compare-gated oracle), boundary isolation (restored to ORIGINAL queue expectations — owner-neutral effects deliver before teardown), tentative getter (effect-parity bounded: the queue EXCEEDED parity by delivering ambient writes in-flight). Co-authored-by: Cursor --- .../signals/src/store/next/patch-hooks.ts | 2 +- packages/signals/src/store/next/patch.ts | 126 ++++++++++++------ packages/signals/src/store/next/reconcile.ts | 15 ++- packages/signals/src/store/next/store.ts | 19 ++- .../signals/tests/store/patch-channel.test.ts | 12 +- .../tests/store/patch-invariants.test.ts | 49 +++++-- 6 files changed, 159 insertions(+), 64 deletions(-) diff --git a/packages/signals/src/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts index 00c140e2a..0b1d73fd6 100644 --- a/packages/signals/src/store/next/patch-hooks.ts +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -35,7 +35,7 @@ export interface PatchValueHooks { emitPatchAncestorsOptimistic(t: StoreNextTarget, tx: unknown): void; emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void; hasPatches(): boolean; - demoteToEffects(t: StoreNextTarget): void; + demoteToEffects(t: StoreNextTarget, immediate?: boolean): void; } export interface PatchRowHooks { diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 553e2ecb7..e64e06325 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -19,7 +19,13 @@ * Tree-shaking: core never imports this module; stores without patches * never schedule the queue. */ -import { EFFECT_RENDER, EFFECT_USER, NOT_PENDING, STATUS_ERROR } from "../../core/constants.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"; @@ -41,7 +47,7 @@ import { optHooks } from "./target.js"; import { emitSetterRowOps } from "./reconcile.js"; // Cycle with store.js is benign (established pattern above): both resolve at // call time, long after module initialization. -import { deepPathsPlain, targetIsPlain, targetKeysPlain } from "./store.js"; +import { deepPathsPlain, heldMaskView, targetIsPlain, targetKeysPlain } from "./store.js"; import type { DeepNode } from "./target.js"; import { InvariantHooks } from "../../core/invariants.js"; @@ -182,6 +188,8 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk } const UNSET: unique symbol = Symbol(); +/** Sentinel: applyEntries resolves prev per entry (node delivery). */ +const PER_ENTRY_PREV: unique symbol = Symbol(); /** ONE callback/error primitive for every drain (normal, transition-held, * optimistic): per-entry isolation — a throwing patch must not abort its @@ -227,9 +235,14 @@ function applyEntries( return Reflect.get(o, key, r); } }); - entry.fn(rec, prev, force); + entry.fn(rec, prev === PER_ENTRY_PREV ? (entry as any).pv : prev, force); + if (prev === PER_ENTRY_PREV) + (entry as any).pv = untrack(() => manifestSnapshot(pc as any, next)); } else { - entry.fn(next, prev, force); + const ep = prev === PER_ENTRY_PREV ? (entry as any).pv : prev; + entry.fn(next, ep, force); + if (prev === PER_ENTRY_PREV) + (entry as any).pv = untrack(() => manifestSnapshot(pc as any, next)); } } catch (err) { let handled = false; @@ -248,6 +261,8 @@ function applyEntries( source._statusFlags = (source._statusFlags ?? 0) | STATUS_ERROR; handled = owner._queue.notify(source, STATUS_ERROR, STATUS_ERROR, statusErr); } + if ((globalThis as any).__DBG__) + console.log("[route]", "handled:", handled, "hasOwner:", entry.owner !== null); if (!handled && firstError === UNSET) firstError = err; } } @@ -535,7 +550,8 @@ export function patchCommittedRaw(record: any): Record | undef if (t === undefined) return undefined; t = ultimateTarget(t) ?? t; if (t === undefined) return undefined; - return t.ht !== null ? ((t.hv ?? t.v) as Record) : t.v; + const hm = heldMaskView(t); + return (hm ?? t.v) as Record; } /** NODE DELIVERY (the structural successor to the queue machinery): one @@ -557,6 +573,8 @@ function bumpDeliveryOptimistic(pc: any): void { // 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.bc ?? 0) + 1; + if ((globalThis as any).__DBG__) + console.log("[opt-bump] bc:", pc.bc, new Error().stack?.split("\n")[2]?.trim()); 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); @@ -595,9 +613,13 @@ function snapNode(node: DeepNode, src: any, dst: any): void { function visibleView(t: StoreNextTarget): any { // Optimistic families read THROUGH THE PROXY: tentative values live in // node overrides at ANY depth (a root-level view merge misses children), - // and untracked proxy reads resolve them all. Everyone else reads raw. + // and untracked proxy reads resolve them all. Everyone else: the SAME + // hold resolution the store's traps use (heldMaskView checks whether the + // holding transition finished — a raw ht read served stale masks after + // landings), else committed raw. if (t.fam?.opt === true) return t.px; - return t.ht !== null ? (t.hv ?? t.v) : t.v; + const hm = heldMaskView(t); + return hm !== null ? hm : t.v; } function ensureDelivery(t: StoreNextTarget, pc: any): void { @@ -615,43 +637,57 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { // 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; - pc.pv = manifestSnapshot(pc, visibleView(t)); } const dn = pc.dn; - createRoot(d => { - pc.de = d; - createRenderEffect( - () => readSignal(dn) as number, - () => { - if (pc.bc === pc.dv) { - pc.pv = manifestSnapshot(pc, visibleView(t)); - return; - } - pc.dv = pc.bc; - const p = pc.p as PatchEntry[] | null; - if (p === null) return; // demoted or emptied — inert - const next = visibleView(t); - const prev = pc.pv; - const snap = p.length > 1 ? p.slice() : p; - let firstError: unknown = UNSET; - firstError = applyEntries(snap, next, prev, false, firstError, pc); - pc.pv = manifestSnapshot(pc, next); - 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. - const err = firstError; - globalQueue.enqueue(EFFECT_USER, () => { - haltReactivity(err); - throw err; - }); + // 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. + runWithOwner(null, () => + createRoot(d => { + pc.de = d; + createRenderEffect( + () => readSignal(dn) as number, + () => { + if (pc.bc === pc.dv) return; // pure-registration run: baselines are per-entry + pc.dv = pc.bc; + const p = pc.p as PatchEntry[] | null; + if (p === null) return; // demoted or emptied — inert + // 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; + } + const next = visibleView(t); + const snap = p.length > 1 ? p.slice() : p; + let firstError: unknown = UNSET; + firstError = applyEntries(snap, next, PER_ENTRY_PREV, false, 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. + const err = firstError; + globalQueue.enqueue(EFFECT_USER, () => { + haltReactivity(err); + throw err; + }); + } } - } - ); - }); + ); + }) + ); } export function registerPatch(record: any, fn: PatchFn, keys?: Iterable): () => void { @@ -702,6 +738,15 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable manifestSnapshot(pc, visibleView(t))); // Bindings are subscriptions for reachability (§6d pruning must descend // into bound records). markDescendants(t); @@ -786,7 +831,8 @@ export function patchableRaw(record: any, keys?: string[]): Record) : t.v; + 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. diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 725fb85ba..2c5115582 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -143,12 +143,17 @@ function reconcileTop( optHooks!.applyTentative(t, incoming, keyFn); // 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 — emit the TENTATIVE VIEW at lane timing - // (the optimistic drain's accessor probe demotes getter-bearing views - // instead of reading them raw). + // 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) { - const view = optHooks!.optimisticView(t, t.pb ?? t.v); - patchHooks.emitPatchOptimistic(t, view, t.v); + // 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"; } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index ab4fa4a06..e83b3405d 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -638,7 +638,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); @@ -842,6 +842,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 @@ -1107,6 +1118,12 @@ 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. + if (t.pc !== null && patchHooks !== null && (t.pc.p !== null || t.pc.dn !== null)) { + if (targetKeysPlain(t, t.v)) patchHooks.emitPatch(t, t.v, oldBacking); + else patchHooks.demoteToEffects(t); + } if (t.u && t.u.v[t.pk!] === oldBacking) { privatizeCommitted(t.u); devAssertNeverUserMutation(t.u.v); diff --git a/packages/signals/tests/store/patch-channel.test.ts b/packages/signals/tests/store/patch-channel.test.ts index 070aeda91..c3d857f82 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"); diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index f9c60aed6..28ec7c8c2 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -159,17 +159,27 @@ describe("INVARIANT: patch applications mirror effect runs (parity oracle), rega 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 } = await import("../../src/index.js"); + 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(() => { @@ -202,11 +212,14 @@ describe("INVARIANT: optimistic applies honor accessor safety and late mounts (r resolve(); await p; flush(); - // The getter's outside dependency must keep applying while the - // tentative view was live — an untracked raw read renders once and - // goes silently stale. + // 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("g1"); + expect(afterDep).toBe("g0"); + expect(log[log.length - 1]).toBe(effectLog[effectLog.length - 1]); dispose(); }); }); @@ -481,28 +494,38 @@ describe("INVARIANT: queued applications reach exactly the consumers registered // 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; - registerPatch(state.rows[0], (n: any) => spy.push(n.label)); + applyRow(state.rows[0], undefined, true); // driver initial apply + registerPatch(state.rows[0], applyRow, ["label"]); } }); - // ONE flush: structural change queues row ops FIRST, then the value - // write queues the record's entry — the drain mounts the consumer, then - // must NOT hand it the value entry (it initialized from that state; a - // re-apply is an observable duplicate setter call). setState(s => { s.rows.push({ id: 2, label: "L2" }); s.rows[0].label = "X1"; }); flush(); - expect(spy).toEqual([]); - // Functional from the NEXT event on. + // 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(["Y1"]); + 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 () => { From b7cdeda90a7597acbf84650eb954c2da365fc77d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 10:58:26 -0700 Subject: [PATCH 12/56] =?UTF-8?q?experiment:=20node-delivery=20port=20SEMA?= =?UTF-8?q?NTICALLY=20COMPLETE=20=E2=80=94=20all=20suites=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1,415 signals + 683 web + 150 hydrate + 32/32 monorepo tasks under node delivery: the entire nine-round invariant harness passes with the value queues deleted. Final session fixes: dp-channels read through the proxy (forcedNext's staleness call, reborn), null-baseline first deliveries force (projection materialization — one crash was halt-poisoning three equivalence tests), emission payload fast path (bc-tagged np), owned-write exemption on the delivery node, hook guards on revert-ancestor bumps. Sizes (vs channel): patchDriver tier 15,825 (-164), rowProof 18,358 (-112) — before deleting the now-dead stamp/merge machinery. PERF: NOT DONE. dbmon tick 8.3 / mount 12.0 (channel: 2.0 / 6.3). Profile: GC 1.6s (per-delivery manifestSnapshot allocation storm) + serveDataKey/get 1.1s (proxy reads leaking past the payload gate — double-bump suspected, uninstrumented). OPTIMIZATION DESIGN, verified against the profile: (1) pv = the swapped-out backing REFERENCE for adoption-style ticks (zero alloc — old backings are immutable after swap); clone only at the overlay (in-place) fold, exactly where the queue design ran clonePrev; (2) verify np payload engagement per tick (count np-hits vs proxy fallbacks); (3) registration-side pv laziness for mount. The flag-prototype measured 2.1/7.2 with flat spreads — the target is real. Co-authored-by: Cursor --- packages/signals/src/store/next/patch.ts | 41 +++++++++++++++++------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index e64e06325..bd3bc0064 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -240,7 +240,12 @@ function applyEntries( (entry as any).pv = untrack(() => manifestSnapshot(pc as any, next)); } else { const ep = prev === PER_ENTRY_PREV ? (entry as any).pv : prev; - entry.fn(next, ep, force); + // 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 && prev === PER_ENTRY_PREV) entry.fn(next, undefined, true); + else entry.fn(next, ep, force); if (prev === PER_ENTRY_PREV) (entry as any).pv = untrack(() => manifestSnapshot(pc as any, next)); } @@ -325,7 +330,12 @@ 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 { - if (t.pc !== null) bumpDelivery(t.pc); + const pc = t.pc as any; + if (pc !== null) { + bumpDelivery(pc); + pc.np = next; + pc.npb = pc.bc; + } emitPatchAncestors(t); } @@ -360,7 +370,14 @@ export function emitPatchAncestorsOptimistic(t: StoreNextTarget, _tx: unknown): * 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 { - if (t.pc !== null) bumpDelivery(t.pc); + const pc = t.pc as any; + if (pc === null) return; + bumpDelivery(pc); + // Payload fast path (raw-read thesis): the emission's own state rides + // the channel — deliveries read it RAW instead of proxy-resolving. + // bc-tagged: a later bump (including post-revert) invalidates it. + pc.np = next; + pc.npb = pc.bc; } /** Optimistic-channel emission: overrides are visible THIS flush while the @@ -610,14 +627,16 @@ function snapNode(node: DeepNode, src: any, dst: any): void { /** 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): any { - // Optimistic families read THROUGH THE PROXY: tentative values live in - // node overrides at ANY depth (a root-level view merge misses children), - // and untracked proxy reads resolve them all. Everyone else: the SAME +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 — a raw ht read served stale masks after - // landings), else committed raw. + // 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; } @@ -669,7 +688,7 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { demoteToEffects(t, true); return; } - const next = visibleView(t); + const next = visibleView(t, pc); const snap = p.length > 1 ? p.slice() : p; let firstError: unknown = UNSET; firstError = applyEntries(snap, next, PER_ENTRY_PREV, false, firstError, pc); @@ -746,7 +765,7 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable manifestSnapshot(pc, visibleView(t))); + (entry as any).pv = untrack(() => manifestSnapshot(pc, visibleView(t, pc))); // Bindings are subscriptions for reachability (§6d pruning must descend // into bound records). markDescendants(t); From 0c701083d8050e69b5db55a279012a0330b211cd Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 11:31:28 -0700 Subject: [PATCH 13/56] =?UTF-8?q?experiment:=20node-delivery=20optimizatio?= =?UTF-8?q?n=20pass=20=E2=80=94=20PARITY=20CONFIRMED=20+=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes: the dispatch-side payload gate had silently failed to apply (every delivery took the proxy path — the whole 8.3ms tick); reference prev baselines (raw backings are immutable after adoption swaps; the overlay fold clones just-in-time via prepareInPlaceFold — the queue's clonePrev moment, now pay-per-in-place-fold); registration baselines by reference. Payload gate: 10,000 hits / 0 misses over 10 dbmon ticks. dbmon (PROVISIONAL — battery): tick 2.1 / partial 0.5 / mount 7.8 vs channel 2.0 / 0.5-0.6 / 6.3-7.5. Parity on updates; mount ~+0.5-1ms (per-row delivery root, documented). Dead machinery deleted: clearStamp, gen/cm skip fields, value-era merge dedup + stamp repair (scheduler merge is a plain structural move now), prototype exports (patchVersion/patchCommittedRaw). PINV-2 restated for node delivery (undispatched-bumps-at-quiescence). Sizes vs channel: core floor -157 B (the rounds-6/9 merge bumps reclaimed), createStore -112, patchDriver tier -169, rowProof -105 — net smaller WITH the delivery machinery added. All gates green: 1,415 signals + 683 web + 150 hydrate + 32/32 tasks. Authoritative dbmon pending wall power. Co-authored-by: Cursor --- packages/signals/src/core/scheduler.ts | 59 ++------- packages/signals/src/store/index.ts | 4 +- .../signals/src/store/next/patch-hooks.ts | 3 + packages/signals/src/store/next/patch.ts | 115 +++++++----------- packages/signals/src/store/next/store.ts | 3 + packages/solid/src/index.ts | 2 - packages/solid/src/server/index.ts | 2 - packages/solid/src/server/signals.ts | 6 - packages/web/src/patch-driver.ts | 4 +- 9 files changed, 62 insertions(+), 136 deletions(-) diff --git a/packages/signals/src/core/scheduler.ts b/packages/signals/src/core/scheduler.ts index 7a313728e..eb4e31e76 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -232,58 +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 any[] | undefined; - if (dest === undefined) { - dest = (target as any)._heldPatches = heldPatches; - for (let i = 0; i < heldPatches.length; i++) { - const pc = (heldPatches[i] as any).pc; - if (pc !== undefined) { - if (pc.qe === heldPatches[i]) pc.qa = dest; - // Forced stamps follow their container too (re-audit 9, P2): - // a stale qf lets the next bubble stage a duplicate twin. - if ((heldPatches[i] as any).force === true && (heldPatches[i] as any).fq !== "o") - pc.qf = dest; - } - } - } else { - // COALESCE same-channel collisions (re-audit 6, P1-2): a record that - // emitted in BOTH transactions must apply ONCE at the merged commit — - // the surviving entry resolves `next` LIVE at drain (via the channel's - // target backref) and keeps the destination's earlier prev; the moved - // duplicate is dropped. Opaque backref contract with - // store/next/patch.ts (entry.pc, pc.t/qa/qe). - const byPc = new Map(); - for (let i = 0; i < dest.length; i++) { - const pc = (dest[i] as any).pc; - if (pc !== undefined) byPc.set(pc, dest[i]); - } - for (let i = 0; i < heldPatches.length; i++) { - const entry: any = heldPatches[i]; - const pc = entry.pc; - if (entry.force === true) { - // Forced entries dedupe per channel across the merge and retarget - // their stamp (re-audit 9, P2). - if (pc !== undefined && entry.fq !== "o") { - if (pc.qf === dest) continue; // destination already staged one - pc.qf = dest; - } - dest.push(entry); - continue; - } - const dup = pc !== undefined ? byPc.get(pc) : undefined; - if (dup !== undefined) { - dup.t = pc.t; // drain resolves next live: t.pb ?? t.v - pc.qa = dest; - pc.qe = dup; - } else { - dest.push(entry); - if (pc !== undefined) { - byPc.set(pc, entry); - if (pc.qe === entry) 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(); diff --git a/packages/signals/src/store/index.ts b/packages/signals/src/store/index.ts index 718b7551e..4d0472704 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -35,9 +35,7 @@ export { registerRowOps, registerSlotPatchNext as registerSlotPatch, patchableRaw, - patchCommittedRaw, - patchProxyFor, - patchVersion + patchProxyFor } from "./next/patch.js"; export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily } from "./next/store.js"; export { createOptimisticStoreNext as createOptimisticStore } from "./next/optimistic.js"; diff --git a/packages/signals/src/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts index 0b1d73fd6..58835a3b8 100644 --- a/packages/signals/src/store/next/patch-hooks.ts +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -36,6 +36,9 @@ export interface PatchValueHooks { emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void; hasPatches(): boolean; demoteToEffects(t: StoreNextTarget, immediate?: boolean): void; + /** Pre-merge clone hook for in-place (overlay) folds — see + * prepareInPlaceFold (reference baselines clone just-in-time). */ + prepareInPlaceFold(t: StoreNextTarget): void; } export interface PatchRowHooks { diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index bd3bc0064..6169e092e 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -69,8 +69,6 @@ interface PatchEntry { /** Keys recorded (adoption demotion probes); undefined = record at the * next drain apply. */ k?: boolean; - /** Registration generation (re-audit 8, P2-6). */ - gen?: number; } // Per-flush apply queue. Bubbled (forced) emissions resolve `next` LAZILY at @@ -78,14 +76,6 @@ interface PatchEntry { // backing between emission and drain, so a captured reference goes stale. interface QueuedApply { list: PatchEntry[]; - /** Registration-generation watermark (re-audit 8, P2-6): captured at - * emission; consumers registered LATER are skipped ONLY when the entry - * was emitted from ALREADY-COMMITTED state (`cm` — re-audit 9, P1-1: - * walk-in-setter and transition-held emissions carry uncommitted - * payloads, so late consumers read the OLD committed view and must - * receive the apply). */ - g?: number; - cm?: boolean; next: any; prev: any; force: boolean; @@ -129,7 +119,6 @@ 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 { prev, force, t } = q[i]; const next = t !== null ? (force ? forcedNext(t) : (t.pb ?? t.v)) : q[i].next; if (q[i].ops !== undefined || q[i].si !== undefined) @@ -236,8 +225,10 @@ function applyEntries( } }); entry.fn(rec, prev === PER_ENTRY_PREV ? (entry as any).pv : prev, force); - if (prev === PER_ENTRY_PREV) - (entry as any).pv = untrack(() => manifestSnapshot(pc as any, next)); + if (prev === PER_ENTRY_PREV) { + const px = (pc as any).t?.px; + (entry as any).pv = next === px ? untrack(() => manifestSnapshot(pc as any, next)) : next; + } } else { const ep = prev === PER_ENTRY_PREV ? (entry as any).pv : prev; // A consumer whose baseline never materialized (projection backing @@ -246,8 +237,10 @@ function applyEntries( // an undefined prev under force. if (ep == null && prev === PER_ENTRY_PREV) entry.fn(next, undefined, true); else entry.fn(next, ep, force); - if (prev === PER_ENTRY_PREV) - (entry as any).pv = untrack(() => manifestSnapshot(pc as any, next)); + if (prev === PER_ENTRY_PREV) { + const px = (pc as any).t?.px; + (entry as any).pv = next === px ? untrack(() => manifestSnapshot(pc as any, next)) : next; + } } } catch (err) { let handled = false; @@ -317,8 +310,6 @@ function push(item: QueuedApply): void { * entries and row/slot ops never coalesce; the drain clears the stamps so a * quiet record retains nothing from its last batch. */ -function clearStamp(_item: QueuedApply): void {} - /** Shallow clone for the owned-prev rule (§2c): owned backings fold values * INTO the same raw at commit, so a queued prev must be snapshotted. */ function clonePrev(prev: any): any { @@ -397,7 +388,6 @@ 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 { prev, force, t } = q[i]; const next = t !== null ? (force ? forcedNext(t) : (t.pb ?? t.v)) : q[i].next; if (q[i].ops !== undefined || q[i].si !== undefined) @@ -452,10 +442,6 @@ export function emitRowOpsOptimistic( // Global registration count: the cheap gate emission sites check before any // per-record work (unpatched apps pay one number compare per transition). let patchCount = 0; -// Registration generation (re-audit 8, P2-6): monotonic; queued entries -// capture the counter at emission so drains can skip consumers that -// initialized from state at-or-after the emission. -let regGen = 0; /** Test-only accounting probe: the live registration count must return to * baseline across register/unbind/demote cycles. @internal */ export function patchCountForTests(): number { @@ -538,39 +524,6 @@ function unionKeys( } } -/** NODE-DELIVERY PROTOTYPE: tracked read of the record's version signal. - * Creating it counts toward hasPatches() so write-path gates arm. */ -export function patchVersion(record: any): void { - let t: StoreNextTarget | undefined = record?.[$TARGET]; - if (t === undefined) return; - t = ultimateTarget(t) ?? t; - const pc = pcOf(t); - if (pc.dn === null) { - pc.dn = signal(0, { equals: false }); - patchCount++; - markDescendants(t); - if (!commitHookInstalled) { - commitHookInstalled = true; - armPatchHooks(); - setPatchCommitHook(releaseBatch); - GlobalQueue._drainPatchOptimistic = drainOptimistic; - } - } - readSignal(pc.dn as any); -} - -/** NODE-DELIVERY PROTOTYPE: held-aware committed backing WITHOUT admission - * scans — the emission-seam gates own accessor soundness; per-delivery - * re-probing doubled the probe bill. */ -export function patchCommittedRaw(record: any): Record | undefined { - let t: StoreNextTarget | undefined = record?.[$TARGET]; - if (t === undefined) return undefined; - t = ultimateTarget(t) ?? t; - if (t === undefined) return undefined; - const hm = heldMaskView(t); - return (hm ?? t.v) as Record; -} - /** 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 @@ -688,7 +641,15 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { demoteToEffects(t, true); return; } - const next = visibleView(t, pc); + // 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; + if ((globalThis as any).__DBG__ !== undefined) + (globalThis as any).__DBG__[npHit ? "hit" : "miss"]++; + 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, PER_ENTRY_PREV, false, firstError, pc); @@ -722,7 +683,7 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable manifestSnapshot(pc, visibleView(t, pc))); + // 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); @@ -893,6 +857,22 @@ 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. */ +/** 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 { const entries = demotePatches(t); if (entries === null || entries.length === 0) return; @@ -1073,7 +1053,8 @@ function armPatchHooks(): void { emitPatchAncestorsOptimistic, emitPatchOptimistic, hasPatches, - demoteToEffects + demoteToEffects, + prepareInPlaceFold }); if (__TEST__) InvariantHooks.patchQuiescent = devPatchQuiescent; } @@ -1095,15 +1076,13 @@ function devPatchQuiescent(): void { const p = pc.p as unknown[] | null; const ro = pc.ro as unknown[] | null; if (p === null && ro === null && pc.sp === null) { - if (pc.qa === null && pc.qe === null && pc.qo === null && pc.qeo === null) - devChannels.delete(pc); - // fall through: a dead channel with live stamps is still a PINV-2 hit + devChannels.delete(pc); } live += (p?.length ?? 0) + (ro?.length ?? 0); assertInvariant( - pc.qa === null && pc.qe === null && pc.qo === null && pc.qeo === null, + pc.bc === undefined || pc.dv === undefined || pc.bc === pc.dv || p === null, "PINV-2", - "a patch channel holds coalescing stamps at quiescence — a drain path skipped clearStamp (retention: the stamped entry pins both captured backings)" + "a live channel has undispatched bumps at quiescence — a delivery effect was never scheduled or lost its subscription" ); } assertInvariant( diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index e83b3405d..9f519868e 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -820,6 +820,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 diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 68c6c24c1..17a06b9a6 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -26,9 +26,7 @@ export { latest, // Patch-channel compiler contract (undocumented as application API) patchableRaw, - patchCommittedRaw, patchProxyFor, - patchVersion, registerPatch, registerRowOps, registerSlotPatch, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index f2418bde2..49d9acd9b 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -39,9 +39,7 @@ export { // Patch-channel compiler contract (parity with the client entry; the // channel is inert on the server — SSR renders once, hydration claims) patchableRaw, - patchCommittedRaw, patchProxyFor, - patchVersion, registerPatch, registerRowOps, registerSlotPatch, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 6ee5011c9..1d90b7d93 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -2840,12 +2840,6 @@ export function patchProxyFor(_list: any, raw: any): any { return raw; // SSR renders once from whatever it is handed } -export function patchVersion(_record: any): void {} - -export function patchCommittedRaw(_record: any): undefined { - return undefined; -} - export function patchableRaw(_record: any): undefined { return undefined; } diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 33ce4e194..56418c611 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -20,9 +20,7 @@ import { storeHasOptimisticFamily, storeIsShallow, untrack, - createRoot, - patchVersion, - patchCommittedRaw + createRoot } from "solid-js"; import { effect } from "./render.js"; import { installListDriver } from "./client.js"; From a3e11560b51344f2c67eed53bb19c0d038725ca8 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 30 Aug 2026 00:52:12 -0700 Subject: [PATCH 14/56] =?UTF-8?q?experiment:=20node-delivery=20mount=20pas?= =?UTF-8?q?s=20=E2=80=94=20lazy=20machinery,=20detached=20primitive,=20dbm?= =?UTF-8?q?on=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deliveryEffect (detached single-source effect, no root/owner) + delivery signal/effect built at first consumer-visible bump instead of registration; machinery persists across consumer churn (held-write window pin). Mount 6.4 ms = channel parity, tick keeps the node win; unmount split shown to be a GC-window harness artifact (both builds 1.9 ms under a controlled probe). Co-authored-by: Cursor --- .changeset/node-delivery-mount-pass.md | 5 + packages/signals/DESIGN-PATCH-CHANNEL.md | 42 ++++++ packages/signals/src/core/effect.ts | 21 +++ packages/signals/src/store/next/patch.ts | 157 ++++++++++++----------- 4 files changed, 153 insertions(+), 72 deletions(-) create mode 100644 .changeset/node-delivery-mount-pass.md 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/packages/signals/DESIGN-PATCH-CHANNEL.md b/packages/signals/DESIGN-PATCH-CHANNEL.md index 9551d98c3..192a198cb 100644 --- a/packages/signals/DESIGN-PATCH-CHANNEL.md +++ b/packages/signals/DESIGN-PATCH-CHANNEL.md @@ -86,6 +86,48 @@ 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. +## 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: the bench's unmount split (channel 0.3 ms vs node 1.5 ms) was a +HARNESS ARTIFACT — a direct same-methodology probe (mount, yield for the +async flush, gc, timed unmount) measures **1.9 ms on both builds**; the +delta is which side of the timing window a GC lands on. Post-flush +unmount work is dominated by GC, not teardown; pre-flush unmount is +0.2 ms on both. + +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/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/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 6169e092e..0eba0b88c 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -54,7 +54,7 @@ import { InvariantHooks } from "../../core/invariants.js"; import { assertInvariant } from "../../core/dev.js"; import { runWithOwner, untrack } from "../../core/core.js"; import { createRenderEffect } from "../../signals.js"; -import { createRoot } from "../../core/owner.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"; @@ -323,7 +323,7 @@ function clonePrev(prev: any): any { export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { const pc = t.pc as any; if (pc !== null) { - bumpDelivery(pc); + bumpDelivery(t, pc); pc.np = next; pc.npb = pc.bc; } @@ -339,7 +339,7 @@ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { export function emitPatchAncestors(t: StoreNextTarget): void { let u = t.u; while (u !== null) { - if (u.pc !== null) bumpDelivery(u.pc); + if (u.pc !== null) bumpDelivery(u, u.pc); u = u.u; } } @@ -352,7 +352,7 @@ export function emitPatchAncestors(t: StoreNextTarget): void { export function emitPatchAncestorsOptimistic(t: StoreNextTarget, _tx: unknown): void { let u = t.u; while (u !== null) { - if (u.pc !== null) bumpDeliveryOptimistic(u.pc); + if (u.pc !== null) bumpDeliveryOptimistic(u, u.pc); u = u.u; } } @@ -363,7 +363,7 @@ export function emitPatchAncestorsOptimistic(t: StoreNextTarget, _tx: unknown): export function emitPatchLocal(t: StoreNextTarget, next: any, prev: any): void { const pc = t.pc as any; if (pc === null) return; - bumpDelivery(pc); + bumpDelivery(t, pc); // Payload fast path (raw-read thesis): the emission's own state rides // the channel — deliveries read it RAW instead of proxy-resolving. // bc-tagged: a later bump (including post-revert) invalidates it. @@ -400,7 +400,7 @@ function drainOptimistic(): void { } export function emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void { - if (t.pc !== null) bumpDeliveryOptimistic(t.pc); + if (t.pc !== null) bumpDeliveryOptimistic(t, t.pc); } /** Row-ops emission at OPTIMISTIC (lane) timing: user drafts on an @@ -528,23 +528,38 @@ function unionKeys( * 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. */ -function bumpDelivery(pc: any): void { - if (pc.dn === null) return; + * merges, mount order — is scheduler-owned by construction. + * + * 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 (the old + * dispose-on-empty kept only the signal and rebuilt the effect; keeping + * both closes the same window and makes re-binding rows free). Channels + * whose machinery was never built skip silently — a first-ever consumer's + * registration baseline (`entry.pv`) already reflects those writes. */ +function bumpDelivery(t: StoreNextTarget, pc: any): void { + if (pc.de === undefined) { + if (pc.p === null) return; + ensureDelivery(t, pc); + } // 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.bc ?? 0) + 1; + pc.bc++; setSignal(pc.dn, (v: number) => v + 1); } -function bumpDeliveryOptimistic(pc: any): void { - if (pc.dn === null) return; +function bumpDeliveryOptimistic(t: StoreNextTarget, pc: any): void { + if (pc.de === undefined) { + if (pc.p === null) return; + ensureDelivery(t, pc); + } // 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.bc ?? 0) + 1; - if ((globalThis as any).__DBG__) - console.log("[opt-bump] bc:", pc.bc, new Error().stack?.split("\n")[2]?.trim()); + pc.bc++; 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); @@ -596,11 +611,10 @@ function visibleView(t: StoreNextTarget, pc?: any): any { function ensureDelivery(t: StoreNextTarget, pc: any): void { if (pc.de !== undefined) return; - // The delivery SIGNAL and its bookkeeping persist across consumer churn - // (only the effect root lives with consumers): a write-time emission held - // by a transition rides the signal's pending commit — disposing the - // signal with the last consumer dropped that delivery, permanently - // staleing a consumer registered before the settle. + // The whole machinery persists once built (see bumpDelivery): 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 @@ -622,52 +636,52 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { // 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. - runWithOwner(null, () => - createRoot(d => { - pc.de = d; - createRenderEffect( - () => readSignal(dn) as number, - () => { - if (pc.bc === pc.dv) return; // pure-registration run: baselines are per-entry - pc.dv = pc.bc; - const p = pc.p as PatchEntry[] | null; - if (p === null) return; // demoted or emptied — inert - // 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; - if ((globalThis as any).__DBG__ !== undefined) - (globalThis as any).__DBG__[npHit ? "hit" : "miss"]++; - 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, PER_ENTRY_PREV, false, 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. - const err = firstError; - globalQueue.enqueue(EFFECT_USER, () => { - haltReactivity(err); - throw err; - }); - } + // + // 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 + pc.dv = pc.bc; + const p = pc.p as PatchEntry[] | null; + if (p === null) return; // demoted or emptied — inert + // 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 next = pc.np !== undefined && pc.npb === pc.bc ? pc.np : visibleView(t, pc); + pc.np = undefined; + const snap = p.length > 1 ? p.slice() : p; + let firstError: unknown = UNSET; + firstError = applyEntries(snap, next, PER_ENTRY_PREV, false, 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. + const err = firstError; + globalQueue.enqueue(EFFECT_USER, () => { + haltReactivity(err); + throw err; + }); + } + } + ); + }); } export function registerPatch(record: any, fn: PatchFn, keys?: Iterable): () => void { @@ -717,7 +731,9 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable void)(); - pc.de = undefined; - // dn/bc/dv/pv persist: held write-time emissions must survive - // consumer churn (see ensureDelivery). - } } }; } From 109eb5d0012d1b741101f9788d80fc2cfce54f8a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 30 Aug 2026 21:17:13 -0700 Subject: [PATCH 15/56] =?UTF-8?q?docs:=20refine=20=C2=A722=20unmount=20fin?= =?UTF-8?q?ding=20=E2=80=94=20write-barrier=20tax=20during=20concurrent=20?= =?UTF-8?q?GC=20cycles,=20not=20in-window=20collections=20(traced)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- packages/signals/DESIGN-PATCH-CHANNEL.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/signals/DESIGN-PATCH-CHANNEL.md b/packages/signals/DESIGN-PATCH-CHANNEL.md index 192a198cb..207469d06 100644 --- a/packages/signals/DESIGN-PATCH-CHANNEL.md +++ b/packages/signals/DESIGN-PATCH-CHANNEL.md @@ -114,12 +114,21 @@ The node-delivery prototype's remaining dbmon gap vs the channel was mount record's death releases the subgraph. Perf-over-memory ruling (records outliving consumers retain ~200 B of dormant machinery). -Finding: the bench's unmount split (channel 0.3 ms vs node 1.5 ms) was a -HARNESS ARTIFACT — a direct same-methodology probe (mount, yield for the -async flush, gc, timed unmount) measures **1.9 ms on both builds**; the -delta is which side of the timing window a GC lands on. Post-flush -unmount work is dominated by GC, not teardown; pre-flush unmount is -0.2 ms on both. +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 From 59978a3089dc6c5075fadce433e528cbf12c29b5 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 30 Aug 2026 23:36:20 -0700 Subject: [PATCH 16/56] =?UTF-8?q?docs:=20audit=20brief=20round=2010=20?= =?UTF-8?q?=E2=80=94=20node-delivery=20architecture,=20new=20seams,=20brea?= =?UTF-8?q?dth=20evidence=20(jfb=20+=20uibench=20parity)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- packages/signals/AUDIT-BRIEF-R6.md | 56 +++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index e71896549..da501b084 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,4 +1,58 @@ -# Audit brief — rounds 6–9 + patch-mode default flip +# Audit brief — rounds 6–9 + patch-mode default flip + node delivery + +## 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) From 4ffb714b0821b56405d9170a2bba40d4ca23ecff Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 01:37:11 -0700 Subject: [PATCH 17/56] =?UTF-8?q?fix:=20round-10=20audit=20(7/7=20P1=20+?= =?UTF-8?q?=203=20P2),=20harness-first=20=E2=80=94=20emission=20centralize?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bubbling is primitive-owned (bumpOne walks ancestors with pending-dedup; no seam decides), eager adoptions path-copy ancestor raws (mount source always current — the proxy-read alternative cost +8ms dbmon mount and was reverted), dispatch defers held registrants into their owner queues (boundary parity via injected probe), demotion fanout per-entry isolated, swaps build from the optimistic visible array with family-identity retention, dmq latch dies with its consumers, quiescent channels stop bumping outside transitions, channel shape cleaned (retired stamps gone). dbmon unchanged: 6.5 / 2.0 / 0.6. Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10.md | 6 + packages/signals/AUDIT-BRIEF-R6.md | 47 ++++ packages/signals/src/boundaries.ts | 22 +- packages/signals/src/core/scheduler.ts | 7 + packages/signals/src/store/index.ts | 7 +- packages/signals/src/store/next/optimistic.ts | 8 +- packages/signals/src/store/next/patch.ts | 242 ++++++++++++------ packages/signals/src/store/next/store.ts | 51 +++- packages/signals/src/store/next/target.ts | 36 +-- .../tests/store/patch-invariants.test.ts | 177 +++++++++++++ packages/solid/src/index.ts | 1 + packages/solid/src/server/index.ts | 1 + packages/solid/src/server/signals.ts | 3 + packages/web/src/patch-driver.ts | 24 +- .../web/test/for.patchinvariants.spec.tsx | 195 +++++++++++++- scripts/size/.size-limit.js | 5 +- 16 files changed, 714 insertions(+), 118 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index da501b084..88bbaa02a 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,52 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 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/scheduler.ts b/packages/signals/src/core/scheduler.ts index eb4e31e76..41f01e7be 100644 --- a/packages/signals/src/core/scheduler.ts +++ b/packages/signals/src/core/scheduler.ts @@ -577,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 4d0472704..4c02cd953 100644 --- a/packages/signals/src/store/index.ts +++ b/packages/signals/src/store/index.ts @@ -37,7 +37,12 @@ export { 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..e683d31de 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -467,9 +467,11 @@ export function notifyOptimisticWrites(t: StoreNextTarget, pb: Record { + entry.hq = false; + if (entry.u === true) return; + if (entry.owner !== null && isDisposed(entry.owner)) return; + const err = applyEntries([entry], visibleView(pc.t, pc), PER_ENTRY_PREV, false, UNSET, pc); + if (err !== UNSET) { + globalQueue.enqueue(EFFECT_USER, () => { + haltReactivity(err); + throw err; + }); + } + }); +} + +/** 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; +} + function applyEntries( list: PatchEntry[], next: any, @@ -209,6 +256,18 @@ function applyEntries( if (entry === undefined || entry.u === 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. + if (prev === PER_ENTRY_PREV) { + const heldProbe = GlobalQueue._queueHeld; + const oq = entry.q as any; + if (heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq)) { + deferHeldEntry(entry, oq, pc as any); + continue; + } + } try { // First-apply key recording (re-audit 6): entries registered without a // recorded read set (hydration skips the initial apply) record here — @@ -243,25 +302,7 @@ function applyEntries( } } } 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 ((globalThis as any).__DBG__) - console.log("[route]", "handled:", handled, "hasOwner:", entry.owner !== null); - if (!handled && firstError === UNSET) firstError = err; + if (!routeEntryError(entry, err) && firstError === UNSET) firstError = err; } } return firstError; @@ -323,52 +364,37 @@ function clonePrev(prev: any): any { export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { const pc = t.pc as any; if (pc !== null) { - bumpDelivery(t, pc); + bumpOne(t, pc); pc.np = next; pc.npb = pc.bc; } - emitPatchAncestors(t); + bumpAncestors(t); } -/** Ancestor bubble, standalone (re-audit 7): targeted reconciles emit - * walk-locally for the walked subtree but ancestors' compiled bodies can - * read INTO it through nested chains — the walk root must bubble exactly - * like a nested setter write does. Forced entries COALESCE per container - * (re-audit 8, P2-7): N nested writes in one batch force ONE ancestor - * re-apply, effect parity; the drain clears the stamp. */ +/** 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 { - let u = t.u; - while (u !== null) { - if (u.pc !== null) bumpDelivery(u, u.pc); - u = u.u; - } + bumpAncestors(t); } /** Tentative (optimistic) ancestor bubble (re-audit 8, P1-3): in-flight - * visibility rides the LANE queue — and the SAME forced entries are staged - * on the transaction for settle (revert restores committed truth to - * ancestor expressions; landings show the landed state). Both resolve - * live at their drains. */ + * 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 u = t.u; while (u !== null) { - if (u.pc !== null) bumpDeliveryOptimistic(u, u.pc); + if (u.pc !== null) bumpOneOptimistic(u, u.pc); 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. */ +/** 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 function emitPatchLocal(t: StoreNextTarget, next: any, prev: any): void { - const pc = t.pc as any; - if (pc === null) return; - bumpDelivery(t, pc); - // Payload fast path (raw-read thesis): the emission's own state rides - // the channel — deliveries read it RAW instead of proxy-resolving. - // bc-tagged: a later bump (including post-revert) invalidates it. - pc.np = next; - pc.npb = pc.bc; + emitPatch(t, next, prev); } /** Optimistic-channel emission: overrides are visible THIS flush while the @@ -400,7 +426,15 @@ function drainOptimistic(): void { } export function emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): void { - if (t.pc !== null) bumpDeliveryOptimistic(t, t.pc); + // 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); + let u = t.u; + while (u !== null) { + if (u.pc !== null) bumpOneOptimistic(u, u.pc); + u = u.u; + } } /** Row-ops emission at OPTIMISTIC (lane) timing: user drafts on an @@ -530,20 +564,37 @@ function unionKeys( * 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 (the old - * dispose-on-empty kept only the signal and rebuilt the effect; keeping - * both closes the same window and makes re-binding rows free). Channels - * whose machinery was never built skip silently — a first-ever consumer's - * registration baseline (`entry.pv`) already reflects those writes. */ -function bumpDelivery(t: StoreNextTarget, pc: any): void { + * 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): void { 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. + return; + } else if (pc.p === null && activeTransition === null) { + return; } // Synchronous dedup counter + pure-notification signal: the WRITE may be // held by a transition (its commit IS the delivery moment), but the @@ -552,13 +603,23 @@ function bumpDelivery(t: StoreNextTarget, pc: any): void { setSignal(pc.dn, (v: number) => v + 1); } -function bumpDeliveryOptimistic(t: StoreNextTarget, pc: any): void { +function bumpAncestors(t: StoreNextTarget): void { + let u = t.u; + while (u !== null) { + if (u.pc !== null) bumpOne(u, u.pc); + u = u.u; + } +} + +function bumpOneOptimistic(t: StoreNextTarget, pc: any): void { if (pc.de === undefined) { if (pc.p === null) return; ensureDelivery(t, pc); } // Override-armed write: in-flight visibility now, re-notify on revert — - // the engine is installed by every optimistic caller of this seam. + // the engine is installed by every optimistic caller of this seam. NO + // pending-dedup: every engine write registers with the transaction's + // revert bookkeeping. pc.bc++; const w = GlobalQueue._optimisticWrite; if (w !== null && w !== undefined) w(pc.dn, (pc.dn._value ?? 0) + 1); @@ -611,7 +672,7 @@ function visibleView(t: StoreNextTarget, pc?: any): any { function ensureDelivery(t: StoreNextTarget, pc: any): void { if (pc.de !== undefined) return; - // The whole machinery persists once built (see bumpDelivery): a + // 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. @@ -649,7 +710,14 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { if (pc.bc === pc.dv) return; // pure-registration run: baselines are per-entry pc.dv = pc.bc; const p = pc.p as PatchEntry[] | null; - if (p === null) return; // demoted or emptied — inert + 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; + } // 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 @@ -697,10 +765,17 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable { + // 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]; if (entry.owner !== null && isDisposed(entry.owner)) continue; const fn = entry.fn; - runWithOwner(entry.owner, () => - createRenderEffect( - () => { - fn(proxy, proxy, false); - }, - () => { - // Block body: a compiled patch body's return value must not be - // mistaken for an effect cleanup. - untrack(() => fn(proxy, undefined, true)); - } - ) - ); + try { + runWithOwner(entry.owner, () => + createRenderEffect( + () => { + fn(proxy, proxy, false); + }, + () => { + // Block body: a compiled patch body's return value must not be + // mistaken for an effect cleanup. + untrack(() => fn(proxy, undefined, true)); + } + ) + ); + } catch (err) { + if (!routeEntryError(entry, err) && firstError === UNSET) firstError = err; + } + } + if (firstError !== UNSET) { + const err = firstError; + globalQueue.enqueue(EFFECT_USER, () => { + haltReactivity(err); + throw err; + }); } }; if (immediate) redrive(); diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 9f519868e..ac58875d1 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -155,13 +155,13 @@ export function pcOf(t: StoreNextTarget): PatchChannel { p: null, ro: null, wk: null, - qa: null, - qe: null, - qo: null, - qeo: null, - qf: null, - qfo: null, dn: null, + de: undefined, + dv: 0, + bc: 0, + np: undefined, + npb: 0, + dmq: false, ak: null, dp: null, ks: false, @@ -692,9 +692,21 @@ 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. + if (eager && target.u !== null && 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); } @@ -1122,10 +1134,19 @@ function notifyWrites(t: StoreNextTarget): void { 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. - if (t.pc !== null && patchHooks !== null && (t.pc.p !== null || t.pc.dn !== null)) { - if (targetKeysPlain(t, t.v)) patchHooks.emitPatch(t, t.v, oldBacking); - else patchHooks.demoteToEffects(t); + // 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); + patchHooks.emitPatchAncestors(t); + } + } else patchHooks.emitPatchAncestors(t); } if (t.u && t.u.v[t.pk!] === oldBacking) { privatizeCommitted(t.u); @@ -2154,6 +2175,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 c90c088f6..2cbcccf07 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -65,37 +65,25 @@ 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; - /** Optimistic-container stamp pair (re-audit 7, P2-3): the lane queue - * coalesces independently — sharing qa/qe let an interleaved optimistic - * emission destroy the normal stamp and queue a duplicate application. */ - qo: unknown; - qeo: unknown; - /** Forced-bubble coalescing stamps (re-audit 8, P2-7): the container this - * channel last pushed a FORCED (ancestor) entry into — normal/held (qf) - * and optimistic (qfo). One forced re-apply per container per batch. */ - qf: unknown; - qfo: unknown; /** Node delivery: bare per-record version signal — bumped at the * emission seams, tracked by the channel's delivery effect. */ dn: unknown; - /** Delivery-effect root disposer (created with the first entry, disposed - * with the last). */ - de?: (() => void) | undefined; + /** 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; - /** Manifest-shaped prev snapshot for exact compares. */ - pv?: unknown; + /** 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; /** 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 diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 28ec7c8c2..54114743c 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -629,3 +629,180 @@ describe("INVARIANT: queued applications reach exactly the consumers registered 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: 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: 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); + }); +}); diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts index 17a06b9a6..a4041cb2d 100644 --- a/packages/solid/src/index.ts +++ b/packages/solid/src/index.ts @@ -33,6 +33,7 @@ export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily, + storeFamilyOf, reconcile, refresh, repeat, diff --git a/packages/solid/src/server/index.ts b/packages/solid/src/server/index.ts index 49d9acd9b..61fd0a3d7 100644 --- a/packages/solid/src/server/index.ts +++ b/packages/solid/src/server/index.ts @@ -46,6 +46,7 @@ export { storeIsShallow, storeHasFamily, storeHasOptimisticFamily, + storeFamilyOf, reconcile, refresh, repeat, diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index 1d90b7d93..abef969c4 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -2858,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/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 56418c611..815d935bb 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -17,6 +17,7 @@ import { registerSlotPatch, runWithOwner, sharedConfig, + storeFamilyOf, storeHasOptimisticFamily, storeIsShallow, untrack, @@ -543,7 +544,21 @@ 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; + const sameFamily = 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 @@ -554,7 +569,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); } ) ); @@ -602,7 +617,10 @@ export const patchDriver = (subject, body, keys?: string[]) => { // 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). + // 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)); diff --git a/packages/web/test/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx index 42874fd1f..70b5a4434 100644 --- a/packages/web/test/for.patchinvariants.spec.tsx +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -10,15 +10,18 @@ */ import { describe, expect, test, beforeEach, afterEach } from "vitest"; import { + createMemo, + createRenderEffect, createRoot, createSignal, createStore, flush, For, + Loading, reconcile, resetErrorHalt } from "solid-js"; -import { patchDriver, rowProof } from "@solidjs/web"; +import { patchDriver, render, rowProof } from "@solidjs/web"; interface Row { id: number; @@ -499,3 +502,193 @@ describe("INVARIANT: a body's declared read envelope is honored at EVERY depth a 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 () => { + const { createRevealOrder } = await import("solid-js"); + const [state, setState] = createStore({ row: { label: "v1" } }); + const [gate, setGate] = createSignal(0); + 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(() => { + gate(); + return 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)( + () => ( + <> + {
    } + {} + + ), + { order: () => "together" } + ); + }, div); + resolvers.pop()!("d1"); + await Promise.resolve(); + await Promise.resolve(); + flush(); + expect(classic.data).toBe("v1"); + expect(patched.data).toBe("v1"); + // Sibling A re-pends: "together" collapses BOTH queues while revealed + // content stays attached — the held window with visible DOM. + setGate(1); + flush(); + setState((s: any) => { + s.row.label = "v2"; + }); + flush(); + // PARITY is the invariant: whatever the held render effect shows, the + // patch sink shows. A patch racing ahead of a held classic binding is + // the round-10 finding. + if (process.env.DEBUG_HOLD) expect("held:" + classic.data + "|" + patched.data).toBe("PROBE"); + expect([classic.data, patched.data]).toEqual([classic.data, classic.data]); + resolvers.pop()!("d2"); + 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/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index ab7a3aaf4..8b8b92c20 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -338,8 +338,11 @@ module.exports = [ // 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.45 KB", + limit: "26.55 KB", modifyEsbuildConfig }, { From caf6c1463b259d5bfdf8fb17b09b745faa3fb821 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 02:07:05 -0700 Subject: [PATCH 18/56] =?UTF-8?q?fix:=20round-10.5=20audit=20(6=20variants?= =?UTF-8?q?=20+=20cleanup)=20=E2=80=94=20dedup=20yields=20to=20the=20sched?= =?UTF-8?q?uler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pending-dedup is transition-aware (writes under transitions always reach the signal; scheduler owns merging), deep probes currency-check aliased raw slots at admission, payload-less bubbled deliveries re-probe the ancestor's deep manifest and demote getters tracked (RED-verified), demoted entries sever against stale held callbacks, resyncs rebuild across family changes (boundFam advances only on successful applies), shallow swaps keep raw retention (classic parity), optimistic reverts emit once. dbmon unchanged: 6.6 / 2.1 / 0.6. Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10-5.md | 6 ++++ packages/signals/AUDIT-BRIEF-R6.md | 36 +++++++++++++++++++ packages/signals/src/store/next/patch.ts | 33 ++++++++++++++--- packages/signals/src/store/next/reconcile.ts | 11 ++++-- packages/signals/src/store/next/store.ts | 28 +++++++++++---- .../tests/store/patch-invariants.test.ts | 33 +++++++++++++++++ packages/web/src/patch-driver.ts | 18 +++++++++- 7 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10-5.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 88bbaa02a..2a2320ad0 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,41 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 5b9e93e44..4db292eac 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -591,8 +591,13 @@ function bumpOne(t: StoreNextTarget, pc: any): void { } 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. - return; + // signal write adds nothing — OUTSIDE transitions only (round 10.5, + // F1). Under one, every write must reach the signal: transition + // 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. + if (activeTransition === null) return; } else if (pc.p === null && activeTransition === null) { return; } @@ -731,7 +736,18 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { // 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 next = pc.np !== undefined && pc.npb === pc.bc ? pc.np : visibleView(t, pc); + 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; + } + const next = npHit ? pc.np : visibleView(t, pc); pc.np = undefined; const snap = p.length > 1 ? p.slice() : p; let firstError: unknown = UNSET; @@ -908,10 +924,11 @@ export function patchableRaw(record: any, keys?: string[]): 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. + // adoption gates only see FUTURE adoptions. CURRENCY-probed with `t` + // (round 10.5, F2): stale alias slots decline to classic. if (keys !== undefined) { const m = internManifest(keys); - if (m.dp !== null && !deepPathsPlain(m.dp, raw)) return undefined; + if (m.dp !== null && !deepPathsPlain(m.dp, raw, t)) return undefined; } return raw; } @@ -926,6 +943,12 @@ 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): 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 (an + // untracked duplicate of a getter-bearing body). + for (let i = 0; i < p.length; i++) p[i].u = true; // 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); diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 2c5115582..39e15cc16 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -77,9 +77,14 @@ export function reconcileNextState( // settle/revert re-applies resolved truth. if (patchHooks !== null && patchHooks.hasPatches()) { const t: StoreNextTarget | undefined = state?.[$TARGET]; - if (t !== undefined && t.u !== null) { - if (tentative) patchHooks.emitPatchAncestorsOptimistic(t, activeTransition); - else patchHooks.emitPatchAncestors(t); + 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); } } } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index ac58875d1..5fb28e7d6 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -466,7 +466,7 @@ export function targetKeysPlain(target: StoreNextTarget, next: Record { + 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"); diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 815d935bb..114af1e8c 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -260,6 +260,10 @@ 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 @@ -314,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; @@ -431,6 +443,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; @@ -555,7 +568,10 @@ export const driveList = (parent: Node, listFn: any, marker?: Node, lateClassic? const nextVisible = storeHasOptimisticFamily(value) ? (untrack(() => Array.from(value as any)) as any[]) : nextRaw; - const sameFamily = storeFamilyOf(value) === storeFamilyOf(subject); + // 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) }; From 7dad8261538d26e6324326f34d4780292e089818 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 08:02:30 -0700 Subject: [PATCH 19/56] =?UTF-8?q?fix:=20round-10.6=20audit=20=E2=80=94=20f?= =?UTF-8?q?lat-alias=20currency,=20hold-aware=20demotion,=20txn-scoped=20d?= =?UTF-8?q?edup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rootKeysCurrent closes the dp===null alias bypass (admission + delivery demote); demotion re-drives use schedule:true into held owner queues (warm owners keep the lane-timed immediate run); dedup is stamped per transaction (bt plain / bo optimistic — also absorbs the tentative double emission). Boundary-hold repro still unachieved from public API — the brief requests the auditor's composition. dbmon unchanged: 6.6 / 2.0 / 0.6. Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10-6.md | 6 ++ packages/signals/AUDIT-BRIEF-R6.md | 31 ++++++++++ packages/signals/src/store/next/patch.ts | 61 +++++++++++++++---- packages/signals/src/store/next/store.ts | 23 +++++++ packages/signals/src/store/next/target.ts | 7 +++ .../web/test/for.patchinvariants.spec.tsx | 39 ++++++------ scripts/size/.size-limit.js | 9 ++- 7 files changed, 141 insertions(+), 35 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10-6.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 2a2320ad0..fdb01410b 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,36 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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.** diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 4db292eac..29c822907 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -47,7 +47,13 @@ import { optHooks } from "./target.js"; import { emitSetterRowOps } from "./reconcile.js"; // Cycle with store.js is benign (established pattern above): both resolve at // call time, long after module initialization. -import { deepPathsPlain, heldMaskView, targetIsPlain, targetKeysPlain } from "./store.js"; +import { + deepPathsPlain, + heldMaskView, + rootKeysCurrent, + targetIsPlain, + targetKeysPlain +} from "./store.js"; import type { DeepNode } from "./target.js"; import { InvariantHooks } from "../../core/invariants.js"; @@ -591,13 +597,14 @@ function bumpOne(t: StoreNextTarget, pc: any): void { } 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 — OUTSIDE transitions only (round 10.5, - // F1). Under one, every write must reach the signal: transition - // 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. - if (activeTransition === null) return; + // 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 (activeTransition === null || pc.bt === activeTransition) return; } else if (pc.p === null && activeTransition === null) { return; } @@ -605,6 +612,7 @@ function bumpOne(t: StoreNextTarget, pc: any): void { // 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 = activeTransition; setSignal(pc.dn, (v: number) => v + 1); } @@ -620,12 +628,19 @@ function bumpOneOptimistic(t: StoreNextTarget, pc: any): void { if (pc.de === undefined) { if (pc.p === null) return; ensureDelivery(t, pc); + } else if (pc.bc !== pc.dv && activeTransition !== null && pc.bo === activeTransition) { + // SAME-TRANSACTION optimistic dedup (round 10.6, P2): 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. + return; } // Override-armed write: in-flight visibility now, re-notify on revert — - // the engine is installed by every optimistic caller of this seam. NO - // pending-dedup: every engine write registers with the transaction's - // revert bookkeeping. + // the engine is installed by every optimistic caller of this seam. pc.bc++; + pc.bo = activeTransition; 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); @@ -747,6 +762,13 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { 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. + if (!npHit && pc.ak !== null && !rootKeysCurrent(t, heldMaskView(t) ?? t.v, 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; @@ -925,10 +947,13 @@ export function patchableRaw(record: any, keys?: string[]): Record createRenderEffect( @@ -1015,7 +1049,8 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { // Block body: a compiled patch body's return value must not be // mistaken for an effect cleanup. untrack(() => fn(proxy, undefined, true)); - } + }, + held ? { schedule: true } : undefined ) ); } catch (err) { diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 5fb28e7d6..e8b209cd9 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -162,6 +162,8 @@ export function pcOf(t: StoreNextTarget): PatchChannel { np: undefined, npb: 0, dmq: false, + bt: null, + bo: null, ak: null, dp: null, ks: false, @@ -469,6 +471,27 @@ export function targetKeysPlain(target: StoreNextTarget, next: Record { const { createRevealOrder } = await import("solid-js"); const [state, setState] = createStore({ row: { label: "v1" } }); - const [gate, setGate] = createSignal(0); + const [gate, setGate] = createRoot(() => createSignal(0)); + const [coll, setColl] = createRoot(() => createSignal(false)); 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 Content = () => { const data = createMemo(() => { gate(); return new Promise(r => resolvers.push(r)); }); - return {data()}; - }; - const B = () => { createRenderEffect( () => state.row.label, (v: string) => { @@ -560,17 +558,14 @@ describe("INVARIANT: mount, delivery, and swap all answer ONE visibility questio }, ["label"] ); - return b; + return {data()}; }; - return (createRevealOrder as any)( - () => ( - <> - {
    } - {} - - ), - { order: () => "together" } - ); + // The collapsed ACCESSOR forces the queue-collapse state directly — + // combined with a pending boundary this is the "genuinely pending + // collapsed CollectionQueue" hold. + return (createRevealOrder as any)(() => {}, { + collapsed: () => coll() + }); }, div); resolvers.pop()!("d1"); await Promise.resolve(); @@ -578,8 +573,8 @@ describe("INVARIANT: mount, delivery, and swap all answer ONE visibility questio flush(); expect(classic.data).toBe("v1"); expect(patched.data).toBe("v1"); - // Sibling A re-pends: "together" collapses BOTH queues while revealed - // content stays attached — the held window with visible DOM. + // Collapse + re-pend: the boundary queue now HOLDS its render effects. + setColl(true); setGate(1); flush(); setState((s: any) => { @@ -588,10 +583,14 @@ describe("INVARIANT: mount, delivery, and swap all answer ONE visibility questio flush(); // PARITY is the invariant: whatever the held render effect shows, the // patch sink shows. A patch racing ahead of a held classic binding is - // the round-10 finding. - if (process.env.DEBUG_HOLD) expect("held:" + classic.data + "|" + patched.data).toBe("PROBE"); - expect([classic.data, patched.data]).toEqual([classic.data, classic.data]); + // the round-10 finding. (Repro gap, documented in the brief: this + // composition has not been observed to actually enter the held state — + // both sinks apply. The dispatch/demotion hold routing mirrors + // CollectionQueue.run's gate; a composition that genuinely holds the + // classic sink should be added here when one is identified.) + expect(patched.data).toBe(classic.data); resolvers.pop()!("d2"); + setColl(false); await Promise.resolve(); await Promise.resolve(); flush(); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 8b8b92c20..eee4e588c 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -400,8 +400,11 @@ module.exports = [ // 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: "16.1 KB", + limit: "16.2 KB", modifyEsbuildConfig }, { @@ -437,8 +440,10 @@ module.exports = [ // // 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: "18.6 KB", + limit: "18.75 KB", modifyEsbuildConfig }, { From 4b6619939dc9d7e87771563574264203b721fca3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 08:52:56 -0700 Subject: [PATCH 20/56] =?UTF-8?q?fix:=20round-10.7=20audit=20=E2=80=94=20c?= =?UTF-8?q?anonical=20stamps,=20held-fanout=20isolation,=20unbind-cancel,?= =?UTF-8?q?=20hold=20repro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedup stamps store/compare through currentTransition and release at delivery (leak + merge-dedup in one); scheduled held redrives once-guard their first run (per-entry isolation at release); demotion severing (dm) split from unbind (u) so explicit unbinds cancel queued redrives. The auditor's two-boundary sequential-reveal composition lands as the hold regression test — RED without the queue-held probe, closing round-10 P1-4 end-to-end. dbmon unchanged: 6.6 / 2.1 / 0.6. Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10-7.md | 5 + packages/signals/AUDIT-BRIEF-R6.md | 28 +++++ packages/signals/src/store/next/patch.ts | 100 +++++++++++++----- .../web/test/for.patchinvariants.spec.tsx | 59 +++++------ scripts/size/.size-limit.js | 6 +- 5 files changed, 141 insertions(+), 57 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10-7.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index fdb01410b..b0d46f167 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,33 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 29c822907..bf5997c85 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -32,6 +32,7 @@ import { haltReactivity } from "../../core/scheduler.js"; import { getOwner, isDisposed } from "../../core/owner.js"; import { activeTransition, + currentTransition, globalQueue, GlobalQueue, setPatchCommitHook, @@ -75,6 +76,10 @@ interface PatchEntry { /** Keys recorded (adoption demotion probes); undefined = record at the * next drain apply. */ k?: 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; /** 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; @@ -209,7 +214,7 @@ function deferHeldEntry(entry: PatchEntry, oq: any, pc: any): void { entry.hq = true; oq.enqueue(EFFECT_RENDER, () => { entry.hq = false; - if (entry.u === true) return; + if (entry.u === true || entry.dm === true) return; if (entry.owner !== null && isDisposed(entry.owner)) return; const err = applyEntries([entry], visibleView(pc.t, pc), PER_ENTRY_PREV, false, UNSET, pc); if (err !== UNSET) { @@ -259,7 +264,7 @@ function applyEntries( const len = snap.length; for (let j = 0; j < len; j++) { const entry = snap[j]; - if (entry === undefined || 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 @@ -591,6 +596,12 @@ function unionKeys( * 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): 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); @@ -604,15 +615,15 @@ function bumpOne(t: StoreNextTarget, pc: any): void { // 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 (activeTransition === null || pc.bt === activeTransition) return; - } else if (pc.p === null && activeTransition === null) { + if (txn === null || (pc.bt != null && currentTransition(pc.bt as Transition) === txn)) 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 = activeTransition; + pc.bt = txn; setSignal(pc.dn, (v: number) => v + 1); } @@ -625,22 +636,29 @@ function bumpAncestors(t: StoreNextTarget): void { } function bumpOneOptimistic(t: StoreNextTarget, pc: any): 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 && activeTransition !== null && pc.bo === activeTransition) { - // SAME-TRANSACTION optimistic dedup (round 10.6, P2): 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. + } 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. 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 = activeTransition; + 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); @@ -729,6 +747,11 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { () => { if (pc.bc === pc.dv) return; // pure-registration run: baselines are per-entry pc.dv = pc.bc; + // 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; const p = pc.p as PatchEntry[] | null; if (p === null) { // Inert (demoted or emptied). A deferred-demotion latch queued for @@ -968,12 +991,14 @@ 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): 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 (an - // untracked duplicate of a getter-bearing body). - for (let i = 0; i < p.length; i++) p[i].u = true; + // 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". + for (let i = 0; i < p.length; i++) p[i].dm = true; // 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); @@ -1029,6 +1054,11 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { const heldProbe = GlobalQueue._queueHeld; 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; // HELD owners schedule their initial run through their own queue @@ -1039,17 +1069,39 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { // in-flight, and deferral would postpone the tentative view). const oq = entry.q as any; const held = heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq); + // FIRST scheduled run is per-entry isolated (round 10.7, P1): the + // queued initial applies run back-to-back at release — an + // unboundaried throw from one must not abort the queue before its + // healthy siblings install (the same contract the immediate path's + // creation try/catch pins). Later runs keep classic effect error + // semantics. + let first = held; + const commit = () => { + if (first) { + first = false; + try { + untrack(() => fn(proxy, undefined, true)); + } catch (err) { + if (!routeEntryError(entry, err)) { + globalQueue.enqueue(EFFECT_USER, () => { + haltReactivity(err); + throw 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 { runWithOwner(entry.owner, () => createRenderEffect( () => { fn(proxy, proxy, false); }, - () => { - // Block body: a compiled patch body's return value must not be - // mistaken for an effect cleanup. - untrack(() => fn(proxy, undefined, true)); - }, + commit, held ? { schedule: true } : undefined ) ); diff --git a/packages/web/test/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx index bb8da7cc0..32b27ab0c 100644 --- a/packages/web/test/for.patchinvariants.spec.tsx +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -531,20 +531,22 @@ describe("INVARIANT: mount, delivery, and swap all answer ONE visibility questio }); 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 [gate, setGate] = createRoot(() => createSignal(0)); - const [coll, setColl] = createRoot(() => createSignal(false)); const resolvers: Array<(v: string) => void> = []; const classic = document.createTextNode(""); const patched = document.createTextNode(""); const div = document.createElement("div"); const disposer = render(() => { - const Content = () => { - const data = createMemo(() => { - gate(); - return new Promise(r => resolvers.push(r)); - }); + const A = () => { + const data = createMemo(() => new Promise(r => resolvers.push(r))); + return {data()}; + }; + const B = () => { createRenderEffect( () => state.row.label, (v: string) => { @@ -558,39 +560,34 @@ describe("INVARIANT: mount, delivery, and swap all answer ONE visibility questio }, ["label"] ); - return {data()}; + return b; }; - // The collapsed ACCESSOR forces the queue-collapse state directly — - // combined with a pending boundary this is the "genuinely pending - // collapsed CollectionQueue" hold. - return (createRevealOrder as any)(() => {}, { - collapsed: () => coll() - }); + return (createRevealOrder as any)( + () => ( + <> + {} + {} + + ), + { collapsed: () => true } + ); }, div); - resolvers.pop()!("d1"); - await Promise.resolve(); - await Promise.resolve(); 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"); - // Collapse + re-pend: the boundary queue now HOLDS its render effects. - setColl(true); - setGate(1); - flush(); + // 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(); - // PARITY is the invariant: whatever the held render effect shows, the - // patch sink shows. A patch racing ahead of a held classic binding is - // the round-10 finding. (Repro gap, documented in the brief: this - // composition has not been observed to actually enter the held state — - // both sinks apply. The dispatch/demotion hold routing mirrors - // CollectionQueue.run's gate; a composition that genuinely holds the - // classic sink should be added here when one is identified.) - expect(patched.data).toBe(classic.data); - resolvers.pop()!("d2"); - setColl(false); + 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(); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index eee4e588c..7805d6182 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -404,7 +404,8 @@ module.exports = [ // transaction-scoped dedup stamps, hold-aware demotion scheduling. // Measured 16.15. path: "csr-app-patch.js", - limit: "16.2 KB", + // Round-10.7: canonical txn stamps + once-guarded held redrives. 16.22. + limit: "16.3 KB", modifyEsbuildConfig }, { @@ -443,7 +444,8 @@ module.exports = [ // Round-10.6 (2026-08-31): same value-tier bytes as csr-app-patch. // Measured 18.66. path: "csr-app-patch-lists.js", - limit: "18.75 KB", + // Round-10.7: same bytes as the value tier. 18.79. + limit: "18.85 KB", modifyEsbuildConfig }, { From cd8439b64c3a88107ddb5489eb76a11bc7c7fe83 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 10:00:56 -0700 Subject: [PATCH 21/56] =?UTF-8?q?fix:=20round-10.8=20audit=20(final)=20?= =?UTF-8?q?=E2=80=94=20scheduled=20demotion=20lifecycle=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute passes capture per entry (throwing getters route to boundaries, never halting held siblings mid-scheduling); re-driven entries own disposable roots so unbind cancels the fallback queued or live (retires the round-8 demoted-rows-outlive-removal edge; round-6 inert-unbind expectation updated). Worth-it ledger in the brief: patch vs classic on the same runtime is 6.6/2.1/0.6 vs 15.1/7.9/1.2 dbmon. Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10-8.md | 5 ++ packages/signals/AUDIT-BRIEF-R6.md | 19 +++++ packages/signals/src/store/next/patch.ts | 72 +++++++++++------ .../signals/tests/store/patch-channel.test.ts | 12 ++- .../tests/store/patch-invariants.test.ts | 78 +++++++++++++++++++ 5 files changed, 160 insertions(+), 26 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10-8.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index b0d46f167..4708a368b 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,24 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index bf5997c85..d632ffbe8 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -29,7 +29,7 @@ import { 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, @@ -80,6 +80,9 @@ interface PatchEntry { * 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; + /** 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; @@ -890,6 +893,10 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable void) => { + try { + run(); + } catch (err) { + if (!routeEntryError(entry, err)) { + const e = err; + globalQueue.enqueue(EFFECT_USER, () => { + haltReactivity(e); + throw e; + }); + } + } + }; // FIRST scheduled run is per-entry isolated (round 10.7, P1): the - // queued initial applies run back-to-back at release — an - // unboundaried throw from one must not abort the queue before its - // healthy siblings install (the same contract the immediate path's - // creation try/catch pins). Later runs keep classic effect error - // semantics. + // queued initial applies run back-to-back at release. Later runs + // keep classic effect error semantics. let first = held; const commit = () => { if (first) { first = false; - try { - untrack(() => fn(proxy, undefined, true)); - } catch (err) { - if (!routeEntryError(entry, err)) { - globalQueue.enqueue(EFFECT_USER, () => { - haltReactivity(err); - throw err; - }); - } - } + captured(() => untrack(() => fn(proxy, undefined, true))); return; } // Block body: a compiled patch body's return value must not be @@ -1096,14 +1112,24 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { 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, instead of leaving it applying until the OWNER + // dies (this also retires the round-8 "demoted list rows outlive + // removal" accepted edge for driver rows, whose per-row unbinds + // run on removal). runWithOwner(entry.owner, () => - createRenderEffect( - () => { - fn(proxy, proxy, false); - }, - commit, - held ? { schedule: true } : undefined - ) + createRoot(d => { + (entry as any).dd = d; + createRenderEffect( + () => { + captured(() => fn(proxy, proxy, false)); + }, + commit, + held ? { schedule: true } : undefined + ); + }) ); } catch (err) { if (!routeEntryError(entry, err) && firstError === UNSET) firstError = err; diff --git a/packages/signals/tests/store/patch-channel.test.ts b/packages/signals/tests/store/patch-channel.test.ts index c3d857f82..388855f2d 100644 --- a/packages/signals/tests/store/patch-channel.test.ts +++ b/packages/signals/tests/store/patch-channel.test.ts @@ -526,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 @@ -536,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-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index e310165a6..caddb72e9 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -801,6 +801,84 @@ describe("INVARIANT: demotion fanout is per-entry isolated (round 10)", () => { }); }); +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"); From 5ef1aa382ca31ba84f50dfba275a71d94f470c58 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 10:36:56 -0700 Subject: [PATCH 22/56] =?UTF-8?q?perf(size):=20patch-channel=20consolidati?= =?UTF-8?q?on=20pass=20=E2=80=94=20recording=20proxy=20deleted,=20single-m?= =?UTF-8?q?ode=20dispatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manifest-less registrations poison the key union (akAll → full-scan probes) replacing the drain-side recording proxy; applyEntries drops its dead prev/force modes; deferHalt/routeEntryError shared across all four dispatch shapes; emitPatchLocal aliased. Value tier 16.22 → 16.05 kB, list 18.79 → 18.60 (ratchets tightened). Includes the auditor's round-10.8 lifecycle suite (8 tests, synthetic held-queue harness). Ticks unchanged in paired A/B (2.4/2.4 under load; mount delta within sd=1.07 noise — quiet-machine confirm noted). Co-authored-by: Cursor --- .changeset/patch-channel-size-pass.md | 6 + packages/signals/src/store/next/patch.ts | 145 ++++------ packages/signals/src/store/next/store.ts | 17 +- packages/signals/src/store/next/target.ts | 4 + .../store/__audit-round108-lifecycle.test.ts | 266 ++++++++++++++++++ packages/web/src/patch-driver.ts | 19 +- .../web/test/__audit-round108-public.spec.tsx | 198 +++++++++++++ scripts/size/.size-limit.js | 8 +- 8 files changed, 545 insertions(+), 118 deletions(-) create mode 100644 .changeset/patch-channel-size-pass.md create mode 100644 packages/signals/tests/store/__audit-round108-lifecycle.test.ts create mode 100644 packages/web/test/__audit-round108-public.spec.tsx 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/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index d632ffbe8..e593a3571 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -73,9 +73,6 @@ interface PatchEntry { owner: Owner | null; /** Unbound mark: dispatch snapshots skip severed consumers. */ u?: boolean; - /** Keys recorded (adoption demotion probes); undefined = record at the - * next drain apply. */ - k?: 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. */ @@ -180,26 +177,13 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk if (item.si !== undefined) entry.fn(item.si, next, item.prev); else entry.fn(next, item.ops); } catch (err) { - let handled = false; - const owner = entry.owner as any; - if (owner !== null) { - 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 as any, err) && firstError === UNSET) firstError = err; } } return firstError; } const UNSET: unique symbol = Symbol(); -/** Sentinel: applyEntries resolves prev per entry (node delivery). */ -const PER_ENTRY_PREV: unique symbol = Symbol(); /** ONE callback/error primitive for every drain (normal, transition-held, * optimistic): per-entry isolation — a throwing patch must not abort its @@ -219,13 +203,8 @@ function deferHeldEntry(entry: PatchEntry, oq: any, pc: any): void { entry.hq = false; if (entry.u === true || entry.dm === true) return; if (entry.owner !== null && isDisposed(entry.owner)) return; - const err = applyEntries([entry], visibleView(pc.t, pc), PER_ENTRY_PREV, false, UNSET, pc); - if (err !== UNSET) { - globalQueue.enqueue(EFFECT_USER, () => { - haltReactivity(err); - throw err; - }); - } + const err = applyEntries([entry], visibleView(pc.t, pc), UNSET, pc); + if (err !== UNSET) deferHalt(err); }); } @@ -247,24 +226,27 @@ function routeEntryError(entry: PatchEntry, err: unknown): boolean { return owner._queue.notify(source, STATUS_ERROR, STATUS_ERROR, statusErr) as boolean; } -function applyEntries( - list: PatchEntry[], - next: any, - prev: any, - force: boolean, - firstError: unknown, - pc?: { ak: PropertyKey[] | null } -): unknown { +/** 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. - const snap = list.length > 1 ? list.slice() : list; // 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; const len = snap.length; + const heldProbe = GlobalQueue._queueHeld; for (let j = 0; j < len; j++) { const entry = snap[j]; if (entry === undefined || entry.u === true || entry.dm === true) continue; @@ -274,47 +256,20 @@ function applyEntries( // 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. - if (prev === PER_ENTRY_PREV) { - const heldProbe = GlobalQueue._queueHeld; - const oq = entry.q as any; - if (heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq)) { - deferHeldEntry(entry, oq, pc as any); - continue; - } + const oq = entry.q as any; + if (heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq)) { + deferHeldEntry(entry, oq, pc); + continue; } try { - // First-apply key recording (re-audit 6): entries registered without a - // recorded read set (hydration skips the initial apply) record here — - // one proxied apply per entry lifetime keeps the adoption demotion - // gate prod-sound for them too. - if (pc !== undefined && entry.k !== true && next !== null && typeof next === "object") { - entry.k = true; - ensureOwnedKeys(pc as any); // interned manifests are copy-on-write - const ak = (pc.ak ??= []); - const rec = new Proxy(next as object, { - get(o, key, r) { - if (ak.indexOf(key) === -1) ak.push(key); - return Reflect.get(o, key, r); - } - }); - entry.fn(rec, prev === PER_ENTRY_PREV ? (entry as any).pv : prev, force); - if (prev === PER_ENTRY_PREV) { - const px = (pc as any).t?.px; - (entry as any).pv = next === px ? untrack(() => manifestSnapshot(pc as any, next)) : next; - } - } else { - const ep = prev === PER_ENTRY_PREV ? (entry as any).pv : prev; - // 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 && prev === PER_ENTRY_PREV) entry.fn(next, undefined, true); - else entry.fn(next, ep, force); - if (prev === PER_ENTRY_PREV) { - const px = (pc as any).t?.px; - (entry as any).pv = next === px ? untrack(() => manifestSnapshot(pc as any, next)) : next; - } - } + 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) { if (!routeEntryError(entry, err) && firstError === UNSET) firstError = err; } @@ -406,10 +361,8 @@ export function emitPatchAncestorsOptimistic(t: StoreNextTarget, _tx: unknown): /** 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 function emitPatchLocal(t: StoreNextTarget, next: any, prev: any): void { - emitPatch(t, next, prev); -} + * 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 @@ -791,7 +744,12 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { // 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. - if (!npHit && pc.ak !== null && !rootKeysCurrent(t, heldMaskView(t) ?? t.v, pc.ak)) { + // 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; } @@ -799,17 +757,13 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { pc.np = undefined; const snap = p.length > 1 ? p.slice() : p; let firstError: unknown = UNSET; - firstError = applyEntries(snap, next, PER_ENTRY_PREV, false, firstError, pc); + 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. - const err = firstError; - globalQueue.enqueue(EFFECT_USER, () => { - haltReactivity(err); - throw err; - }); + deferHalt(firstError); } } ); @@ -867,7 +821,14 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable { - haltReactivity(e); - throw e; - }); - } + if (!routeEntryError(entry, err)) deferHalt(err); } }; // FIRST scheduled run is per-entry isolated (round 10.7, P1): the @@ -1135,13 +1090,7 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { if (!routeEntryError(entry, err) && firstError === UNSET) firstError = err; } } - if (firstError !== UNSET) { - const err = firstError; - globalQueue.enqueue(EFFECT_USER, () => { - haltReactivity(err); - throw err; - }); - } + if (firstError !== UNSET) deferHalt(firstError); }; if (immediate) redrive(); else globalQueue.enqueue(EFFECT_RENDER, redrive); diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index e8b209cd9..75d663ef7 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -167,6 +167,7 @@ export function pcOf(t: StoreNextTarget): PatchChannel { ak: null, dp: null, ks: false, + akAll: false, t }) ); @@ -457,7 +458,10 @@ export function targetIsPlain(target: StoreNextTarget): boolean { * applied) get a full fresh scan of the same object. */ export function targetKeysPlain(target: StoreNextTarget, next: Record): boolean { if (!isPlainProto(next)) return false; - const ak = target.pc !== null ? target.pc.ak : null; + // 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) @@ -479,11 +483,16 @@ export function targetKeysPlain(target: StoreNextTarget, next: Record 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); + expect(log).toEqual(["thrower-commit", "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); + expect(log).toEqual(["thrower-commit", "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"); + expect(log).toEqual(["commit"]); + resetErrorHalt(); + setThrows(false); + flush(); + expect(log).toEqual(["commit", "commit"]); + setDep("d2"); + flush(); + expect(log).toEqual(["commit", "commit", "commit"]); + dispose(); + }); +}); diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 114af1e8c..1f0c24351 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -642,21 +642,12 @@ export const patchDriver = (subject, body, keys?: string[]) => { untrack(() => body(src, undefined, true)); } unbind = registerPatch(subject, body, keys); - } else if (!sharedConfig.hydrating) { - // Manifest-less callers (hand-written registrations): record the - // EXECUTED read set through the initial force-apply. Incomplete for - // branch-reading bodies by construction — compiled output always - // ships the manifest. - const rkeys = new Set(); - const rec = new Proxy(raw, { - get(o, k, r) { - rkeys.add(k); - return Reflect.get(o, k, r); - } - }); - body(rec, undefined, true); - unbind = registerPatch(subject, body, rkeys); } 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); 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/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 7805d6182..98eb6af1d 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -405,7 +405,10 @@ module.exports = [ // Measured 16.15. path: "csr-app-patch.js", // Round-10.7: canonical txn stamps + once-guarded held redrives. 16.22. - limit: "16.3 KB", + // Size pass (2026-08-31): recording proxy deleted (akAll full-scan), + // applyEntries single-mode, deferHalt/routeEntryError consolidation. + // Measured 16.05 — ratchet tightened. + limit: "16.15 KB", modifyEsbuildConfig }, { @@ -445,7 +448,8 @@ module.exports = [ // Measured 18.66. path: "csr-app-patch-lists.js", // Round-10.7: same bytes as the value tier. 18.79. - limit: "18.85 KB", + // Size pass (2026-08-31): same trims. Measured 18.60 — tightened. + limit: "18.7 KB", modifyEsbuildConfig }, { From 5a54d9fdf172fb1a6f65f680cbff00a9e71f9e5b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 11:16:34 -0700 Subject: [PATCH 23/56] =?UTF-8?q?dev:=20channel-side=20HUGE=5FFAN=5FOUT/WI?= =?UTF-8?q?DE=5FWRITE=20twins=20=E2=80=94=20patch=20keeps=20fanout=20diagn?= =?UTF-8?q?osable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch consumers aren't graph subscribers (_subCount sees one delivery edge), so the channel witnesses its own shape: registration milestones fire HUGE_FAN_OUT, wide dispatches fire WIDE_WRITE with a doubling memo — same codes/thresholds as the graph twins, dev-only (sizes unchanged). Multi-subject/dynamic-key expressions stay patch-ineligible and keep classic graph attribution. Stale createSelector advice updated. Co-authored-by: Cursor --- .../patch-channel-fanout-diagnostics.md | 5 ++ packages/signals/src/core/attribution.ts | 2 +- packages/signals/src/core/dev.ts | 2 +- packages/signals/src/store/next/patch.ts | 48 ++++++++++++++++++- packages/signals/src/store/next/target.ts | 2 + .../tests/attribution-wide-write.test.ts | 2 +- .../tests/store/patch-invariants.test.ts | 23 +++++++++ 7 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 .changeset/patch-channel-fanout-diagnostics.md 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/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index 4fd6329ab..294f06a9f 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -322,7 +322,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", 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/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index e593a3571..1ed77e2c6 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -58,7 +58,12 @@ import { import type { DeepNode } from "./target.js"; import { InvariantHooks } from "../../core/invariants.js"; -import { assertInvariant } from "../../core/dev.js"; +import { + assertInvariant, + emitDiagnostic, + GRAPH_SIZE_WARN_AT, + shouldWarnGraphSize +} from "../../core/dev.js"; import { runWithOwner, untrack } from "../../core/core.js"; import { createRenderEffect } from "../../signals.js"; import { deliveryEffect } from "../../core/effect.js"; @@ -717,6 +722,26 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { pc.dmq = false; return; } + if (__DEV__ && p.length >= GRAPH_SIZE_WARN_AT && p.length >= (pc.dw ?? 0) * 2) { + // WIDE_WRITE parity for the channel (see the registration-side + // HUGE_FAN_OUT twin): a delivery to N consumers is the same + // cost the graph warning polices, made invisible to `_subCount` + // by design. Doubling memo, matching checkWideWrite. + pc.dw = p.length; + const message = + `[WIDE_WRITE] a store write dispatched to ${p.length} patch template consumers ` + + `on one record — 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, + data: { patchConsumers: p.length } + }); + console.warn(message); + } // 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 @@ -795,6 +820,27 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable { 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/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index caddb72e9..14c75d348 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -801,6 +801,29 @@ describe("INVARIANT: demotion fanout is per-entry isolated (round 10)", () => { }); }); +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. + setState((s: any) => { + s.cfg.theme = "b"; + }); + flush(); + expect(warnSpy.mock.calls.some(c => String(c[0]).includes("[WIDE_WRITE]"))).toBe(true); + 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")); From 2f1017c38e0a05c4e1619c2eb12154ee5302ab5d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 11:36:52 -0700 Subject: [PATCH 24/56] dev: attribution cause chains through patch deliveries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit patchEmit hook re-stamps the delivery signal after each bump with the record's store path (name-only on bubbles, value previews on self emissions), and delivery effects self-name patchDelivery(store.path) — rerun events for patch-applied updates now read as the causing record write. Dev-only via attrHooks (no engine coupling, no-try rule kept, sizes unchanged). Pinned by tests/attribution-patch.test.ts. Co-authored-by: Cursor --- .changeset/patch-attribution-cause-chains.md | 5 +++ .../signals/src/core/attribution-hooks.ts | 11 ++++++ packages/signals/src/core/attribution.ts | 9 +++++ packages/signals/src/store/next/patch.ts | 26 +++++++++++++ .../signals/tests/attribution-patch.test.ts | 38 +++++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 .changeset/patch-attribution-cause-chains.md create mode 100644 packages/signals/tests/attribution-patch.test.ts 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/packages/signals/src/core/attribution-hooks.ts b/packages/signals/src/core/attribution-hooks.ts index 37528c988..aeb738639 100644 --- a/packages/signals/src/core/attribution-hooks.ts +++ b/packages/signals/src/core/attribution-hooks.ts @@ -55,6 +55,17 @@ 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 re-stamp + * name-only. + */ + patchEmit(dn: Signal, name: string, prev: unknown, next: unknown, withValues: boolean): void; } export let attrHooks: AttributionHooks | null = null; diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index 294f06a9f..2fa059f90 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -741,6 +741,15 @@ const engineHooks: AttributionHooks = { write(el, prev, value) { stampWrite(el, "write", prev, value); }, + patchEmit(dn, name, prev, next, withValues) { + // 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; + if (withValues) stampWrite(dn, "write", prev, next); + else stampWrite(dn, "write"); + }, refreshed(el) { stampWrite(el, "refresh"); }, diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 1ed77e2c6..22e4dabbb 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -64,6 +64,7 @@ import { GRAPH_SIZE_WARN_AT, 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"; @@ -341,6 +342,10 @@ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { bumpOne(t, pc); 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); } @@ -586,6 +591,23 @@ function bumpOne(t: StoreNextTarget, pc: any): void { 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. + if (__DEV__ && attrHooks !== null) attrHooks.patchEmit(pc.dn, targetPath(t), null, null, false); +} + +/** 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 { @@ -623,6 +645,7 @@ function bumpOneOptimistic(t: StoreNextTarget, pc: any): void { 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); } /** Manifest-shaped prev snapshot: roots copied flat, deep paths rebuilt as @@ -792,6 +815,9 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { } } ); + // 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) + ")"; }); } diff --git a/packages/signals/tests/attribution-patch.test.ts b/packages/signals/tests/attribution-patch.test.ts new file mode 100644 index 000000000..e08adf4ca --- /dev/null +++ b/packages/signals/tests/attribution-patch.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRoot, 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"); + }); +}); From df8c2161ef5ad710da70d832601086a678ed365c Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 12:04:45 -0700 Subject: [PATCH 25/56] =?UTF-8?q?fix:=20round-10.9=20audit=20=E2=80=94=20d?= =?UTF-8?q?emotion=20computes=20read=20own=20envelopes,=20failed=20compute?= =?UTF-8?q?s=20never=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-entry manifest envelopes for demoted bodies (the channel union would subscribe and fail every sibling on one sibling's keys); computeFailed latch skips the commit after a routed compute error (the auditor's lifecycle suite updated from the swallow-then-apply behavior it pinned); redrive roots transparent (hydration-ID depth parity); akAll ref-counted via entry.ml/pc.mlc, released on unbind and demotion. Three tier ratchets (+105/+23/+8 B — demotion lifecycle bytes). Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10-9.md | 5 + packages/signals/src/store/next/patch.ts | 108 +++++++++++++----- packages/signals/src/store/next/store.ts | 1 + packages/signals/src/store/next/target.ts | 4 +- .../store/__audit-round108-lifecycle.test.ts | 18 ++- .../tests/store/patch-invariants.test.ts | 19 +++ scripts/size/.size-limit.js | 13 ++- 7 files changed, 132 insertions(+), 36 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10-9.md 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/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 22e4dabbb..a58c41aac 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -83,6 +83,14 @@ interface PatchEntry { * 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?: { roots: PropertyKey[]; dp: DeepNode[] | null } | 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; @@ -597,6 +605,17 @@ function bumpOne(t: StoreNextTarget, pc: any): void { if (__DEV__ && attrHooks !== null) attrHooks.patchEmit(pc.dn, targetPath(t), null, null, false); } +/** Tracked read of a manifest deep-path subtree THROUGH the proxy — the + * demotion fallback's compute pass (round 10.9): subscribes exactly the + * declared envelope, runs no body code. */ +function readDeepNode(node: DeepNode, o: any): void { + if (o === null || typeof o !== "object") return; + const v = o[node.k]; + const children = node.c; + if (children !== null && v !== null && typeof v === "object") + for (let i = 0; i < children.length; i++) readDeepNode(children[i], v); +} + /** 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). */ @@ -883,6 +902,7 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable String(k)); + entry.mk = internManifest(arr); + unionKeys(pc, arr); } } else { // MANIFEST-LESS consumer (hand-written registerPatch; size pass): the @@ -900,6 +924,12 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable= 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 @@ -1038,7 +1070,12 @@ export function demotePatches(t: StoreNextTarget): PatchEntry[] | null { // 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". - for (let i = 0; i < p.length; i++) p[i].dm = true; + // 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); @@ -1109,18 +1146,34 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { // in-flight, and deferral would postpone the tentative view). const oq = entry.q as any; const held = heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq); - // COMPUTE throws are captured PER ENTRY (round 10.8, P1): a throwing - // getter in the tracked pass would otherwise route through the - // effect's own error machinery — an unboundaried one calls - // haltReactivity DURING creation/scheduling, poisoning the system - // before held healthy siblings ever release (the outer try/catch - // only sees the rethrow, too late). Reads before the throw stay - // tracked; unhandled errors defer one halt a phase later, after the - // fanout — the dispatch loop's exact contract. - const captured = (run: () => void) => { + // 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 { - run(); + if (mk !== null) { + const roots = mk.roots; + for (let k = 0; k < roots.length; k++) (proxy as any)[roots[k]]; + const dp = mk.dp; + if (dp !== null) for (let k = 0; k < dp.length; k++) readDeepNode(dp[k], proxy); + } else fn(proxy, proxy, false); } catch (err) { + computeFailed = true; if (!routeEntryError(entry, err)) deferHalt(err); } }; @@ -1129,9 +1182,14 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { // keep classic effect error semantics. let first = held; const commit = () => { + if (computeFailed) return; // the tracked pass failed — no apply if (first) { first = false; - captured(() => untrack(() => fn(proxy, undefined, true))); + 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 @@ -1142,21 +1200,17 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { // 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, instead of leaving it applying until the OWNER - // dies (this also retires the round-8 "demoted list rows outlive - // removal" accepted edge for driver rows, whose per-row unbinds - // run on removal). + // 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( - () => { - captured(() => fn(proxy, proxy, false)); - }, - commit, - held ? { schedule: true } : undefined - ); - }) + 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; diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 75d663ef7..30db67190 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -168,6 +168,7 @@ export function pcOf(t: StoreNextTarget): PatchChannel { dp: null, ks: false, akAll: false, + mlc: 0, t }) ); diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 979cc42a1..1eb9b91da 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -88,8 +88,10 @@ export interface PatchChannel { dw?: number; /** 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. */ + * 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 diff --git a/packages/signals/tests/store/__audit-round108-lifecycle.test.ts b/packages/signals/tests/store/__audit-round108-lifecycle.test.ts index 125362734..21e560dea 100644 --- a/packages/signals/tests/store/__audit-round108-lifecycle.test.ts +++ b/packages/signals/tests/store/__audit-round108-lifecycle.test.ts @@ -165,7 +165,11 @@ describe("compute capture and tracking", () => { expect(queue.queues[0]).toHaveLength(2); resetErrorHalt(); queue.run(EFFECT_RENDER); - expect(log).toEqual(["thrower-commit", "healthy:v1"]); + // 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(); }); @@ -196,7 +200,8 @@ describe("compute capture and tracking", () => { expect(() => flush()).not.toThrow(); expect(queue.queues[0]).toHaveLength(2); queue.run(EFFECT_RENDER); - expect(log).toEqual(["thrower-commit", "healthy:v1"]); + // Round 10.9: handled or not, a failed compute never commits. + expect(log).toEqual(["healthy:v1"]); owner.dispose(); }); @@ -253,14 +258,17 @@ describe("compute capture and tracking", () => { }); }); expect(() => flush()).toThrow("recoverable"); - expect(log).toEqual(["commit"]); + // Round 10.9: the failed compute's commit is skipped… + expect(log).toEqual([]); resetErrorHalt(); setThrows(false); flush(); - expect(log).toEqual(["commit", "commit"]); + // …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", "commit"]); + expect(log).toEqual(["commit", "commit"]); dispose(); }); }); diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 14c75d348..a91c33c7b 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -801,6 +801,25 @@ describe("INVARIANT: demotion fanout is per-entry isolated (round 10)", () => { }); }); +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" } }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 98eb6af1d..664861454 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -342,7 +342,9 @@ module.exports = [ // hold routing (per-entry queue defer), demotion fanout isolation, // family retention token. Measured 26.47. path: "hydrating-store-app.js", - limit: "26.55 KB", + // Round-10.9 (2026-08-31): demotion-lifecycle bytes (per-entry + // envelopes, commit skip, akAll refcount). Measured 26.56. + limit: "26.65 KB", modifyEsbuildConfig }, { @@ -408,7 +410,10 @@ module.exports = [ // Size pass (2026-08-31): recording proxy deleted (akAll full-scan), // applyEntries single-mode, deferHalt/routeEntryError consolidation. // Measured 16.05 — ratchet tightened. - limit: "16.15 KB", + // 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. + limit: "16.3 KB", modifyEsbuildConfig }, { @@ -449,7 +454,9 @@ module.exports = [ path: "csr-app-patch-lists.js", // Round-10.7: same bytes as the value tier. 18.79. // Size pass (2026-08-31): same trims. Measured 18.60 — tightened. - limit: "18.7 KB", + // Round-10.9 (2026-08-31): demotion-lifecycle bytes (see value tier). + // Measured 18.72. + limit: "18.8 KB", modifyEsbuildConfig }, { From 19260319899986e77430d08d6111c19716db115d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 12:55:03 -0700 Subject: [PATCH 26/56] =?UTF-8?q?fix:=20round-10.10=20audit=20=E2=80=94=20?= =?UTF-8?q?single-read=20envelopes,=20symbol=20manifests,=20observability?= =?UTF-8?q?=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readDeepChildren descends from the once-read root value (unstable getters track what they commit) and through functions; iterable manifests keep PropertyKey symbols (RED-verified: stringified envelopes tracked "Symbol(x)" and went stale); WIDE_WRITE twin engine-owned via patchDispatch (options.wideWrites, same memo/metadata); patchEmit origin threads the true write source through ancestor bubbles (chains report the child, not the ancestor); row-ops/slot registrations fire the HUGE_FAN_OUT milestones. Boundary-recovery thread closed by the auditor as a faulty probe. Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10-10.md | 5 + .../signals/src/core/attribution-hooks.ts | 21 ++- packages/signals/src/core/attribution.ts | 36 ++++- packages/signals/src/store/next/patch.ts | 150 +++++++++--------- packages/signals/src/store/next/target.ts | 2 - .../signals/tests/attribution-patch.test.ts | 23 +++ .../tests/store/patch-invariants.test.ts | 66 +++++++- 7 files changed, 224 insertions(+), 79 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10-10.md 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/packages/signals/src/core/attribution-hooks.ts b/packages/signals/src/core/attribution-hooks.ts index aeb738639..7475fefcc 100644 --- a/packages/signals/src/core/attribution-hooks.ts +++ b/packages/signals/src/core/attribution-hooks.ts @@ -62,10 +62,25 @@ export interface AttributionHooks { * 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 re-stamp - * name-only. + * 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): void; + patchEmit( + dn: Signal, + name: string, + prev: unknown, + next: unknown, + withValues: boolean, + origin?: Signal | string | null + ): void; + /** + * A patch channel is about to dispatch to `count` template consumers. + * The engine applies the SAME wide-write policy (threshold, doubling + * memo, metadata) it applies to graph subscriber counts. + */ + patchDispatch(dn: Signal, count: 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 2fa059f90..4154c8dbe 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -741,7 +741,7 @@ const engineHooks: AttributionHooks = { write(el, prev, value) { stampWrite(el, "write", prev, value); }, - patchEmit(dn, name, prev, next, withValues) { + 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 @@ -749,6 +749,40 @@ const engineHooks: AttributionHooks = { (dn as AttributedNode & Signal)._name = name; if (withValues) stampWrite(dn, "write", prev, next); else stampWrite(dn, "write"); + // 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) { + const rec = (dn as AttributedNode)._devChange!; + const oc = + typeof origin === "string" + ? ({ seq: rec.seq, kind: "write", name: origin } as ChangeRecord) + : (origin as AttributedNode)._devChange; + if (oc !== undefined) rec.causes = [oc]; + } + }, + patchDispatch(dn, count) { + // SAME policy as graph wide-writes (round 10.10): threshold from + // options.wideWrites, doubling memo, subscriber metadata — the channel + // consumer list IS this node's fan-out, invisible to `_subCount`. + const limit = options.wideWrites; + if (typeof limit !== "number") return; + const attributed = dn as AttributedNode; + if (count < limit || count < (attributed._devWideWriteWarnedAt ?? 0) * 2) return; + attributed._devWideWriteWarnedAt = count; + const message = + `[WIDE_WRITE] write to "${nodeName(dn)}" dispatched to ${count} patch template ` + + `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: nodeName(dn), + data: { subscribers: count, write: "patch" } + }); + console.warn(message); }, refreshed(el) { stampWrite(el, "refresh"); diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index a58c41aac..00fe7245d 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -58,12 +58,7 @@ import { import type { DeepNode } from "./target.js"; import { InvariantHooks } from "../../core/invariants.js"; -import { - assertInvariant, - emitDiagnostic, - GRAPH_SIZE_WARN_AT, - shouldWarnGraphSize -} from "../../core/dev.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"; @@ -370,9 +365,12 @@ export function emitPatchAncestors(t: StoreNextTarget): void { * 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).dn : targetPath(t); let u = t.u; while (u !== null) { - if (u.pc !== null) bumpOneOptimistic(u, u.pc); + if (u.pc !== null) bumpOneOptimistic(u, u.pc, origin); u = u.u; } } @@ -415,11 +413,7 @@ export function emitPatchOptimistic(t: StoreNextTarget, next: any, prev: any): v // 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); - let u = t.u; - while (u !== null) { - if (u.pc !== null) bumpOneOptimistic(u, u.pc); - u = u.u; - } + emitPatchAncestorsOptimistic(t, null); } /** Row-ops emission at OPTIMISTIC (lane) timing: user drafts on an @@ -475,7 +469,7 @@ interface ProcessedManifest { roots: PropertyKey[]; dp: DeepNode[] | null; } -const manifestCache = new WeakMap(); +const manifestCache = new WeakMap(); /** Insert a dot-split path into the prefix tree (see PatchChannel.dp). */ function insertPath(dp: DeepNode[], segs: string[]): void { @@ -496,7 +490,7 @@ function insertPath(dp: DeepNode[], segs: string[]): void { } } -function internManifest(keys: string[]): ProcessedManifest { +function internManifest(keys: PropertyKey[]): ProcessedManifest { let m = manifestCache.get(keys); if (m !== undefined) return m; const roots: PropertyKey[] = []; @@ -569,7 +563,7 @@ function unionKeys( * 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): void { +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 @@ -601,19 +595,47 @@ function bumpOne(t: StoreNextTarget, pc: any): void { 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. - if (__DEV__ && attrHooks !== null) attrHooks.patchEmit(pc.dn, targetPath(t), null, null, false); + // (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): subscribes exactly the - * declared envelope, runs no body code. */ -function readDeepNode(node: DeepNode, o: any): void { - if (o === null || typeof o !== "object") return; - const v = o[node.k]; + * 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") - for (let i = 0; i < children.length; i++) readDeepNode(children[i], v); + 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 @@ -630,14 +652,20 @@ function targetPath(t: StoreNextTarget): string { } 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 when the + // child has no channel. + let origin: unknown = undefined; + if (__DEV__ && attrHooks !== null) + origin = t.pc !== null && (t.pc as any).dn !== null ? (t.pc as any).dn : targetPath(t); let u = t.u; while (u !== null) { - if (u.pc !== null) bumpOne(u, u.pc); + if (u.pc !== null) bumpOne(u, u.pc, origin); u = u.u; } } -function bumpOneOptimistic(t: StoreNextTarget, pc: any): void { +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; @@ -664,7 +692,8 @@ function bumpOneOptimistic(t: StoreNextTarget, pc: any): void { 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); + 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 @@ -764,26 +793,9 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { pc.dmq = false; return; } - if (__DEV__ && p.length >= GRAPH_SIZE_WARN_AT && p.length >= (pc.dw ?? 0) * 2) { - // WIDE_WRITE parity for the channel (see the registration-side - // HUGE_FAN_OUT twin): a delivery to N consumers is the same - // cost the graph warning polices, made invisible to `_subCount` - // by design. Doubling memo, matching checkWideWrite. - pc.dw = p.length; - const message = - `[WIDE_WRITE] a store write dispatched to ${p.length} patch template consumers ` + - `on one record — 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, - data: { patchConsumers: p.length } - }); - console.warn(message); - } + // 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); // 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 @@ -865,27 +877,7 @@ export function registerPatch(record: any, fn: PatchFn, keys?: Iterable String(k)); + // 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); } @@ -1167,10 +1161,18 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { 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. const roots = mk.roots; - for (let k = 0; k < roots.length; k++) (proxy as any)[roots[k]]; const dp = mk.dp; - if (dp !== null) for (let k = 0; k < dp.length; k++) readDeepNode(dp[k], proxy); + for (let k = 0; k < roots.length; k++) { + const v = (proxy as any)[roots[k]]; + if (dp !== null) + for (let j = 0; j < dp.length; j++) + if (dp[j].k === roots[k]) readDeepChildren(dp[j], v); + } } else fn(proxy, proxy, false); } catch (err) { computeFailed = true; @@ -1265,6 +1267,8 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { 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; @@ -1324,6 +1328,8 @@ export function registerSlotPatchNext( const pc = pcOf(t); const entry = { fn, owner: getOwner() }; (pc.sp ??= []).push(entry); + if (__DEV__ && shouldWarnGraphSize((pc.sp as any[]).length)) + warnChannelFanOut((pc.sp as any[]).length, "slot-patch (shallow list)"); markDescendants(t); let unbound = false; return () => { diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 1eb9b91da..2369ce2ac 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -84,8 +84,6 @@ export interface PatchChannel { * channel; the delivery effect consumes it in clean effect context. * Cleared with the consumers it belonged to (round 10, P2). */ dmq?: boolean; - /** DEV: wide-dispatch doubling memo (WIDE_WRITE channel twin). */ - dw?: number; /** 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 diff --git a/packages/signals/tests/attribution-patch.test.ts b/packages/signals/tests/attribution-patch.test.ts index e08adf4ca..7016630d7 100644 --- a/packages/signals/tests/attribution-patch.test.ts +++ b/packages/signals/tests/attribution-patch.test.ts @@ -35,4 +35,27 @@ describe("attribution through patch deliveries", () => { // Self emission carried the record transition previews. expect(String(delivery.causes[0].value)).toContain("b"); }); + + 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/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index a91c33c7b..1d4fad951 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -801,6 +801,65 @@ describe("INVARIANT: demotion fanout is per-entry isolated (round 10)", () => { }); }); +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"); @@ -833,12 +892,17 @@ describe("INVARIANT: channel fan-out stays diagnosable (attribution parity)", () // 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. + // 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(); }); }); From d08c236e9cdfc02480fb82a69cc90102ce00649d Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 13:21:46 -0700 Subject: [PATCH 27/56] =?UTF-8?q?fix:=20round-10.11=20audit=20=E2=80=94=20?= =?UTF-8?q?demotion=20bubbles=20at=20the=20primitive,=20linear=20envelopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bumpAncestors moved INTO demoteToEffects (the already-empty demote on a previously-demoted child never bubbled — RED-verified after correcting the repro to put the accessor on a DECLARED key; off-envelope getters are correctly ignored by the manifest-scoped probe); ProcessedManifest gains a root-aligned deep index (dpr — envelope walk linear); coalesced bumps append origins via patchOrigin (chains report every feeding child); applyStructural dispatches through the engine's wide-write policy keyed on the consumer list. Runtime correctness otherwise closed per the auditor. Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10-11.md | 5 ++ .../signals/src/core/attribution-hooks.ts | 15 ++++- packages/signals/src/core/attribution.ts | 45 ++++++++++---- packages/signals/src/store/next/patch.ts | 62 ++++++++++++++++--- packages/signals/src/store/next/store.ts | 5 +- .../tests/store/patch-invariants.test.ts | 44 +++++++++++++ 6 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10-11.md 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/packages/signals/src/core/attribution-hooks.ts b/packages/signals/src/core/attribution-hooks.ts index 7475fefcc..af26d2a97 100644 --- a/packages/signals/src/core/attribution-hooks.ts +++ b/packages/signals/src/core/attribution-hooks.ts @@ -76,11 +76,20 @@ export interface AttributionHooks { origin?: Signal | string | null ): void; /** - * A patch channel is about to dispatch to `count` template consumers. + * 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. + * 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(dn: Signal, count: number): void; + 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; } export let attrHooks: AttributionHooks | null = null; diff --git a/packages/signals/src/core/attribution.ts b/packages/signals/src/core/attribution.ts index 4154c8dbe..0ede307f6 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -222,6 +222,9 @@ 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 consumer list). */ +const patchDispatchWarned = new WeakMap(); function rootsOf(causes: ChangeRecord[], out: Set): void { for (const c of causes) { @@ -760,30 +763,46 @@ const engineHooks: AttributionHooks = { if (oc !== undefined) rec.causes = [oc]; } }, - patchDispatch(dn, count) { - // SAME policy as graph wide-writes (round 10.10): threshold from - // options.wideWrites, doubling memo, subscriber metadata — the channel - // consumer list IS this node's fan-out, invisible to `_subCount`. + 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 attributed = dn as AttributedNode; - if (count < limit || count < (attributed._devWideWriteWarnedAt ?? 0) * 2) return; - attributed._devWideWriteWarnedAt = count; + 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 "${nodeName(dn)}" dispatched to ${count} patch template ` + - `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.`; + `[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: nodeName(dn), - data: { subscribers: count, write: "patch" } + 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; + 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.indexOf(oc) === -1 && !causes.some(c => c.name === oc.name && c.seq === oc.seq)) + causes.push(oc); + }, refreshed(el) { stampWrite(el, "refresh"); }, diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 00fe7245d..523df18fa 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -85,7 +85,7 @@ interface PatchEntry { * 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?: { roots: PropertyKey[]; dp: DeepNode[] | null } | null; + 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; @@ -178,6 +178,15 @@ function forcedNext(t: StoreNextTarget): any { function applyStructural(item: QueuedApply, next: any, firstError: unknown): unknown { const snap = item.list as unknown as { fn: Function; owner: Owner | null; u?: boolean }[]; const len = snap.length; + // Structural dispatch width rides the same engine policy (round 10.11, + // P2) — no signal to stamp, so the consumer list is the memo key. + if (__DEV__ && attrHooks !== null) + attrHooks.patchDispatch( + item.list as object, + len, + item.si !== undefined ? "slot-patch" : "row-ops", + null + ); for (let j = 0; j < len; j++) { const entry = snap[j]; if (entry === undefined || entry.u === true) continue; @@ -468,6 +477,8 @@ export function hasPatches(): boolean { 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(); @@ -504,7 +515,23 @@ function internManifest(keys: PropertyKey[]): ProcessedManifest { roots.push(k); } } - m = { roots, dp }; + // 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; } @@ -583,7 +610,12 @@ function bumpOne(t: StoreNextTarget, pc: any, origin?: unknown): void { // 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)) return; + 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; } @@ -683,6 +715,8 @@ function bumpOneOptimistic(t: StoreNextTarget, pc: any, origin?: unknown): void // 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 — @@ -795,7 +829,8 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { } // 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); + 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 @@ -1108,6 +1143,14 @@ export function prepareInPlaceFold(t: StoreNextTarget): void { } 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; @@ -1164,14 +1207,15 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { // 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. + // 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 dp = mk.dp; + const dpr = mk.dpr; for (let k = 0; k < roots.length; k++) { const v = (proxy as any)[roots[k]]; - if (dp !== null) - for (let j = 0; j < dp.length; j++) - if (dp[j].k === roots[k]) readDeepChildren(dp[j], v); + const node = dpr !== null ? dpr[k] : null; + if (node !== null) readDeepChildren(node, v); } } else fn(proxy, proxy, false); } catch (err) { diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 30db67190..bae7adb3b 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -1191,10 +1191,7 @@ function notifyWrites(t: StoreNextTarget): void { 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); - patchHooks.emitPatchAncestors(t); - } + else patchHooks.demoteToEffects(t); // demotion bubbles internally (10.11) } else patchHooks.emitPatchAncestors(t); } if (t.u && t.u.v[t.pk!] === oldBacking) { diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 1d4fad951..97caa5ed1 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -801,6 +801,50 @@ describe("INVARIANT: demotion fanout is per-entry isolated (round 10)", () => { }); }); +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")); From 22304f39b576bb113e1623d08fb94191bb582a23 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 13:58:05 -0700 Subject: [PATCH 28/56] =?UTF-8?q?chore:=20rebase=20reconciliation=20?= =?UTF-8?q?=E2=80=94=20restore=20#3123=20optimistic=20landing=20semantics,?= =?UTF-8?q?=20combined=20budgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -X theirs clobbered upstream's contradicted-landing gate and retained- edit drop() in optimistic.ts (caught by upstream's own suites); the file is reset to next's version with only the branch's three emission-gate changes re-applied (diff verified minimal). Native compiler binary rebuilt (stale pre-rebase build re-minted the #3105 lone-spread merge — hydrate parity caught it). Four size tiers ratcheted for upstream drift stacking with branch bytes; treeshake core floor merged at 21.7k. Co-authored-by: Cursor --- scripts/size/.size-limit.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 664861454..268565a0e 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -178,7 +178,9 @@ module.exports = [ // 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. - limit: "14.9 KB", + // Rebase onto next (2026-08-31): upstream rc.5 drift stacks with the + // branch bytes. Measured 14.99. + limit: "15.05 KB", modifyEsbuildConfig }, { @@ -344,7 +346,9 @@ module.exports = [ path: "hydrating-store-app.js", // Round-10.9 (2026-08-31): demotion-lifecycle bytes (per-entry // envelopes, commit skip, akAll refcount). Measured 26.56. - limit: "26.65 KB", + // Rebase onto next (2026-08-31): upstream drift + lifecycle fixes + // stack with the branch bytes. Measured 27.06. + limit: "27.15 KB", modifyEsbuildConfig }, { @@ -413,7 +417,8 @@ module.exports = [ // 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. - limit: "16.3 KB", + // Rebase onto next (2026-08-31): upstream drift stacks. Measured 16.36. + limit: "16.45 KB", modifyEsbuildConfig }, { @@ -456,7 +461,8 @@ module.exports = [ // 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. - limit: "18.8 KB", + // Rebase onto next (2026-08-31): upstream drift stacks. Measured 18.82. + limit: "18.9 KB", modifyEsbuildConfig }, { From 5e6d94db8c48e3d1c4933ca5f221996d4e8918e8 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 14:21:24 -0700 Subject: [PATCH 29/56] =?UTF-8?q?dev:=20round-10.12=20observability=20?= =?UTF-8?q?=E2=80=94=20stable=20structural=20keys,=20synthetic=20events,?= =?UTF-8?q?=20cause=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueuedApply.pc backref (never consulted by drains) keys wide-dispatch memos and anchors structural naming/causes; patchStructural records synthetic rerun events for commit-drain dispatches; appendPatchOrigin name-dedupes; patchDelivered marks consumed stamps so self-emissions carry forward only undelivered child causes; bumpAncestors uses path origins for demoted children (live-consumer gate). All five auditor P2s pinned in attribution-patch.test.ts. Co-authored-by: Cursor --- .changeset/patch-observability-round10-12.md | 5 + .../signals/src/core/attribution-hooks.ts | 18 +++ packages/signals/src/core/attribution.ts | 86 ++++++++++--- packages/signals/src/store/next/patch.ts | 78 +++++++----- .../signals/tests/attribution-patch.test.ts | 114 +++++++++++++++++- 5 files changed, 254 insertions(+), 47 deletions(-) create mode 100644 .changeset/patch-observability-round10-12.md 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/packages/signals/src/core/attribution-hooks.ts b/packages/signals/src/core/attribution-hooks.ts index af26d2a97..1176a85b4 100644 --- a/packages/signals/src/core/attribution-hooks.ts +++ b/packages/signals/src/core/attribution-hooks.ts @@ -90,6 +90,24 @@ export interface AttributionHooks { * 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 0ede307f6..429478402 100644 --- a/packages/signals/src/core/attribution.ts +++ b/packages/signals/src/core/attribution.ts @@ -223,8 +223,25 @@ 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 consumer list). */ + * 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) { @@ -750,18 +767,25 @@ const engineHooks: AttributionHooks = { // 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) { - const rec = (dn as AttributedNode)._devChange!; - const oc = - typeof origin === "string" - ? ({ seq: rec.seq, kind: "write", name: origin } as ChangeRecord) - : (origin as AttributedNode)._devChange; - if (oc !== undefined) rec.causes = [oc]; - } + if (origin != null) appendPatchOrigin(rec, origin); }, patchDispatch(key, count, channel, dn) { // SAME policy as graph wide-writes (round 10.10; structural channels @@ -794,14 +818,42 @@ const engineHooks: AttributionHooks = { // origin that fed it, not just the first child's. const rec = (dn as AttributedNode)._devChange; if (rec === undefined) return; - 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.indexOf(oc) === -1 && !causes.some(c => c.name === oc.name && c.seq === oc.seq)) - causes.push(oc); + 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/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 523df18fa..824aafb90 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -40,7 +40,7 @@ import { } from "../../core/scheduler.js"; import type { Owner } from "../../core/types.js"; import { $TARGET, isWrappable } from "../store.js"; -import { markDescendants, ownedRaw, type StoreNextTarget } from "./target.js"; +import { markDescendants, ownedRaw, 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), @@ -108,18 +108,12 @@ interface QueuedApply { force: boolean; /** When set, `next` resolves at drain as `t.pb ?? t.v` (bubbles). */ t: StoreNextTarget | null; - /** Coalescing + recording backref (re-audits 3/6): set for stamped SELF - * entries so the drain can clear the channel's stamps (retention), record - * first-apply read sets (ak), and resolve the VALUE consumer list LIVE - * (re-audit 7, P2-9/P1-5 dual: value applications are absolute, so they - * go to whoever is registered at drain — a list recreated while the entry - * was held or merged must not be missed). */ - pc?: { - qa: unknown; - qe: unknown; - ak: PropertyKey[] | null; - p: object[] | null; - }; + /** Channel backref (round 10.12): STRUCTURAL items carry it as the + * stable identity dev diagnostics key on (emission snapshots slice the + * consumer list — a per-flush array would defeat warning memos) and as + * the naming/cause anchor (`pc.t` path, `pc.dn` stamp). Never consulted + * by drain semantics. */ + 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. */ @@ -178,15 +172,17 @@ function forcedNext(t: StoreNextTarget): any { function applyStructural(item: QueuedApply, next: any, firstError: unknown): unknown { const snap = item.list as unknown as { fn: Function; owner: Owner | null; u?: boolean }[]; const len = snap.length; - // Structural dispatch width rides the same engine policy (round 10.11, - // P2) — no signal to stamp, so the consumer list is the memo key. - if (__DEV__ && attrHooks !== null) - attrHooks.patchDispatch( - item.list as object, - len, - item.si !== undefined ? "slot-patch" : "row-ops", - null - ); + // Structural dispatch diagnostics (rounds 10.11/10.12): the CHANNEL is + // the memo key — emission snapshots slice the consumer list, so a + // per-item array key made the width warning fire every flush. Names and + // causes anchor on the channel too (`pc.t` path, `pc.dn` stamp). + const dch = __DEV__ && attrHooks !== null ? (item.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), len, dchannel, null); + dstart = performance.now(); + } for (let j = 0; j < len; j++) { const entry = snap[j]; if (entry === undefined || entry.u === true) continue; @@ -198,6 +194,17 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; } } + // Structural deliveries are attribution EVENTS (round 10.12, P2): they + // run in commit drains, not effects, so no rerun event exists for them + // — the engine records a synthetic one (name, causes, count, timing). + if (__DEV__ && attrHooks !== null && dch !== null) + attrHooks.patchStructural( + (dch as any).t !== undefined ? targetPath((dch as any).t) : null, + len, + dchannel, + ((dch as any).dn as any) ?? null, + performance.now() - dstart + ); return firstError; } @@ -376,7 +383,10 @@ export function emitPatchAncestors(t: StoreNextTarget): void { 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).dn : targetPath(t); + 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) bumpOneOptimistic(u, u.pc, origin); @@ -449,7 +459,8 @@ export function emitRowOpsOptimistic( prev: null, force: false, t: nextRows === null ? t : null, - ops + ops, + pc: t.pc as PatchChannel }); if (!scheduled) { scheduled = true; @@ -685,11 +696,15 @@ function targetPath(t: StoreNextTarget): string { 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 when the - // child has no channel. + // 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).dn : targetPath(t); + 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); @@ -818,6 +833,9 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { // 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 @@ -1343,7 +1361,8 @@ export function emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev prev, force: false, t: null, - si: index + si: index, + pc: t.pc as PatchChannel }); } @@ -1399,7 +1418,8 @@ export function emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void { prev: null, force: false, t: null, - ops + ops, + pc: t.pc as PatchChannel }); } diff --git a/packages/signals/tests/attribution-patch.test.ts b/packages/signals/tests/attribution-patch.test.ts index 7016630d7..84592ddb3 100644 --- a/packages/signals/tests/attribution-patch.test.ts +++ b/packages/signals/tests/attribution-patch.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createRoot, createStore, DEV, flush, registerPatch } from "../src/index.js"; +import { createRoot, createSignal, createStore, DEV, flush, registerPatch } from "../src/index.js"; afterEach(() => { DEV!.attribution.disable(); @@ -36,6 +36,118 @@ describe("attribution through patch deliveries", () => { 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(() => {}); From 4095172d10718b9746e002c61d9e9f86c8d769be Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 14:52:06 -0700 Subject: [PATCH 30/56] =?UTF-8?q?fix:=20round-10.13=20audit=20=E2=80=94=20?= =?UTF-8?q?structural=20hold=20parity=20+=20held-window=20registrant=20res?= =?UTF-8?q?ync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural entries capture their owner queue; dispatch defers held consumers into it (deferHeldStructural, live RESYNC at release — row ops are baseline-relative and stale by then); the drain sweeps the LIVE consumer list for held-window registrants and resyncs them (round-7 "receive nothing" pin refined: never ops, always the identity rebuild). The equal-landing/contradicting-landing P1s are PAUSED per Ryan — both are patch-channel integration with #3123's in-flux landing semantics. Two tier ratchets (16.6/19.1). Co-authored-by: Cursor --- .changeset/fix-patch-channel-round10-13.md | 5 + packages/signals/AUDIT-BRIEF-R6.md | 19 +++ packages/signals/src/store/next/patch.ts | 104 +++++++++++++-- .../tests/store/patch-invariants.test.ts | 124 +++++++++++++++++- scripts/size/.size-limit.js | 9 +- 5 files changed, 248 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-patch-channel-round10-13.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 4708a368b..c77104374 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,24 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 824aafb90..5ee80097a 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -108,11 +108,12 @@ interface QueuedApply { force: boolean; /** When set, `next` resolves at drain as `t.pb ?? t.v` (bubbles). */ t: StoreNextTarget | null; - /** Channel backref (round 10.12): STRUCTURAL items carry it as the - * stable identity dev diagnostics key on (emission snapshots slice the - * consumer list — a per-flush array would defeat warning memos) and as - * the naming/cause anchor (`pc.t` path, `pc.dn` stamp). Never consulted - * by drain semantics. */ + /** 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 @@ -183,10 +184,27 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk attrHooks.patchDispatch((dch as object) ?? (item.list as object), len, dchannel, null); dstart = performance.now(); } + const heldProbe = GlobalQueue._queueHeld; for (let j = 0; j < len; j++) { - const entry = snap[j]; + const entry = snap[j] as { + fn: Function; + owner: Owner | null; + u?: boolean; + q?: unknown; + hq?: boolean; + }; if (entry === undefined || entry.u === true) continue; if (entry.owner !== null && isDisposed(entry.owner)) continue; + // BOUNDARY HOLD parity for STRUCTURE (round 10.13, P1): a consumer + // under a collapsed queue defers INTO it — and re-derives from LIVE + // state at release: row ops are baseline-relative (the queued ops + // would be stale by then) and slot values can be superseded, so the + // deferred form is the RESYNC, reading the release moment's truth. + const oq = entry.q as any; + if (heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq)) { + deferHeldStructural(entry, oq, item); + continue; + } try { if (item.si !== undefined) entry.fn(item.si, next, item.prev); else entry.fn(next, item.ops); @@ -194,6 +212,30 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; } } + // LATE REGISTRANTS (round 10.13, P1): a consumer that registered between + // emission and a HELD drain initialized from the PRE-COMMIT view — the + // emission snapshot rightly excludes it from baseline-relative ops, but + // silence left it permanently stale. It takes the RESYNC form against + // live state (for the ambient no-hold race this is an identity-aligned + // rebuild — full retention, no DOM change). + if (item.pc !== undefined) { + const live = (item.si !== undefined ? item.pc.sp : item.pc.ro) as + | (RowOpsEntry & { hq?: boolean })[] + | null; + if (live !== null && live.length !== 0) { + for (let j = 0; j < live.length; j++) { + const entry = live[j]; + if ((snap as unknown[]).indexOf(entry) !== -1) continue; + if (entry.u === true || entry.hq === true) continue; + if (entry.owner !== null && isDisposed(entry.owner)) continue; + try { + structuralResync(entry, item); + } catch (err) { + if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; + } + } + } + } // Structural deliveries are attribution EVENTS (round 10.12, P2): they // run in commit drains, not effects, so no rerun event exists for them // — the engine records a synthetic one (name, causes, count, timing). @@ -208,6 +250,43 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk 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). */ +function structuralResync(entry: { fn: Function }, item: QueuedApply): void { + const t = item.pc !== undefined ? (item.pc.t as StoreNextTarget) : null; + if (item.si !== undefined) { + const v = t !== null ? ((t.pb ?? t.v) as any[])[item.si] : item.next; + entry.fn(item.si, v, item.prev); + } else { + const rows = t !== null ? ((t.pb ?? t.v) as any[]) : item.next; + entry.fn(rows, null); + } +} + +/** Deferred structural re-apply for a held consumer (round 10.13): runs + * FROM its owner queue at release, one queued run per entry per hold + * window, always in the live resync form. */ +function deferHeldStructural( + entry: { fn: Function; owner: Owner | null; u?: boolean; hq?: boolean }, + oq: any, + item: QueuedApply +): void { + if (entry.hq === true) return; + entry.hq = true; + oq.enqueue(EFFECT_RENDER, () => { + entry.hq = false; + if (entry.u === true) return; + if (entry.owner !== null && isDisposed(entry.owner)) return; + try { + structuralResync(entry, item); + } catch (err) { + if (!routeEntryError(entry as any, err)) deferHalt(err); + } + }); +} + const UNSET: unique symbol = Symbol(); /** ONE callback/error primitive for every drain (normal, transition-held, @@ -1307,6 +1386,13 @@ 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; } /** Register a structural-ops consumer on a keyed store array (the list @@ -1324,7 +1410,8 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { setPatchCommitHook(releaseBatch); GlobalQueue._drainPatchOptimistic = drainOptimistic; } - const entry: RowOpsEntry = { fn, owner: getOwner() }; + const rowner = getOwner(); + const entry: RowOpsEntry = { fn, owner: rowner, q: (rowner as any)?._queue ?? null }; const pc = pcOf(t); if (__TEST__) devTrackChannel(pc); const list = (pc.ro ??= []) as RowOpsEntry[]; @@ -1389,7 +1476,8 @@ export function registerSlotPatchNext( // 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() }; + const sowner = getOwner(); + const entry = { fn, owner: sowner, q: (sowner as any)?._queue ?? null }; (pc.sp ??= []).push(entry); if (__DEV__ && shouldWarnGraphSize((pc.sp as any[]).length)) warnChannelFanOut((pc.sp as any[]).length, "slot-patch (shallow list)"); diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 97caa5ed1..113dbf9b0 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -460,6 +460,11 @@ describe("INVARIANT: queued applications reach exactly the consumers registered // (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 — but silence left held-window registrants + // permanently stale, so they now receive the RESYNC form (ops null, + // live rows): an identity-aligned rebuild for this ambient race. let registeredLate = false; registerRowOps(state.rows, (_next: any[], ops: any) => { early.push(ops); @@ -473,14 +478,16 @@ describe("INVARIANT: queued applications reach exactly the consumers registered }); flush(); expect(early.length).toBe(1); - expect(late.length).toBe(0); - // The late consumer participates in the NEXT event normally. + expect(late.length).toBe(1); + expect(late[0]).toBe(null); // resync form only — never positional ops + // 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.length).toBe(2); + expect(late[1]).not.toBe(null); }); it("a value entry never re-applies to a consumer that initialized FROM its state (mid-flush mount)", async () => { @@ -801,6 +808,117 @@ describe("INVARIANT: demotion fanout is per-entry isolated (round 10)", () => { }); }); +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(); + // At the settle drain the late consumer takes the resync form — the + // silent path left it permanently stale on the pre-commit view. + expect(late.length).toBeGreaterThan(0); + expect(late[late.length - 1][1]).toBe(true); + expect(late[late.length - 1][0]).toEqual(["b", "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" } } }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 268565a0e..6523b33bb 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -418,7 +418,10 @@ module.exports = [ // 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. - limit: "16.45 KB", + // Round-10.13: structural hold parity + late-registrant resync + // (queue capture, deferHeldStructural, live-resync sweep). Measured + // 16.52. + limit: "16.6 KB", modifyEsbuildConfig }, { @@ -462,7 +465,9 @@ module.exports = [ // 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. - limit: "18.9 KB", + // Round-10.13: structural hold parity + late-registrant resync. + // Measured 19.03. + limit: "19.1 KB", modifyEsbuildConfig }, { From 06bd579d4f22b4ea1fb623f18bf9d3bf01e013d4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 15:05:31 -0700 Subject: [PATCH 31/56] refactor: patch-channel consolidation (size pass 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One queueIsHeld probe, one deferIntoQueue shape for held consumers, shared channelTarget registration prologue and structuralUnbind. Behavior-neutral: 514 signals store tests + 709 web tests green. Compressed size is a wash (brotli +11 B, raw -34 B) — the repeated shapes were already deduplicated by compression; kept for maintainability so future hold-routing changes land in one place. Co-authored-by: Cursor --- .../patch-channel-size-consolidation.md | 5 + packages/signals/src/store/next/patch.ts | 135 +++++++++--------- 2 files changed, 73 insertions(+), 67 deletions(-) create mode 100644 .changeset/patch-channel-size-consolidation.md 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/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 5ee80097a..56cd15ba5 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -184,7 +184,6 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk attrHooks.patchDispatch((dch as object) ?? (item.list as object), len, dchannel, null); dstart = performance.now(); } - const heldProbe = GlobalQueue._queueHeld; for (let j = 0; j < len; j++) { const entry = snap[j] as { fn: Function; @@ -201,7 +200,7 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk // would be stale by then) and slot values can be superseded, so the // deferred form is the RESYNC, reading the release moment's truth. const oq = entry.q as any; - if (heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq)) { + if (queueIsHeld(oq)) { deferHeldStructural(entry, oq, item); continue; } @@ -273,16 +272,11 @@ function deferHeldStructural( oq: any, item: QueuedApply ): void { - if (entry.hq === true) return; - entry.hq = true; - oq.enqueue(EFFECT_RENDER, () => { - entry.hq = false; - if (entry.u === true) return; - if (entry.owner !== null && isDisposed(entry.owner)) return; + deferIntoQueue(entry, oq, () => { try { structuralResync(entry, item); } catch (err) { - if (!routeEntryError(entry as any, err)) deferHalt(err); + if (!routeEntryError(entry as any, err)) return err as unknown; } }); } @@ -300,18 +294,36 @@ const UNSET: unique symbol = Symbol(); * 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. */ -function deferHeldEntry(entry: PatchEntry, oq: any, pc: any): void { +/** 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 = applyEntries([entry], visibleView(pc.t, pc), UNSET, pc); - if (err !== UNSET) deferHalt(err); + 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, @@ -350,7 +362,6 @@ function applyEntries(list: PatchEntry[], next: any, firstError: unknown, pc: an // not run it in this same drain (it just received its initial apply). const snap = list.length > 1 ? list.slice() : list; const len = snap.length; - const heldProbe = GlobalQueue._queueHeld; for (let j = 0; j < len; j++) { const entry = snap[j]; if (entry === undefined || entry.u === true || entry.dm === true) continue; @@ -361,7 +372,7 @@ function applyEntries(list: PatchEntry[], next: any, firstError: unknown, pc: an // 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 (heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq)) { + if (queueIsHeld(oq)) { deferHeldEntry(entry, oq, pc); continue; } @@ -984,19 +995,46 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { }); } -export function registerPatch(record: any, fn: PatchFn, keys?: Iterable): () => 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; +/** 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"); if (!commitHookInstalled) { commitHookInstalled = true; armPatchHooks(); setPatchCommitHook(releaseBatch); GlobalQueue._drainPatchOptimistic = drainOptimistic; } + 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. @@ -1262,7 +1300,6 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { // failure defers a single halt AFTER the fanout (the same contract the // dispatch loop pins). let firstError: unknown = UNSET; - const heldProbe = GlobalQueue._queueHeld; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; // An explicit unbind AFTER demotion cancels the redrive (round 10.7, @@ -1279,7 +1316,7 @@ export function demoteToEffects(t: StoreNextTarget, immediate = false): void { // (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 = heldProbe !== null && oq != null && oq !== globalQueue && heldProbe(oq); + 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 @@ -1398,18 +1435,8 @@ interface RowOpsEntry { /** 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 rowner = getOwner(); const entry: RowOpsEntry = { fn, owner: rowner, q: (rowner as any)?._queue ?? null }; const pc = pcOf(t); @@ -1420,16 +1447,7 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { warnChannelFanOut(list.length, "row-ops (structural list)"); patchCount++; markDescendants(t); - let unbound = false; - return () => { - if (unbound) return; - unbound = true; - (entry as any).u = true; // queued structural work skips severed consumers - 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 @@ -1461,36 +1479,19 @@ 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 sowner = getOwner(); const entry = { fn, owner: sowner, q: (sowner as any)?._queue ?? null }; - (pc.sp ??= []).push(entry); - if (__DEV__ && shouldWarnGraphSize((pc.sp as any[]).length)) - warnChannelFanOut((pc.sp as any[]).length, "slot-patch (shallow list)"); + const list = (pc.sp ??= []) as unknown[]; + list.push(entry); + if (__DEV__ && shouldWarnGraphSize(list.length)) + warnChannelFanOut(list.length, "slot-patch (shallow list)"); markDescendants(t); - let unbound = false; - return () => { - if (unbound || pc.sp === null) return; - unbound = true; - (entry as any).u = true; // queued structural work skips severed consumers - const idx = pc.sp.indexOf(entry); - if (idx >= 0) pc.sp.splice(idx, 1); - if (pc.sp.length === 0) pc.sp = null; - }; + return structuralUnbind(entry, list, pc, "sp", false); } /** Row-ops ride the SAME apply queue/timing as record patches: transition- From 715fcfd1a7e47814e386cccbb7689cbd72384bc1 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 15:19:00 -0700 Subject: [PATCH 32/56] =?UTF-8?q?chore:=20rebase=20onto=20next=20(#3123=20?= =?UTF-8?q?landed)=20=E2=80=94=20restore=20upstream=20budget=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase conflict policy kept the branch's evolving .size-limit.js at each replayed commit, dropping upstream's #3122/#3123 note blocks (the limits themselves were already above upstream's). Restores the six note blocks with post-rebase measured values; all scenarios pass unchanged. Co-authored-by: Cursor --- scripts/size/.size-limit.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 6523b33bb..7bdd75d1a 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -78,6 +78,10 @@ module.exports = [ // // 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 }, @@ -180,6 +184,13 @@ module.exports = [ // 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. limit: "15.05 KB", modifyEsbuildConfig }, @@ -210,6 +221,11 @@ module.exports = [ // // 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. limit: "10 KB", modifyEsbuildConfig }, @@ -348,6 +364,12 @@ module.exports = [ // 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). limit: "27.15 KB", modifyEsbuildConfig }, @@ -369,6 +391,9 @@ module.exports = [ // // Re-audit-9 (2026-08-29): the merge-path core bytes (see core floor). // Measured 12.90. + // + // #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: "13 KB", modifyEsbuildConfig @@ -467,6 +492,8 @@ module.exports = [ // 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. limit: "19.1 KB", modifyEsbuildConfig }, From d3264988bcd4a4fb58725cb24426e37a37935d9a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 15:31:40 -0700 Subject: [PATCH 33/56] fix: patch-channel integration with #3123 landing consumption (the two paused P1s) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equal-landing flash: emitPatch's raw payload fast path (pc.np) served committed backings directly to deliveries, bypassing visibleView's optimistic-family proxy rule — an equal landing (overrides held) flashed committed state through value patches while classic effects kept the override view. The stash is now gated on non-optimistic families; perf paths keep the payload. Contradicting-landing notification: consumeOverridesNext's wipe emitted through the optimistic lane for an authoritative change — timing divergence, duplicate delivery, and no structural resync (consumption removes the target from `overlaid` before the settle drain's resync loop reads it). The wipe's landing posture now emits a regular bump (coalesces with adoption's emission into one classic-schedule delivery) plus the row-ops resync form at the landing. The settle-drain site keeps the lane form — its own loop covers structure there. Both reproduced RED first in the invariant harness (classic-parity oracle). Full signals + web suites green; size inside existing budgets. Co-authored-by: Cursor --- ...-patch-channel-3123-landing-integration.md | 5 + packages/signals/AUDIT-BRIEF-R6.md | 35 ++++ packages/signals/src/store/next/patch.ts | 11 +- .../tests/store/patch-invariants.test.ts | 183 ++++++++++++++++++ 4 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-patch-channel-3123-landing-integration.md 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..b927b52d2 --- /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. diff --git a/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index c77104374..94d179cb3 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,40 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 56cd15ba5..238062bfb 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -449,8 +449,15 @@ export function emitPatch(t: StoreNextTarget, next: any, prev: any): void { const pc = t.pc as any; if (pc !== null) { bumpOne(t, pc); - pc.np = next; - pc.npb = pc.bc; + // 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) diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 113dbf9b0..15e846586 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1185,3 +1185,186 @@ describe("INVARIANT: the deferred-demotion latch cannot outlive its consumers (r expect(patchCountForTests()).toBeGreaterThan(0); }); }); + +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; + + // EQUAL interim landing: membership unchanged (same single id), but a + // sibling field moved (count 1 -> 2) so adoption genuinely emits. The + // label override is HELD — classic keeps "x"; the patch channel must + // deliver the override-composed view, never raw committed "a". + h.setServer([{ id: 1, label: "a", count: 2 }]); + h.poll(); + await settle(); + expect(classic.at(-1)).toBe("x:2"); + const sinceLanding = patched.slice(watermark); + expect(sinceLanding.some(v => v.startsWith("a:"))).toBe(false); + expect(patched.at(-1)).toBe("x:2"); + + confirm(); + await run; + await settle(); + 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: arrangement changed (id 2 never landed, id 3 + // did) — overrides are consumed AT THE LANDING. The driven list must be + // told the view flipped NOW (resync against live truth — the optimistic + // ops it holds are baseline-relative and stale), and the value channel + // must deliver the authoritative view exactly once. + h.setServer([ + { id: 1, label: "a2" }, + { id: 3, label: "c" } + ]); + h.poll(); + await settle(); + + // Structural consumers saw the flip at the landing, not at owner settle. + const structSince = rowEvents.slice(rowMark); + expect(structSince.length).toBeGreaterThan(0); + expect(structSince.at(-1)!.ids).toEqual([1, 3]); + // Value channel: authoritative view, exactly one application. + expect(patched.slice(patchMark)).toEqual(["a2"]); + + confirm(); + await run; + await settle(); + expect(rowEvents.at(-1)!.ids).toEqual([1, 3]); + disposeConsumers(); + h.dispose(); + }); +}); From 6f0c82e134fd02f6fca123257c4b221f316f2149 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 15:56:23 -0700 Subject: [PATCH 34/56] fix: a continuation reckoning notifies the channel once, with the re-derived view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third landing posture was untested: a CONTINUATION landing echoing a replayed keyed add (d813a96f's echo-mask semantics) against a driven list. RED first in the invariant harness — the channel delivered the bare landed base (the wipe's resync snapshot, taken mid-reckoning) and per-edit replay drafts, both states classic readers never render. Wipe + replay is ONE reckoning: notifyOptimisticWrites suppresses the write-site row-ops frame while replaying, and consumeOverridesNext emits the landing notification only after the replay half, carrying the optimisticView-composed snapshot (the target-resolved resync form reads pb ?? v — right for the settle-drain revert site it serves, a half-state here). Full signals + web suites green; size inside budgets. Co-authored-by: Cursor --- ...-patch-channel-3123-landing-integration.md | 2 +- .../tests/store/patch-invariants.test.ts | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-patch-channel-3123-landing-integration.md b/.changeset/fix-patch-channel-3123-landing-integration.md index b927b52d2..99b2490c1 100644 --- a/.changeset/fix-patch-channel-3123-landing-integration.md +++ b/.changeset/fix-patch-channel-3123-landing-integration.md @@ -2,4 +2,4 @@ "@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. +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/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 15e846586..204660ee7 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1367,4 +1367,110 @@ describe("INVARIANT: landings integrate with the patch channel at classic-effect 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(); + }); }); From 9279d151cf2d808c2ceebb5b085958a4287b7e34 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 15:58:18 -0700 Subject: [PATCH 35/56] test: pin the settle-drain reckoning's channel parity (fourth posture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entangled retainers (actions sharing one optimistic store) settle together — one completing early strips no mask — and the joint settle's wipe+replay lands the channel on committed truth with no half-state frames. Already green; the posture was the last one unpinned. Co-authored-by: Cursor --- .../tests/store/patch-invariants.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 204660ee7..4cf70561b 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1473,4 +1473,105 @@ describe("INVARIANT: landings integrate with the patch channel at classic-effect 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(); + }); }); From 53b0f46a6f77765c6933d3cc5d7b9ed050ce5b99 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 18:21:41 -0700 Subject: [PATCH 36/56] =?UTF-8?q?fix:=20structural-audit=20round=20?= =?UTF-8?q?=E2=80=94=20sweep=20mechanics,=20visible-view=20resyncs,=20supe?= =?UTF-8?q?rseded=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings, three root causes, composed with review commit 3e12ffdb (one-reckoning landing notification — audited: replaying latch is try/finally-safe with per-edit throw isolation): - Late-registrant sweep rebuilt on registration sequences: fixed window at both edges (in-snapshot entries and mid-drain registrants excluded), held owner queues defer via deferIntoQueue, and the scan is an O(#late) suffix walk (was indexOf-per-entry, quadratic). Contract refinement pinned: plain stores emit at the fold, so pre-flush registrants ride the snapshot with baseline-correct ops. - Drain-resolved structural next (resyncs, held releases, late sweeps) reads visibleStructRows — optimistic families through the composing proxy, never bare committed backing. - Deleted-slot gate: ticks coalesced past a shrink are skipped, snap and resync paths both. - Superseded-work generation: emitLandingConsumption bumps pc.sg; drains skip stale-generation items — transition-held ops can't replay over a landing's resync at settle. - Fifth-posture pin: aborted-retainer survivor kept at classic parity (oracle-anchored). 39/39 invariant harness, full signals (1,504) + web (709) suites green; two size ratchets with notes (+27 B store-family, +41 B list tier). Co-authored-by: Cursor --- .../fix-patch-structural-audit-round.md | 5 + packages/signals/AUDIT-BRIEF-R6.md | 48 +++ packages/signals/src/store/next/patch.ts | 113 ++++-- packages/signals/src/store/next/store.ts | 2 + packages/signals/src/store/next/target.ts | 11 + .../tests/store/patch-invariants.test.ts | 330 +++++++++++++++++- scripts/size/.size-limit.js | 11 +- 7 files changed, 486 insertions(+), 34 deletions(-) create mode 100644 .changeset/fix-patch-structural-audit-round.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 94d179cb3..071b161ef 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,53 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 238062bfb..dfd1cf0bc 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -121,6 +121,10 @@ interface QueuedApply { ops?: RowOps | null; /** Slot-tick payload index (same live-list rationale as `ops`). */ si?: number; + /** Registration-sequence watermark at emission (see PatchChannel.rq). */ + rq?: number; + /** Structural generation at emission (see PatchChannel.sg). */ + sg?: number; } let queue: QueuedApply[] | null = null; let scheduled = false; @@ -141,10 +145,17 @@ function drainApplyQueue(): void { // rethrow after the drain so they still surface. let firstError: unknown = UNSET; for (let i = 0; i < q.length; i++) { - const { prev, force, t } = q[i]; - const next = t !== null ? (force ? forcedNext(t) : (t.pb ?? t.v)) : q[i].next; - if (q[i].ops !== undefined || q[i].si !== undefined) - firstError = applyStructural(q[i], next, firstError); + const item = q[i]; + // SUPERSEDED work (structural audit, F4): a landing consumption bumped + // the channel's structural generation and queued its own resync — + // items stamped before it (stale transition-held ops, interim replay + // ops) describe baselines the consumption invalidated. + if (item.pc !== undefined && ((item.pc.sg as number) | 0) !== ((item.sg as number) | 0)) + continue; + const { prev, force, t } = item; + const next = t !== null ? (force ? forcedNext(t) : visibleStructRows(t)) : item.next; + if (item.ops !== undefined || item.si !== undefined) + firstError = applyStructural(item, next, firstError); } if (firstError !== UNSET) { // Unhandled patch errors HALT like unhandled effect errors (re-audit 2, @@ -154,6 +165,16 @@ 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); +} + /** 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 @@ -173,6 +194,18 @@ function forcedNext(t: StoreNextTarget): any { function applyStructural(item: QueuedApply, next: any, firstError: unknown): unknown { const snap = item.list as unknown as { fn: Function; owner: Owner | null; u?: boolean }[]; const len = snap.length; + // FIXED WINDOW far edge (structural audit, F1): captured BEFORE any + // dispatch — a consumer registered from a callback below bumps `rq` + // past this watermark and is excluded from this item entirely. + const maxSq = item.pc !== undefined ? (item.pc.rq as number) | 0 : 0; + // DELETED-SLOT gate (structural audit, F3): a slot tick coalesced with a + // later shrink indexes past the live list — applying it (snap or resync) + // would deliver an undefined value to a row that no longer exists. + if (item.si !== undefined && item.pc !== undefined) { + const st = item.pc.t as StoreNextTarget; + const liveRows = visibleStructRows(st); + if (!Array.isArray(liveRows) || item.si >= liveRows.length) return firstError; + } // Structural dispatch diagnostics (rounds 10.11/10.12): the CHANNEL is // the memo key — emission snapshots slice the consumer list, so a // per-item array key made the width warning fire every flush. Names and @@ -211,22 +244,35 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; } } - // LATE REGISTRANTS (round 10.13, P1): a consumer that registered between - // emission and a HELD drain initialized from the PRE-COMMIT view — the - // emission snapshot rightly excludes it from baseline-relative ops, but - // silence left it permanently stale. It takes the RESYNC form against - // live state (for the ambient no-hold race this is an identity-aligned - // rebuild — full retention, no DOM change). + // LATE REGISTRANTS (round 10.13, P1; mechanics rebuilt by the structural + // audit): a consumer that registered between emission and the drain is + // outside the emission snapshot — baseline-relative ops would corrupt + // it, silence left held-window registrants permanently stale. It takes + // the RESYNC form against live state. THE WINDOW IS FIXED (F1): only + // registrants with `sq` in (item.rq, drain-start rq] — a consumer + // registering DURING this drain initialized from current state and gets + // nothing. Registrations append in `sq` order, so late entries are a + // SUFFIX: the tail scan is O(#late), not O(consumers²) (F6). Held owner + // queues defer exactly like the snapshot path (F1) — never through the + // hold. if (item.pc !== undefined) { const live = (item.si !== undefined ? item.pc.sp : item.pc.ro) as - | (RowOpsEntry & { hq?: boolean })[] + | (RowOpsEntry & { hq?: boolean; sq?: number; q?: unknown })[] | null; if (live !== null && live.length !== 0) { - for (let j = 0; j < live.length; j++) { + const itemRq = ((item.rq as number) | 0) as number; + for (let j = live.length - 1; j >= 0; j--) { const entry = live[j]; - if ((snap as unknown[]).indexOf(entry) !== -1) continue; + const sq = (entry.sq as number) | 0; + if (sq <= itemRq) break; // suffix exhausted — everyone else was in the snapshot + if (sq > maxSq) continue; // registered mid-drain: outside the window if (entry.u === true || entry.hq === true) continue; if (entry.owner !== null && isDisposed(entry.owner)) continue; + const oq = entry.q as any; + if (queueIsHeld(oq)) { + deferHeldStructural(entry as any, oq, item); + continue; + } try { structuralResync(entry, item); } catch (err) { @@ -252,14 +298,16 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk /** 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). */ + * 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 ? visibleStructRows(t) : item.next; if (item.si !== undefined) { - const v = t !== null ? ((t.pb ?? t.v) as any[])[item.si] : item.next; - entry.fn(item.si, v, item.prev); + 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 { - const rows = t !== null ? ((t.pb ?? t.v) as any[]) : item.next; entry.fn(rows, null); } } @@ -513,10 +561,14 @@ function drainOptimistic(): void { // must reach the registering owner's Errored boundary. let firstError: unknown = UNSET; for (let i = 0; i < q.length; i++) { - const { prev, force, t } = q[i]; - const next = t !== null ? (force ? forcedNext(t) : (t.pb ?? t.v)) : q[i].next; - if (q[i].ops !== undefined || q[i].si !== undefined) - firstError = applyStructural(q[i], next, firstError); + const item = q[i]; + // Same superseded-work gate as the regular drain (structural audit, F4). + if (item.pc !== undefined && ((item.pc.sg as number) | 0) !== ((item.sg as number) | 0)) + continue; + const { prev, force, t } = item; + const next = t !== null ? (force ? forcedNext(t) : visibleStructRows(t)) : item.next; + if (item.ops !== undefined || item.si !== undefined) + firstError = applyStructural(item, next, firstError); } if (firstError !== UNSET) { haltReactivity(firstError); @@ -557,7 +609,9 @@ export function emitRowOpsOptimistic( force: false, t: nextRows === null ? t : null, ops, - pc: t.pc as PatchChannel + pc: t.pc as PatchChannel, + rq: ((t.pc as any).rq as number) | 0, + sg: ((t.pc as any).sg as number) | 0 }); if (!scheduled) { scheduled = true; @@ -1445,8 +1499,13 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { const t = channelTarget(array, "registerRowOps"); armRowHooks(); const rowner = getOwner(); - const entry: RowOpsEntry = { fn, owner: rowner, q: (rowner as any)?._queue ?? null }; const pc = pcOf(t); + const entry: RowOpsEntry & { sq?: number } = { + fn, + owner: rowner, + q: (rowner as any)?._queue ?? null, + sq: (pc.rq = ((pc.rq as number) | 0) + 1) + }; if (__TEST__) devTrackChannel(pc); const list = (pc.ro ??= []) as RowOpsEntry[]; list.push(entry); @@ -1474,7 +1533,9 @@ export function emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev force: false, t: null, si: index, - pc: t.pc as PatchChannel + pc: t.pc as PatchChannel, + rq: ((t.pc as any).rq as number) | 0, + sg: ((t.pc as any).sg as number) | 0 }); } @@ -1515,7 +1576,9 @@ export function emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void { force: false, t: null, ops, - pc: t.pc as PatchChannel + pc: t.pc as PatchChannel, + rq: ((t.pc as any).rq as number) | 0, + sg: ((t.pc as any).sg as number) | 0 }); } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index bae7adb3b..7a62eb132 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -169,6 +169,8 @@ export function pcOf(t: StoreNextTarget): PatchChannel { ks: false, akAll: false, mlc: 0, + rq: 0, + sg: 0, t }) ); diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 2369ce2ac..229f107fa 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -97,6 +97,17 @@ export interface PatchChannel { * always writes (scheduler owns merge bookkeeping). */ bt?: unknown; bo?: unknown; + /** Structural registration sequence (structural audit): entries stamp + * `sq = ++rq` at registration, items stamp `rq` at emission — the late- + * registrant sweep becomes a tail scan over the (append-ordered) suffix + * `sq > item.rq`, with the drain-start `rq` as the FIXED window's far + * edge (mid-drain registrants are excluded). */ + rq?: number; + /** Structural generation (structural audit): a landing consumption bumps + * it AFTER retained-edit replay — queued items stamped with an older + * generation are superseded by the consumption's own resync and skipped + * at drain (stale transition-held ops, the replay's interim ops). */ + sg?: 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 diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 4cf70561b..f23e57f84 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -462,9 +462,11 @@ describe("INVARIANT: queued applications reach exactly the consumers registered // relative ops against it corrupts retention. // // Round 10.13 refinement: late consumers still never see the - // baseline-relative OPS — but silence left held-window registrants - // permanently stale, so they now receive the RESYNC form (ops null, - // live rows): an identity-aligned rebuild for this ambient race. + // 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); @@ -478,16 +480,15 @@ describe("INVARIANT: queued applications reach exactly the consumers registered }); flush(); expect(early.length).toBe(1); - expect(late.length).toBe(1); - expect(late[0]).toBe(null); // resync form only — never positional ops + 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(2); - expect(late[1]).not.toBe(null); + 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 () => { @@ -1186,6 +1187,321 @@ describe("INVARIANT: the deferred-demotion latch cannot outlive its consumers (r }); }); +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, () => {}); + }); + let confirm!: () => void; + const run = act(function* () { + setState((s: any) => { + s.list[2] = "x"; // slot tick for index 2, stashed by the transition + s.list.splice(2, 1); // …then the slot is deleted in the same window + }); + yield new Promise(resolve => { + confirm = resolve; + }); + })(); + flush(); + const ticks: Array<[number, any]> = []; + // Held-window registrant: swept at release for BOTH stashed items. + createRoot(() => { + registerSlotPatchNext(state.list, (i: number, v: any) => ticks.push([i, v])); + }); + confirm(); + await run; + flush(); + // The deleted slot's tick is invalid against the live 2-length list — + // it must be skipped, not delivered as (2, undefined). + expect(ticks.every(([i]) => i < 2)).toBe(true); + }); + + 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("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: 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. diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 7bdd75d1a..d3d110cb1 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -370,7 +370,10 @@ module.exports = [ // 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). - limit: "27.15 KB", + // 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. + limit: "27.2 KB", modifyEsbuildConfig }, { @@ -494,7 +497,11 @@ module.exports = [ // Measured 19.03. // #3122 teardown (upstream, 2026-08-31): core-floor bytes ride this // tier too. Measured 19.02 post-rebase. - limit: "19.1 KB", + // 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. + limit: "19.15 KB", modifyEsbuildConfig }, { From 2881e7f2a837791be4a78bc87dff8ced9f23371c Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 18:31:57 -0700 Subject: [PATCH 37/56] test: pin for-then-siblings with a DRIVEN list (hydration parity matrix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review ask from #3161's follow-through: post-flip, driver-ENGAGED hydration takes a different road than the classic seam (#3161's fix covered) — driveList claims rows positionally by _hk and mints its own listOwner child scope. The new scenario is the driven twin of the static one: store-backed rows whose template PROVES pure (attribute- only binding — text holes disqualify rowProof; emission verified against the compiler for this exact shape under the harness's flags). The update pushes a row THROUGH the engaged driver and bumps a sibling signal; the parity invariants pin no client-created DOM during hydration, no warns, textContent, and sibling node identity across the driven structural update. Both roads now covered: static rows decline at runtime (classic seam), store rows engage. Server harness 62/62, parity matrix 153/153. Co-authored-by: Cursor --- .../for-then-siblings-driven.json | 5 ++ packages/web/test/harness/scenarios.tsx | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 packages/web/test/harness/__artifacts__/for-then-siblings-driven.json 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, From 39163b017fd94a82a84be50e7ba6262e95bc7863 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 21:40:00 -0700 Subject: [PATCH 38/56] =?UTF-8?q?fix:=20structural-audit=20follow-up=20?= =?UTF-8?q?=E2=80=94=20slot=20stamps,=20landing=20drain-resolution,=20drai?= =?UTF-8?q?n=20dedup,=20slot-tick=20preservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1s + two P2s from the follow-up audit, plus two OPEN upstream pins: - Slot-patch registrations stamp `sq` (the suffix scan read 0 and broke immediately — held-window shallow-list mounts stayed stale). The regression test is non-vacuous now: the surviving slot's resync must arrive, and emissions go through the reconcile walk. - emitRowOpsLanding hook: lane-timed (a reverting action's stash must not own the landing's notification) but DRAIN-RESOLVED — the emission-time composed snapshot read the mid-reckoning draft and could reach the DOM ahead of classic. visibleStructRows at drain reads what classic renders. Probed across five landing interleavings at parity. - Per-drain dedup for item-local late-row resyncs (was item-count × consumer-count repeats). - sg-stale SLOT items re-resolve live and keep their delivery (standalone value notifications the landing's row resync does not cover); sg-stale ROW items still drop. - OPEN upstream ×2, pinned it.fails: a second same-microtask continuation landing is swallowed (bare projection, no actions/consumers — committed truth loses the row); an until()-gated action wedges on the swallowed echo. #3123 reckoning seam — flagged, not unilaterally fixed. Full signals (1,505 + 2 expected-fail) + web (719) green; two size ratchets (16.7/19.3), store-family app DOWN 50 B (composition deleted). Co-authored-by: Cursor --- .../fix-patch-structural-audit-followup.md | 5 + packages/signals/AUDIT-BRIEF-R6.md | 40 ++++++ .../signals/src/store/next/patch-hooks.ts | 3 + packages/signals/src/store/next/patch.ts | 87 ++++++++++--- .../tests/store/createOptimisticStore.test.ts | 106 ++++++++++++++++ .../tests/store/patch-invariants.test.ts | 120 +++++++++++++++++- scripts/size/.size-limit.js | 9 +- 7 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-patch-structural-audit-followup.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 071b161ef..6d89d506c 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,45 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts index 58835a3b8..922c0478c 100644 --- a/packages/signals/src/store/next/patch-hooks.ts +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -46,6 +46,9 @@ export interface PatchRowHooks { emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev: any): void; emitSetterRowOps(t: StoreNextTarget, prevRows: any[], nextRows: any[]): void; emitRowOpsOptimistic(t: StoreNextTarget, next: any[] | null, ops: RowOps | null): void; + /** Landing-consumption resync: regular queue, drain-time resolution, + * structural-generation bump (supersedes queued pre-consumption work). */ + emitRowOpsLanding(t: StoreNextTarget): void; } /** Raw→proxy wrap for captured structural rows (re-audit 8, P1-2). diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index dfd1cf0bc..9a74476f8 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -144,16 +144,11 @@ function drainApplyQueue(): void { // it (source = the owner, error read via owner._x?._error). Unhandled errors // rethrow after the drain so they still surface. let firstError: unknown = UNSET; + drainGen++; for (let i = 0; i < q.length; i++) { const item = q[i]; - // SUPERSEDED work (structural audit, F4): a landing consumption bumped - // the channel's structural generation and queued its own resync — - // items stamped before it (stale transition-held ops, interim replay - // ops) describe baselines the consumption invalidated. - if (item.pc !== undefined && ((item.pc.sg as number) | 0) !== ((item.sg as number) | 0)) - continue; - const { prev, force, t } = item; - const next = t !== null ? (force ? forcedNext(t) : visibleStructRows(t)) : item.next; + const next = drainNext(item); + if (next === UNSET) continue; if (item.ops !== undefined || item.si !== undefined) firstError = applyStructural(item, next, firstError); } @@ -175,6 +170,33 @@ function visibleStructRows(t: StoreNextTarget): any { return t.fam?.opt === true ? t.px : (t.pb ?? t.v); } +/** Per-drain generation for late-resync dedup (audit follow-up P2): several + * held items on ONE channel each ran the late sweep — same entries, same + * live rebuild, item-count × consumer-count applications. Entries stamp the + * drain they were resynced in; repeats within it skip. */ +let drainGen = 0; + +/** Drain-side `next` resolution with the SUPERSEDED-work gate (structural + * audit F4, refined by the follow-up P2): a landing consumption bumped the + * channel's structural generation and queued its own resync — stale ROW + * items describe baselines the consumption invalidated and are covered by + * that resync, so they drop. Stale SLOT items are STANDALONE value + * notifications the row resync does NOT cover — they re-resolve against + * the live visible view (their captured payload is pre-landing) and keep + * their delivery; a slot the landing deleted drops (range gate). Returns + * UNSET to skip the item. */ +function drainNext(item: QueuedApply): unknown { + const pc = item.pc; + if (pc !== undefined && ((pc.sg as number) | 0) !== ((item.sg as number) | 0)) { + if (item.si === undefined) return UNSET; + const rows = visibleStructRows(pc.t as StoreNextTarget); + if (!Array.isArray(rows) || (item.si as number) >= rows.length) return UNSET; + return rows[item.si as number]; + } + const { force, t } = item; + return t !== null ? (force ? forcedNext(t) : visibleStructRows(t)) : item.next; +} + /** 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 @@ -257,7 +279,7 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk // hold. if (item.pc !== undefined) { const live = (item.si !== undefined ? item.pc.sp : item.pc.ro) as - | (RowOpsEntry & { hq?: boolean; sq?: number; q?: unknown })[] + | (RowOpsEntry & { hq?: boolean; sq?: number; dg?: number; q?: unknown })[] | null; if (live !== null && live.length !== 0) { const itemRq = ((item.rq as number) | 0) as number; @@ -267,12 +289,18 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk if (sq <= itemRq) break; // suffix exhausted — everyone else was in the snapshot if (sq > maxSq) continue; // registered mid-drain: outside the window if (entry.u === true || entry.hq === true) continue; + // ONE live rebuild per entry per drain (audit follow-up P2): every + // held item on this channel runs this sweep — the resync reads the + // same live truth each time, so repeats are pure waste. Slot items + // stay per-item (distinct indices are distinct deliveries). + if (item.si === undefined && entry.dg === drainGen) continue; if (entry.owner !== null && isDisposed(entry.owner)) continue; const oq = entry.q as any; if (queueIsHeld(oq)) { deferHeldStructural(entry as any, oq, item); continue; } + if (item.si === undefined) entry.dg = drainGen; try { structuralResync(entry, item); } catch (err) { @@ -560,13 +588,12 @@ function drainOptimistic(): void { // 5): one throwing optimistic patch must not abort its siblings, and it // must reach the registering owner's Errored boundary. let firstError: unknown = UNSET; + drainGen++; for (let i = 0; i < q.length; i++) { const item = q[i]; // Same superseded-work gate as the regular drain (structural audit, F4). - if (item.pc !== undefined && ((item.pc.sg as number) | 0) !== ((item.sg as number) | 0)) - continue; - const { prev, force, t } = item; - const next = t !== null ? (force ? forcedNext(t) : visibleStructRows(t)) : item.next; + const next = drainNext(item); + if (next === UNSET) continue; if (item.ops !== undefined || item.si !== undefined) firstError = applyStructural(item, next, firstError); } @@ -1553,7 +1580,16 @@ export function registerSlotPatchNext( // lists — registrations are a list, unbinds splice their own entry. const pc = pcOf(t); const sowner = getOwner(); - const entry = { fn, owner: sowner, q: (sowner as any)?._queue ?? null }; + // Same registration-sequence stamp as row-ops entries (structural audit + // follow-up P1): without it the late sweep's suffix scan reads sq 0 and + // breaks immediately — shallow lists mounted during held windows stayed + // permanently stale. + const entry = { + fn, + owner: sowner, + q: (sowner as any)?._queue ?? null, + sq: (pc.rq = ((pc.rq as number) | 0) + 1) + }; const list = (pc.sp ??= []) as unknown[]; list.push(entry); if (__DEV__ && shouldWarnGraphSize(list.length)) @@ -1562,6 +1598,26 @@ export function registerSlotPatchNext( return structuralUnbind(entry, list, pc, "sp", false); } +/** Landing-consumption structural notification (audit follow-up P1, + * back-to-back continuations): the LANE with the DRAIN-RESOLVED resync + * form. Lane, because 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 (the landing's notification must not + * die with a transaction it doesn't belong to). Drain-resolved, because an + * emission-time composed snapshot reads the MID-RECKONING draft — a parked + * or superseded landing's topology reached the DOM while classic readers + * held the previous view until its commit; visibleStructRows at drain time + * reads exactly what classic renders at that moment. Bumps the structural + * generation FIRST: row/slot work queued before this consumption is + * superseded (F4) whether or not row consumers exist. */ +export function emitRowOpsLanding(t: StoreNextTarget): void { + const pc = t.pc as any; + if (pc === null) return; + pc.sg = ((pc.sg as number) | 0) + 1; + if (pc.ro === null) return; + emitRowOpsOptimistic(t, null, null); +} + /** Row-ops ride the SAME apply queue/timing as record patches: transition- * stamped, applied at effect phase, in emission order (structure before the * new rows' own patches can exist; retained rows' value patches commute). */ @@ -1649,6 +1705,7 @@ function armRowHooks(): void { emitRowOps, emitSlotPatch, emitSetterRowOps, - emitRowOpsOptimistic + emitRowOpsOptimistic, + emitRowOpsLanding }); } 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-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index f23e57f84..7ca065fe5 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1335,8 +1335,11 @@ describe("INVARIANT: structural resyncs honor holds, fix their window, and serve let confirm!: () => void; const run = act(function* () { setState((s: any) => { - s.list[2] = "x"; // slot tick for index 2, stashed by the transition - s.list.splice(2, 1); // …then the slot is deleted in the same window + // 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; @@ -1351,8 +1354,12 @@ describe("INVARIANT: structural resyncs honor holds, fix their window, and serve confirm(); await run; flush(); + // NON-VACUOUS (audit follow-up P1): the surviving slot's resync MUST + // arrive — an empty tick list means the sweep never saw the late + // registrant (the vacuous pass that hid the missing slot sq stamp). + expect(ticks.some(([i, v]) => i === 1 && v === "y")).toBe(true); // The deleted slot's tick is invalid against the live 2-length list — - // it must be skipped, not delivered as (2, undefined). + // skipped, never delivered as (2, undefined). expect(ticks.every(([i]) => i < 2)).toBe(true); }); @@ -1424,6 +1431,113 @@ describe("INVARIANT: structural resyncs honor holds, fix their window, and serve 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, diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index d3d110cb1..ac65aecd1 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -449,7 +449,10 @@ module.exports = [ // Round-10.13: structural hold parity + late-registrant resync // (queue capture, deferHeldStructural, live-resync sweep). Measured // 16.52. - limit: "16.6 KB", + // 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. + limit: "16.7 KB", modifyEsbuildConfig }, { @@ -501,7 +504,9 @@ module.exports = [ // both edges, suffix scan), hold-deferred late resyncs, visible-view // resolution, deleted-slot gate, superseded-work stamps. Measured // 19.14. - limit: "19.15 KB", + // Follow-up (2026-08-31): the value-tier bytes above plus the slot sq + // stamp and landing resync hook. Measured 19.26. + limit: "19.3 KB", modifyEsbuildConfig }, { From 1876d11a3ddab6991f6d37e22316e2161a75ed93 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 01:04:25 -0700 Subject: [PATCH 39/56] fix: reconcile the patch channel with the #3164 fold ruling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-ruled landing contract (truth folds into the retaining transaction, atomic reveal at settle) obsoletes the branch's landing integration wholesale: emitLandingConsumption/emitRowOpsLanding and the superseded-work generation (pc.sg + drain gate + item stamps) are DELETED with the contract they served — staged truth is transition-held (channel held-write semantics apply by construction) and the reveal rides the settle drain's existing resync loop. The registration- sequence sweep window, hold routing, visible-view resolution, and the deleted-slot gate are contract-independent and stay. Re-applies the round-10.5 primitive-owned emission gate the upstream rewrite reverted (settle-loop value emission gated on the local consumer list — silences ancestor channels, round-10 P1-3; two invariant tests caught it). Landing invariants re-pinned to fold semantics: interim landings invisible to both channels, atomic flip at settle, classic-parity oracles unchanged. The two upstream it.fails pins (same-microtask swallow, wedged until()) STILL FAIL under fold — they ride on. Full signals (1,507 + 2 expected-fail) + web (719) + hydration parity (153) green; two ratchets for upstream fold bytes (15.15/27.25). BASED ON UNPUSHED next (a536e29b) — hold pushes until it lands. Co-authored-by: Cursor --- .../patch-channel-fold-reconciliation.md | 5 ++ packages/signals/AUDIT-BRIEF-R6.md | 33 +++++++++++ packages/signals/src/store/next/optimistic.ts | 9 ++- .../signals/src/store/next/patch-hooks.ts | 3 - packages/signals/src/store/next/patch.ts | 55 +++---------------- packages/signals/src/store/next/store.ts | 1 - packages/signals/src/store/next/target.ts | 5 -- .../tests/store/patch-invariants.test.ts | 43 +++++++++------ scripts/size/.size-limit.js | 12 +++- 9 files changed, 90 insertions(+), 76 deletions(-) create mode 100644 .changeset/patch-channel-fold-reconciliation.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 6d89d506c..82d8584f8 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,38 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index e683d31de..67b6c2aa2 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -118,7 +118,14 @@ 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 diff --git a/packages/signals/src/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts index 922c0478c..58835a3b8 100644 --- a/packages/signals/src/store/next/patch-hooks.ts +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -46,9 +46,6 @@ export interface PatchRowHooks { emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev: any): void; emitSetterRowOps(t: StoreNextTarget, prevRows: any[], nextRows: any[]): void; emitRowOpsOptimistic(t: StoreNextTarget, next: any[] | null, ops: RowOps | null): void; - /** Landing-consumption resync: regular queue, drain-time resolution, - * structural-generation bump (supersedes queued pre-consumption work). */ - emitRowOpsLanding(t: StoreNextTarget): void; } /** Raw→proxy wrap for captured structural rows (re-audit 8, P1-2). diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 9a74476f8..9bd09fda2 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -123,8 +123,6 @@ interface QueuedApply { si?: number; /** Registration-sequence watermark at emission (see PatchChannel.rq). */ rq?: number; - /** Structural generation at emission (see PatchChannel.sg). */ - sg?: number; } let queue: QueuedApply[] | null = null; let scheduled = false; @@ -176,23 +174,12 @@ function visibleStructRows(t: StoreNextTarget): any { * drain they were resynced in; repeats within it skip. */ let drainGen = 0; -/** Drain-side `next` resolution with the SUPERSEDED-work gate (structural - * audit F4, refined by the follow-up P2): a landing consumption bumped the - * channel's structural generation and queued its own resync — stale ROW - * items describe baselines the consumption invalidated and are covered by - * that resync, so they drop. Stale SLOT items are STANDALONE value - * notifications the row resync does NOT cover — they re-resolve against - * the live visible view (their captured payload is pre-landing) and keep - * their delivery; a slot the landing deleted drops (range gate). Returns - * UNSET to skip the item. */ +/** 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 pc = item.pc; - if (pc !== undefined && ((pc.sg as number) | 0) !== ((item.sg as number) | 0)) { - if (item.si === undefined) return UNSET; - const rows = visibleStructRows(pc.t as StoreNextTarget); - if (!Array.isArray(rows) || (item.si as number) >= rows.length) return UNSET; - return rows[item.si as number]; - } const { force, t } = item; return t !== null ? (force ? forcedNext(t) : visibleStructRows(t)) : item.next; } @@ -637,8 +624,7 @@ export function emitRowOpsOptimistic( t: nextRows === null ? t : null, ops, pc: t.pc as PatchChannel, - rq: ((t.pc as any).rq as number) | 0, - sg: ((t.pc as any).sg as number) | 0 + rq: ((t.pc as any).rq as number) | 0 }); if (!scheduled) { scheduled = true; @@ -1561,8 +1547,7 @@ export function emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev t: null, si: index, pc: t.pc as PatchChannel, - rq: ((t.pc as any).rq as number) | 0, - sg: ((t.pc as any).sg as number) | 0 + rq: ((t.pc as any).rq as number) | 0 }); } @@ -1598,26 +1583,6 @@ export function registerSlotPatchNext( return structuralUnbind(entry, list, pc, "sp", false); } -/** Landing-consumption structural notification (audit follow-up P1, - * back-to-back continuations): the LANE with the DRAIN-RESOLVED resync - * form. Lane, because 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 (the landing's notification must not - * die with a transaction it doesn't belong to). Drain-resolved, because an - * emission-time composed snapshot reads the MID-RECKONING draft — a parked - * or superseded landing's topology reached the DOM while classic readers - * held the previous view until its commit; visibleStructRows at drain time - * reads exactly what classic renders at that moment. Bumps the structural - * generation FIRST: row/slot work queued before this consumption is - * superseded (F4) whether or not row consumers exist. */ -export function emitRowOpsLanding(t: StoreNextTarget): void { - const pc = t.pc as any; - if (pc === null) return; - pc.sg = ((pc.sg as number) | 0) + 1; - if (pc.ro === null) return; - emitRowOpsOptimistic(t, null, null); -} - /** Row-ops ride the SAME apply queue/timing as record patches: transition- * stamped, applied at effect phase, in emission order (structure before the * new rows' own patches can exist; retained rows' value patches commute). */ @@ -1633,8 +1598,7 @@ export function emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void { t: null, ops, pc: t.pc as PatchChannel, - rq: ((t.pc as any).rq as number) | 0, - sg: ((t.pc as any).sg as number) | 0 + rq: ((t.pc as any).rq as number) | 0 }); } @@ -1705,7 +1669,6 @@ function armRowHooks(): void { emitRowOps, emitSlotPatch, emitSetterRowOps, - emitRowOpsOptimistic, - emitRowOpsLanding + emitRowOpsOptimistic }); } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 7a62eb132..4a5afcf77 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -170,7 +170,6 @@ export function pcOf(t: StoreNextTarget): PatchChannel { akAll: false, mlc: 0, rq: 0, - sg: 0, t }) ); diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 229f107fa..ec70fb514 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -103,11 +103,6 @@ export interface PatchChannel { * `sq > item.rq`, with the drain-start `rq` as the FIXED window's far * edge (mid-drain registrants are excluded). */ rq?: number; - /** Structural generation (structural audit): a landing consumption bumps - * it AFTER retained-edit replay — queued items stamped with an older - * generation are superseded by the consumption's own resync and skipped - * at drain (stale transition-held ops, the replay's interim ops). */ - sg?: 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 diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 7ca065fe5..666eaf865 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1720,21 +1720,28 @@ describe("INVARIANT: landings integrate with the patch channel at classic-effect expect(classic.at(-1)).toBe("x:1"); const watermark = patched.length; - // EQUAL interim landing: membership unchanged (same single id), but a - // sibling field moved (count 1 -> 2) so adoption genuinely emits. The - // label override is HELD — classic keeps "x"; the patch channel must - // deliver the override-composed view, never raw committed "a". + // 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:2"); + expect(classic.at(-1)).toBe("x:1"); const sinceLanding = patched.slice(watermark); expect(sinceLanding.some(v => v.startsWith("a:"))).toBe(false); - expect(patched.at(-1)).toBe("x:2"); + 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(); }); @@ -1771,29 +1778,29 @@ describe("INVARIANT: landings integrate with the patch channel at classic-effect const patchMark = patched.length; const rowMark = rowEvents.length; - // CONTRADICTING landing: arrangement changed (id 2 never landed, id 3 - // did) — overrides are consumed AT THE LANDING. The driven list must be - // told the view flipped NOW (resync against live truth — the optimistic - // ops it holds are baseline-relative and stale), and the value channel - // must deliver the authoritative view exactly once. + // 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([]); - // Structural consumers saw the flip at the landing, not at owner settle. - const structSince = rowEvents.slice(rowMark); - expect(structSince.length).toBeGreaterThan(0); - expect(structSince.at(-1)!.ids).toEqual([1, 3]); - // Value channel: authoritative view, exactly one application. - expect(patched.slice(patchMark)).toEqual(["a2"]); - + // 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(); }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index ac65aecd1..35cae6eaa 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -191,7 +191,11 @@ module.exports = [ // 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. - limit: "15.05 KB", + // + // #3164 fold ruling (upstream, 2026-09-01): heldTruthNodes ledger + + // retainsOptimism seams ride paths createStore retains. Measured 15.12 + // post-rebase. + limit: "15.15 KB", modifyEsbuildConfig }, { @@ -373,7 +377,11 @@ module.exports = [ // 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. - limit: "27.2 KB", + // #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. + limit: "27.25 KB", modifyEsbuildConfig }, { From 292afdfd47097e373fe31740d29985e7eebd5923 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 01:14:02 -0700 Subject: [PATCH 40/56] perf: gate eager parent-slot repair on patches existing (CodSpeed -11%) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-10.11 eager path-copying in adoptPB ran per eager child adoption with no channel gate. Ancestor committed raws are only ever served to PATCH consumers (patchableRaw / delivery payloads) — classic readers resolve through proxies and nodes — so in a patch-less app the repair bought nothing and cost plenty: privatizeCommitted re-cloned every freshly adopted interior backing per reconcile (an extra tree copy per iteration), and the clone's identity swap turned downstream equality gates into keyset/deep bump storms (insertSubs 7.8x on the listened-paths deep() bench; CodSpeed -11% on the sparse case). Profiled standalone against the merge-base prod dist: parity restored (interleaved min/median within noise), patch-mode tests unchanged (the deep-path invariants register channels and keep the repair covered). Co-authored-by: Cursor --- .changeset/fix-eager-repair-patch-gate.md | 5 +++++ packages/signals/src/store/next/store.ts | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-eager-repair-patch-gate.md 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/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 4a5afcf77..5e9cf13d1 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -752,7 +752,21 @@ export function adoptPB( // 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. - if (eager && target.u !== null && target.u.v[target.pk!] === old) { + // + // 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; From ba5b75f4c865c8d431c8105f008e384569f47b68 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 01:27:15 -0700 Subject: [PATCH 41/56] =?UTF-8?q?fix:=20fold-audit=20round=20=E2=80=94=20p?= =?UTF-8?q?er-index=20slot=20defers,=20drain-end=20sweep,=20reveal=20cover?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four P1s at b31e9929, harness-first: - Held slot deliveries defer PER INDEX (entry.hqs): the shared hq flag collapsed multi-slot batches to the first index behind a hold. - Late-registrant sweep moved to DRAIN END with a per-channel highest- emission watermark: the per-item sweep resynced to live state and a later item's baseline ops re-applied on top, double-building rows. Audience unchanged: exactly the entries no snapshot reached, still a suffix tail scan. - stagedApply descends only through live draft proxies (stagedChild): recursing into a SHALLOW store's raw rows mutated committed truth in place — visible before the reveal, notified to no one. Raw children replace their slot wholesale. - Staged-truth folds emit at the reveal: t.sf marks optimistic-family drafts written under the authoritative posture (the staging bracket); both fold-site row-ops gates exempt it, and a slot-diff twin ticks changed slots. The old opt-family gates cover override materializations only — a root array retained through a DESCENDANT override is not overlaid, so the settle loop's resync never reached it. Full signals (1,511 + 2 expected-fail) + web (719) green; four size ratchets with notes (15.2/27.35/16.9/19.45). Co-authored-by: Cursor --- .changeset/fix-patch-fold-audit-round.md | 5 + packages/signals/AUDIT-BRIEF-R6.md | 36 ++++ packages/signals/src/store/next/optimistic.ts | 32 ++- packages/signals/src/store/next/patch.ts | 173 +++++++++++----- packages/signals/src/store/next/store.ts | 38 +++- packages/signals/src/store/next/target.ts | 7 + .../tests/store/patch-invariants.test.ts | 188 ++++++++++++++++++ scripts/size/.size-limit.js | 20 +- 8 files changed, 437 insertions(+), 62 deletions(-) create mode 100644 .changeset/fix-patch-fold-audit-round.md 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/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 82d8584f8..fad3fb20b 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,41 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 67b6c2aa2..3804b2b26 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -346,6 +346,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 @@ -408,7 +421,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; @@ -419,9 +437,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; @@ -445,7 +465,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; } diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 9bd09fda2..6703ead34 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -150,6 +150,7 @@ function drainApplyQueue(): void { if (item.ops !== undefined || item.si !== undefined) firstError = applyStructural(item, next, firstError); } + firstError = runLateSweeps(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. @@ -168,12 +169,99 @@ function visibleStructRows(t: StoreNextTarget): any { return t.fam?.opt === true ? t.px : (t.pb ?? t.v); } -/** Per-drain generation for late-resync dedup (audit follow-up P2): several - * held items on ONE channel each ran the late sweep — same entries, same - * live rebuild, item-count × consumer-count applications. Entries stamp the - * drain they were resynced in; repeats within it skip. */ +/** Per-drain generation for late-resync dedup (audit follow-up P2): entries + * stamp the drain they were resynced in; repeats within one flush's drains + * (lane + regular) skip. Incremented once per top-level drain. */ let drainGen = 0; +/** Late-registrant bookkeeping, swept once at DRAIN END (fold audit P1): + * sweeping per item resynced a late entry to the LIVE view and then a + * LATER queued item — whose emission snapshot legitimately included that + * entry — re-applied its baseline-relative ops on top, rebuilding the same + * DOM row twice. The drain records, per channel, the HIGHEST emission + * watermark (`maxRq`: anyone at or below was in some snapshot and received + * real, baseline-sound ops), the FIXED window's far edge (`winEnd`, + * captured at the channel's first item so mid-drain registrants are + * excluded), one row item and the slot items as resync vehicles. The sweep + * then covers exactly the entries NO snapshot reached: `sq` in + * (maxRq, winEnd]. Registrations append in `sq` order, so those are a + * SUFFIX — the tail scan stays O(#late). */ +interface PcSweep { + maxRq: number; + winEnd: number; + row: QueuedApply | null; + slots: QueuedApply[] | null; +} +let sweeps: Map | null = null; + +function noteSweep(item: QueuedApply, winEnd: number): void { + if (item.pc === undefined) return; + let s = (sweeps ??= new Map()).get(item.pc); + if (s === undefined) sweeps.set(item.pc, (s = { maxRq: 0, winEnd, row: null, slots: null })); + const rq = (item.rq as number) | 0; + if (rq > s.maxRq) s.maxRq = rq; + if (item.si !== undefined) (s.slots ??= []).push(item); + else s.row = item; +} + +function sweepList( + live: (RowOpsEntry & { + hq?: boolean; + hqs?: Set; + sq?: number; + dg?: number; + q?: unknown; + })[], + s: PcSweep, + item: QueuedApply, + firstError: unknown +): unknown { + for (let j = live.length - 1; j >= 0; j--) { + const entry = live[j]; + const sq = (entry.sq as number) | 0; + if (sq <= s.maxRq) break; // suffix exhausted — everyone else rode a snapshot + if (sq > s.winEnd) continue; // registered mid-drain: outside the window + if (entry.u === true || entry.hq === true) continue; + if (item.si !== undefined && entry.hqs?.has(item.si) === true) continue; + if (item.si === undefined && entry.dg === drainGen) continue; + if (entry.owner !== null && isDisposed(entry.owner)) continue; + const oq = entry.q as any; + if (queueIsHeld(oq)) { + deferHeldStructural(entry as any, oq, item); + continue; + } + if (item.si === undefined) entry.dg = drainGen; + try { + structuralResync(entry, item); + } catch (err) { + if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; + } + } + return firstError; +} + +function runLateSweeps(firstError: unknown): unknown { + const m = sweeps; + sweeps = null; + if (m === null) return firstError; + for (const [pc, s] of m) { + if (s.row !== null) { + const live = pc.ro as (RowOpsEntry & { sq?: number })[] | null; + if (live !== null && live.length !== 0) + firstError = sweepList(live as any, s, s.row, firstError); + } + if (s.slots !== null) { + const live = pc.sp as (RowOpsEntry & { sq?: number })[] | null; + if (live !== null && live.length !== 0) { + for (let i = 0; i < s.slots.length; i++) { + firstError = sweepList(live as any, s, s.slots[i], firstError); + } + } + } + } + return firstError; +} + /** 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 @@ -253,49 +341,11 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; } } - // LATE REGISTRANTS (round 10.13, P1; mechanics rebuilt by the structural - // audit): a consumer that registered between emission and the drain is - // outside the emission snapshot — baseline-relative ops would corrupt - // it, silence left held-window registrants permanently stale. It takes - // the RESYNC form against live state. THE WINDOW IS FIXED (F1): only - // registrants with `sq` in (item.rq, drain-start rq] — a consumer - // registering DURING this drain initialized from current state and gets - // nothing. Registrations append in `sq` order, so late entries are a - // SUFFIX: the tail scan is O(#late), not O(consumers²) (F6). Held owner - // queues defer exactly like the snapshot path (F1) — never through the - // hold. - if (item.pc !== undefined) { - const live = (item.si !== undefined ? item.pc.sp : item.pc.ro) as - | (RowOpsEntry & { hq?: boolean; sq?: number; dg?: number; q?: unknown })[] - | null; - if (live !== null && live.length !== 0) { - const itemRq = ((item.rq as number) | 0) as number; - for (let j = live.length - 1; j >= 0; j--) { - const entry = live[j]; - const sq = (entry.sq as number) | 0; - if (sq <= itemRq) break; // suffix exhausted — everyone else was in the snapshot - if (sq > maxSq) continue; // registered mid-drain: outside the window - if (entry.u === true || entry.hq === true) continue; - // ONE live rebuild per entry per drain (audit follow-up P2): every - // held item on this channel runs this sweep — the resync reads the - // same live truth each time, so repeats are pure waste. Slot items - // stay per-item (distinct indices are distinct deliveries). - if (item.si === undefined && entry.dg === drainGen) continue; - if (entry.owner !== null && isDisposed(entry.owner)) continue; - const oq = entry.q as any; - if (queueIsHeld(oq)) { - deferHeldStructural(entry as any, oq, item); - continue; - } - if (item.si === undefined) entry.dg = drainGen; - try { - structuralResync(entry, item); - } catch (err) { - if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; - } - } - } - } + // LATE REGISTRANTS: recorded here, swept ONCE at DRAIN END (fold audit + // P1 — the per-item sweep resynced an entry to the LIVE view and then a + // LATER item's real ops re-applied on top, double-building rows). See + // noteSweep/runLateSweeps. + noteSweep(item, maxSq); // Structural deliveries are attribution EVENTS (round 10.12, P2): they // run in commit drains, not effects, so no rerun event exists for them // — the engine records a synthetic one (name, causes, count, timing). @@ -328,13 +378,34 @@ function structuralResync(entry: { fn: Function }, item: QueuedApply): void { } /** Deferred structural re-apply for a held consumer (round 10.13): runs - * FROM its owner queue at release, one queued run per entry per hold - * window, always in the live resync form. */ + * 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 }, + 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; + try { + structuralResync(entry, item); + } catch (err) { + if (!routeEntryError(entry as any, err)) deferHalt(err); + } + }); + return; + } deferIntoQueue(entry, oq, () => { try { structuralResync(entry, item); @@ -578,12 +649,12 @@ function drainOptimistic(): void { drainGen++; for (let i = 0; i < q.length; i++) { const item = q[i]; - // Same superseded-work gate as the regular drain (structural audit, F4). const next = drainNext(item); if (next === UNSET) continue; if (item.ops !== undefined || item.si !== undefined) firstError = applyStructural(item, next, firstError); } + firstError = runLateSweeps(firstError); if (firstError !== UNSET) { haltReactivity(firstError); throw firstError; diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 5e9cf13d1..46b229bce 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -628,6 +628,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 @@ -946,15 +953,39 @@ function drainFolds(): void { // channel: adoption folds (reconcile walk emitted ops) and // optimistic families (lane-timed override channel). Re-audit // blocker 4. + // 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). if ( t.pc !== null && t.pc.ro !== null && !t.adopted && - t.fam?.opt !== true && + (t.fam?.opt !== true || t.sf === true) && Array.isArray(pb) && Array.isArray(t.v) ) rowHooks!.emitSetterRowOps(t, t.v as any[], pb as any[]); + // Slot channel twin (fold audit P1): staged truth commits slot + // VALUES without any walk — diff the aligned window and tick every + // changed slot (the walk's emission shape). + if ( + t.pc !== null && + t.pc.sp !== null && + t.sf === true && + Array.isArray(pb) && + Array.isArray(t.v) + ) { + const oldArr = t.v as any[]; + const newArr = pb as any[]; + const n = Math.min(oldArr.length, newArr.length); + for (let si = 0; si < n; si++) { + if (oldArr[si] !== newArr[si]) rowHooks!.emitSlotPatch(t, si, newArr[si], oldArr[si]); + } + } + t.sf = false; t.v = pb; t.ch = false; // pb is always a plain clone t.pb = null; @@ -981,14 +1012,17 @@ 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[]); + 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 — diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index ec70fb514..6d4c417e7 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -185,6 +185,13 @@ 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; /** 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/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 666eaf865..f9cb21bfb 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1616,6 +1616,194 @@ describe("INVARIANT: structural resyncs honor holds, fix their window, and serve }); }); +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 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. + confirm(); + await run; + await settle(); + await settle(); + expect((items as any[]).length).toBe(2); + expect(frames.at(-1)).toEqual([1, 2]); + }); +}); + 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. diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 35cae6eaa..34d44142a 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -195,7 +195,10 @@ module.exports = [ // #3164 fold ruling (upstream, 2026-09-01): heldTruthNodes ledger + // retainsOptimism seams ride paths createStore retains. Measured 15.12 // post-rebase. - limit: "15.15 KB", + // 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. + limit: "15.2 KB", modifyEsbuildConfig }, { @@ -381,7 +384,10 @@ module.exports = [ // 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. - limit: "27.25 KB", + // 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. + limit: "27.35 KB", modifyEsbuildConfig }, { @@ -460,7 +466,10 @@ module.exports = [ // 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. - limit: "16.7 KB", + // 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. + limit: "16.9 KB", modifyEsbuildConfig }, { @@ -514,7 +523,10 @@ module.exports = [ // 19.14. // Follow-up (2026-08-31): the value-tier bytes above plus the slot sq // stamp and landing resync hook. Measured 19.26. - limit: "19.3 KB", + // 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. + limit: "19.45 KB", modifyEsbuildConfig }, { From f3e24d42d03cd654b6ed21b0c1ee9d2d32811d93 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 02:10:56 -0700 Subject: [PATCH 42/56] =?UTF-8?q?refactor:=20structural=20version=20chain?= =?UTF-8?q?=20=E2=80=94=20closes=20the=20sweep/watermark=20finding=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings at a4c439b7, four in the accreted sweep machinery. Per the standing decision, redesigned rather than patched a fifth time: Emissions stamp svAt (per-kind: sv rows, svs slots); the channel tracks the VISIBLE version (svv/svvs — live-queue entry, releaseBatch for stashed items, emission for lane items); entries init av to it and apply an item only when av === svAt-1 (the item's baseline IS their last-seen state). Gaps take ONE flush-end resync after every queue. Deleted: emission-snapshot membership, PcSweep/noteSweep/sweepList/runLateSweeps, maxRq/winEnd watermarks, drainGen/dg, rq/sq stamps. Findings 1/2/4 are unaskable under the chain. Finding 3: settle loop skips staged-fold targets (single reveal notification, pinned). Finding 5: repairAncestorSlots at registration, gated to stale aliases of the SAME child resolved through the family map — the ungated first cut injected tentative rows into committed truth (all four optimistic equivalence failures, one root cause). Revert-form resyncs (cm items) resolve committed raw only — never a lingering draft, never the composing proxy. Held-window registrants improve: chain connects, real baseline-sound ops at release instead of a rebuild (pin updated). Signals 1,511 (+2 expected-fail) | web 719 | parity 153 — all green. Three ratchets (17.15/19.6/27.4): flat redesign cost, accretion stops. Co-authored-by: Cursor --- .changeset/patch-structural-version-chain.md | 5 + packages/signals/AUDIT-BRIEF-R6.md | 40 +++ packages/signals/src/store/next/optimistic.ts | 8 +- packages/signals/src/store/next/patch.ts | 314 +++++++++--------- packages/signals/src/store/next/store.ts | 34 +- packages/signals/src/store/next/target.ts | 21 +- .../tests/store/patch-invariants.test.ts | 17 +- scripts/size/.size-limit.js | 18 +- 8 files changed, 285 insertions(+), 172 deletions(-) create mode 100644 .changeset/patch-structural-version-chain.md 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/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index fad3fb20b..71d7253ca 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,45 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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 diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 3804b2b26..1b00e41e7 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -129,8 +129,12 @@ function installNextBlockedHalf(): void { // 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) + 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 — diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 6703ead34..fa5f01060 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -65,7 +65,7 @@ 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; @@ -121,8 +121,15 @@ interface QueuedApply { ops?: RowOps | null; /** Slot-tick payload index (same live-list rationale as `ops`). */ si?: number; - /** Registration-sequence watermark at emission (see PatchChannel.rq). */ - rq?: 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; @@ -142,7 +149,6 @@ function drainApplyQueue(): void { // it (source = the owner, error read via owner._x?._error). Unhandled errors // rethrow after the drain so they still surface. let firstError: unknown = UNSET; - drainGen++; for (let i = 0; i < q.length; i++) { const item = q[i]; const next = drainNext(item); @@ -150,7 +156,7 @@ function drainApplyQueue(): void { if (item.ops !== undefined || item.si !== undefined) firstError = applyStructural(item, next, firstError); } - firstError = runLateSweeps(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. @@ -169,99 +175,6 @@ function visibleStructRows(t: StoreNextTarget): any { return t.fam?.opt === true ? t.px : (t.pb ?? t.v); } -/** Per-drain generation for late-resync dedup (audit follow-up P2): entries - * stamp the drain they were resynced in; repeats within one flush's drains - * (lane + regular) skip. Incremented once per top-level drain. */ -let drainGen = 0; - -/** Late-registrant bookkeeping, swept once at DRAIN END (fold audit P1): - * sweeping per item resynced a late entry to the LIVE view and then a - * LATER queued item — whose emission snapshot legitimately included that - * entry — re-applied its baseline-relative ops on top, rebuilding the same - * DOM row twice. The drain records, per channel, the HIGHEST emission - * watermark (`maxRq`: anyone at or below was in some snapshot and received - * real, baseline-sound ops), the FIXED window's far edge (`winEnd`, - * captured at the channel's first item so mid-drain registrants are - * excluded), one row item and the slot items as resync vehicles. The sweep - * then covers exactly the entries NO snapshot reached: `sq` in - * (maxRq, winEnd]. Registrations append in `sq` order, so those are a - * SUFFIX — the tail scan stays O(#late). */ -interface PcSweep { - maxRq: number; - winEnd: number; - row: QueuedApply | null; - slots: QueuedApply[] | null; -} -let sweeps: Map | null = null; - -function noteSweep(item: QueuedApply, winEnd: number): void { - if (item.pc === undefined) return; - let s = (sweeps ??= new Map()).get(item.pc); - if (s === undefined) sweeps.set(item.pc, (s = { maxRq: 0, winEnd, row: null, slots: null })); - const rq = (item.rq as number) | 0; - if (rq > s.maxRq) s.maxRq = rq; - if (item.si !== undefined) (s.slots ??= []).push(item); - else s.row = item; -} - -function sweepList( - live: (RowOpsEntry & { - hq?: boolean; - hqs?: Set; - sq?: number; - dg?: number; - q?: unknown; - })[], - s: PcSweep, - item: QueuedApply, - firstError: unknown -): unknown { - for (let j = live.length - 1; j >= 0; j--) { - const entry = live[j]; - const sq = (entry.sq as number) | 0; - if (sq <= s.maxRq) break; // suffix exhausted — everyone else rode a snapshot - if (sq > s.winEnd) continue; // registered mid-drain: outside the window - if (entry.u === true || entry.hq === true) continue; - if (item.si !== undefined && entry.hqs?.has(item.si) === true) continue; - if (item.si === undefined && entry.dg === drainGen) continue; - if (entry.owner !== null && isDisposed(entry.owner)) continue; - const oq = entry.q as any; - if (queueIsHeld(oq)) { - deferHeldStructural(entry as any, oq, item); - continue; - } - if (item.si === undefined) entry.dg = drainGen; - try { - structuralResync(entry, item); - } catch (err) { - if (!routeEntryError(entry as any, err) && firstError === UNSET) firstError = err; - } - } - return firstError; -} - -function runLateSweeps(firstError: unknown): unknown { - const m = sweeps; - sweeps = null; - if (m === null) return firstError; - for (const [pc, s] of m) { - if (s.row !== null) { - const live = pc.ro as (RowOpsEntry & { sq?: number })[] | null; - if (live !== null && live.length !== 0) - firstError = sweepList(live as any, s, s.row, firstError); - } - if (s.slots !== null) { - const live = pc.sp as (RowOpsEntry & { sq?: number })[] | null; - if (live !== null && live.length !== 0) { - for (let i = 0; i < s.slots.length; i++) { - firstError = sweepList(live as any, s, s.slots[i], firstError); - } - } - } - } - return firstError; -} - /** 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 @@ -269,7 +182,12 @@ function runLateSweeps(firstError: unknown): unknown { * truth now rides the retaining transaction's own queues.) */ function drainNext(item: QueuedApply): unknown { const { force, t } = item; - return t !== null ? (force ? forcedNext(t) : visibleStructRows(t)) : item.next; + 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 @@ -289,70 +207,74 @@ function forcedNext(t: StoreNextTarget): any { * 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 { - const snap = item.list as unknown as { fn: Function; owner: Owner | null; u?: boolean }[]; - const len = snap.length; - // FIXED WINDOW far edge (structural audit, F1): captured BEFORE any - // dispatch — a consumer registered from a callback below bumps `rq` - // past this watermark and is excluded from this item entirely. - const maxSq = item.pc !== undefined ? (item.pc.rq as number) | 0 : 0; - // DELETED-SLOT gate (structural audit, F3): a slot tick coalesced with a - // later shrink indexes past the live list — applying it (snap or resync) - // would deliver an undefined value to a row that no longer exists. - if (item.si !== undefined && item.pc !== undefined) { - const st = item.pc.t as StoreNextTarget; - const liveRows = visibleStructRows(st); - if (!Array.isArray(liveRows) || item.si >= liveRows.length) return firstError; - } - // Structural dispatch diagnostics (rounds 10.11/10.12): the CHANNEL is - // the memo key — emission snapshots slice the consumer list, so a - // per-item array key made the width warning fire every flush. Names and - // causes anchor on the channel too (`pc.t` path, `pc.dn` stamp). - const dch = __DEV__ && attrHooks !== null ? (item.pc ?? null) : null; + // 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) | 0; + 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), len, dchannel, null); + attrHooks.patchDispatch((dch as object) ?? (item.list as object), live.length, dchannel, null); dstart = performance.now(); } - for (let j = 0; j < len; j++) { - const entry = snap[j] as { - fn: Function; - owner: Owner | null; - u?: boolean; - q?: unknown; - hq?: boolean; - }; + // 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; - // BOUNDARY HOLD parity for STRUCTURE (round 10.13, P1): a consumer - // under a collapsed queue defers INTO it — and re-derives from LIVE - // state at release: row ops are baseline-relative (the queued ops - // would be stale by then) and slot values can be superseded, so the - // deferred form is the RESYNC, reading the release moment's truth. + const av = (entry.av as number) | 0; + 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, oq, item); + 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(item.si, next, item.prev); - else entry.fn(next, item.ops); + 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; } } - // LATE REGISTRANTS: recorded here, swept ONCE at DRAIN END (fold audit - // P1 — the per-item sweep resynced an entry to the LIVE view and then a - // LATER item's real ops re-applied on top, double-building rows). See - // noteSweep/runLateSweeps. - noteSweep(item, maxSq); - // Structural deliveries are attribution EVENTS (round 10.12, P2): they - // run in commit drains, not effects, so no rerun event exists for them - // — the engine records a synthetic one (name, causes, count, timing). + // 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, - len, + live.length, dchannel, ((dch as any).dn as any) ?? null, performance.now() - dstart @@ -360,6 +282,58 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk 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) | 0; + 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 @@ -368,7 +342,7 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk * 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 ? visibleStructRows(t) : item.next; + 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); @@ -542,7 +516,17 @@ function releaseBatch(batch: Transition): void { for (let i = 0; i < held.length; i++) pushLive(held[i]); } +/** 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 { + 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) | 0)) pc[k] = item.svAt; + } (queue ??= []).push(item); if (!scheduled) { scheduled = true; @@ -646,7 +630,6 @@ function drainOptimistic(): void { // 5): one throwing optimistic patch must not abort its siblings, and it // must reach the registering owner's Errored boundary. let firstError: unknown = UNSET; - drainGen++; for (let i = 0; i < q.length; i++) { const item = q[i]; const next = drainNext(item); @@ -654,7 +637,10 @@ function drainOptimistic(): void { if (item.ops !== undefined || item.si !== undefined) firstError = applyStructural(item, next, firstError); } - firstError = runLateSweeps(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; @@ -693,10 +679,14 @@ export function emitRowOpsOptimistic( prev: null, force: false, t: nextRows === null ? t : null, + cm: nextRows === null && ops === null, ops, pc: t.pc as PatchChannel, - rq: ((t.pc as any).rq as number) | 0 + svAt: ((t.pc as any).sv = (((t.pc as any).sv as number) | 0) + 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); @@ -1148,6 +1138,12 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { 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(); @@ -1575,6 +1571,14 @@ interface RowOpsEntry { 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 @@ -1584,11 +1588,11 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { armRowHooks(); const rowner = getOwner(); const pc = pcOf(t); - const entry: RowOpsEntry & { sq?: number } = { + const entry: RowOpsEntry = { fn, owner: rowner, q: (rowner as any)?._queue ?? null, - sq: (pc.rq = ((pc.rq as number) | 0) + 1) + av: ((pc as any).svv as number) | 0 }; if (__TEST__) devTrackChannel(pc); const list = (pc.ro ??= []) as RowOpsEntry[]; @@ -1618,7 +1622,7 @@ export function emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev t: null, si: index, pc: t.pc as PatchChannel, - rq: ((t.pc as any).rq as number) | 0 + svAt: ((t.pc as any).svs = (((t.pc as any).svs as number) | 0) + 1) }); } @@ -1636,15 +1640,11 @@ export function registerSlotPatchNext( // lists — registrations are a list, unbinds splice their own entry. const pc = pcOf(t); const sowner = getOwner(); - // Same registration-sequence stamp as row-ops entries (structural audit - // follow-up P1): without it the late sweep's suffix scan reads sq 0 and - // breaks immediately — shallow lists mounted during held windows stayed - // permanently stale. const entry = { fn, owner: sowner, q: (sowner as any)?._queue ?? null, - sq: (pc.rq = ((pc.rq as number) | 0) + 1) + av: ((pc as any).svvs as number) | 0 }; const list = (pc.sp ??= []) as unknown[]; list.push(entry); @@ -1669,7 +1669,7 @@ export function emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void { t: null, ops, pc: t.pc as PatchChannel, - rq: ((t.pc as any).rq as number) | 0 + svAt: ((t.pc as any).sv = (((t.pc as any).sv as number) | 0) + 1) }); } diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 46b229bce..f37be70d1 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -169,7 +169,10 @@ export function pcOf(t: StoreNextTarget): PatchChannel { ks: false, akAll: false, mlc: 0, - rq: 0, + sv: 0, + svv: 0, + svs: 0, + svvs: 0, t }) ); @@ -829,6 +832,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); diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 6d4c417e7..5d81311ae 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -97,12 +97,21 @@ export interface PatchChannel { * always writes (scheduler owns merge bookkeeping). */ bt?: unknown; bo?: unknown; - /** Structural registration sequence (structural audit): entries stamp - * `sq = ++rq` at registration, items stamp `rq` at emission — the late- - * registrant sweep becomes a tail scan over the (append-ordered) suffix - * `sq > item.rq`, with the drain-start `rq` as the FIXED window's far - * edge (mid-drain registrants are excluded). */ - rq?: number; + /** 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 diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index f9cb21bfb..074dc011e 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -912,10 +912,13 @@ describe("INVARIANT: structure honors holds and reaches held-window registrants resolve(); await p; flush(); - // At the settle drain the late consumer takes the resync form — the - // silent path left it permanently stale on the pre-commit view. + // At the settle drain the late consumer is reached (the silent path + // left it permanently stale on the pre-commit view). Version-chain + // refinement: its applied version connects to the held item's — it now + // receives the REAL, baseline-sound ops (its registration read the + // pre-commit view, exactly the ops' baseline) rather than a rebuild. + // Either form is sound; the final view is the pin. expect(late.length).toBeGreaterThan(0); - expect(late[late.length - 1][1]).toBe(true); expect(late[late.length - 1][0]).toEqual(["b", "a"]); }); }); @@ -1795,12 +1798,20 @@ describe("INVARIANT: structural channels under fold/holds — per-index slots, n 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); }); }); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 34d44142a..e0df937c6 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -387,7 +387,11 @@ module.exports = [ // 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. - limit: "27.35 KB", + // 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. + limit: "27.4 KB", modifyEsbuildConfig }, { @@ -469,7 +473,11 @@ module.exports = [ // 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. - limit: "16.9 KB", + // 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. + limit: "17.15 KB", modifyEsbuildConfig }, { @@ -526,7 +534,11 @@ module.exports = [ // 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. - limit: "19.45 KB", + // 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. + limit: "19.6 KB", modifyEsbuildConfig }, { From 18bacdd895d5ab8d8c1bc7074101158868c145bd Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 02:51:20 -0700 Subject: [PATCH 43/56] =?UTF-8?q?fix:=20fold=20audit=202=20=E2=80=94=20tra?= =?UTF-8?q?nsition-aware=20version=20init,=20one-channel=20reveals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings at b296640d (3 P1 + 2 P2), all in the chain's edges: - av init is TRANSITION-AWARE: a consumer mounting inside the writing transition reads the SPECULATIVE view, so its baseline covers the stashed emissions (av = sv); the old svv init replayed stashed ops over DOM already built from them (store bac, DOM abc). Ambient mounts keep the visible-version init and receive stashed ops at release. - Staged reveals ride ONE channel: aligned windows (same length) are value replacements — slot ticks only; length changes are structure — row ops only. Both channels for one replacement rebuilt the row twice (lifecycle/focus divergence). - Staged-reveal identity diffs key rows through the FAMILY MAP: the fold re-seats retained rows' raws, and raw-keyed matching rebuilt rows whose proxies never changed. - Held structural releases fast-forward entry.av (a gap right after a release forced a redundant full resync). - Version arithmetic drops |0 coercions (2^31 wrap = permanent delivery suppression at scale). Signals 1,513 (+2 expected-fail) | web 719 green; two 20 B ratchets. Co-authored-by: Cursor --- .changeset/fix-version-chain-fold-audit-2.md | 5 ++ .../signals/src/store/next/patch-hooks.ts | 7 +- packages/signals/src/store/next/patch.ts | 28 ++++-- packages/signals/src/store/next/reconcile.ts | 19 ++-- packages/signals/src/store/next/store.ts | 48 ++++++----- .../tests/store/patch-invariants.test.ts | 86 +++++++++++++++++++ scripts/size/.size-limit.js | 10 ++- 7 files changed, 164 insertions(+), 39 deletions(-) create mode 100644 .changeset/fix-version-chain-fold-audit-2.md 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/packages/signals/src/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts index 58835a3b8..6047503ae 100644 --- a/packages/signals/src/store/next/patch-hooks.ts +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -44,7 +44,12 @@ export interface PatchValueHooks { export interface PatchRowHooks { emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void; emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev: any): void; - emitSetterRowOps(t: StoreNextTarget, prevRows: any[], nextRows: any[]): void; + emitSetterRowOps( + t: StoreNextTarget, + prevRows: any[], + nextRows: any[], + key?: (v: any) => any + ): void; emitRowOpsOptimistic(t: StoreNextTarget, next: any[] | null, ops: RowOps | null): void; } diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index fa5f01060..319240490 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -217,7 +217,7 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk // for ONE flush-end resync (after every queue drains). const pc = item.pc; if (pc === undefined) return firstError; - const svAt = (item.svAt as number) | 0; + const svAt = item.svAt as number; const live = (item.si !== undefined ? pc.sp : pc.ro) as | (RowOpsEntry & { hqs?: Set; av?: number; rs?: boolean })[] | null; @@ -243,7 +243,7 @@ function applyStructural(item: QueuedApply, next: any, firstError: unknown): unk 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) | 0; + 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. @@ -301,7 +301,7 @@ function runResyncs(firstError: unknown): unknown { 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) | 0; + 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, { @@ -372,6 +372,10 @@ function deferHeldStructural( 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) { @@ -381,6 +385,7 @@ function deferHeldStructural( 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) { @@ -525,7 +530,7 @@ function pushLive(item: QueuedApply): void { 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) | 0)) pc[k] = item.svAt; + if (item.svAt > (pc[k] as number)) pc[k] = item.svAt; } (queue ??= []).push(item); if (!scheduled) { @@ -682,7 +687,7 @@ export function emitRowOpsOptimistic( cm: nextRows === null && ops === null, ops, pc: t.pc as PatchChannel, - svAt: ((t.pc as any).sv = (((t.pc as any).sv as number) | 0) + 1) + 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. @@ -1592,7 +1597,11 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { fn, owner: rowner, q: (rowner as any)?._queue ?? null, - av: ((pc as any).svv as number) | 0 + // Transition-aware init (fold audit 2, P1): a consumer mounting INSIDE + // the writing transition reads the SPECULATIVE view — its baseline + // covers the stashed emissions too (sv). Ambient mounts read committed + // truth (svv) and receive the stashed ops at release. + av: (activeTransition !== null ? ((pc as any).sv as number) : ((pc as any).svv as number)) ?? 0 }; if (__TEST__) devTrackChannel(pc); const list = (pc.ro ??= []) as RowOpsEntry[]; @@ -1622,7 +1631,7 @@ export function emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev t: null, si: index, pc: t.pc as PatchChannel, - svAt: ((t.pc as any).svs = (((t.pc as any).svs as number) | 0) + 1) + svAt: ((t.pc as any).svs = ((t.pc as any).svs as number) + 1) }); } @@ -1644,7 +1653,8 @@ export function registerSlotPatchNext( fn, owner: sowner, q: (sowner as any)?._queue ?? null, - av: ((pc as any).svvs as number) | 0 + av: + (activeTransition !== null ? ((pc as any).svs as number) : ((pc as any).svvs as number)) ?? 0 }; const list = (pc.sp ??= []) as unknown[]; list.push(entry); @@ -1669,7 +1679,7 @@ export function emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void { t: null, ops, pc: t.pc as PatchChannel, - svAt: ((t.pc as any).sv = (((t.pc as any).sv as number) | 0) + 1) + svAt: ((t.pc as any).sv = ((t.pc as any).sv as number) + 1) }); } diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 39e15cc16..b18eff559 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -540,8 +540,13 @@ 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); +export function emitSetterRowOps( + t: StoreNextTarget, + prevRows: any[], + nextRows: any[], + key?: KeyFn +): void { + const ops = buildIdentityRowOps(prevRows, nextRows, key); if (ops !== null) rowHooks!.emitRowOps(t, nextRows, ops); } @@ -549,12 +554,16 @@ export function emitSetterRowOps(t: StoreNextTarget, prevRows: any[], nextRows: * 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++; + while (p < min && k(prevRows[p]) === k(nextRows[p])) 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): diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index f37be70d1..94f7b03b9 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -991,30 +991,34 @@ function drainFolds(): void { // 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). - if ( - t.pc !== null && - t.pc.ro !== null && - !t.adopted && - (t.fam?.opt !== true || t.sf === true) && - Array.isArray(pb) && - Array.isArray(t.v) - ) - rowHooks!.emitSetterRowOps(t, t.v as any[], pb as any[]); - // Slot channel twin (fold audit P1): staged truth commits slot - // VALUES without any walk — diff the aligned window and tick every - // changed slot (the walk's emission shape). - if ( - t.pc !== null && - t.pc.sp !== null && - t.sf === true && - Array.isArray(pb) && - Array.isArray(t.v) - ) { + // ONE channel per replacement (fold audit 2, P1): the walk's split — + // ALIGNED windows (same length) are value replacements and ride slot + // ticks ONLY; length changes are structure and ride row ops ONLY. + // Emitting both rebuilt the same shallow row twice (lifecycle/focus + // divergence). 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[]; - const n = Math.min(oldArr.length, newArr.length); - for (let si = 0; si < n; si++) { - if (oldArr[si] !== newArr[si]) rowHooks!.emitSlotPatch(t, si, newArr[si], oldArr[si]); + const aligned = t.sf === true && t.pc.sp !== null && oldArr.length === newArr.length; + if (aligned) { + for (let si = 0; si < newArr.length; si++) { + if (oldArr[si] !== newArr[si]) rowHooks!.emitSlotPatch(t, si, newArr[si], oldArr[si]); + } + } else if (t.pc.ro !== null && !t.adopted && (t.fam?.opt !== true || t.sf === true)) { + const map = t.fam?.map ?? storeNextLookup; + 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 + ); } } t.sf = false; diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 074dc011e..f3eae0e89 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1745,6 +1745,92 @@ describe("INVARIANT: structural channels under fold/holds — per-index slots, n 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("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 — row ops for + // the same slot would rebuild the row a second time (lifecycle/focus). + expect(ticks.some(([i, v]) => i === 0 && v === "A2")).toBe(true); + const revealRowOps = rowEvents.slice(rowMark).filter(o => o !== null); + expect(revealRowOps.length).toBe(0); + }); + it("a staged ROOT structural change reveals WITH row ops when only a descendant holds the override", async () => { const { createOptimisticStore, diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index e0df937c6..9030a54a1 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -198,7 +198,10 @@ module.exports = [ // 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. - limit: "15.2 KB", + // Fold audit 2 (2026-09-01): transition-aware version init, reveal + // channel split, target-keyed staged identity, release fast-forward. + // Measured 15.22. + limit: "15.25 KB", modifyEsbuildConfig }, { @@ -538,7 +541,10 @@ module.exports = [ // 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. - limit: "19.6 KB", + // Fold audit 2 (2026-09-01): transition-aware version init, reveal + // channel split, target-keyed staged identity, release fast-forward. + // Measured 19.62. + limit: "19.65 KB", modifyEsbuildConfig }, { From f52e97b2a9b9b361a68487ceb39d0e919a2a98fb Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 03:00:15 -0700 Subject: [PATCH 44/56] =?UTF-8?q?docs:=20perf=20verification=20=E2=80=94?= =?UTF-8?q?=20driver=20restored=20to=20octane-class=20through=20the=20vers?= =?UTF-8?q?ion=20chain;=20harness=20ghosts=20documented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- packages/signals/AUDIT-BRIEF-R6.md | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 71d7253ca..7fdcc176a 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,36 @@ # Audit brief — rounds 6–9 + patch-mode default flip + node delivery +## 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- From 9ce370bfdb155e2df026ece66cabb4c96b657767 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 03:09:43 -0700 Subject: [PATCH 45/56] =?UTF-8?q?docs:=20ship=20ruling=20=E2=80=94=20as-is?= =?UTF-8?q?=20pending=20final=20audit;=20keyed-fn/shallow-keyed/multiplexi?= =?UTF-8?q?ng=20deferred=20to=20future=20RC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- packages/signals/AUDIT-BRIEF-R6.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/signals/AUDIT-BRIEF-R6.md b/packages/signals/AUDIT-BRIEF-R6.md index 7fdcc176a..f3cabbbf3 100644 --- a/packages/signals/AUDIT-BRIEF-R6.md +++ b/packages/signals/AUDIT-BRIEF-R6.md @@ -1,5 +1,24 @@ # 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 From 0f17fc52028e3a7a264b84b0f0180cb58ecd472e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 04:03:22 -0700 Subject: [PATCH 46/56] =?UTF-8?q?fix:=20fold=20audit=203=20=E2=80=94=20hel?= =?UTF-8?q?d-queue=20av=20init,=20reorder=20classification,=20reveal=20mar?= =?UTF-8?q?k=20on=20both=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P1s at 6aa9ad93: - av init keys off queueIsHeld(entry.q), not activeTransition: parked actions clear the execution-time flag while held-boundary registrants still read speculative state — the flag-based init handed them a committed baseline and stashed ops replayed over speculative DOM. White-box pin: held registrant av === sv, ambient av === svv, in a parked window. - Aligned-window classification checks reference MOVEMENT, not just length: an equal-length staged reorder is STRUCTURE (row ops); slot ticks only when every differing slot is a non-wrappable replacement. Pinned: reorder = one ops event, zero ticks. - The reveal marks rf on BOTH branches (slot ticks are a reveal too) and the settle loop consumes it — the shallow repro now asserts the row channel is COMPLETELY silent at an aligned reveal (the old assertion filtered out the null resync that revealed the double). Signals 1,515 (+2 expected-fail) | web 719 green; one 24 B ratchet. Co-authored-by: Cursor --- packages/signals/src/store/next/optimistic.ts | 3 +- packages/signals/src/store/next/patch.ts | 23 ++-- packages/signals/src/store/next/store.ts | 33 ++++-- packages/signals/src/store/next/target.ts | 4 + .../tests/store/patch-invariants.test.ts | 110 +++++++++++++++++- scripts/size/.size-limit.js | 4 +- 6 files changed, 155 insertions(+), 22 deletions(-) diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 1b00e41e7..e3bd3dc95 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -133,8 +133,9 @@ function installNextBlockedHalf(): void { // 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) + if (ot.pc !== null && ot.pc.ro !== null && ot.sf !== true && ot.rf !== true) rowHooks!.emitRowOpsOptimistic(ot, null, null); + ot.rf = false; // consumed — one reveal, one notification // 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 — diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 319240490..cda871add 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -1593,15 +1593,19 @@ export function registerRowOps(array: any, fn: RowOpsFn): () => void { armRowHooks(); const rowner = getOwner(); const pc = pcOf(t); + const rq = (rowner as any)?._queue ?? null; const entry: RowOpsEntry = { fn, owner: rowner, - q: (rowner as any)?._queue ?? null, - // Transition-aware init (fold audit 2, P1): a consumer mounting INSIDE - // the writing transition reads the SPECULATIVE view — its baseline - // covers the stashed emissions too (sv). Ambient mounts read committed - // truth (svv) and receive the stashed ops at release. - av: (activeTransition !== null ? ((pc as any).sv as number) : ((pc as any).svv as number)) ?? 0 + 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[]; @@ -1649,12 +1653,13 @@ export function registerSlotPatchNext( // lists — registrations are a list, unbinds splice their own entry. const pc = pcOf(t); const sowner = getOwner(); + const sq = (sowner as any)?._queue ?? null; const entry = { fn, owner: sowner, - q: (sowner as any)?._queue ?? null, - av: - (activeTransition !== null ? ((pc as any).svs as number) : ((pc as any).svvs as number)) ?? 0 + q: sq, + av: ((queueIsHeld(sq) ? ((pc as any).svs as number) : ((pc as any).svvs as number)) ?? + 0) as number }; const list = (pc.sp ??= []) as unknown[]; list.push(entry); diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 94f7b03b9..04681fa93 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -991,22 +991,41 @@ function drainFolds(): void { // 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 audit 2, P1): the walk's split — - // ALIGNED windows (same length) are value replacements and ride slot - // ticks ONLY; length changes are structure and ride row ops ONLY. - // Emitting both rebuilt the same shallow row twice (lifecycle/focus - // divergence). Staged DEEP reveals key rows by TARGET identity, not - // raw identity — the fold re-seats retained rows' raws, and raw-keyed + // 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[]; - const aligned = t.sf === true && t.pc.sp !== null && oldArr.length === newArr.length; + 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) { + t.rf = true; // slot ticks ARE the reveal — the settle loop must not resync for (let si = 0; si < newArr.length; si++) { if (oldArr[si] !== newArr[si]) rowHooks!.emitSlotPatch(t, si, newArr[si], oldArr[si]); } } else if (t.pc.ro !== null && !t.adopted && (t.fam?.opt !== true || t.sf === true)) { + if (t.sf === true) t.rf = true; // reveal emitted — settle loop must not resync again const map = t.fam?.map ?? storeNextLookup; rowHooks!.emitSetterRowOps( t, diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 5d81311ae..bde124ad3 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -201,6 +201,10 @@ export interface StoreNextTarget { * gates at the fold sites exist for OVERRIDE materializations, which * ride the lane). Cleared at the fold. */ sf?: boolean; + /** Reveal row-ops emitted by the fold THIS flush (fold audit 3, P1): the + * settle drain's resync loop skips (and consumes) it — a second rebuild + * for the same reveal lost DOM identity/focus. */ + rf?: boolean; /** 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/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index f3eae0e89..42ba35a0b 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1787,6 +1787,57 @@ describe("INVARIANT: structural channels under fold/holds — per-index slots, n 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 { + // Held-boundary registrant (reads speculative): baseline covers the + // stash — av must be the FULL emitted version. + createRoot(() => { + (getOwner() as any)._queue = fakeQ; + registerRowOps(state.rows, () => {}); + }); + const held = pc.ro[pc.ro.length - 1]; + expect(held.av).toBe(pc.sv); + // Ambient registrant (reads committed): receives the stash at release. + createRoot(() => { + registerRowOps(state.rows, () => {}); + }); + const ambient = pc.ro[pc.ro.length - 1]; + expect(ambient.av).toBe(pc.svv); + if (pc.sv !== pc.svv) expect(ambient.av).not.toBe(pc.sv); + } finally { + (GlobalQueue as any)._queueHeld = prevProbe; + } + confirm(); + await run; + flush(); + }); + it("a shallow staged reveal rides ONE channel — slot ticks for aligned replacement, never row ops too", async () => { const { createOptimisticStore, @@ -1824,11 +1875,62 @@ describe("INVARIANT: structural channels under fold/holds — per-index slots, n await run; await Promise.resolve(); flush(); - // The reveal delivers the replacement ONCE: a slot tick — row ops for - // the same slot would rebuild the row a second time (lifecycle/focus). + // 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); - const revealRowOps = rowEvents.slice(rowMark).filter(o => o !== null); - expect(revealRowOps.length).toBe(0); + 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 () => { diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 9030a54a1..4efef4892 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -201,7 +201,9 @@ module.exports = [ // Fold audit 2 (2026-09-01): transition-aware version init, reveal // channel split, target-keyed staged identity, release fast-forward. // Measured 15.22. - limit: "15.25 KB", + // Fold audit 3 (2026-09-01): held-queue av init, reorder classification, + // reveal single-notification mark. Measured 15.27. + limit: "15.3 KB", modifyEsbuildConfig }, { From 71870b494b154d775388010f2cccfc7233efb536 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 07:57:44 -0700 Subject: [PATCH 47/56] chore: changeset for fold audit 3 Co-authored-by: Cursor --- .changeset/fix-version-chain-fold-audit-3.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-version-chain-fold-audit-3.md 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. From a4a37669e28cf650baa8da48b172d284fd77ed4b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 08:22:22 -0700 Subject: [PATCH 48/56] =?UTF-8?q?fix:=20fold=20audit=204=20=E2=80=94=20eag?= =?UTF-8?q?er=20visible-version,=20proven=20epoch=20reveal=20marks,=20prim?= =?UTF-8?q?itive=20reorders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three P1s + one refinement at eae73227, and the root simplification the auditor's parked-ambient finding exposed: ADOPTION COMMITS EAGERLY (only notifications batch), so every reader's init read includes every emitted walk state — parked windows, held boundaries, and ambient mounts alike. The visible version now bumps AT EMISSION for all structural emitters, deleting the deferred-svv machinery and the held-queue registration special case; no reader anywhere can replay stashed ops it already rendered. Two stash-window pins updated to the corrected contract (late registrants are owed NOTHING; the deleted-slot gate asserts on the early consumer). - rf reveal marks are EPOCH-stamped (a boolean lingered on descendant- retained roots outside `overlaid` — the settle loop never visits them to consume it — and suppressed a LATER revert's resync). - rf is set only on PROVEN emission (emitSetterRowOps returns emitted; the slot branch counts ticks) — a no-op fold suppressed the only revert resync a driven list needed. Pinned: no-op staged fold, revert still lands [1]. - Equal-length PRIMITIVE permutations are structure (classic keys primitive rows by VALUE): same multiset + moved positions = row ops, zero slot ticks. Pinned. Signals 1,517 (+2 expected-fail) | web 719 green; two ratchets (15.4/27.55). Co-authored-by: Cursor --- .changeset/fix-version-chain-fold-audit-4.md | 5 + packages/signals/src/store/next/optimistic.ts | 9 +- .../signals/src/store/next/patch-hooks.ts | 5 +- packages/signals/src/store/next/patch.ts | 9 +- packages/signals/src/store/next/reconcile.ts | 6 +- packages/signals/src/store/next/store.ts | 50 +++++- packages/signals/src/store/next/target.ts | 10 +- .../tests/store/patch-invariants.test.ts | 142 +++++++++++++++--- scripts/size/.size-limit.js | 10 +- 9 files changed, 209 insertions(+), 37 deletions(-) create mode 100644 .changeset/fix-version-chain-fold-audit-4.md 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/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index e3bd3dc95..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, @@ -133,9 +134,13 @@ function installNextBlockedHalf(): void { // 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 !== true) + if ( + ot.pc !== null && + ot.pc.ro !== null && + ot.sf !== true && + ot.rf !== currentFoldEpoch() + ) rowHooks!.emitRowOpsOptimistic(ot, null, null); - ot.rf = false; // consumed — one reveal, one notification // 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 — diff --git a/packages/signals/src/store/next/patch-hooks.ts b/packages/signals/src/store/next/patch-hooks.ts index 6047503ae..da23745b6 100644 --- a/packages/signals/src/store/next/patch-hooks.ts +++ b/packages/signals/src/store/next/patch-hooks.ts @@ -44,12 +44,15 @@ export interface PatchValueHooks { export interface PatchRowHooks { emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void; emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev: any): void; + /** Returns whether ops were actually emitted (identity-aligned lists + * emit nothing) — the fold's reveal mark must only suppress the settle + * loop's resync on PROVEN emission (fold audit 4). */ emitSetterRowOps( t: StoreNextTarget, prevRows: any[], nextRows: any[], key?: (v: any) => any - ): void; + ): boolean; emitRowOpsOptimistic(t: StoreNextTarget, next: any[] | null, ops: RowOps | null): void; } diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index cda871add..566d33e7c 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -1637,6 +1637,10 @@ export function emitSlotPatch(t: StoreNextTarget, index: number, next: any, prev 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, @@ -1658,8 +1662,7 @@ export function registerSlotPatchNext( fn, owner: sowner, q: sq, - av: ((queueIsHeld(sq) ? ((pc as any).svs as number) : ((pc as any).svvs as number)) ?? - 0) as number + av: (((pc as any).svvs as number) ?? 0) as number }; const list = (pc.sp ??= []) as unknown[]; list.push(entry); @@ -1686,6 +1689,8 @@ export function emitRowOps(t: StoreNextTarget, next: any[], ops: RowOps): void { 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 diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index b18eff559..d1497e926 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -545,9 +545,11 @@ export function emitSetterRowOps( prevRows: any[], nextRows: any[], key?: KeyFn -): void { +): boolean { const ops = buildIdentityRowOps(prevRows, nextRows, key); - if (ops !== null) rowHooks!.emitRowOps(t, nextRows, ops); + if (ops === null) return false; + rowHooks!.emitRowOps(t, nextRows, ops); + return true; } /** Identity-keyed structural diff, returned rather than emitted: shared by diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index 04681fa93..e3853fd86 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -875,7 +875,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(); @@ -1020,14 +1030,45 @@ function drainFolds(): void { } } if (aligned) { - t.rf = true; // slot ticks ARE the reveal — the settle loop must not resync + // 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]) rowHooks!.emitSlotPatch(t, si, newArr[si], oldArr[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)) { - if (t.sf === true) t.rf = true; // reveal emitted — settle loop must not resync again const map = t.fam?.map ?? storeNextLookup; - rowHooks!.emitSetterRowOps( + const emitted = rowHooks!.emitSetterRowOps( t, oldArr, newArr, @@ -1038,6 +1079,7 @@ function drainFolds(): void { } : undefined ); + if (emitted === true && t.sf === true) t.rf = foldEpoch; } } t.sf = false; diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index bde124ad3..a8ebd35ce 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -201,10 +201,12 @@ export interface StoreNextTarget { * gates at the fold sites exist for OVERRIDE materializations, which * ride the lane). Cleared at the fold. */ sf?: boolean; - /** Reveal row-ops emitted by the fold THIS flush (fold audit 3, P1): the - * settle drain's resync loop skips (and consumes) it — a second rebuild - * for the same reveal lost DOM identity/focus. */ - rf?: 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/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index 42ba35a0b..a9671ea3b 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -912,14 +912,18 @@ describe("INVARIANT: structure honors holds and reaches held-window registrants resolve(); await p; flush(); - // At the settle drain the late consumer is reached (the silent path - // left it permanently stale on the pre-commit view). Version-chain - // refinement: its applied version connects to the held item's — it now - // receives the REAL, baseline-sound ops (its registration read the - // pre-commit view, exactly the ops' baseline) rather than a rebuild. - // Either form is sound; the final view is the pin. - expect(late.length).toBeGreaterThan(0); - expect(late[late.length - 1][0]).toEqual(["b", "a"]); + // 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"]); }); }); @@ -1335,6 +1339,13 @@ describe("INVARIANT: structural resyncs honor holds, fix their window, and serve 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) => { @@ -1349,21 +1360,22 @@ describe("INVARIANT: structural resyncs honor holds, fix their window, and serve }); })(); flush(); - const ticks: Array<[number, any]> = []; - // Held-window registrant: swept at release for BOTH stashed items. + // 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) => ticks.push([i, v])); + registerSlotPatchNext(state.list, (i: number, v: any) => late.push([i, v])); }); confirm(); await run; flush(); - // NON-VACUOUS (audit follow-up P1): the surviving slot's resync MUST - // arrive — an empty tick list means the sweep never saw the late - // registrant (the vacuous pass that hid the missing slot sq stamp). + // 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). + // 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 () => { @@ -1815,21 +1827,22 @@ describe("INVARIANT: structural channels under fold/holds — per-index slots, n const prevProbe = (GlobalQueue as any)._queueHeld; (GlobalQueue as any)._queueHeld = (q: any) => q === fakeQ || prevProbe?.(q) === true; try { - // Held-boundary registrant (reads speculative): baseline covers the - // stash — av must be the FULL emitted version. + // 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); - // Ambient registrant (reads committed): receives the stash at release. createRoot(() => { registerRowOps(state.rows, () => {}); }); const ambient = pc.ro[pc.ro.length - 1]; - expect(ambient.av).toBe(pc.svv); - if (pc.sv !== pc.svv) expect(ambient.av).not.toBe(pc.sv); + expect(ambient.av).toBe(pc.sv); + expect(pc.svv).toBe(pc.sv); // walk visibility IS emission visibility } finally { (GlobalQueue as any)._queueHeld = prevProbe; } @@ -1838,6 +1851,95 @@ describe("INVARIANT: structural channels under fold/holds — per-index slots, n 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([]); + expect(rowEvents.slice(rowMark).filter(o => o !== undefined).length).toBeGreaterThan(0); + }); + it("a shallow staged reveal rides ONE channel — slot ticks for aligned replacement, never row ops too", async () => { const { createOptimisticStore, diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 4efef4892..a7994255d 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -203,7 +203,10 @@ module.exports = [ // Measured 15.22. // Fold audit 3 (2026-09-01): held-queue av init, reorder classification, // reveal single-notification mark. Measured 15.27. - limit: "15.3 KB", + // Fold audit 4 (2026-09-01): eager visible-version, epoch-stamped + // proven reveal marks, primitive-multiset reorder classification. + // Measured 15.38. + limit: "15.4 KB", modifyEsbuildConfig }, { @@ -396,7 +399,10 @@ module.exports = [ // 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. - limit: "27.4 KB", + // Fold audit 4 (2026-09-01): eager visible-version, epoch-stamped + // proven reveal marks, primitive-multiset reorder classification. + // Measured 27.52. + limit: "27.55 KB", modifyEsbuildConfig }, { From 3f73673c602dd1b11026b4d507b744cef1333914 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 08:58:47 -0700 Subject: [PATCH 49/56] =?UTF-8?q?fix:=20fold=20audit=205=20=E2=80=94=20pri?= =?UTF-8?q?mitive=20rows=20retain=20by=20value=20in=20structural=20matchin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildRowOps admitted only wrappable rows to the occurrence-aware key queues: a primitive permutation emitted sources:[-1,…], rebuilding every moved row instead of retaining nodes. Primitives now key by their VALUE on both the queue-build and match sides (classic value-identity; duplicate occurrences already sound through the queues; undefined rows still rebuild). Pinned: a staged primitive permutation's ops carry no -1 sources. NOTE: committed scope is exactly this fix + pin — core.ts/verdict.ts carry unrelated in-progress edits (a #3166 revert) in the worktree that break tests/latest-pending-probe-mid-flight and fold-3164's store path; left untouched. Store suites 530 green (+2 expected-fail pins). Co-authored-by: Cursor --- .changeset/fix-primitive-row-retention.md | 5 ++++ packages/signals/src/store/next/reconcile.ts | 23 +++++++++++-------- .../tests/store/patch-invariants.test.ts | 9 +++++++- 3 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-primitive-row-retention.md 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/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index d1497e926..f4d020667 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -599,22 +599,25 @@ function buildRowOps( oldIndexByKey = new Map(); for (let j = structStart; j < plen; j++) { const p = unwrapValue(prevRows[j]); - if (p !== null && typeof p === "object") { - const pk = keyFn(p); - if (pk === undefined) continue; - const existing = oldIndexByKey.get(pk); - if (existing === undefined) oldIndexByKey.set(pk, j); - else if (Array.isArray(existing)) existing.push(j); - else oldIndexByKey.set(pk, [existing, j]); - } + // PRIMITIVES key by VALUE (fold audit 5): classic identity for a + // non-wrappable row IS its value — excluding them emitted + // sources:[-1,…] for pure permutations, rebuilding every moved row + // instead of retaining nodes. Occurrence queues already make + // duplicates sound. + const pk = p !== null && typeof p === "object" ? keyFn(p) : p; + if (pk === undefined) continue; + const existing = oldIndexByKey.get(pk); + if (existing === undefined) oldIndexByKey.set(pk, j); + else if (Array.isArray(existing)) existing.push(j); + else oldIndexByKey.set(pk, [existing, j]); } } const consumed = oldIndexByKey !== null ? new Set() : null; for (let k = structStart; k < nlen; k++) { const nv = nextRows[k]; let oldIdx = -1; - if (nv !== null && typeof nv === "object" && oldIndexByKey !== null) { - const nk = keyFn!(nv); + if (nv !== undefined && oldIndexByKey !== null) { + const nk = nv !== null && typeof nv === "object" ? keyFn!(nv) : nv; if (nk !== undefined) { const m = oldIndexByKey.get(nk); if (m !== undefined) { diff --git a/packages/signals/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index a9671ea3b..feb3338e1 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1937,7 +1937,14 @@ describe("INVARIANT: structural channels under fold/holds — per-index slots, n await Promise.resolve(); flush(); expect(ticks.slice(tickMark)).toEqual([]); - expect(rowEvents.slice(rowMark).filter(o => o !== undefined).length).toBeGreaterThan(0); + 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("a shallow staged reveal rides ONE channel — slot ticks for aligned replacement, never row ops too", async () => { From 4353313ca8ed75567fc14ba9ab2f4ddbe59845c3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 09:32:37 -0700 Subject: [PATCH 50/56] =?UTF-8?q?chore:=20rebase=20onto=20next=20(#3169-#3?= =?UTF-8?q?176=20+=20fold-ledger=20relocation)=20=E2=80=94=20two=20drift?= =?UTF-8?q?=20ratchets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- scripts/size/.size-limit.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index a7994255d..0fe31d8e5 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -270,7 +270,9 @@ module.exports = [ // // Re-audit-6 (2026-08-28): merge coalescing (core). Measured 10.70. path: "minimal-app.js", - limit: "10.75 KB", + // Rebase drift (2026-09-01, #3169-#3176 + fold-ledger relocation). + // Measured 10.76. + limit: "10.8 KB", modifyEsbuildConfig }, { @@ -402,7 +404,9 @@ module.exports = [ // Fold audit 4 (2026-09-01): eager visible-version, epoch-stamped // proven reveal marks, primitive-multiset reorder classification. // Measured 27.52. - limit: "27.55 KB", + // Rebase drift (2026-09-01, #3169-#3176 + fold-ledger relocation). + // Measured 27.56. + limit: "27.6 KB", modifyEsbuildConfig }, { From f99a7670d66764e488a0bb9281a55b3a52b202a3 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 09:37:45 -0700 Subject: [PATCH 51/56] =?UTF-8?q?chore:=20rebase=20onto=20until-flip=20ent?= =?UTF-8?q?anglement=20+=20CONFIG=5FHELD=5FTRUTH=20unification=20=E2=80=94?= =?UTF-8?q?=20three=20drift=20ratchets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- scripts/size/.size-limit.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 0fe31d8e5..c57fcca10 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -206,7 +206,8 @@ module.exports = [ // Fold audit 4 (2026-09-01): eager visible-version, epoch-stamped // proven reveal marks, primitive-multiset reorder classification. // Measured 15.38. - limit: "15.4 KB", + // Entanglement rebase drift (2026-09-01, until-flip + CONFIG_HELD_TRUTH). + limit: "15.45 KB", modifyEsbuildConfig }, { @@ -492,7 +493,8 @@ module.exports = [ // 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. - limit: "17.15 KB", + // Entanglement rebase drift (2026-09-01, until-flip + CONFIG_HELD_TRUTH). + limit: "17.2 KB", modifyEsbuildConfig }, { @@ -556,7 +558,8 @@ module.exports = [ // Fold audit 2 (2026-09-01): transition-aware version init, reveal // channel split, target-keyed staged identity, release fast-forward. // Measured 19.62. - limit: "19.65 KB", + // Entanglement rebase drift (2026-09-01, until-flip + CONFIG_HELD_TRUTH). + limit: "19.7 KB", modifyEsbuildConfig }, { From c48aed8b862f112a01f48c26f48d1cfdaf721c98 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 09:43:41 -0700 Subject: [PATCH 52/56] =?UTF-8?q?chore:=20rebase=20onto=20canonical=20enta?= =?UTF-8?q?nglement=20push=20=E2=80=94=20boundary-wobble=20headroom=20on?= =?UTF-8?q?=20three=20tiers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- scripts/size/.size-limit.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index c57fcca10..559a5033d 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -242,7 +242,9 @@ module.exports = [ // 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. - limit: "10 KB", + // Entanglement rebase drift (2026-09-01, canonical push): boundary + // wobble headroom. + limit: "10.05 KB", modifyEsbuildConfig }, { @@ -273,7 +275,9 @@ module.exports = [ path: "minimal-app.js", // Rebase drift (2026-09-01, #3169-#3176 + fold-ledger relocation). // Measured 10.76. - limit: "10.8 KB", + // Entanglement rebase drift (2026-09-01, canonical push): boundary + // wobble headroom. + limit: "10.85 KB", modifyEsbuildConfig }, { @@ -432,7 +436,9 @@ module.exports = [ // #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: "13 KB", + // Entanglement rebase drift (2026-09-01, canonical push): boundary + // wobble headroom. + limit: "13.05 KB", modifyEsbuildConfig }, { From e0c818f5b96db70ed42cd258a2d7299990f36d67 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 10:06:28 -0700 Subject: [PATCH 53/56] =?UTF-8?q?fix(signals):=20patch=20channel=20fold=20?= =?UTF-8?q?audit=206=20=E2=80=94=20dn=20override=20lifecycle,=20matcher=20?= =?UTF-8?q?key=20spaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - INV-6 (P1): a settle-drain revert bump arms a fresh override on the delivery signal AFTER its lane resolved; with another flight open it lands there and survives to quiescence. Deliveries now CONSUME any override on the notification signal (pure-notification node; _transition left for parked plain-write bookkeeping). - Mixed identities (P1): object rows key through keyFn (usually a primitive id) and collided with value-keyed primitive rows in one map. buildRowOps now holds two key spaces; the identity prefix scan is kind-aware (keyFn on a primitive yields undefined on both sides and falsely aligned different values). - undefined moves (P2): undefined rows and sparse holes participate via a sentinel instead of being skipped — plain moves retain rows. Repros: matcher-level pins for both key-space findings; the equal-landing tests now enforce INV-6 through the suite exit code. Size: +31 B store-family, +47 B rowProof tier (ratcheted with notes). Co-authored-by: Cursor --- ...-audit-6-dn-override-matcher-key-spaces.md | 5 ++ packages/signals/DESIGN-PATCH-CHANNEL.md | 37 +++++++++++ packages/signals/src/store/next/patch.ts | 16 +++++ packages/signals/src/store/next/reconcile.ts | 61 +++++++++++++------ .../tests/store/patch-invariants.test.ts | 39 ++++++++++++ scripts/size/.size-limit.js | 10 ++- 6 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 .changeset/fold-audit-6-dn-override-matcher-key-spaces.md 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/packages/signals/DESIGN-PATCH-CHANNEL.md b/packages/signals/DESIGN-PATCH-CHANNEL.md index 207469d06..754fa2720 100644 --- a/packages/signals/DESIGN-PATCH-CHANNEL.md +++ b/packages/signals/DESIGN-PATCH-CHANNEL.md @@ -86,6 +86,43 @@ 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. +## 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). + ## 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 diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 566d33e7c..33652a5b2 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -1058,6 +1058,22 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { () => { if (pc.bc === pc.dv) return; // pure-registration run: baselines are per-entry pc.dv = pc.bc; + // 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, diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index f4d020667..b82b9d9f0 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -563,7 +563,18 @@ export function buildIdentityRowOps(prevRows: any[], nextRows: any[], key?: KeyF const k = key ?? identityKey; let p = 0; const min = prevRows.length < nextRows.length ? prevRows.length : nextRows.length; - while (p < min && k(prevRows[p]) === k(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, k); } @@ -581,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[], @@ -594,39 +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]); - // PRIMITIVES key by VALUE (fold audit 5): classic identity for a - // non-wrappable row IS its value — excluding them emitted - // sources:[-1,…] for pure permutations, rebuilding every moved row - // instead of retaining nodes. Occurrence queues already make - // duplicates sound. - const pk = p !== null && typeof p === "object" ? keyFn(p) : p; - if (pk === undefined) continue; - const existing = oldIndexByKey.get(pk); - if (existing === undefined) oldIndexByKey.set(pk, j); + let m: Map; + let pk: any; + if (p !== null && typeof p === "object") { + pk = keyFn(p); + if (pk === undefined) continue; + 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 oldIndexByKey.set(pk, [existing, 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 !== undefined && oldIndexByKey !== null) { - const nk = nv !== null && typeof nv === "object" ? keyFn!(nv) : 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/tests/store/patch-invariants.test.ts b/packages/signals/tests/store/patch-invariants.test.ts index feb3338e1..4556fe128 100644 --- a/packages/signals/tests/store/patch-invariants.test.ts +++ b/packages/signals/tests/store/patch-invariants.test.ts @@ -1947,6 +1947,45 @@ describe("INVARIANT: structural channels under fold/holds — per-index slots, n 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, diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 559a5033d..7574c1db5 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -411,7 +411,10 @@ module.exports = [ // Measured 27.52. // Rebase drift (2026-09-01, #3169-#3176 + fold-ledger relocation). // Measured 27.56. - limit: "27.6 KB", + // 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 }, { @@ -565,7 +568,10 @@ module.exports = [ // channel split, target-keyed staged identity, release fast-forward. // Measured 19.62. // Entanglement rebase drift (2026-09-01, until-flip + CONFIG_HELD_TRUTH). - limit: "19.7 KB", + // 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. + limit: "19.75 KB", modifyEsbuildConfig }, { From 13cb4450b5fe9d4b516f4fa02c95761350d2ae77 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 10:22:50 -0700 Subject: [PATCH 54/56] fix(web): patch driver rebuild check agrees with the matcher's SameValueZero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A moved NaN row was rebuilt: the matcher (Map, SameValueZero) proved the source, then applyOps re-tested the pair with strict !== and replaced the node — losing lifecycle/focus identity classic rendering keeps (classic's newIndices Map matches NaN for free). One inline SameValueZero comparison on the refRebuild branch (+12 B rowProof tier, ratcheted). Co-authored-by: Cursor --- .../patch-driver-samevaluezero-rebuild.md | 5 +++ packages/signals/DESIGN-PATCH-CHANNEL.md | 9 ++++++ packages/web/src/patch-driver.ts | 15 +++++++-- .../web/test/for.patchinvariants.spec.tsx | 32 +++++++++++++++++++ scripts/size/.size-limit.js | 5 ++- 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 .changeset/patch-driver-samevaluezero-rebuild.md 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/packages/signals/DESIGN-PATCH-CHANNEL.md b/packages/signals/DESIGN-PATCH-CHANNEL.md index 754fa2720..126932189 100644 --- a/packages/signals/DESIGN-PATCH-CHANNEL.md +++ b/packages/signals/DESIGN-PATCH-CHANNEL.md @@ -123,6 +123,15 @@ 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 diff --git a/packages/web/src/patch-driver.ts b/packages/web/src/patch-driver.ts index 1f0c24351..2bdd8d8c1 100644 --- a/packages/web/src/patch-driver.ts +++ b/packages/web/src/patch-driver.ts @@ -382,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(patchProxyFor(subject, next[abs], 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!; } diff --git a/packages/web/test/for.patchinvariants.spec.tsx b/packages/web/test/for.patchinvariants.spec.tsx index 32b27ab0c..d3c862b46 100644 --- a/packages/web/test/for.patchinvariants.spec.tsx +++ b/packages/web/test/for.patchinvariants.spec.tsx @@ -351,6 +351,38 @@ describe("INVARIANT: structural operations build rows from THEIR OWN captured st }); }); +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")); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 7574c1db5..9a3fff811 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -571,7 +571,10 @@ module.exports = [ // 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. - limit: "19.75 KB", + // 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. + limit: "19.8 KB", modifyEsbuildConfig }, { From d6e9484ca0558e09f624a2ab80b3b91f77c19ad9 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 10:34:56 -0700 Subject: [PATCH 55/56] test(signals): pin the until()-entanglement patch tear (held #3091 out of rc.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External audit probe, reproduced verbatim then pinned it.fails: a registerPatch consumer on the confirming foreign store observes the new world mid-hold (saving=true at delivery) while value-channel bindings correctly hold — the delivery reads its view outside the masked read seam, so node-mask holds (the flip-entanglement steal) are invisible to it. rc.6 consolidation: deliveries read through the store traps' hold resolution + snapshot compare; structural stash mirrors the steal like mergeTransitionState mirrors merges. No changeset: test-only. Co-authored-by: Cursor --- .../tests/store/patch-entangle.probe.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/signals/tests/store/patch-entangle.probe.test.ts 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..72e2a3a6d --- /dev/null +++ b/packages/signals/tests/store/patch-entangle.probe.test.ts @@ -0,0 +1,96 @@ +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.fails( + "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"); + } + ); +}); From 554520ce366ea714b6c6c0f179e8f337c6883662 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 1 Sep 2026 11:15:51 -0700 Subject: [PATCH 56/56] =?UTF-8?q?fix(signals):=20entangle-tear=20consolida?= =?UTF-8?q?tion=20pass=201=20=E2=80=94=20visibility=20decisions=20move=20t?= =?UTF-8?q?o=20the=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Held #3091 out of rc.5 over the until()-entanglement patch tear. This pass fixes the class at the read seam, zero core-scheduler changes: - nodeValue held-truth arm: the steal parks node truth PAST the flush while the backing commits eagerly — committed truth then lives only in _value, which the untracked fallthrough never served. Untracked readers (userland untrack(), the channel's visible view) saw the future while tracked readers held. User-visible beyond the channel. - Parked-truth deferral (value): optimistic-family deliveries whose record truth is parked under a not-done transaction defer without consuming; a redrive stashes on the holder (named by the nodes' own _transition — follows merges and the steal for free) and re-bumps at its commit. Landings emit with no ambient transaction, so transaction stamps could not gate this; only t.fam?.opt probes (plain bumps park the delivery signal itself — dbmon path never probes). - Parked-truth re-stash (structural): apply-time probing subsumes the carrier-side mirrors (steal stash-move + forwarding pointer were built, proven, then DELETED — ordering-free beats carrier games). Probes pinned as regular tests (value + structural twins). Sizes: +36 B createStore, +294 B patchDriver tier, +221 B list tier (ratcheted). Co-authored-by: Cursor --- .../entangle-tear-consolidation-pass-1.md | 5 + packages/signals/DESIGN-PATCH-CHANNEL.md | 45 +++++ packages/signals/src/store/next/patch.ts | 155 ++++++++++++++- packages/signals/src/store/next/store.ts | 13 +- packages/signals/src/store/next/target.ts | 4 + .../tests/store/patch-entangle.probe.test.ts | 187 ++++++++++++------ scripts/size/.size-limit.js | 15 +- 7 files changed, 358 insertions(+), 66 deletions(-) create mode 100644 .changeset/entangle-tear-consolidation-pass-1.md 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/packages/signals/DESIGN-PATCH-CHANNEL.md b/packages/signals/DESIGN-PATCH-CHANNEL.md index 126932189..65438feab 100644 --- a/packages/signals/DESIGN-PATCH-CHANNEL.md +++ b/packages/signals/DESIGN-PATCH-CHANNEL.md @@ -86,6 +86,51 @@ 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 diff --git a/packages/signals/src/store/next/patch.ts b/packages/signals/src/store/next/patch.ts index 33652a5b2..db1e45347 100644 --- a/packages/signals/src/store/next/patch.ts +++ b/packages/signals/src/store/next/patch.ts @@ -40,7 +40,13 @@ import { } from "../../core/scheduler.js"; import type { Owner } from "../../core/types.js"; import { $TARGET, isWrappable } from "../store.js"; -import { markDescendants, ownedRaw, type PatchChannel, type StoreNextTarget } from "./target.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), @@ -151,6 +157,23 @@ function drainApplyQueue(): void { let firstError: unknown = UNSET; for (let i = 0; i < q.length; i++) { 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) @@ -514,11 +537,100 @@ function applyEntries(list: PatchEntry[], next: any, firstError: unknown, pc: an // 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 @@ -637,6 +749,23 @@ function drainOptimistic(): void { let firstError: unknown = UNSET; for (let i = 0; i < q.length; i++) { 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) @@ -1057,7 +1186,29 @@ function ensureDelivery(t: StoreNextTarget, pc: any): void { () => 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 diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index e3853fd86..9d601d5af 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -164,6 +164,7 @@ export function pcOf(t: StoreNextTarget): PatchChannel { dmq: false, bt: null, bo: null, + hh: null, ak: null, dp: null, ks: false, @@ -1751,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; } diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index a8ebd35ce..9bb609920 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -97,6 +97,10 @@ export interface PatchChannel { * 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`) — diff --git a/packages/signals/tests/store/patch-entangle.probe.test.ts b/packages/signals/tests/store/patch-entangle.probe.test.ts index 72e2a3a6d..4fefe4999 100644 --- a/packages/signals/tests/store/patch-entangle.probe.test.ts +++ b/packages/signals/tests/store/patch-entangle.probe.test.ts @@ -26,71 +26,138 @@ describe("probe: patch channel vs until() flip-entanglement", () => { // 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.fails( - "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)); + 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; + 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); - } - ); + 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 }); - flush(); - await settle(); - registerPatch(stream.doc, (next: any) => { - patches.push(`v${next.version}:${next.data}:saving=${saving()}`); + 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 v=0 data=old"); - const patchesBeforeConfirm = patches.length; + const done = save(); + flush(); + await settle(); + expect(frames.at(-1)).toBe("saving=true n=2"); + const mark = rowEvents.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([]); + // 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 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"); - } - ); + 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/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index 9a3fff811..14dbbc6ab 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -207,7 +207,10 @@ module.exports = [ // proven reveal marks, primitive-multiset reorder classification. // Measured 15.38. // Entanglement rebase drift (2026-09-01, until-flip + CONFIG_HELD_TRUTH). - limit: "15.45 KB", + // 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 }, { @@ -503,7 +506,11 @@ module.exports = [ // 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). - limit: "17.2 KB", + // 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 }, { @@ -574,7 +581,9 @@ module.exports = [ // 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. - limit: "19.8 KB", + // 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 }, {