import { ReactiveFlags } from "./contents"; import { computed, signal, isSignal } from "./core"; /** * deepSignal: wrap an object / array / Set graph in lazy per-property signals plus an optional deep patch stream. * - `$prop` returns a signal; plain prop returns its current value. * - Getter props become computed signals. * - Arrays expose `$` (index signals) & `$length`; Sets emit structural entry patches with synthetic ids. * - subscribeDeepMutations(root, cb) batches set/delete ops per microtask (DeepPatch[]). * - shallow(obj) skips deep proxying of a subtree. */ /** 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)[]; } & ( | 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 }; } 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; } export interface DeepObjectAddPatch { /** Mutation kind applied at the resolved `path`. */ op: "add"; type: "object"; } export interface DeepRemovePatch { /** 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; } /** Callback signature for subscribeDeepMutations. */ 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; } // Proxy -> metadata const proxyMeta = new WeakMap(); // Root symbol -> subscribers const mutationSubscribers = new Map>(); // Pending patches grouped per root (flushed once per microtask) let pendingPatches: Map | null = null; let microtaskScheduled = false; /** Sentinel symbol; get concrete root id via getDeepSignalRootId(proxy). */ export const DEEP_SIGNAL_ROOT_ID = Symbol("alienDeepSignalRootId"); function buildPath( 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; } 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)); } }); } } /** Subscribe to microtask-batched deep patches for a root (returns unsubscribe). */ export function subscribeDeepMutations( 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); }; } /** 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; } // Proxy -> Map of property name -> signal function /** Proxy -> Map (lazy). */ const proxyToSignals = new WeakMap(); // Raw object/array/Set -> stable proxy const objToProxy = new WeakMap(); // Raw array -> `$` meta proxy with index signals const arrayToArrayOfSignals = new WeakMap(); // Objects already proxied or marked shallow const ignore = new WeakSet(); // Object -> signal counter for enumeration invalidation const objToIterable = new WeakMap(); const rg = /^\$/; const descriptor = Object.getOwnPropertyDescriptor; 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; }; // 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; }; /** Assign (or override) synthetic id before Set.add(). */ export function setSetEntrySyntheticId(obj: object, id: string | number) { 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; }; /** Add entry with synthetic id; returns proxied object if applicable. */ export function addWithId( 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; } /** Is value a deepSignal-managed proxy? */ export const isDeepSignal = (source: any) => { return proxyToSignals.has(source); }; /** Was value explicitly marked shallow? */ export const isShallow = (source: any) => { 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); }; /** Read property without tracking (untracked read). */ export const peek = < T extends DeepSignalObject, K extends keyof RevertDeepSignalObject >( obj: T, key: K ): 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; } const createProxy = ( 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; }; // Set-specific access & structural patch emission. function getFromSet(raw: Set, key: string, 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); 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 === "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 (key === Symbol.iterator.toString()) { // string form access of iterator symbol; pass through } 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)." ); }; // Does target define a getter for key? function hasGetter(target: any, key: any) { 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)!; } // 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); } // Normalize raw property key (handles $-prefix & array meta) -> { key, returnSignal } function normalizeKey( 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) }; } returnSignal = fullKey === "$length"; } 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 ) { 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 && typeof fullKey === "string") { return getFromSet(target as Set, fullKey, 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(); }; // 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. const parentMeta = proxyMeta.get(receiver)!; 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", }); } else { queuePatch({ root: meta.root, path: buildPath(receiver, fullKey), op: "add", value: val, }); } } 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); }, }; // Array `$` meta proxy handlers (index signals only) const arrayHandlers = { 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") ); // 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); }; /** 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; /** 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]: 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>; }; /** Marker utility type for objects passed through without deep proxying. */ export type Shallow = T & { [shallowFlag]: true }; /** Framework adapter hook returning a DeepSignal proxy. */ export declare const useDeepSignal: (obj: T) => DeepSignal; // @ts-ignore // Strip `$`-prefixed synthetic signal accessors from key union. type FilterSignals = K extends `$${string}` ? never : K; /** Reverse of DeepSignalObject: remove signal accessors to obtain original object shape. */ type RevertDeepSignalObject = Pick>; /** Reverse of DeepSignalArray: omit meta accessors. */ type RevertDeepSignalArray = Omit; /** Inverse mapped type removing deepSignal wrapper affordances. */ 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";