From 5aee76d6ae4063cfc408d390e881c11f0368f5e0 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 11 Sep 2026 00:28:29 -0700 Subject: [PATCH] perf(store): stamp owned backings with $OWNER instead of weak-collection registrations (#3360) Every backing the store allocates (CoW clones, privatized committed backings) now carries its owning target under an enumerable symbol. This replaces the per-draft ownedRaw.add + raw->target map.set that dominated the one-key write floor (V8 identity hash + ephemeron registration on a fresh object). storeNextLookup / family maps now hold only unowned raws; lookupTarget resolves stamp-first within a family. The stamp is hidden by the get/has/getOwnPropertyDescriptor/ownKeys traps, skipped by snapshot, membership and deep-witness diffs, reconcile, optimistic staging and affects walks, and never acquires a node. Steady-state single-key writes: 340 -> ~178 ns (rc.7 674, rc.0 214). Fixes #3360 Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/store-owner-stamp.md | 5 + packages/signals/src/store/next/optimistic.ts | 17 +- packages/signals/src/store/next/reconcile.ts | 18 +- packages/signals/src/store/next/store.ts | 112 ++++---- packages/signals/src/store/next/target.ts | 39 ++- packages/signals/src/store/store.ts | 36 ++- .../signals/tests/store/next-smoke.test.ts | 4 +- .../signals/tests/store/owner-stamp.test.ts | 239 ++++++++++++++++++ scripts/size/.size-limit.js | 11 +- 9 files changed, 387 insertions(+), 94 deletions(-) create mode 100644 .changeset/store-owner-stamp.md create mode 100644 packages/signals/tests/store/owner-stamp.test.ts diff --git a/.changeset/store-owner-stamp.md b/.changeset/store-owner-stamp.md new file mode 100644 index 000000000..4e3e7f425 --- /dev/null +++ b/.changeset/store-owner-stamp.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Store-owned backings now carry their owning target under an internal enumerable symbol stamp instead of registering in two weak collections (ownership set + raw→target map) on every draft — the identity-hash/ephemeron cost of those registrations was the remaining floor of a one-key store write (#3360, part two). Steady-state single-key writes drop from ~340 ns to ~180 ns; reconcile and projection benches improve 10–80%. The stamp is invisible through the proxy (`ownKeys`, `in`, descriptors, spreads), never appears in `snapshot()` output, never acquires a node, and is skipped by every key walk (membership/deep-witness diffs, reconcile, optimistic staging, affects scopes). diff --git a/packages/signals/src/store/next/optimistic.ts b/packages/signals/src/store/next/optimistic.ts index 01dbb609d..f6ab0090b 100644 --- a/packages/signals/src/store/next/optimistic.ts +++ b/packages/signals/src/store/next/optimistic.ts @@ -78,7 +78,7 @@ import { // Cycle with reconcile.js is benign: the binding resolves at call time (the // optimistic write), long after both modules initialize. import { sameKey } from "./reconcile.js"; -import { setOptHooks, storeNextLookup } from "./target.js"; +import { $OWNER, lookupTarget, setOptHooks } from "./target.js"; type KeyFn = (item: any) => any; import { isRawValue, isWrappable, rawValuesUsed, setNextOptimisticViewResolver } from "../store.js"; import type { StoreNextFamily, StoreNextTarget } from "./target.js"; @@ -501,7 +501,7 @@ function stagedApply(cur: any, incoming: any, keyFn: KeyFn | null): void { // Object merge; also the degenerate root-kind-change shape (arrays accept // keyed writes/deletes, so a wholesale restatement still lands staged). for (const k of Reflect.ownKeys(incoming)) { - if (curArr && k === "length") continue; + if ((curArr && k === "length") || k === $OWNER) continue; const nv = (incoming as any)[k]; const pv = unwrapValue(cur[k]); if (pv === nv) continue; @@ -522,7 +522,7 @@ function stagedApply(cur: any, incoming: any, keyFn: KeyFn | null): void { } } for (const k of Reflect.ownKeys(cur)) { - if ((curArr && k === "length") || k in incoming) continue; + if ((curArr && k === "length") || k === $OWNER || k in incoming) continue; delete cur[k]; } } @@ -571,7 +571,7 @@ export function notifyOptimisticWrites(t: StoreNextTarget, pb: Record undefined); @@ -654,7 +654,7 @@ export function optimisticView( function applyTentative(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null): void { const base = t.pb ?? t.v; const view = optimisticView(t, base); - const map = t.fam!.map; + const fam = t.fam!; const isArr = Array.isArray(incoming); if (Array.isArray(view) !== isArr) return; // kind change at root: flat overrides below const pairs: Array<[StoreNextTarget, any]> = []; @@ -662,7 +662,7 @@ function applyTentative(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null): if (isArr) pbLike = [...(incoming as any[])]; else { pbLike = {}; - for (const k of Reflect.ownKeys(incoming)) pbLike[k] = (incoming as any)[k]; + for (const k of Reflect.ownKeys(incoming)) if (k !== $OWNER) pbLike[k] = (incoming as any)[k]; } const match = (pv: any, nv: any): StoreNextTarget | null => { if (!isWrappable(pv) || !isWrappable(nv)) return null; @@ -675,7 +675,7 @@ function applyTentative(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null): // channel — NaN keys are self-equal. if (pk !== undefined && nk !== undefined && !sameKey(pk, nk)) return null; } - return map.get(unwrapValue(pv)) ?? null; + return lookupTarget(unwrapValue(pv), fam) ?? null; }; if (isArr) { const viewRows = view as any[]; @@ -724,6 +724,7 @@ function applyTentative(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null): } } else { for (const k of Reflect.ownKeys(incoming)) { + if (k === $OWNER) continue; const pv = unwrapValue((view as any)[k]); const nv = (incoming as any)[k]; const ct = match(pv, nv); diff --git a/packages/signals/src/store/next/reconcile.ts b/packages/signals/src/store/next/reconcile.ts index 8162f84e9..867f9d7ce 100644 --- a/packages/signals/src/store/next/reconcile.ts +++ b/packages/signals/src/store/next/reconcile.ts @@ -40,7 +40,9 @@ import { unwrapValue } from "./store.js"; import { - ownedRaw, + $OWNER, + isOwned, + lookupTarget, storeNextLookup, type StoreNextFamily, type StoreNextTarget, @@ -96,7 +98,10 @@ export function reconcileNextState( // across an entity change even when their own keys align (proj R7). // Displaced-raw unregistration (proj R10): the outgoing raw stops // resolving to this proxy; re-handed later it wraps fresh. - (t.fam?.map ?? storeNextLookup).delete(t.pb ?? t.v); + const out = t.pb ?? t.v; + if (isOwned(out)) + delete (out as any)[$OWNER]; // disown: wraps fresh if re-handed + else (t.fam?.map ?? storeNextLookup).delete(out); adoptPB(t, incoming); return; } @@ -116,7 +121,7 @@ export function reconcileNextState( function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj = false): void { const prev = t.pb ?? t.v; // The sound identity skip (O7): same reference AND we never diverged it. - if (incoming === prev && !ownedRaw.has(prev)) return; + if (incoming === prev && !isOwned(prev)) return; const fam = t.fam; // §6b (R28): the diff's previous-arrangement baseline is the LANE VIEW — // optimistic rows must be visible to key matching so a landing carrying the @@ -181,7 +186,7 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj break; // misaligned: fall to the keyed remainder below // Identity skip inline (FINDING-1 guard), then descend the pair. if ( - (pvRaw !== nv || (nv !== null && typeof nv === "object" && ownedRaw.has(nv))) && + (pvRaw !== nv || (nv !== null && typeof nv === "object" && isOwned(nv))) && nv !== null && typeof nv === "object" ) @@ -319,7 +324,7 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj const isObj = nv !== null && typeof nv === "object"; if ( ov === nv && - (!isObj || !ownedRaw.has(nv)) && + (!isObj || !isOwned(nv)) && (nodes === null || nodes[k] === undefined || !hasAccessorFlag(nodes[k])) ) { if (nodes !== null && nodes[k] !== undefined) nodesHit++; @@ -345,6 +350,7 @@ function applyAdopt(t: StoreNextTarget, incoming: any, keyFn: KeyFn | null, proj const syms = Object.getOwnPropertySymbols(incoming); for (let i = 0; i < syms.length; i++) { const k = syms[i]; + if (k === $OWNER) continue; const nv = (incoming as any)[k]; if (!shallow && nv !== null && typeof nv === "object") descend(unwrapValue((prevView as any)[k]), nv, keyFn, fam, proj); @@ -396,7 +402,7 @@ function descend( // wrappables acquire targets; rawValues never wrap) — one WeakMap get // replaces isWrappable(pv) + isRawValue(pv), and a miss prunes untracked // subtrees before any further checks. - const ct = (fam?.map ?? storeNextLookup).get(pv); + const ct = lookupTarget(pv, fam); if (ct === undefined) return; // nothing proxied below this pair // The NEW side still validates fully: a frozen/platform/markRaw'd incoming // value is a leaf for reconcile — replaced by reference, never recursed diff --git a/packages/signals/src/store/next/store.ts b/packages/signals/src/store/next/store.ts index dd9109458..270bf8bfd 100644 --- a/packages/signals/src/store/next/store.ts +++ b/packages/signals/src/store/next/store.ts @@ -90,8 +90,10 @@ import { import { devAssertNeverUserMutation, ingestedRaw, + isOwned, + lookupTarget, markDescendants, - ownedRaw, + $OWNER, storeNextLookup, type StoreNextFamily, type StoreNextTarget, @@ -185,7 +187,7 @@ function createTarget( // proxy off looked-up targets as a field. (t as any)[$PROXY] = t.px; (fam?.map ?? storeNextLookup).set(value, t); - if (__TEST__ && ingestedRaw && !ownedRaw.has(value)) ingestedRaw.add(value); + if (__TEST__ && ingestedRaw && !isOwned(value)) ingestedRaw.add(value); return t; } @@ -198,7 +200,7 @@ export function wrapNext>( // markRaw'd values never wrap through ANY store (R42; sticky raw-marking // is one half of the never-both-wrapped-and-raw invariant, RUL-12). if (rawValuesUsed && isRawValue(value)) return value; - const existing = (fam?.map ?? storeNextLookup).get(value); + const existing = lookupTarget(value, fam); if (existing !== undefined) return existing.px; const t: StoreNextTarget | undefined = (value as any)[$TARGET]; if (t !== undefined && t.px === value) { @@ -313,9 +315,8 @@ export function getNode( function sameLogicalSlot(target: StoreNextTarget, a: any, b: any): boolean { if (a === null || typeof a !== "object" || b === null || typeof b !== "object") return false; - const map = target.fam?.map ?? storeNextLookup; - const at = map.get(a); - return at !== undefined && at === map.get(b); + const at = lookupTarget(a, target.fam); + return at !== undefined && at === lookupTarget(b, target.fam); } export function getHasNode( @@ -420,15 +421,18 @@ export function bumpDeep(t: StoreNextTarget): void { const foldOlds = new Map>(); let hookInstalled = false; -function cloneRaw(source: Record, t?: StoreNextTarget): Record { +/** Shallow-clone `source` as a backing OWNED by `t` (stamped `$OWNER`, see + * target.ts). Callers always pass their own committed backing as `source`. */ +function cloneRaw(source: Record, t: StoreNextTarget): Record { // Plain-data fast path (#3360): a scanned `Object.prototype` container // whose own keys are all enumerable data clones by spread — the same // result as the descriptor walk below (own enumerable string+symbol keys, - // normalized writable+configurable), at ~1/50th the cost. Callers always - // pass their own committed backing as `source`. - if (t) { - t.sc || scanAccessorsOnce(t); - if (t.sc === 2) return { ...source }; + // normalized writable+configurable), at ~1/50th the cost. + t.sc || scanAccessorsOnce(t); + if (t.sc === 2) { + const clone = { ...source }; + (clone as any)[$OWNER] = t; + return clone; } // Descriptor-preserving shallow clone (R29: installed getters stay live; // ruled 2026-08-17: frozen sources clone unfrozen — theirs stays frozen). @@ -442,11 +446,13 @@ function cloneRaw(source: Record, t?: StoreNextTarget): Record if (key === "length" && Array.isArray(source)) continue; d.configurable = true; if (!d.get && !d.set) d.writable = true; - else if (t) t.a = true; + else t.a = true; } - return Array.isArray(source) + const clone = Array.isArray(source) ? (Object.defineProperties([], descs) as any) : Object.create(Object.getPrototypeOf(source), descs); + clone[$OWNER] = t; + return clone; } /** Copy own `key` from `from` onto `to`. A plain data slot (enumerable, @@ -504,10 +510,6 @@ export function materializePB(target: StoreNextTarget): void { for (const key of target.del) delete (clone as any)[key]; target.del = null; } - const map = target.fam?.map ?? storeNextLookup; - map.delete(proto); - ownedRaw.add(clone); - map.set(clone, target); target.pb = clone; target.ovl = false; } @@ -563,8 +565,10 @@ function ensurePB(target: StoreNextTarget): Record { !Array.isArray(v) && (target.sc !== 0 ? !target.a : scanAccessorsOnce(target)) && target.kc > OVERLAY_MIN_KEYS && - ownedRaw.has(v) + isOwned(v) ) { + // Inherits `v`'s $OWNER stamp — the overlay resolves and reads as + // owned without a registration of its own. pb = target.pb = Object.create(v) as Record; target.ovl = true; } else pb = target.pb = cloneRaw(v, target); @@ -592,8 +596,6 @@ function ensurePB(target: StoreNextTarget): Record { } } } - ownedRaw.add(pb); - (target.fam?.map ?? storeNextLookup).set(pb, target); queueFold(target); } return pb; @@ -681,8 +683,13 @@ export function adoptPB( target.wk = null; // adoption supersedes staged trap writes target.v = incoming; target.ch = (incoming as any)[$TARGET] !== undefined; - (target.fam?.map ?? storeNextLookup).set(incoming, target); - if (__TEST__ && ingestedRaw && !ownedRaw.has(incoming)) ingestedRaw.add(incoming); + // An adoptee we own within this family (a draft aliasing one of the + // family's own backings) re-stamps to its new owner — the stamp is the + // family's registration for it; everything else registers in the map. + const owner: StoreNextTarget | undefined = (incoming as any)[$OWNER]; + if (owner !== undefined && owner.fam === target.fam) (incoming as any)[$OWNER] = target; + else (target.fam?.map ?? storeNextLookup).set(incoming, target); + if (__TEST__ && ingestedRaw && !isOwned(incoming)) ingestedRaw.add(incoming); } /** Sentinel for `t.wk`: the written-keys bound is unusable this batch (an @@ -750,16 +757,9 @@ function draftSeesOverrides(target: StoreNextTarget): boolean { /** Committed-time privatization for parent-chain slot updates (path copying). */ function privatizeCommitted(target: StoreNextTarget): void { - if (ownedRaw.has(target.v)) return; + if (isOwned(target.v)) return; const before = target.v; const clone = cloneRaw(before, target); - ownedRaw.add(clone); - // Register in the target's OWN registration map (#3284): family targets - // (derived stores, projections, optimistic) resolve children through - // fam.map — a clone parked only in the global lookup makes the next parent - // read miss, wrap a fresh target, and orphan every node (subscribers) on - // this one. - (target.fam?.map ?? storeNextLookup).set(clone, target); target.v = clone; target.ch = false; if (target.u) { @@ -810,7 +810,6 @@ function flattenOverlay(t: StoreNextTarget, pb: Record): void for (const key of t.del) delete (v as any)[key]; t.del = null; } - (t.fam?.map ?? storeNextLookup).delete(pb); t.pb = null; t.ovl = false; t.wk = null; // written-keys window closes with the commit @@ -915,7 +914,6 @@ function drainFolds(): void { if (!hasOwn.call(pb, key)) delete (v as any)[key]; } } - (t.fam?.map ?? storeNextLookup).delete(pb); t.pb = null; t.wk = null; // written-keys window closes with the fold commit } else { @@ -972,7 +970,7 @@ function reportReplacedContainers( const keys = writtenKeys ?? Reflect.ownKeys(pb); const isArray = Array.isArray(pb); for (const key of keys) { - if (isArray && key === "length") continue; + if ((isArray && key === "length") || key === $OWNER) continue; if (t.del !== null && t.del.has(key)) continue; const ov = unwrapValue(old[key as any]); const nv = unwrapValue(pb[key as any]); @@ -1064,13 +1062,13 @@ function notifyWrites(t: StoreNextTarget): void { if (t.ovl) materializePB(t); pb = t.pb!; for (const key of Reflect.ownKeys(pb)) { - if (Array.isArray(pb) && key === "length") continue; + if ((Array.isArray(pb) && key === "length") || key === $OWNER) continue; const ov = old[key as any]; const nv = pb[key as any]; if (!isEqual(ov, nv)) DEV.hooks.onStoreNodeUpdate(t.px, key, nv, ov); } for (const key of Reflect.ownKeys(old)) { - if (key in pb) continue; + if (key in pb || key === $OWNER) continue; DEV.hooks.onStoreNodeUpdate(t.px, key, undefined, old[key as any]); } } @@ -1139,6 +1137,7 @@ function notifyWrites(t: StoreNextTarget): void { if (t.del !== null && t.del.size !== 0) bumpDeep(t); else for (const key of writtenKeys ?? Reflect.ownKeys(pb)) { + if (key === $OWNER) continue; const nv = pb[key as any]; const ov = old[key as any]; if (nv !== null && typeof nv === "object" ? !targetsEqual(ov, nv) : !isEqual(ov, nv)) { @@ -1213,9 +1212,9 @@ const FORCE: unique symbol = Symbol(); /** Same logical slot: both values resolve to one (re-pointed) child target — * adoption preserved identity, so the slot did not change (R9). */ export function targetsEqual(ov: any, nv: any): boolean { - if (ov === null || typeof ov !== "object") return false; - const ot = storeNextLookup.get(ov); - return ot !== undefined && ot === storeNextLookup.get(nv); + if (ov === null || typeof ov !== "object" || nv === null || typeof nv !== "object") return false; + const ot = lookupTarget(ov, null); + return ot !== undefined && ot === lookupTarget(nv, null); } export function arrayStructureChanged(old: any[], neu: any[]): boolean { @@ -1233,8 +1232,9 @@ export function membershipChanged( neu: Record ): boolean { const nk = Reflect.ownKeys(neu); - if (Reflect.ownKeys(old).length !== nk.length) return true; - for (const key of nk) if (!(key in old)) return true; + // The $OWNER stamp is not membership: an owned side counts one key more. + if (Reflect.ownKeys(old).length - +isOwned(old) !== nk.length - +isOwned(neu)) return true; + for (const key of nk) if (key !== $OWNER && !(key in old)) return true; return false; } @@ -1645,7 +1645,7 @@ function resolveChainedRaw(target: StoreNextTarget, key: PropertyKey, v: object) const iv = resolveChainedRaw(innerT, key, v); return iv === v ? v : wrapNext(iv, innerT, key); } - const owned = (innerT.fam?.map ?? storeNextLookup).get(v); + const owned = lookupTarget(v, innerT.fam); if (owned !== undefined) return owned.px; if ((innerT.v[key as any] === v || innerT.pb?.[key as any] === v) && isWrappable(v)) return wrapNext(v, innerT, key); @@ -1785,6 +1785,7 @@ const traps: ProxyHandler = { if (typeof key !== "string") { if (key === $TARGET) return target; if (key === $PROXY) return receiver; + if (key === $OWNER) return undefined; // ownership stamp: never a user key // refresh()/isPending resolve the projection computed through $REFRESH. if (key === $REFRESH) return target.fam?.node ?? undefined; if (key === $TRACK) { @@ -1957,6 +1958,7 @@ const traps: ProxyHandler = { has(target, key) { if (key === $TARGET || key === $PROXY || key === $TRACK) return true; + if (key === $OWNER) return false; if (pendingCheckActive) witnessAffectsMark(target as any, key); if (target.fam !== null && getObserver() === null && !inDraft(target)) firewallGate(target); const src = readSource(target); @@ -1991,6 +1993,7 @@ const traps: ProxyHandler = { }, getOwnPropertyDescriptor(target, key) { + if (key === $OWNER) return undefined; const desc = visibleDescriptor(target, readSource(target), key); if (desc === undefined) return undefined; // Array targets carry a real non-configurable `length` the proxy @@ -2169,7 +2172,7 @@ export function createStoreNext>( if (shallow && __DEV__) { // Never both deep-wrapped and raw (R41/R44): a value already tracked as // a DEEP store cannot be ingested shallow. - const existing = storeNextLookup.get(initialValue); + const existing = lookupTarget(initialValue, null); if (existing !== undefined && !(existing as any).s) throw new Error("createStore({ shallow }): value is already tracked as a deep store"); if ((initialValue as any)[$TARGET]) @@ -2248,6 +2251,14 @@ function visibleKeys(target: StoreNextTarget, src: Record): (s if (!hasOwn.call(target.v, key)) keys.push(key); } } else keys = Reflect.ownKeys(src); + // Drop the $OWNER stamp (owned backings carry it as an own enumerable + // symbol). Symbols enumerate last, so the scan stops at the first string. + for (let i = keys.length - 1; i >= 0 && typeof keys[i] === "symbol"; i--) { + if (keys[i] === $OWNER) { + keys.splice(i, 1); + break; + } + } if ( !authoritativeServe() && target.fam?.opt && @@ -2320,14 +2331,13 @@ export function deepNext(value: T): T { child: object, key: PropertyKey ): StoreNextTarget | undefined => { - const map = t.fam?.map ?? storeNextLookup; - let ct: StoreNextTarget | undefined = map.get(child); + let ct: StoreNextTarget | undefined = lookupTarget(child, t.fam); if (ct === undefined) { if (!isWrappable(child)) return undefined; wrapNext(child, t, key); // Stored proxies (chained slots) that this family passes through // resolve to their own target. - ct = map.get(child) ?? (child as any)[$TARGET]; + ct = lookupTarget(child, t.fam) ?? (child as any)[$TARGET]; } return ct; }; @@ -2412,8 +2422,8 @@ function snapshotWalk(value: any, seen: Map, fam: StoreNextFamily | for (let entry = true; ; entry = false) { const viaProxy = src?.[$TARGET]?.v !== undefined; let t: StoreNextTarget | undefined = viaProxy ? src[$TARGET] : undefined; - if (t === undefined && fam !== null) t = fam.map.get(src); - if (t === undefined) t = storeNextLookup.get(src); + if (t === undefined && fam !== null) t = lookupTarget(src, fam); + if (t === undefined) t = lookupTarget(src, null); if (t === undefined) break; // Entering a level from a RAW below a chained family: the raw resolves to // the INNER store's target, but this family's wrapper for it — keyed by @@ -2453,7 +2463,7 @@ function snapshotWalk(value: any, seen: Map, fam: StoreNextFamily | const copy: any = isArr ? [] : Object.create(Object.getPrototypeOf(view)); seen.set(src, copy); for (const key of Reflect.ownKeys(view)) { - if (isArr && key === "length") continue; + if ((isArr && key === "length") || key === $OWNER) continue; const cv = (view as any)[key]; copy[key] = cv !== null && typeof cv === "object" ? snapshotWalk(cv, seen, fam) : cv; } @@ -2468,12 +2478,12 @@ function snapshotWalk(value: any, seen: Map, fam: StoreNextFamily | // subtrees "unmodified relative to source"): non-enumerable symbols are // excluded (recon-snap R29), and the copy registers BEFORE descent so // cycles keep identity (FINDING-3). - if (ownedRaw.has(src)) { + if (isOwned(src)) { const isArr = Array.isArray(src); const copy: any = isArr ? [] : Object.create(Object.getPrototypeOf(src)); seen.set(src, copy); for (const key of Reflect.ownKeys(src)) { - if (isArr && key === "length") continue; + if ((isArr && key === "length") || key === $OWNER) continue; const desc = Object.getOwnPropertyDescriptor(src, key)!; if (typeof key === "symbol" && !desc.enumerable) continue; if (desc.get || desc.set) { diff --git a/packages/signals/src/store/next/target.ts b/packages/signals/src/store/next/target.ts index 831198652..33cc544ec 100644 --- a/packages/signals/src/store/next/target.ts +++ b/packages/signals/src/store/next/target.ts @@ -147,21 +147,48 @@ export interface StoreNextTarget { } /** - * Ownership (first cut, decision 2026-08-16d): one WeakSet of store-owned - * backings serving both the production identity-skip guard and the __TEST__ - * no-mutation oracle. + * Ownership stamp (#3360): every backing the store ALLOCATES (CoW clones, + * privatized committed backings) carries its owning target under this + * enumerable symbol. One property write replaces the two weak-collection + * registrations (ownership set + raw→target map) a fresh object used to pay + * per draft — V8's identity-hash + ephemeron cost dominated the one-key + * write floor. Enumerable so a spread copy (the plain-data clone path) stays + * on the fast path and carries the stamp along. + * + * Owned backings are never user-reachable (`snapshot` copies them, the traps + * hide the key), so every raw key walk in the store must skip `$OWNER`, and + * ownership is answered by `isOwned` — a user object never carries it. + * Overlay drafts (`Object.create(v)` over an owned `v`) inherit the stamp. */ -export const ownedRaw = new WeakSet(); +export const $OWNER: unique symbol = Symbol(__DEV__ ? "STORE_OWNER" : 0); -/** raw → target. The only raw-keyed lookup; boundary mechanism (O8). */ +/** raw → target for UNOWNED backings (user-ingested, adopted); owned + * backings resolve through their `$OWNER` stamp. Boundary mechanism (O8). */ export const storeNextLookup = new WeakMap(); +/** A backing the store allocated and may mutate in place. */ +export function isOwned(raw: object): boolean { + return (raw as any)[$OWNER] !== undefined; +} + +/** raw → target within a family (`null` = plain stores / the global map). + * The stamp answers for backings owned by a target OF THAT FAMILY; anything + * else (user objects, adoptees, another family's backings the family + * re-registered for its own wrapper) resolves through the family's map. */ +export function lookupTarget( + raw: object, + fam: StoreNextFamily | null +): StoreNextTarget | undefined { + const owner: StoreNextTarget | undefined = (raw as any)[$OWNER]; + return owner !== undefined && owner.fam === fam ? owner : (fam?.map ?? storeNextLookup).get(raw); +} + /** __TEST__ oracle: every object ingested from a user (never mutate). */ export const ingestedRaw: WeakSet | null = __DEV__ ? new WeakSet() : null; export function devAssertNeverUserMutation(target: object): void { if (!__TEST__ || !ingestedRaw) return; - if (ingestedRaw.has(target) && !ownedRaw.has(target)) { + if (ingestedRaw.has(target) && !isOwned(target)) { throw new Error( "[STORE-NEXT INV] write path mutated a user-provided (non-owned) object — CoW privatization was bypassed" ); diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index b10c61d9f..5ded7f36f 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -2,7 +2,7 @@ import { getObserver, type Signal } from "../core/index.js"; import { ext } from "../core/core.js"; import type { Refreshable } from "../core/index.js"; import { GlobalQueue } from "../core/scheduler.js"; -import { storeNextLookup } from "./next/target.js"; +import { $OWNER, lookupTarget as lookupNextTarget, type StoreNextFamily } from "./next/target.js"; /** A reactive view of a store's value. Update it through the paired `StoreSetter`. */ export type Store = T; @@ -112,14 +112,10 @@ export type NotWrappable = | undefined | SolidStore.Unwrappable[keyof SolidStore.Unwrappable]; -function lookupTarget(value: any, lookup?: WeakMap): StoreNode | undefined { - // Family maps (projections/optimistic) map raw -> target; the global next - // lookup maps raw -> target too. Proxies resolve through $TARGET directly. - if (lookup !== undefined) { - const p = lookup.get(value); - if (p !== undefined) return p[$TARGET] ?? p; - } - return storeNextLookup.get(value) as any; +function lookupTarget(value: any, fam: StoreNextFamily | null | undefined): StoreNode | undefined { + // Family registrations (projections/optimistic) first, then the global + // next lookup. Proxies resolve through $TARGET directly. + return ((fam ? lookupNextTarget(value, fam) : undefined) ?? lookupNextTarget(value, null)) as any; } // Values marked raw never acquire a proxy identity: wrap() serves them as-is // everywhere — deep stores hold them as leaf values replaced by reference. @@ -163,7 +159,7 @@ export function markRawOne(v: any) { // wrapping it in their own family, and their writes landed in the // upstream store's override layer (#2932). if (v[$TARGET] !== undefined) return; - if (__DEV__ && storeNextLookup.has(v)) + if (__DEV__ && lookupNextTarget(v, null) !== undefined) throw new Error( "shallow store: an ingested record is already tracked as a deep store — one value cannot present both wrapped and raw" ); @@ -319,14 +315,14 @@ function walkAffectsScope( value: any, entry: AffectsScope, found: DataNode[], - lookup: WeakMap | undefined, + fam: StoreNextFamily | null | undefined, // Cycle guard, fresh per declaration: the scope itself can't serve — a // re-declaration on the same carrier unions into a scope that already // holds the root, and must still descend to pick up records added since. visited: Set ): void { if (!isWrappable(value)) return; - const target: StoreNode | undefined = value[$TARGET] || lookupTarget(value, lookup); + const target: StoreNode | undefined = value[$TARGET] || lookupTarget(value, fam); // Next targets: walk the pending backing when present (a draft's writes are // in motion too) and cover BOTH identities in the scope. let raw = target ? ((target as any).pb ?? target[STORE_VALUE]) : value; @@ -346,28 +342,30 @@ function walkAffectsScope( // scope must mark them like any property node. if ((target as any).k) found.push((target as any).k); if ((target as any).dk) found.push((target as any).dk); - // Carry the effective lookup into untouched descendants (family maps for - // projections/optimistic stores; the global next lookup otherwise). - lookup = (target as any).fam?.map ?? lookup ?? storeNextLookup; + // Carry the effective family into untouched descendants (projections/ + // optimistic stores register children under their family). + fam = (target as any).fam ?? fam; } // Overlays are gone (next has no layer): raw enumeration; the optimistic // view composition above already folded armed-node membership/values in. if (Array.isArray(raw)) { for (let i = 0, len = raw.length; i < len; i++) { - walkAffectsScope(raw[i], entry, found, lookup, visited); + walkAffectsScope(raw[i], entry, found, fam, visited); } const symbols = Object.getOwnPropertySymbols(raw); for (let i = 0, l = symbols.length; i < l; i++) { + if (symbols[i] === $OWNER) continue; const desc = Object.getOwnPropertyDescriptor(raw, symbols[i]); if (!desc || desc.get) continue; - walkAffectsScope(desc.value, entry, found, lookup, visited); + walkAffectsScope(desc.value, entry, found, fam, visited); } } else { const keys = Reflect.ownKeys(raw); for (let i = 0, l = keys.length; i < l; i++) { + if (keys[i] === $OWNER) continue; const desc = Object.getOwnPropertyDescriptor(raw, keys[i]); if (!desc || desc.get) continue; - walkAffectsScope(desc.value, entry, found, lookup, visited); + walkAffectsScope(desc.value, entry, found, fam, visited); } } } @@ -451,7 +449,7 @@ export function getStoreAffectsNodes(target: StoreNode, key?: PropertyKey): Data let entry = affectsScopes.get(carrier); if (!entry) affectsScopes.set(carrier, (entry = { scope: new Set(), inherited: [] })); const result = [carrier]; - walkAffectsScope(target[$PROXY], entry, result, (target as any).fam?.map, new Set()); + walkAffectsScope(target[$PROXY], entry, result, (target as any).fam, new Set()); return result; } const node = (target as any).n?.[key] ?? nextAffectsNodeResolver!(target, key); diff --git a/packages/signals/tests/store/next-smoke.test.ts b/packages/signals/tests/store/next-smoke.test.ts index 93c2723a6..4ea8fa06c 100644 --- a/packages/signals/tests/store/next-smoke.test.ts +++ b/packages/signals/tests/store/next-smoke.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "vitest"; import { createEffect, createRoot, flush } from "../../src/index.js"; import { createStoreNext } from "../../src/store/next/store.js"; -import { ownedRaw, storeNextLookup } from "../../src/store/next/target.js"; +import { isOwned, storeNextLookup } from "../../src/store/next/target.js"; describe("store-next increment 1", () => { it("wraps, tracks per-property, and batches like signals", () => { @@ -89,7 +89,7 @@ describe("store-next increment 1", () => { // Backing privatized (owned), original still resolves to the same proxy. expect(storeNextLookup.get(source)).toBeDefined(); - expect(ownedRaw.has(storeNextLookup.get(source)!.v)).toBe(true); + expect(isOwned(storeNextLookup.get(source)!.v)).toBe(true); expect(storeNextLookup.get(source)!.v).not.toBe(source); expect(storeNextLookup.get(source)!.px).toBe(s); }); diff --git a/packages/signals/tests/store/owner-stamp.test.ts b/packages/signals/tests/store/owner-stamp.test.ts new file mode 100644 index 000000000..8e53f0bab --- /dev/null +++ b/packages/signals/tests/store/owner-stamp.test.ts @@ -0,0 +1,239 @@ +/** + * #3360 (part two): store-owned backings carry their owner under the + * enumerable `$OWNER` symbol instead of registering in two weak collections + * per draft. The stamp is an implementation detail of the raw — these pin + * that it never surfaces (proxy traps, snapshots, key walks, notifications) + * and that ownership/lookup semantics survive the swap (same-family alias, + * cross-family hand-off). + */ +import { describe, expect, it } from "vitest"; +import { + createEffect, + createProjection, + createRoot, + createStore, + deep, + flush, + snapshot +} from "../../src/index.js"; +import { $OWNER, isOwned, storeNextLookup } from "../../src/store/next/target.js"; + +describe("ownership stamp (#3360)", () => { + it("stamps the committed backing after a write; the user's object stays clean", () => { + const src = { a: 1, nested: { x: 1 } }; + const [s, set] = createStore(src); + expect(isOwned(src)).toBe(false); + set(d => { + d.a = 2; + d.nested.x = 2; + }); + flush(); + const t = storeNextLookup.get(src)!; + expect(t.v).not.toBe(src); + expect(isOwned(t.v)).toBe(true); + expect((t.v as any)[$OWNER]).toBe(t); + expect(isOwned(t.v.nested)).toBe(true); + expect(Object.getOwnPropertySymbols(src)).toEqual([]); + expect(src).toEqual({ a: 1, nested: { x: 1 } }); + void s; + }); + + it("is invisible through every proxy trap", () => { + const [s, set] = createStore<{ a: number; nested: { x: number }; [k: symbol]: unknown }>({ + a: 1, + nested: { x: 1 } + }); + set(d => { + d.a = 2; + d.nested.x = 2; + }); + flush(); + for (const o of [s, s.nested]) { + expect(Object.getOwnPropertySymbols(o)).toEqual([]); + expect(Reflect.ownKeys(o)).toEqual(o === s ? ["a", "nested"] : ["x"]); + expect($OWNER in o).toBe(false); + expect(Object.getOwnPropertyDescriptor(o, $OWNER)).toBeUndefined(); + expect((o as any)[$OWNER]).toBeUndefined(); + expect(Reflect.ownKeys({ ...o })).toEqual(Reflect.ownKeys(o)); + } + // A user symbol still enumerates — only the stamp is filtered. + const sym = Symbol("user"); + set(d => { + d[sym] = 1; + }); + flush(); + expect(Object.getOwnPropertySymbols(s)).toEqual([sym]); + expect(Reflect.ownKeys(s)).toEqual(["a", "nested", sym]); + }); + + it("never leaks into a snapshot", () => { + const [s, set] = createStore({ a: 1, nested: { x: 1 }, list: [{ y: 1 }] }); + set(d => { + d.a = 2; + d.nested.x = 2; + d.list[0].y = 2; + }); + flush(); + const snap = snapshot(s); + expect(snap).toEqual({ a: 2, nested: { x: 2 }, list: [{ y: 2 }] }); + expect(Object.getOwnPropertySymbols(snap)).toEqual([]); + expect(Object.getOwnPropertySymbols(snap.nested)).toEqual([]); + expect(Object.getOwnPropertySymbols(snap.list)).toEqual([]); + expect(Object.getOwnPropertySymbols(snap.list[0])).toEqual([]); + expect(isOwned(snap)).toBe(false); + expect(isOwned(snap.nested)).toBe(false); + }); + + it("never acquires a node or a has-node", () => { + const src = { a: 1 }; + const [s, set] = createStore(src); + createRoot(() => { + createEffect( + () => [s.a, Object.keys(s).length, "a" in s, deep(s)], + () => {} + ); + }); + flush(); + set(d => { + d.a = 2; + }); + flush(); + const t = storeNextLookup.get(src)!; + expect(Reflect.ownKeys(t.n!)).toEqual(["a"]); + expect(t.h === null || !($OWNER in t.h)).toBe(true); + }); + + it("the first commit onto an unowned backing does not report a membership change", () => { + const [s, set] = createStore({ a: 1, b: 2 }); + let keyRuns = 0; + createRoot(() => { + createEffect( + () => Object.keys(s).join(), + () => { + keyRuns++; + } + ); + }); + flush(); + expect(keyRuns).toBe(1); + set(d => { + d.a = 10; + }); + flush(); + expect(keyRuns).toBe(1); // value write: same key set + set(d => { + d.c = 3; + }); + flush(); + expect(keyRuns).toBe(2); // a real add + }); + + it("a same-value index write on an unowned array does not bump the deep witness", () => { + const [s, set] = createStore({ list: [1, 2, 3] }); + let deepRuns = 0; + createRoot(() => { + createEffect( + () => deep(s.list), + () => { + deepRuns++; + } + ); + }); + flush(); + expect(deepRuns).toBe(1); + // Arrays have no written-keys bound: the fold walks every own key of the + // (stamped) clone against the unstamped old side. + set(d => { + d.list[1] = 2; + }); + flush(); + expect(deepRuns).toBe(1); + set(d => { + d.list[1] = 20; + }); + flush(); + expect(deepRuns).toBe(2); + }); + + it("a same-value write on a non-plain record does not bump the deep witness", () => { + class Point { + constructor( + public x = 1, + public y = 2 + ) {} + } + const [s, set] = createStore({ p: new Point() }); + let deepRuns = 0; + createRoot(() => { + createEffect( + () => deep(s.p), + () => { + deepRuns++; + } + ); + }); + flush(); + expect(deepRuns).toBe(1); + // Non-plain prototype: the fold walks every pb key (no written-keys + // bound) — the stamp must not read as a changed slot. + set(d => { + d.p.x = 1; + }); + flush(); + expect(deepRuns).toBe(1); + set(d => { + d.p.x = 5; + }); + flush(); + expect(deepRuns).toBe(2); + }); + + it("an owned backing aliased into another slot of the same store resolves to its target", () => { + const [s, set] = createStore<{ a: { x: number }; b?: { x: number } }>({ a: { x: 1 } }); + set(d => { + d.a.x = 2; // a's backing is now store-owned + }); + flush(); + set(d => { + d.b = d.a; + }); + flush(); + expect(s.b).toBe(s.a); + set(d => { + d.b!.x = 3; + }); + flush(); + expect(s.a.x).toBe(3); + expect(snapshot(s)).toEqual({ a: { x: 3 }, b: { x: 3 } }); + }); + + it("a store's owned backing handed to a projection stays owned across families", () => { + const src = { item: { x: 1 } }; + const [s, set] = createStore(src); + set(d => { + d.item.x = 2; + }); + flush(); + const ownedRaw = storeNextLookup.get(src)!.v.item; + expect(isOwned(ownedRaw)).toBe(true); + + const proj = createRoot(() => + createProjection<{ item?: { x: number } }>( + draft => { + draft.item = s.item; + }, + {}, + { key: null } + ) + ); + expect(proj.item!.x).toBe(2); + flush(); + // The projection wraps the foreign owned raw in its own family; snapshot + // copies owned subtrees instead of sharing them. + const snap = snapshot(proj); + expect(snap.item).toEqual({ x: 2 }); + expect(snap.item).not.toBe(ownedRaw); + expect(Object.getOwnPropertySymbols(snap.item!)).toEqual([]); + expect(Object.getOwnPropertySymbols(proj.item!)).toEqual([]); + }); +}); diff --git a/scripts/size/.size-limit.js b/scripts/size/.size-limit.js index fe32e5a85..776296b74 100644 --- a/scripts/size/.size-limit.js +++ b/scripts/size/.size-limit.js @@ -361,7 +361,12 @@ module.exports = [ // the target, the spread arm in cloneRaw, the width/ownership gate on // the overlay, and the bare-assignment arms in the set trap and // flatten. Buys 1.85x on per-write cost (629 -> 340 ns steady state). - limit: "14.97 KB", + // Ownership stamp (#3360 part two, 2026-09-10): 14.97 -> 15.06 KB, + // measured at 15.012 (was 14.921). Owned backings carry `$OWNER` instead + // of two weak-collection registrations per draft: the stamp-first + // lookup helper, the stamp filter in the ownKeys trap / snapshot / + // membership diff / key walks, and the trap guards. 340 -> ~178 ns. + limit: "15.06 KB", modifyEsbuildConfig }, { @@ -712,7 +717,9 @@ module.exports = [ // rest brotli layout across the store family bundle). See the createStore note. // Narrow-store write floor (#3360, 2026-09-10): 27.48 -> 27.57 KB, // measured at 27.518 (was 27.391). The createStore arm; see that note. - limit: "27.57 KB", + // Ownership stamp (#3360 part two, 2026-09-10): 27.57 -> 27.66 KB, + // measured at 27.608 (was 27.518). The createStore arm; see that note. + limit: "27.66 KB", modifyEsbuildConfig }, {