diff --git a/sdk/js/alien-deepsignals/src/deepSignal.ts b/sdk/js/alien-deepsignals/src/deepSignal.ts index 0d90548..48490bc 100644 --- a/sdk/js/alien-deepsignals/src/deepSignal.ts +++ b/sdk/js/alien-deepsignals/src/deepSignal.ts @@ -11,46 +11,46 @@ import { computed, signal, isSignal } from "./core"; /** A batched deep mutation (set/add/remove) from a deepSignal root. */ export type DeepPatch = { - /** Unique identifier for the deep signal root which produced this patch. */ - root: symbol; - /** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */ - path: (string | number)[]; + /** Unique identifier for the deep signal root which produced this patch. */ + root: symbol; + /** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */ + path: (string | number)[]; } & ( - | DeepSetAddPatch - | DeepSetRemovePatch - | DeepObjectAddPatch - | DeepRemovePatch - | DeepLiteralAddPatch + | DeepSetAddPatch + | DeepSetRemovePatch + | DeepObjectAddPatch + | DeepRemovePatch + | DeepLiteralAddPatch ); export interface DeepSetAddPatch { - /** Mutation kind applied at the resolved `path`. */ - op: "add"; - type: "set"; - /** New value for `set` mutations (omitted for `delete`). */ - value: (number | string | boolean)[] | { [id: string]: object }; + /** Mutation kind applied at the resolved `path`. */ + op: "add"; + type: "set"; + /** New value for `set` mutations (omitted for `delete`). */ + value: (number | string | boolean)[] | { [id: string]: object }; } export interface DeepSetRemovePatch { - /** Mutation kind applied at the resolved `path`. */ - op: "remove"; - type: "set"; - /** The value to be removed from the set. Either a literal or the key (id) of an object. */ - value: string | number | boolean; + /** Mutation kind applied at the resolved `path`. */ + op: "remove"; + type: "set"; + /** The value to be removed from the set. Either a literal or the key (id) of an object. */ + value: string | number | boolean; } export interface DeepObjectAddPatch { - /** Mutation kind applied at the resolved `path`. */ - op: "add"; - type: "object"; + /** Mutation kind applied at the resolved `path`. */ + op: "add"; + type: "object"; } export interface DeepRemovePatch { - /** Mutation kind applied at the resolved `path`. */ - op: "remove"; + /** Mutation kind applied at the resolved `path`. */ + op: "remove"; } export interface DeepLiteralAddPatch { - /** Mutation kind applied at the resolved `path` */ - op: "add"; - /** The literal value to be added at the resolved `path` */ - value: string | number | boolean; + /** Mutation kind applied at the resolved `path` */ + op: "add"; + /** The literal value to be added at the resolved `path` */ + value: string | number | boolean; } /** Callback signature for subscribeDeepMutations. */ @@ -58,12 +58,12 @@ export type DeepPatchSubscriber = (patches: DeepPatch[]) => void; /** Minimal per-proxy metadata for path reconstruction. */ interface ProxyMeta { - /** Parent proxy in the object graph (undefined for root). */ - parent?: object; - /** Key within the parent pointing to this proxy (undefined for root). */ - key?: string | number; - /** Stable root id symbol shared by the entire deepSignal tree. */ - root: symbol; + /** Parent proxy in the object graph (undefined for root). */ + parent?: object; + /** Key within the parent pointing to this proxy (undefined for root). */ + key?: string | number; + /** Stable root id symbol shared by the entire deepSignal tree. */ + root: symbol; } // Proxy -> metadata @@ -78,73 +78,117 @@ let microtaskScheduled = false; export const DEEP_SIGNAL_ROOT_ID = Symbol("alienDeepSignalRootId"); function buildPath( - startProxy: object, - leafKey: string | number + startProxy: object, + leafKey: string | number ): (string | number)[] { - const path: (string | number)[] = [leafKey]; - let cur: object | undefined = startProxy; - while (cur) { - const meta = proxyMeta.get(cur); - if (!meta) break; // Defensive: metadata should always exist. - if (meta.key === undefined) break; // Reached root (no key recorded). - path.unshift(meta.key); - cur = meta.parent; - } - return path; + const path: (string | number)[] = [leafKey]; + let cur: object | undefined = startProxy; + while (cur) { + const meta = proxyMeta.get(cur); + if (!meta) break; // Defensive: metadata should always exist. + if (meta.key === undefined) break; // Reached root (no key recorded). + path.unshift(meta.key); + cur = meta.parent; + } + return path; } function queuePatch(patch: DeepPatch) { - if (!pendingPatches) pendingPatches = new Map(); - const root = patch.root; - let list = pendingPatches.get(root); - if (!list) { - list = []; - pendingPatches.set(root, list); - } - list.push(patch); - if (!microtaskScheduled) { - microtaskScheduled = true; - queueMicrotask(() => { - microtaskScheduled = false; - const groups = pendingPatches; - pendingPatches = null; - if (!groups) return; - for (const [rootId, patches] of groups) { - if (!patches.length) continue; - const subs = mutationSubscribers.get(rootId); - if (subs) subs.forEach((cb) => cb(patches)); - } + if (!pendingPatches) pendingPatches = new Map(); + const root = patch.root; + let list = pendingPatches.get(root); + if (!list) { + list = []; + pendingPatches.set(root, list); + } + list.push(patch); + if (!microtaskScheduled) { + microtaskScheduled = true; + queueMicrotask(() => { + microtaskScheduled = false; + const groups = pendingPatches; + pendingPatches = null; + if (!groups) return; + for (const [rootId, patches] of groups) { + if (!patches.length) continue; + const subs = mutationSubscribers.get(rootId); + if (subs) subs.forEach((cb) => cb(patches)); + } + }); + } +} + +/** Recursively emit patches for all nested properties of a newly attached object. */ +function queueDeepPatches( + val: any, + rootId: symbol, + basePath: (string | number)[] +) { + if (!val || typeof val !== "object") { + // Emit patch for primitive leaf + queuePatch({ + root: rootId, + path: basePath, + op: "add", + value: val, + }); + return; + } + + // Emit patch for the object/array/Set itself + queuePatch({ + root: rootId, + path: basePath, + op: "add", + type: "object", }); - } + + // Recursively process nested properties + if (Array.isArray(val)) { + for (let i = 0; i < val.length; i++) { + queueDeepPatches(val[i], rootId, [...basePath, i]); + } + } else if (val instanceof Set) { + for (const entry of val) { + const key = getSetEntryKey(entry); + queueDeepPatches(entry, rootId, [...basePath, key]); + } + } else if (val.constructor === Object) { + for (const key in val) { + if (Object.prototype.hasOwnProperty.call(val, key)) { + queueDeepPatches(val[key], rootId, [...basePath, key]); + } + } + } } /** Subscribe to microtask-batched deep patches for a root (returns unsubscribe). */ export function subscribeDeepMutations( - root: object | symbol, - sub: DeepPatchSubscriber + root: object | symbol, + sub: DeepPatchSubscriber ): () => void { - const rootId = typeof root === "symbol" ? root : getDeepSignalRootId(root); - if (!rootId) - throw new Error( - "subscribeDeepMutations() expects a deepSignal root proxy or root id symbol" - ); - let set = mutationSubscribers.get(rootId); - if (!set) { - set = new Set(); - mutationSubscribers.set(rootId, set); - } - set.add(sub); - return () => { - const bucket = mutationSubscribers.get(rootId); - if (!bucket) return; - bucket.delete(sub); - if (bucket.size === 0) mutationSubscribers.delete(rootId); - }; + const rootId = typeof root === "symbol" ? root : getDeepSignalRootId(root); + if (!rootId) + throw new Error( + "subscribeDeepMutations() expects a deepSignal root proxy or root id symbol" + ); + let set = mutationSubscribers.get(rootId); + if (!set) { + set = new Set(); + mutationSubscribers.set(rootId, set); + } + set.add(sub); + return () => { + const bucket = mutationSubscribers.get(rootId); + if (!bucket) return; + bucket.delete(sub); + if (bucket.size === 0) mutationSubscribers.delete(rootId); + }; } /** Return the stable root symbol for any deepSignal proxy (undefined if not one). */ export function getDeepSignalRootId(obj: any): symbol | undefined { - return proxyMeta.get(obj)?.root; + return proxyMeta.get(obj)?.root; } // Proxy -> Map of property name -> signal function @@ -164,636 +208,648 @@ let peeking = false; // Deep array interface refining callback parameter types. type DeepArray = Array & { - map: ( - callbackfn: ( - value: DeepSignal, - index: number, - array: DeepSignalArray - ) => U, - thisArg?: any - ) => U[]; - forEach: ( - callbackfn: ( - value: DeepSignal, - index: number, - array: DeepSignalArray - ) => void, - thisArg?: any - ) => void; - concat(...items: ConcatArray[]): DeepSignalArray; - concat(...items: (T | ConcatArray)[]): DeepSignalArray; - reverse(): DeepSignalArray; - shift(): DeepSignal | undefined; - slice(start?: number, end?: number): DeepSignalArray; - splice(start: number, deleteCount?: number): DeepSignalArray; - splice( - start: number, - deleteCount: number, - ...items: T[] - ): DeepSignalArray; - filter( - predicate: ( - value: DeepSignal, - index: number, - array: DeepSignalArray - ) => value is DeepSignal, - thisArg?: any - ): DeepSignalArray; - filter( - predicate: ( - value: DeepSignal, - index: number, - array: DeepSignalArray - ) => unknown, - thisArg?: any - ): DeepSignalArray; - reduce( - callbackfn: ( - previousValue: DeepSignal, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => T - ): DeepSignal; - reduce( - callbackfn: ( - previousValue: DeepSignal, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => DeepSignal, - initialValue: T - ): DeepSignal; - reduce( - callbackfn: ( - previousValue: U, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => U, - initialValue: U - ): U; - reduceRight( - callbackfn: ( - previousValue: DeepSignal, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => T - ): DeepSignal; - reduceRight( - callbackfn: ( - previousValue: DeepSignal, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => DeepSignal, - initialValue: T - ): DeepSignal; - reduceRight( - callbackfn: ( - previousValue: U, - currentValue: DeepSignal, - currentIndex: number, - array: DeepSignalArray - ) => U, - initialValue: U - ): U; + map: ( + callbackfn: ( + value: DeepSignal, + index: number, + array: DeepSignalArray + ) => U, + thisArg?: any + ) => U[]; + forEach: ( + callbackfn: ( + value: DeepSignal, + index: number, + array: DeepSignalArray + ) => void, + thisArg?: any + ) => void; + concat(...items: ConcatArray[]): DeepSignalArray; + concat(...items: (T | ConcatArray)[]): DeepSignalArray; + reverse(): DeepSignalArray; + shift(): DeepSignal | undefined; + slice(start?: number, end?: number): DeepSignalArray; + splice(start: number, deleteCount?: number): DeepSignalArray; + splice( + start: number, + deleteCount: number, + ...items: T[] + ): DeepSignalArray; + filter( + predicate: ( + value: DeepSignal, + index: number, + array: DeepSignalArray + ) => value is DeepSignal, + thisArg?: any + ): DeepSignalArray; + filter( + predicate: ( + value: DeepSignal, + index: number, + array: DeepSignalArray + ) => unknown, + thisArg?: any + ): DeepSignalArray; + reduce( + callbackfn: ( + previousValue: DeepSignal, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => T + ): DeepSignal; + reduce( + callbackfn: ( + previousValue: DeepSignal, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => DeepSignal, + initialValue: T + ): DeepSignal; + reduce( + callbackfn: ( + previousValue: U, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => U, + initialValue: U + ): U; + reduceRight( + callbackfn: ( + previousValue: DeepSignal, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => T + ): DeepSignal; + reduceRight( + callbackfn: ( + previousValue: DeepSignal, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => DeepSignal, + initialValue: T + ): DeepSignal; + reduceRight( + callbackfn: ( + previousValue: U, + currentValue: DeepSignal, + currentIndex: number, + array: DeepSignalArray + ) => U, + initialValue: U + ): U; }; // Synthetic ids for Set entry objects (stable key for patches) let __blankNodeCounter = 0; const setObjectIds = new WeakMap(); const assignBlankNodeId = (obj: any) => { - if (setObjectIds.has(obj)) return setObjectIds.get(obj)!; - const id = `_b${++__blankNodeCounter}`; - setObjectIds.set(obj, id); - return id; + if (setObjectIds.has(obj)) return setObjectIds.get(obj)!; + const id = `_b${++__blankNodeCounter}`; + setObjectIds.set(obj, id); + return id; }; /** Assign (or override) synthetic id before Set.add(). */ export function setSetEntrySyntheticId(obj: object, id: string | number) { - setObjectIds.set(obj, String(id)); + setObjectIds.set(obj, String(id)); } const getSetEntryKey = (val: any): string | number => { - if (val && typeof val === "object") { - if (setObjectIds.has(val)) return setObjectIds.get(val)!; - if ( - typeof (val as any).id === "string" || - typeof (val as any).id === "number" - ) - return (val as any).id; - if ( - typeof (val as any)["@id"] === "string" || - typeof (val as any)["@id"] === "number" - ) - return (val as any)["@id"]; - return assignBlankNodeId(val); - } - return val as any; + if (val && typeof val === "object") { + if (setObjectIds.has(val)) return setObjectIds.get(val)!; + if ( + typeof (val as any).id === "string" || + typeof (val as any).id === "number" + ) + return (val as any).id; + if ( + typeof (val as any)["@id"] === "string" || + typeof (val as any)["@id"] === "number" + ) + return (val as any)["@id"]; + return assignBlankNodeId(val); + } + return val as any; }; /** Add entry with synthetic id; returns proxied object if applicable. */ export function addWithId( - set: Set, - entry: T, - id: string | number + set: Set, + entry: T, + id: string | number ): DeepSignal; export function addWithId(set: Set, entry: T, id: string | number): T; export function addWithId(set: Set, entry: any, id: string | number) { - if (entry && typeof entry === "object") setSetEntrySyntheticId(entry, id); - (set as any).add(entry); - if (entry && typeof entry === "object" && objToProxy.has(entry)) - return objToProxy.get(entry); - return entry; + if (entry && typeof entry === "object") setSetEntrySyntheticId(entry, id); + (set as any).add(entry); + if (entry && typeof entry === "object" && objToProxy.has(entry)) + return objToProxy.get(entry); + return entry; } /** Is value a deepSignal-managed proxy? */ export const isDeepSignal = (source: any) => { - return proxyToSignals.has(source); + return proxyToSignals.has(source); }; /** Was value explicitly marked shallow? */ export const isShallow = (source: any) => { - return ignore.has(source); + return ignore.has(source); }; /** Create (or reuse) a deep reactive proxy for an object / array / Set. */ export const deepSignal = (obj: T): DeepSignal => { - if (!shouldProxy(obj)) throw new Error("This object can't be observed."); - if (!objToProxy.has(obj)) { - // Create a unique root id symbol to identify this deep signal tree in patches. - const rootId = Symbol("deepSignalRoot"); - const proxy = createProxy(obj, objectHandlers, rootId) as DeepSignal; - const meta = proxyMeta.get(proxy)!; - meta.parent = undefined; // root has no parent - meta.key = undefined; // root not addressed by a key - meta.root = rootId; // ensure root id stored (explicit) - // Pre-register an empty signals map so isDeepSignal() is true before any property access. - if (!proxyToSignals.has(proxy)) proxyToSignals.set(proxy, new Map()); - objToProxy.set(obj, proxy); - } - return objToProxy.get(obj); + if (!shouldProxy(obj)) throw new Error("This object can't be observed."); + if (!objToProxy.has(obj)) { + // Create a unique root id symbol to identify this deep signal tree in patches. + const rootId = Symbol("deepSignalRoot"); + const proxy = createProxy(obj, objectHandlers, rootId) as DeepSignal; + const meta = proxyMeta.get(proxy)!; + meta.parent = undefined; // root has no parent + meta.key = undefined; // root not addressed by a key + meta.root = rootId; // ensure root id stored (explicit) + // Pre-register an empty signals map so isDeepSignal() is true before any property access. + if (!proxyToSignals.has(proxy)) proxyToSignals.set(proxy, new Map()); + objToProxy.set(obj, proxy); + } + return objToProxy.get(obj); }; /** Read property without tracking (untracked read). */ export const peek = < - T extends DeepSignalObject, - K extends keyof RevertDeepSignalObject + T extends DeepSignalObject, + K extends keyof RevertDeepSignalObject, >( - obj: T, - key: K + obj: T, + key: K ): RevertDeepSignal[K]> => { - peeking = true; - const value = obj[key]; - try { - peeking = false; - } catch (e) {} - return value as RevertDeepSignal[K]>; + peeking = true; + const value = obj[key]; + try { + peeking = false; + } catch (e) {} + return value as RevertDeepSignal[K]>; }; const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW); /** Mark object to skip deep proxying (only reference changes tracked). */ export function shallow(obj: T): Shallow { - ignore.add(obj); - return obj as Shallow; + ignore.add(obj); + return obj as Shallow; } const createProxy = ( - target: object, - handlers: ProxyHandler, - rootId?: symbol + target: object, + handlers: ProxyHandler, + rootId?: symbol ) => { - const proxy = new Proxy(target, handlers); - ignore.add(proxy); - // Initialize proxy metadata if not present. Root proxies provide a stable root id. - if (!proxyMeta.has(proxy)) { - proxyMeta.set(proxy, { root: rootId || Symbol("deepSignalDetachedRoot") }); - } - return proxy; + const proxy = new Proxy(target, handlers); + ignore.add(proxy); + // Initialize proxy metadata if not present. Root proxies provide a stable root id. + if (!proxyMeta.has(proxy)) { + proxyMeta.set(proxy, { + root: rootId || Symbol("deepSignalDetachedRoot"), + }); + } + return proxy; }; // Set-specific access & structural patch emission. function getFromSet( - raw: Set, - key: string | symbol, - receiver: object + raw: Set, + key: string | symbol, + receiver: object ): any { - const meta = proxyMeta.get(receiver); - // Helper to proxy a single entry (object) & assign synthetic id if needed. - const ensureEntryProxy = (entry: any) => { - if ( - entry && - typeof entry === "object" && - shouldProxy(entry) && - !objToProxy.has(entry) - ) { - const synthetic = getSetEntryKey(entry); - const childProxy = createProxy(entry, objectHandlers, meta!.root); - const childMeta = proxyMeta.get(childProxy)!; - childMeta.parent = receiver; - childMeta.key = synthetic; - objToProxy.set(entry, childProxy); - return childProxy; - } - if (objToProxy.has(entry)) return objToProxy.get(entry); - return entry; - }; - // Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration) - if (meta) raw.forEach(ensureEntryProxy); - if (key === "add" || key === "delete" || key === "clear") { - const fn: Function = (raw as any)[key]; - return function (this: any, ...args: any[]) { - const sizeBefore = raw.size; - const result = fn.apply(raw, args); - if (raw.size !== sizeBefore) { - const metaNow = proxyMeta.get(receiver); + const meta = proxyMeta.get(receiver); + // Helper to proxy a single entry (object) & assign synthetic id if needed. + const ensureEntryProxy = (entry: any) => { if ( - metaNow && - metaNow.parent !== undefined && - metaNow.key !== undefined + entry && + typeof entry === "object" && + shouldProxy(entry) && + !objToProxy.has(entry) ) { - const containerPath = buildPath(metaNow.parent, metaNow.key); - if (key === "add") { - const entry = args[0]; - let synthetic = getSetEntryKey(entry); - if (entry && typeof entry === "object") { - for (const existing of raw.values()) { - if (existing === entry) continue; - if (getSetEntryKey(existing) === synthetic) { - synthetic = assignBlankNodeId(entry); - break; - } - } - } - let entryVal = entry; - if ( - entryVal && - typeof entryVal === "object" && - shouldProxy(entryVal) && - !objToProxy.has(entryVal) - ) { - const childProxy = createProxy( - entryVal, - objectHandlers, - metaNow.root - ); - const childMeta = proxyMeta.get(childProxy)!; - childMeta.parent = receiver; - childMeta.key = synthetic; - objToProxy.set(entryVal, childProxy); - entryVal = childProxy; - } - // Set entry add: emit object vs literal variant. - if (entryVal && typeof entryVal === "object") { - queuePatch({ - root: metaNow.root, - path: [...containerPath, synthetic], - op: "add", - type: "object", - }); - } else { - queuePatch({ - root: metaNow.root, - path: [...containerPath, synthetic], - op: "add", - value: entryVal, - }); - } - } else if (key === "delete") { - const entry = args[0]; const synthetic = getSetEntryKey(entry); - queuePatch({ - root: metaNow.root, - path: [...containerPath, synthetic], - op: "remove", - }); - } else if (key === "clear") { - // Structural clear: remove prior entry-level patches for this Set this tick. - if (pendingPatches) { - const group = pendingPatches.get(metaNow.root); - if (group && group.length) { - for (let i = group.length - 1; i >= 0; i--) { - const p = group[i]; - if ( - p.path.length === containerPath.length + 1 && - containerPath.every((seg, idx) => p.path[idx] === seg) - ) { - group.splice(i, 1); - } - } - } - } - queuePatch({ - root: metaNow.root, - path: containerPath, - op: "add", - type: "set", - value: [], - }); - } + const childProxy = createProxy(entry, objectHandlers, meta!.root); + const childMeta = proxyMeta.get(childProxy)!; + childMeta.parent = receiver; + childMeta.key = synthetic; + objToProxy.set(entry, childProxy); + return childProxy; } - } - return result; - }; - } - const makeIterator = (pair: boolean) => { - return function thisIter(this: any) { - const iterable = raw.values(); - return { - [Symbol.iterator]() { - return { - next() { - const n = iterable.next(); - if (n.done) return n; - const entry = ensureEntryProxy(n.value); - return { value: pair ? [entry, entry] : entry, done: false }; - }, - }; - }, - } as Iterable; - }; - }; - if (key === "values" || key === "keys") return makeIterator(false); - if (key === "entries") return makeIterator(true); - if (key === "forEach") { - return function thisForEach(this: any, cb: any, thisArg?: any) { - raw.forEach((entry: any) => { - cb.call(thisArg, ensureEntryProxy(entry), ensureEntryProxy(entry), raw); - }); + if (objToProxy.has(entry)) return objToProxy.get(entry); + return entry; }; - } - // Properly handle native iteration (for..of, Array.from, spread) by binding to the raw Set. - if (key === Symbol.iterator) { - // Return a function whose `this` is the raw Set (avoids brand check failure on the proxy). - return function (this: any) { - // Use raw.values() so we can still ensure child entries are proxied lazily. - const iterable = raw.values(); - return { - [Symbol.iterator]() { - return this; - }, - next() { - const n = iterable.next(); - if (n.done) return n; - const entry = ensureEntryProxy(n.value); - return { value: entry, done: false }; - }, - } as Iterator; + // Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration) + if (meta) raw.forEach(ensureEntryProxy); + if (key === "add" || key === "delete" || key === "clear") { + const fn: Function = (raw as any)[key]; + return function (this: any, ...args: any[]) { + const sizeBefore = raw.size; + const result = fn.apply(raw, args); + if (raw.size !== sizeBefore) { + const metaNow = proxyMeta.get(receiver); + if ( + metaNow && + metaNow.parent !== undefined && + metaNow.key !== undefined + ) { + const containerPath = buildPath( + metaNow.parent, + metaNow.key + ); + if (key === "add") { + const entry = args[0]; + let synthetic = getSetEntryKey(entry); + if (entry && typeof entry === "object") { + for (const existing of raw.values()) { + if (existing === entry) continue; + if (getSetEntryKey(existing) === synthetic) { + synthetic = assignBlankNodeId(entry); + break; + } + } + } + let entryVal = entry; + if ( + entryVal && + typeof entryVal === "object" && + shouldProxy(entryVal) && + !objToProxy.has(entryVal) + ) { + const childProxy = createProxy( + entryVal, + objectHandlers, + metaNow.root + ); + const childMeta = proxyMeta.get(childProxy)!; + childMeta.parent = receiver; + childMeta.key = synthetic; + objToProxy.set(entryVal, childProxy); + entryVal = childProxy; + } + // Set entry add: emit object vs literal variant. + if (entryVal && typeof entryVal === "object") { + queuePatch({ + root: metaNow.root, + path: [...containerPath, synthetic], + op: "add", + type: "object", + }); + } else { + queuePatch({ + root: metaNow.root, + path: [...containerPath, synthetic], + op: "add", + value: entryVal, + }); + } + } else if (key === "delete") { + const entry = args[0]; + const synthetic = getSetEntryKey(entry); + queuePatch({ + root: metaNow.root, + path: [...containerPath, synthetic], + op: "remove", + }); + } else if (key === "clear") { + // Structural clear: remove prior entry-level patches for this Set this tick. + if (pendingPatches) { + const group = pendingPatches.get(metaNow.root); + if (group && group.length) { + for (let i = group.length - 1; i >= 0; i--) { + const p = group[i]; + if ( + p.path.length === + containerPath.length + 1 && + containerPath.every( + (seg, idx) => p.path[idx] === seg + ) + ) { + group.splice(i, 1); + } + } + } + } + queuePatch({ + root: metaNow.root, + path: containerPath, + op: "add", + type: "set", + value: [], + }); + } + } + } + return result; + }; + } + const makeIterator = (pair: boolean) => { + return function thisIter(this: any) { + const iterable = raw.values(); + return { + [Symbol.iterator]() { + return { + next() { + const n = iterable.next(); + if (n.done) return n; + const entry = ensureEntryProxy(n.value); + return { + value: pair ? [entry, entry] : entry, + done: false, + }; + }, + }; + }, + } as Iterable; + }; }; - } - if (key === Symbol.iterator.toString()) { - // string form access of iterator symbol; pass through (rare path) - } - const val = (raw as any)[key]; - if (typeof val === "function") return val.bind(raw); - return val; + if (key === "values" || key === "keys") return makeIterator(false); + if (key === "entries") return makeIterator(true); + if (key === "forEach") { + return function thisForEach(this: any, cb: any, thisArg?: any) { + raw.forEach((entry: any) => { + cb.call( + thisArg, + ensureEntryProxy(entry), + ensureEntryProxy(entry), + raw + ); + }); + }; + } + // Properly handle native iteration (for..of, Array.from, spread) by binding to the raw Set. + if (key === Symbol.iterator) { + // Return a function whose `this` is the raw Set (avoids brand check failure on the proxy). + return function (this: any) { + // Use raw.values() so we can still ensure child entries are proxied lazily. + const iterable = raw.values(); + return { + [Symbol.iterator]() { + return this; + }, + next() { + const n = iterable.next(); + if (n.done) return n; + const entry = ensureEntryProxy(n.value); + return { value: entry, done: false }; + }, + } as Iterator; + }; + } + if (key === Symbol.iterator.toString()) { + // string form access of iterator symbol; pass through (rare path) + } + const val = (raw as any)[key]; + if (typeof val === "function") return val.bind(raw); + return val; } const throwOnMutation = () => { - throw new Error( - "Don't mutate the signals directly (use the underlying property/value instead)." - ); + throw new Error( + "Don't mutate the signals directly (use the underlying property/value instead)." + ); }; // Does target define a getter for key? function hasGetter(target: any, key: any) { - return typeof descriptor(target, key)?.get === "function"; + return typeof descriptor(target, key)?.get === "function"; } // Lazily allocate / fetch signal map for a proxy receiver. function getSignals(receiver: object) { - if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); - return proxyToSignals.get(receiver)!; + if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); + return proxyToSignals.get(receiver)!; } // Wrap & link child object/array/Set if needed. function ensureChildProxy(value: any, parent: object, key: string | number) { - if (!shouldProxy(value)) return value; - if (!objToProxy.has(value)) { - const parentMeta = proxyMeta.get(parent)!; - const childProxy = createProxy(value, objectHandlers, parentMeta.root); - const childMeta = proxyMeta.get(childProxy)!; - childMeta.parent = parent; - childMeta.key = key as string; - objToProxy.set(value, childProxy); - } - return objToProxy.get(value); + if (!shouldProxy(value)) return value; + if (!objToProxy.has(value)) { + const parentMeta = proxyMeta.get(parent)!; + const childProxy = createProxy(value, objectHandlers, parentMeta.root); + const childMeta = proxyMeta.get(childProxy)!; + childMeta.parent = parent; + childMeta.key = key as string; + objToProxy.set(value, childProxy); + } + return objToProxy.get(value); } // Normalize raw property key (handles $-prefix & array meta) -> { key, returnSignal } function normalizeKey( - target: any, - fullKey: string, - isArrayMeta: boolean, - receiver: object + target: any, + fullKey: string, + isArrayMeta: boolean, + receiver: object ) { - let returnSignal = isArrayMeta || fullKey[0] === "$"; - if (!isArrayMeta && Array.isArray(target) && returnSignal) { - if (fullKey === "$") { - // Provide $ meta proxy for array index signals - if (!arrayToArrayOfSignals.has(target)) { - arrayToArrayOfSignals.set( - target, - createProxy(target, arrayHandlers, proxyMeta.get(receiver)?.root) - ); - } - return { shortCircuit: arrayToArrayOfSignals.get(target) }; + let returnSignal = isArrayMeta || fullKey[0] === "$"; + if (!isArrayMeta && Array.isArray(target) && returnSignal) { + if (fullKey === "$") { + // Provide $ meta proxy for array index signals + if (!arrayToArrayOfSignals.has(target)) { + arrayToArrayOfSignals.set( + target, + createProxy( + target, + arrayHandlers, + proxyMeta.get(receiver)?.root + ) + ); + } + return { shortCircuit: arrayToArrayOfSignals.get(target) }; + } + returnSignal = fullKey === "$length"; } - returnSignal = fullKey === "$length"; - } - const key = returnSignal ? fullKey.replace(rg, "") : fullKey; - return { key, returnSignal } as any; + const key = returnSignal ? fullKey.replace(rg, "") : fullKey; + return { key, returnSignal } as any; } // Create computed signal for getter property if needed. function ensureComputed( - signals: Map, - target: any, - key: any, - receiver: any + signals: Map, + target: any, + key: any, + receiver: any ) { - if (!signals.has(key) && hasGetter(target, key)) { - signals.set( - key, - computed(() => Reflect.get(target, key, receiver)) - ); - } + if (!signals.has(key) && hasGetter(target, key)) { + signals.set( + key, + computed(() => Reflect.get(target, key, receiver)) + ); + } } // Unified get trap factory (object / array meta variant) const get = - (isArrayMeta: boolean) => - (target: object, fullKey: string, receiver: object): unknown => { - if (peeking) return Reflect.get(target, fullKey, receiver); - // Set handling delegated completely. - if (target instanceof Set) { - return getFromSet(target as Set, fullKey as any, receiver); - } - const norm = normalizeKey(target, fullKey, isArrayMeta, receiver); - if ((norm as any).shortCircuit) return (norm as any).shortCircuit; // returned meta proxy - const { key, returnSignal } = norm as { - key: string; - returnSignal: boolean; + (isArrayMeta: boolean) => + (target: object, fullKey: string, receiver: object): unknown => { + if (peeking) return Reflect.get(target, fullKey, receiver); + // Set handling delegated completely. + if (target instanceof Set) { + return getFromSet(target as Set, fullKey as any, receiver); + } + const norm = normalizeKey(target, fullKey, isArrayMeta, receiver); + if ((norm as any).shortCircuit) return (norm as any).shortCircuit; // returned meta proxy + const { key, returnSignal } = norm as { + key: string; + returnSignal: boolean; + }; + // Symbol fast-path + if (typeof key === "symbol" && wellKnownSymbols.has(key)) + return Reflect.get(target, key, receiver); + const signals = getSignals(receiver); + ensureComputed(signals, target, key, receiver); + if (!signals.has(key)) { + let value = Reflect.get(target, key, receiver); + if (returnSignal && typeof value === "function") return; // user asked for signal wrapper of function => ignore + value = ensureChildProxy(value, receiver, key); + signals.set(key, signal(value)); + } + const sig = signals.get(key); + return returnSignal ? sig : sig(); }; - // Symbol fast-path - if (typeof key === "symbol" && wellKnownSymbols.has(key)) - return Reflect.get(target, key, receiver); - const signals = getSignals(receiver); - ensureComputed(signals, target, key, receiver); - if (!signals.has(key)) { - let value = Reflect.get(target, key, receiver); - if (returnSignal && typeof value === "function") return; // user asked for signal wrapper of function => ignore - value = ensureChildProxy(value, receiver, key); - signals.set(key, signal(value)); - } - const sig = signals.get(key); - return returnSignal ? sig : sig(); - }; // Standard object / array handlers const objectHandlers = { - get: get(false), - set(target: object, fullKey: string, val: any, receiver: object): boolean { - // Respect original getter/setter semantics - if (typeof descriptor(target, fullKey)?.set === "function") - return Reflect.set(target, fullKey, val, receiver); - if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); - const signals = proxyToSignals.get(receiver); - if (fullKey[0] === "$") { - if (!isSignal(val)) throwOnMutation(); - const key = fullKey.replace(rg, ""); - signals.set(key, val); - return Reflect.set(target, key, val.peek(), receiver); - } else { - let internal = val; - if (shouldProxy(val)) { - if (!objToProxy.has(val)) { - // Link newly wrapped child to its parent for path reconstruction. - // In some edge cases parent metadata might not yet be initialized (e.g., - // if a proxied structure was reconstructed in a way that bypassed the - // original deepSignal root path). Fall back to creating/assigning it. - let parentMeta = proxyMeta.get(receiver); - if (!parentMeta) { - // Assign a root id (new symbol) so downstream patches remain groupable. - const created: ProxyMeta = { - root: Symbol("deepSignalRootAuto"), - } as ProxyMeta; - proxyMeta.set(receiver, created); - parentMeta = created; - } - const childProxy = createProxy(val, objectHandlers, parentMeta!.root); - const childMeta = proxyMeta.get(childProxy)!; - childMeta.parent = receiver; - childMeta.key = fullKey; - objToProxy.set(val, childProxy); - } - internal = objToProxy.get(val); - } - const isNew = !(fullKey in target); - const result = Reflect.set(target, fullKey, val, receiver); - - if (!signals.has(fullKey)) { - // First write after structure change -> create signal. - signals.set(fullKey, signal(internal)); - } else { - // Subsequent writes -> update underlying signal. - signals.get(fullKey).set(internal); - } - if (isNew && objToIterable.has(target)) objToIterable.get(target).value++; - if (Array.isArray(target) && signals.has("length")) - signals.get("length").set(target.length); - // Emit patch (after mutation) so subscribers get final value snapshot. - const meta = proxyMeta.get(receiver); - if (meta) { - // Object/Array/Set assignment at property path. - if (val && typeof val === "object") { - queuePatch({ - root: meta.root, - path: buildPath(receiver, fullKey), - op: "add", - type: "object", - }); + get: get(false), + set(target: object, fullKey: string, val: any, receiver: object): boolean { + // Respect original getter/setter semantics + if (typeof descriptor(target, fullKey)?.set === "function") + return Reflect.set(target, fullKey, val, receiver); + if (!proxyToSignals.has(receiver)) + proxyToSignals.set(receiver, new Map()); + const signals = proxyToSignals.get(receiver); + if (fullKey[0] === "$") { + if (!isSignal(val)) throwOnMutation(); + const key = fullKey.replace(rg, ""); + signals.set(key, val); + return Reflect.set(target, key, val.peek(), receiver); } else { - queuePatch({ - root: meta.root, - path: buildPath(receiver, fullKey), - op: "add", - value: val, - }); + let internal = val; + if (shouldProxy(val)) { + if (!objToProxy.has(val)) { + // Link newly wrapped child to its parent for path reconstruction. + // In some edge cases parent metadata might not yet be initialized (e.g., + // if a proxied structure was reconstructed in a way that bypassed the + // original deepSignal root path). Fall back to creating/assigning it. + let parentMeta = proxyMeta.get(receiver); + if (!parentMeta) { + // Assign a root id (new symbol) so downstream patches remain groupable. + const created: ProxyMeta = { + root: Symbol("deepSignalRootAuto"), + } as ProxyMeta; + proxyMeta.set(receiver, created); + parentMeta = created; + } + const childProxy = createProxy( + val, + objectHandlers, + parentMeta!.root + ); + const childMeta = proxyMeta.get(childProxy)!; + childMeta.parent = receiver; + childMeta.key = fullKey; + objToProxy.set(val, childProxy); + } + internal = objToProxy.get(val); + } + const isNew = !(fullKey in target); + const result = Reflect.set(target, fullKey, val, receiver); + + if (!signals.has(fullKey)) { + // First write after structure change -> create signal. + signals.set(fullKey, signal(internal)); + } else { + // Subsequent writes -> update underlying signal. + signals.get(fullKey).set(internal); + } + if (isNew && objToIterable.has(target)) + objToIterable.get(target).value++; + if (Array.isArray(target) && signals.has("length")) + signals.get("length").set(target.length); + // Emit patch (after mutation) so subscribers get final value snapshot. + const meta = proxyMeta.get(receiver); + if (meta) { + // Recursively emit patches for all nested properties of newly attached objects + queueDeepPatches(val, meta.root, buildPath(receiver, fullKey)); + } + return result; } - } - return result; - } - }, - deleteProperty(target: object, key: string): boolean { - if (key[0] === "$") throwOnMutation(); - const signals = proxyToSignals.get(objToProxy.get(target)); - const result = Reflect.deleteProperty(target, key); - if (signals && signals.has(key)) signals.get(key).value = undefined; - objToIterable.has(target) && objToIterable.get(target).value++; - // Emit deletion patch - const receiverProxy = objToProxy.get(target); - const meta = receiverProxy && proxyMeta.get(receiverProxy); - if (meta) { - queuePatch({ - root: meta.root, - path: buildPath(receiverProxy, key), - op: "remove", - }); - } - return result; - }, - ownKeys(target: object): (string | symbol)[] { - if (!objToIterable.has(target)) objToIterable.set(target, signal(0)); - (objToIterable as any)._ = objToIterable.get(target).get(); - return Reflect.ownKeys(target); - }, + }, + deleteProperty(target: object, key: string): boolean { + if (key[0] === "$") throwOnMutation(); + const signals = proxyToSignals.get(objToProxy.get(target)); + const result = Reflect.deleteProperty(target, key); + if (signals && signals.has(key)) signals.get(key).value = undefined; + objToIterable.has(target) && objToIterable.get(target).value++; + // Emit deletion patch + const receiverProxy = objToProxy.get(target); + const meta = receiverProxy && proxyMeta.get(receiverProxy); + if (meta) { + queuePatch({ + root: meta.root, + path: buildPath(receiverProxy, key), + op: "remove", + }); + } + return result; + }, + ownKeys(target: object): (string | symbol)[] { + if (!objToIterable.has(target)) objToIterable.set(target, signal(0)); + (objToIterable as any)._ = objToIterable.get(target).get(); + return Reflect.ownKeys(target); + }, }; // Array `$` meta proxy handlers (index signals only) const arrayHandlers = { - get: get(true), - set: throwOnMutation, - deleteProperty: throwOnMutation, + get: get(true), + set: throwOnMutation, + deleteProperty: throwOnMutation, }; const wellKnownSymbols = new Set( - Object.getOwnPropertyNames(Symbol) - .map((key) => Symbol[key as WellKnownSymbols]) - .filter((value) => typeof value === "symbol") + Object.getOwnPropertyNames(Symbol) + .map((key) => Symbol[key as WellKnownSymbols]) + .filter((value) => typeof value === "symbol") ); // Supported constructors (Map intentionally excluded for now) const supported = new Set([Object, Array, Set]); const shouldProxy = (val: any): boolean => { - if (typeof val !== "object" || val === null) return false; - return supported.has(val.constructor) && !ignore.has(val); + if (typeof val !== "object" || val === null) return false; + return supported.has(val.constructor) && !ignore.has(val); }; /** TYPES **/ // Structural deep reactive view of an input type. export type DeepSignal = T extends Function - ? T - : T extends { [shallowFlag]: true } - ? T - : T extends Array - ? DeepSignalArray - : T extends object - ? DeepSignalObject - : T; + ? T + : T extends { [shallowFlag]: true } + ? T + : T extends Array + ? DeepSignalArray + : T extends object + ? DeepSignalObject + : T; /** Recursive mapped type converting an object graph into its deepSignal proxy shape. */ export type DeepSignalObject = { - [P in keyof T & string as `$${P}`]?: T[P] extends Function - ? never - : ReturnType>; + [P in keyof T & string as `$${P}`]?: T[P] extends Function + ? never + : ReturnType>; } & { - [P in keyof T]: DeepSignal; + [P in keyof T]: DeepSignal; }; /** Extract element type from an array. */ type ArrayType = T extends Array ? I : T; /** DeepSignal-enhanced array type (numeric indices & `$` meta accessors). */ type DeepSignalArray = DeepArray> & { - [key: number]: DeepSignal>; - $?: { [key: number]: ReturnType>> }; - $length?: ReturnType>; + [key: number]: DeepSignal>; + $?: { [key: number]: ReturnType>> }; + $length?: ReturnType>; }; /** Marker utility type for objects passed through without deep proxying. */ @@ -810,24 +866,25 @@ type RevertDeepSignalObject = Pick>; type RevertDeepSignalArray = Omit; /** Inverse mapped type removing deepSignal wrapper affordances. */ -export type RevertDeepSignal = T extends Array - ? RevertDeepSignalArray - : T extends object - ? RevertDeepSignalObject - : T; +export type RevertDeepSignal = + T extends Array + ? RevertDeepSignalArray + : T extends object + ? RevertDeepSignalObject + : T; /** Subset of ECMAScript well-known symbols we explicitly pass through without proxy wrapping. */ type WellKnownSymbols = - | "asyncIterator" - | "hasInstance" - | "isConcatSpreadable" - | "iterator" - | "match" - | "matchAll" - | "replace" - | "search" - | "species" - | "split" - | "toPrimitive" - | "toStringTag" - | "unscopables"; + | "asyncIterator" + | "hasInstance" + | "isConcatSpreadable" + | "iterator" + | "match" + | "matchAll" + | "replace" + | "search" + | "species" + | "split" + | "toPrimitive" + | "toStringTag" + | "unscopables"; diff --git a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts index 8d2d067..e15603c 100644 --- a/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts +++ b/sdk/js/alien-deepsignals/src/test/watchPatches.test.ts @@ -1,357 +1,424 @@ import { describe, it, expect } from "vitest"; import { - deepSignal, - setSetEntrySyntheticId, - addWithId, - DeepPatch, + deepSignal, + setSetEntrySyntheticId, + addWithId, + DeepPatch, } from "../deepSignal"; import { watch, observe } from "../watch"; describe("watch (patch mode)", () => { - it("emits set patches with correct paths and batching", async () => { - const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] }); - const received: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => { - received.push(patches); + it("emits set patches with correct paths and batching", async () => { + const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] }); + const received: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => { + received.push(patches); + }); + state.a.b = 2; + (state.arr[1] as any).x = 3; + state.arr.push(5); + await Promise.resolve(); + expect(received.length).toBe(1); + const batch = received[0]; + const paths = batch.map((p) => p.path.join(".")).sort(); + expect(paths).toContain("a.b"); + expect(paths).toContain("arr.1.x"); + expect(paths).toContain("arr.2"); + const addOps = batch.filter((p) => p.op === "add").length; + expect(addOps).toBe(batch.length); + stop(); }); - state.a.b = 2; - (state.arr[1] as any).x = 3; - state.arr.push(5); - await Promise.resolve(); - expect(received.length).toBe(1); - const batch = received[0]; - const paths = batch.map((p) => p.path.join(".")).sort(); - expect(paths).toContain("a.b"); - expect(paths).toContain("arr.1.x"); - expect(paths).toContain("arr.2"); - const addOps = batch.filter((p) => p.op === "add").length; - expect(addOps).toBe(batch.length); - stop(); - }); - it("emits delete patches without value", async () => { - const state = deepSignal<{ a: { b?: number }; c?: number }>({ - a: { b: 1 }, - c: 2, + it("emits delete patches without value", async () => { + const state = deepSignal<{ a: { b?: number }; c?: number }>({ + a: { b: 1 }, + c: 2, + }); + const out: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => + out.push(patches) + ); + delete state.a.b; + delete state.c; + await Promise.resolve(); + expect(out.length).toBe(1); + const [batch] = out; + const deletePatches = batch.filter((p) => p.op === "remove"); + const delPaths = deletePatches.map((p) => p.path.join(".")).sort(); + expect(delPaths).toEqual(["a.b", "c"]); + deletePatches.forEach((p: any) => expect(p.value).toBeUndefined()); + stop(); }); - const out: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => - out.push(patches) - ); - delete state.a.b; - delete state.c; - await Promise.resolve(); - expect(out.length).toBe(1); - const [batch] = out; - const deletePatches = batch.filter((p) => p.op === "remove"); - const delPaths = deletePatches.map((p) => p.path.join(".")).sort(); - expect(delPaths).toEqual(["a.b", "c"]); - deletePatches.forEach((p: any) => expect(p.value).toBeUndefined()); - stop(); - }); - it("observe patch mode mirrors watch patch mode", async () => { - const state = deepSignal({ a: 1 }); - const wp: DeepPatch[][] = []; - const ob: DeepPatch[][] = []; - const { stopListening: stop1 } = watch(state, ({ patches }) => - wp.push(patches) - ); - const { stopListening: stop2 } = observe(state, ({ patches }) => - ob.push(patches) - ); - state.a = 2; - await Promise.resolve(); - expect(wp.length).toBe(1); - expect(ob.length).toBe(1); - expect(wp[0][0].path.join(".")).toBe("a"); - stop1(); - stop2(); - }); + it("observe patch mode mirrors watch patch mode", async () => { + const state = deepSignal({ a: 1 }); + const wp: DeepPatch[][] = []; + const ob: DeepPatch[][] = []; + const { stopListening: stop1 } = watch(state, ({ patches }) => + wp.push(patches) + ); + const { stopListening: stop2 } = observe(state, ({ patches }) => + ob.push(patches) + ); + state.a = 2; + await Promise.resolve(); + expect(wp.length).toBe(1); + expect(ob.length).toBe(1); + expect(wp[0][0].path.join(".")).toBe("a"); + stop1(); + stop2(); + }); - it("filters out patches from other roots", async () => { - const a = deepSignal({ x: 1 }); - const b = deepSignal({ y: 2 }); - const out: DeepPatch[][] = []; - const { stopListening: stop } = watch(a, ({ patches }) => - out.push(patches) - ); - b.y = 3; - a.x = 2; - await Promise.resolve(); - expect(out.length).toBe(1); - expect(out[0][0].path.join(".")).toBe("x"); - stop(); - }); + it("filters out patches from other roots", async () => { + const a = deepSignal({ x: 1 }); + const b = deepSignal({ y: 2 }); + const out: DeepPatch[][] = []; + const { stopListening: stop } = watch(a, ({ patches }) => + out.push(patches) + ); + b.y = 3; + a.x = 2; + await Promise.resolve(); + expect(out.length).toBe(1); + expect(out[0][0].path.join(".")).toBe("x"); + stop(); + }); - it("emits patches for Set structural mutations (add/delete)", async () => { - const state = deepSignal<{ s: Set }>({ s: new Set([1, 2]) }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => - batches.push(patches) - ); - state.s.add(3); - state.s.delete(1); - await Promise.resolve(); - expect(batches.length >= 1).toBe(true); - const allPaths = batches.flatMap((b) => b.map((p) => p.path.join("."))); - expect(allPaths.some((p) => p.startsWith("s."))).toBe(true); - stop(); - }); + it("emits patches for Set structural mutations (add/delete)", async () => { + const state = deepSignal<{ s: Set }>({ s: new Set([1, 2]) }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => + batches.push(patches) + ); + state.s.add(3); + state.s.delete(1); + await Promise.resolve(); + expect(batches.length >= 1).toBe(true); + const allPaths = batches.flatMap((b) => b.map((p) => p.path.join("."))); + expect(allPaths.some((p) => p.startsWith("s."))).toBe(true); + stop(); + }); - it("emits patches for nested objects added after initialization", async () => { - const state = deepSignal<{ root: any }>({ root: {} }); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches: batch }) => - patches.push(batch) - ); - state.root.child = { level: { value: 1 } }; - state.root.child.level.value = 2; - await Promise.resolve(); - const flat = patches.flat().map((p) => p.path.join(".")); - expect(flat).toContain("root.child"); - expect(flat).toContain("root.child.level.value"); - stop(); - }); + it("emits patches for nested objects added after initialization", async () => { + const state = deepSignal<{ root: any }>({ root: {} }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + state.root.child = { level: { value: 1 }, l1: "val" }; + await Promise.resolve(); + const flat = patches.flat().map((p) => p.path.join(".")); + expect(flat).toContain("root.child"); + expect(flat).toContain("root.child.level.value"); + stop(); + }); - it("emits structural patches for sets of sets", async () => { - const innerA = new Set([{ id: "node1", x: 1 }]); - const s = new Set([innerA]); - const state = deepSignal<{ graph: Set }>({ graph: s }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => - batches.push(patches) - ); - const innerB = new Set([{ id: "node2", x: 5 }]); - state.graph.add(innerB); - ([...innerA][0] as any).x = 2; - await Promise.resolve(); - const pathStrings = batches.flat().map((p) => p.path.join(".")); - expect(pathStrings.some((p) => p.startsWith("graph."))).toBe(true); - stop(); - }); + it("emits patches for deeply nested arrays and objects", async () => { + const state = deepSignal<{ data: any }>({ data: null }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + state.data = { + users: [ + { + id: 1, + profile: { name: "Alice", settings: { theme: "dark" } }, + }, + { + id: 2, + profile: { name: "Bob", settings: { theme: "light" } }, + }, + ], + meta: { count: 2, active: true }, + }; + await Promise.resolve(); - it("tracks deep nested object mutation inside a Set entry after iteration", async () => { - const rawEntry = { id: "n1", data: { val: 1 } }; - const st = deepSignal({ bag: new Set([rawEntry]) }); - const collected: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - collected.push(patches) - ); - let proxied: any; - for (const e of st.bag.values()) { - proxied = e; - e.data.val; - } - proxied.data.val = 2; - await Promise.resolve(); - const flat = collected.flat().map((p: DeepPatch) => p.path.join(".")); - expect(flat.some((p: string) => p.endsWith("n1.data.val"))).toBe(true); - stop(); - }); + const flat = patches.flat().map((p) => p.path.join(".")); + // Check for root object + expect(flat).toContain("data"); + // Check for nested array + expect(flat).toContain("data.users"); + // Check for array elements + expect(flat).toContain("data.users.0"); + expect(flat).toContain("data.users.1"); + // Check for deeply nested properties + expect(flat).toContain("data.users.0.profile.settings.theme"); + expect(flat).toContain("data.users.1.profile.settings.theme"); + expect(flat).toContain("data.meta.count"); + expect(flat).toContain("data.meta.active"); + stop(); + }); - it("allows custom synthetic id for Set entry", async () => { - const node = { name: "x" }; - const state = deepSignal({ s: new Set() }); - const collected2: DeepPatch[][] = []; - const { stopListening: stop } = watch(state, ({ patches }) => - collected2.push(patches) - ); - addWithId(state.s as any, node, "custom123"); - await Promise.resolve(); - const flat = collected2.flat().map((p: DeepPatch) => p.path.join(".")); - expect(flat.some((p: string) => p === "s.custom123")).toBe(true); - stop(); - }); + it("emits patches for Set with nested objects added as one operation", async () => { + const state = deepSignal<{ container: any }>({ container: {} }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches: batch }) => + patches.push(batch) + ); + state.container.items = new Set([ + { id: "a", data: { nested: { value: 1 } } }, + { id: "b", data: { nested: { value: 2 } } }, + ]); + await Promise.resolve(); - describe("Set", () => { - it("emits single structural patch on Set.clear()", async () => { - const st = deepSignal({ s: new Set() }); - addWithId(st.s as any, { id: "a", x: 1 }, "a"); - addWithId(st.s as any, { id: "b", x: 2 }, "b"); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - st.s.clear(); - await Promise.resolve(); - const all = batches.flat().map((p) => p.path.join(".")); - expect(all).toEqual(["s"]); - stop(); - }); - it("emits delete patch for object entry", async () => { - const st = deepSignal({ s: new Set() }); - const obj = { id: "n1", x: 1 }; - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.add(obj); - st.s.delete(obj); - await Promise.resolve(); - const all = patches - .flat() - .filter((p) => p.op === "remove") - .map((p) => p.path.join(".")); - expect(all).toContain("s.n1"); - stop(); - }); - it("does not emit patch for duplicate add", async () => { - const st = deepSignal({ s: new Set([1]) }); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.add(1); - await Promise.resolve(); - expect(patches.length).toBe(0); - stop(); - }); - it("does not emit patch deleting non-existent entry", async () => { - const st = deepSignal({ s: new Set([1]) }); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.delete(2); - await Promise.resolve(); - expect(patches.length).toBe(0); - stop(); - }); - it("addWithId primitive returns primitive and emits patch with primitive key", async () => { - const st = deepSignal({ s: new Set() }); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - const ret = addWithId(st.s as any, 5, "ignored"); - expect(ret).toBe(5); - await Promise.resolve(); - const paths = patches.flat().map((p) => p.path.join(".")); - expect(paths).toContain("s.5"); - stop(); - }); - it("setSetEntrySyntheticId applies custom id without helper", async () => { - const st = deepSignal({ s: new Set() }); - const obj = { name: "x" }; - setSetEntrySyntheticId(obj, "customX"); - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.add(obj); - await Promise.resolve(); - const paths = patches.flat().map((p) => p.path.join(".")); - expect(paths).toContain("s.customX"); - stop(); - }); - it("values/entries/forEach proxy nested mutation", async () => { - const st = deepSignal({ s: new Set() }); - const entry = addWithId(st.s as any, { id: "e1", inner: { v: 1 } }, "e1"); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - for (const e of st.s.values()) { - e.inner.v; - } - entry.inner.v = 2; - await Promise.resolve(); - const vPaths = batches.flat().map((p) => p.path.join(".")); - expect(vPaths.some((p) => p.endsWith("e1.inner.v"))).toBe(true); - stop(); + const flat = patches.flat().map((p) => p.path.join(".")); + + // Check for the Set itself + expect(flat).toContain("container.items"); + // Check for Set entries (using their id as synthetic key) + expect(flat.some((p) => p.startsWith("container.items.a"))).toBe(true); + expect(flat.some((p) => p.startsWith("container.items.b"))).toBe(true); + // Check for deeply nested properties within Set entries + expect(flat).toContain("container.items.a.data.nested.value"); + expect(flat).toContain("container.items.b.data.nested.value"); + stop(); }); - it("raw reference mutation produces no deep patch while proxied does", async () => { - const raw = { id: "id1", data: { x: 1 } }; - const st = deepSignal({ s: new Set([raw]) }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - raw.data.x = 2; - await Promise.resolve(); - const afterRaw = batches.flat().map((p) => p.path.join(".")); - expect(afterRaw.some((p) => p.endsWith("id1.data.x"))).toBe(false); - let proxied: any; - for (const e of st.s.values()) proxied = e; - proxied.data.x = 3; - await Promise.resolve(); - const afterProxied = batches.flat().map((p) => p.path.join(".")); - expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe(true); - stop(); + + it("emits structural patches for sets of sets", async () => { + const innerA = new Set([{ id: "node1", x: 1 }]); + const s = new Set([innerA]); + const state = deepSignal<{ graph: Set }>({ graph: s }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => + batches.push(patches) + ); + const innerB = new Set([{ id: "node2", x: 5 }]); + state.graph.add(innerB); + ([...innerA][0] as any).x = 2; + await Promise.resolve(); + const pathStrings = batches.flat().map((p) => p.path.join(".")); + expect(pathStrings.some((p) => p.startsWith("graph."))).toBe(true); + stop(); }); - it("synthetic id collision assigns unique blank node id", async () => { - const st = deepSignal({ s: new Set() }); - const a1 = { id: "dup", v: 1 }; - const a2 = { id: "dup", v: 2 }; - const patches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches: batch }) => - patches.push(batch) - ); - st.s.add(a1); - st.s.add(a2); - await Promise.resolve(); - const keys = patches - .flat() - .filter((p) => p.op === "add") - .map((p) => p.path.slice(-1)[0]); - expect(new Set(keys).size).toBe(2); - stop(); + + it("tracks deep nested object mutation inside a Set entry after iteration", async () => { + const rawEntry = { id: "n1", data: { val: 1 } }; + const st = deepSignal({ bag: new Set([rawEntry]) }); + const collected: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + collected.push(patches) + ); + let proxied: any; + for (const e of st.bag.values()) { + proxied = e; + e.data.val; + } + proxied.data.val = 2; + await Promise.resolve(); + const flat = collected.flat().map((p: DeepPatch) => p.path.join(".")); + expect(flat.some((p: string) => p.endsWith("n1.data.val"))).toBe(true); + stop(); }); - it("allows Array.from() and spread on Set without brand errors and tracks nested mutation", async () => { - const st = deepSignal({ - s: new Set([{ id: "eIter", inner: { v: 1 } }]), - }); - // Regression: previously 'values method called on incompatible Proxy' was thrown here. - const arr = Array.from(st.s); - expect(arr.length).toBe(1); - expect(arr[0].inner.v).toBe(1); - const spread = [...st.s]; - expect(spread[0].inner.v).toBe(1); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - spread[0].inner.v = 2; // mutate nested field of iterated (proxied) entry - await Promise.resolve(); - const flat = batches.flat().map((p) => p.path.join(".")); - expect(flat.some((p) => p.endsWith("eIter.inner.v"))).toBe(true); - stop(); + it("allows custom synthetic id for Set entry", async () => { + const node = { name: "x" }; + const state = deepSignal({ s: new Set() }); + const collected2: DeepPatch[][] = []; + const { stopListening: stop } = watch(state, ({ patches }) => + collected2.push(patches) + ); + addWithId(state.s as any, node, "custom123"); + await Promise.resolve(); + const flat = collected2.flat().map((p: DeepPatch) => p.path.join(".")); + expect(flat.some((p: string) => p === "s.custom123")).toBe(true); + stop(); }); - }); - describe("Arrays & mixed batch", () => { - it("emits patches for splice/unshift/shift in single batch", async () => { - const st = deepSignal({ arr: [1, 2, 3] }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - st.arr.splice(1, 1, 99, 100); - st.arr.unshift(0); - st.arr.shift(); - await Promise.resolve(); - const paths = batches.flat().map((p) => p.path.join(".")); - expect(paths.some((p) => p.startsWith("arr."))).toBe(true); - stop(); + describe("Set", () => { + it("emits single structural patch on Set.clear()", async () => { + const st = deepSignal({ s: new Set() }); + addWithId(st.s as any, { id: "a", x: 1 }, "a"); + addWithId(st.s as any, { id: "b", x: 2 }, "b"); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.s.clear(); + await Promise.resolve(); + const all = batches.flat().map((p) => p.path.join(".")); + expect(all).toEqual(["s"]); + stop(); + }); + it("emits delete patch for object entry", async () => { + const st = deepSignal({ s: new Set() }); + const obj = { id: "n1", x: 1 }; + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.add(obj); + st.s.delete(obj); + await Promise.resolve(); + const all = patches + .flat() + .filter((p) => p.op === "remove") + .map((p) => p.path.join(".")); + expect(all).toContain("s.n1"); + stop(); + }); + it("does not emit patch for duplicate add", async () => { + const st = deepSignal({ s: new Set([1]) }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.add(1); + await Promise.resolve(); + expect(patches.length).toBe(0); + stop(); + }); + it("does not emit patch deleting non-existent entry", async () => { + const st = deepSignal({ s: new Set([1]) }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.delete(2); + await Promise.resolve(); + expect(patches.length).toBe(0); + stop(); + }); + it("addWithId primitive returns primitive and emits patch with primitive key", async () => { + const st = deepSignal({ s: new Set() }); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + const ret = addWithId(st.s as any, 5, "ignored"); + expect(ret).toBe(5); + await Promise.resolve(); + const paths = patches.flat().map((p) => p.path.join(".")); + expect(paths).toContain("s.5"); + stop(); + }); + it("setSetEntrySyntheticId applies custom id without helper", async () => { + const st = deepSignal({ s: new Set() }); + const obj = { name: "x" }; + setSetEntrySyntheticId(obj, "customX"); + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.add(obj); + await Promise.resolve(); + const paths = patches.flat().map((p) => p.path.join(".")); + expect(paths).toContain("s.customX"); + stop(); + }); + it("values/entries/forEach proxy nested mutation", async () => { + const st = deepSignal({ s: new Set() }); + const entry = addWithId( + st.s as any, + { id: "e1", inner: { v: 1 } }, + "e1" + ); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + for (const e of st.s.values()) { + e.inner.v; + } + entry.inner.v = 2; + await Promise.resolve(); + const vPaths = batches.flat().map((p) => p.path.join(".")); + expect(vPaths.some((p) => p.endsWith("e1.inner.v"))).toBe(true); + stop(); + }); + it("raw reference mutation produces no deep patch while proxied does", async () => { + const raw = { id: "id1", data: { x: 1 } }; + const st = deepSignal({ s: new Set([raw]) }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + raw.data.x = 2; + await Promise.resolve(); + const afterRaw = batches.flat().map((p) => p.path.join(".")); + expect(afterRaw.some((p) => p.endsWith("id1.data.x"))).toBe(false); + let proxied: any; + for (const e of st.s.values()) proxied = e; + proxied.data.x = 3; + await Promise.resolve(); + const afterProxied = batches.flat().map((p) => p.path.join(".")); + expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe( + true + ); + stop(); + }); + it("synthetic id collision assigns unique blank node id", async () => { + const st = deepSignal({ s: new Set() }); + const a1 = { id: "dup", v: 1 }; + const a2 = { id: "dup", v: 2 }; + const patches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches: batch }) => + patches.push(batch) + ); + st.s.add(a1); + st.s.add(a2); + await Promise.resolve(); + const keys = patches + .flat() + .filter((p) => p.op === "add") + .map((p) => p.path.slice(-1)[0]); + expect(new Set(keys).size).toBe(2); + stop(); + }); + + it("allows Array.from() and spread on Set without brand errors and tracks nested mutation", async () => { + const st = deepSignal({ + s: new Set([{ id: "eIter", inner: { v: 1 } }]), + }); + // Regression: previously 'values method called on incompatible Proxy' was thrown here. + const arr = Array.from(st.s); + expect(arr.length).toBe(1); + expect(arr[0].inner.v).toBe(1); + const spread = [...st.s]; + expect(spread[0].inner.v).toBe(1); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + spread[0].inner.v = 2; // mutate nested field of iterated (proxied) entry + await Promise.resolve(); + const flat = batches.flat().map((p) => p.path.join(".")); + expect(flat.some((p) => p.endsWith("eIter.inner.v"))).toBe(true); + stop(); + }); }); - it("mixed object/array/Set mutations batch together", async () => { - const st = deepSignal({ o: { a: 1 }, arr: [1], s: new Set() }); - const batches: DeepPatch[][] = []; - const { stopListening: stop } = watch(st, ({ patches }) => - batches.push(patches) - ); - st.o.a = 2; - st.arr.push(2); - addWithId(st.s as any, { id: "z", v: 1 }, "z"); - await Promise.resolve(); - expect(batches.length).toBe(1); - const paths = batches[0].map((p) => p.path.join(".")); - expect(paths).toContain("o.a"); - expect(paths).toContain("arr.1"); - expect(paths.some((p) => p.startsWith("s."))).toBe(true); - stop(); + + describe("Arrays & mixed batch", () => { + it("emits patches for splice/unshift/shift in single batch", async () => { + const st = deepSignal({ arr: [1, 2, 3] }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.arr.splice(1, 1, 99, 100); + st.arr.unshift(0); + st.arr.shift(); + await Promise.resolve(); + const paths = batches.flat().map((p) => p.path.join(".")); + expect(paths.some((p) => p.startsWith("arr."))).toBe(true); + stop(); + }); + it("mixed object/array/Set mutations batch together", async () => { + const st = deepSignal({ o: { a: 1 }, arr: [1], s: new Set() }); + const batches: DeepPatch[][] = []; + const { stopListening: stop } = watch(st, ({ patches }) => + batches.push(patches) + ); + st.o.a = 2; + st.arr.push(2); + addWithId(st.s as any, { id: "z", v: 1 }, "z"); + await Promise.resolve(); + expect(batches.length).toBe(1); + const paths = batches[0].map((p) => p.path.join(".")); + expect(paths).toContain("o.a"); + expect(paths).toContain("arr.1"); + expect(paths.some((p) => p.startsWith("s."))).toBe(true); + stop(); + }); }); - }); });