Fork of https://github.com/CCherry07/alien-deepsignals ported to alien-signals v2 with support for per-value modification tracking.
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
809 lines
30 KiB
809 lines
30 KiB
import { ReactiveFlags } from "./contents";
|
|
import { computed, signal, isSignal } from "./core";
|
|
/**
|
|
* Implementation overview (supplementary to header docs):
|
|
*
|
|
* Data structures:
|
|
* - proxyToSignals: WeakMap<proxy, Map<prop, signalFn>> maps each proxied object to its per-key signal.
|
|
* - objToProxy: WeakMap<rawObject, proxy> ensures stable proxy instance per raw object.
|
|
* - arrayToArrayOfSignals: WeakMap<array, proxy> holds special `$` array meta proxy (index signals + length).
|
|
* - ignore: WeakSet marks objects already proxied or shallow-wrapped to avoid double proxying.
|
|
* - objToIterable: WeakMap<object, signal<number>> used to re-trigger enumeration dependent computed/effects.
|
|
* - proxyMeta: WeakMap<proxy, { parent, key, root }> chain info for patch path reconstruction.
|
|
*
|
|
* Key decisions:
|
|
* - Signals are created lazily: first read of value or its `$` accessor.
|
|
* - Getter properties become computed signals (readonly) so derived values stay consistent.
|
|
* - Setting via `$prop` enforces passing a signal (allows external signal assignment path).
|
|
* - Deep patches reconstruct path on mutation (O(depth)); no bookkeeping per property upfront.
|
|
* - Arrays use `$` (returns array-of-signals proxy) & `$length` (length signal) while skipping function-valued entries.
|
|
*
|
|
* Performance characteristics:
|
|
* - Read of untouched property: ~O(1) creating one signal + potential child proxy.
|
|
* - Mutation: signal update + optional patch path build; batching coalesces multiple patches in same microtask.
|
|
* - Enumeration tracking: ownKeys/for..in increments a dedicated counter signal (objToIterable) to invalidate dependents.
|
|
*/
|
|
/**
|
|
* deepSignal()
|
|
* =====================================================================
|
|
* Core idea: Wrap a plain object/array tree in a Proxy that lazily creates
|
|
* per-property signals on first access. Property access without a `$` prefix
|
|
* returns the underlying value (tracking the read). Access with `$` (e.g. obj.$foo)
|
|
* returns the signal function itself. Arrays have special `$` for index signals and
|
|
* `$length` for the length signal.
|
|
*
|
|
* Why function signals? Native alien-signals v2 returns a function you call to read,
|
|
* call with an argument to write. We keep that but provide `.value` in tagging layer.
|
|
*
|
|
* Getter logic summary:
|
|
* 1. Determine if the caller wants a signal (prefix `$` or array meta) vs value.
|
|
* 2. If property is a getter on the original object, wrap it in a computed signal.
|
|
* 3. Otherwise create (once) a writable signal seeded with (possibly proxied) child value.
|
|
* 4. Return either the signal function or its invocation (value) depending on caller form.
|
|
*
|
|
* Setter logic summary:
|
|
* - Writes update the raw target via Reflect.set.
|
|
* - If the property signal already exists, update it; otherwise create it.
|
|
* - Array length & object key enumeration signals are nudged (length / ownKeys tracking).
|
|
* - A deep mutation patch is queued capturing: root id, type, path, new value.
|
|
*
|
|
* Patch batching:
|
|
* - Mutations push a patch into a per-microtask buffer (pendingPatches).
|
|
* - A queued microtask flush delivers the accumulated array to each subscriber.
|
|
* - This enables consumers (framework adapters) to materialize minimal changes.
|
|
*
|
|
* Metadata chain (proxyMeta): parent + key + root symbol; used to reconstruct full path
|
|
* for patches without storing full paths on every node.
|
|
*/
|
|
/**
|
|
* Deep mutation patch system (for Svelte rune integration / granular updates)
|
|
* -------------------------------------------------------------------------
|
|
* This augmention adds an optional patch stream that reports every deep mutation
|
|
* (set/delete) with a property path relative to the root deepSignal() object.
|
|
*
|
|
* Rationale: The core library already has per-property signals and is efficient
|
|
* for effect re-execution. However, consumers that want to mirror the entire
|
|
* nested structure into another reactive container (e.g. Svelte $state) gain
|
|
* from receiving a batch of fine-grained patches instead of re-cloning.
|
|
*
|
|
* Design:
|
|
* 1. Each proxy created by deepSignal has lightweight metadata (parent, key, root id).
|
|
* 2. On each mutation (set/delete) we reconstruct the path by walking parents.
|
|
* 3. Patches are batched in a microtask and delivered to subscribers.
|
|
* 4. Zero cost for projects that never call subscribeDeepMutations(): only minimal
|
|
* metadata storage and O(depth) path build per mutation.
|
|
*/
|
|
|
|
/**
|
|
* A granular description of a deep mutation originating from a {@link deepSignal} root.
|
|
* Patches are batched per microtask and delivered in order of occurrence.
|
|
*
|
|
* Invariants:
|
|
* - `path` is never empty (the root object itself is not represented by a patch without a key)
|
|
* - For `type === "delete"` the `value` field is omitted
|
|
* - For `type === "set"` the `value` is the post‑mutation (proxied if object/array/set) value snapshot
|
|
*/
|
|
export interface DeepPatch {
|
|
/** Unique identifier for the deep signal root which produced this patch. */
|
|
root: symbol;
|
|
/** Mutation kind applied at the resolved `path`. */
|
|
type: "set" | "delete";
|
|
/** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */
|
|
path: (string | number)[];
|
|
/** New value for `set` mutations (omitted for `delete`). */
|
|
value?: any;
|
|
}
|
|
|
|
/** Function signature for subscribers passed to {@link subscribeDeepMutations}. */
|
|
export type DeepPatchSubscriber = (patches: DeepPatch[]) => void;
|
|
|
|
/**
|
|
* Lightweight metadata stored per proxy enabling reconstruction of a property's full path
|
|
* at mutation time without eager bookkeeping of every descendant.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/** Internal lookup from proxy -> {@link ProxyMeta}. */
|
|
const proxyMeta = new WeakMap<object, ProxyMeta>();
|
|
/** Global registry of batch mutation subscribers (filtered per root at delivery time). */
|
|
const mutationSubscribers = new Set<DeepPatchSubscriber>();
|
|
let pendingPatches: DeepPatch[] | null = null;
|
|
let microtaskScheduled = false;
|
|
|
|
/** Sentinal constant for root id retrieval (exported for external helpers). */
|
|
/**
|
|
* Sentinel symbol used internally / by helpers to retrieve a deepSignal root id.
|
|
* You normally obtain a concrete root id via {@link getDeepSignalRootId} on a proxy instance.
|
|
*/
|
|
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 = [];
|
|
pendingPatches.push(patch);
|
|
if (!microtaskScheduled) {
|
|
microtaskScheduled = true;
|
|
queueMicrotask(() => {
|
|
microtaskScheduled = false;
|
|
const batch = pendingPatches;
|
|
pendingPatches = null;
|
|
if (!batch || batch.length === 0) return;
|
|
mutationSubscribers.forEach((sub) => sub(batch));
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a mutation batch listener for all active deepSignal roots.
|
|
*
|
|
* Each microtask in which one or more deep mutations occur produces at most one callback
|
|
* invocation containing the ordered list of {@link DeepPatch} objects.
|
|
*
|
|
* @param sub Callback receiving a batch array (never empty) of deep patches.
|
|
* @returns An unsubscribe function that detaches the listener when invoked.
|
|
*/
|
|
export function subscribeDeepMutations(sub: DeepPatchSubscriber): () => void {
|
|
mutationSubscribers.add(sub);
|
|
return () => mutationSubscribers.delete(sub);
|
|
}
|
|
|
|
/**
|
|
* Obtain the stable root id symbol for a given deepSignal proxy (or any nested proxy).
|
|
* Returns `undefined` if the value is not a deepSignal-managed proxy.
|
|
*
|
|
* @example
|
|
* const state = deepSignal({ a: { b: 1 } });
|
|
* const id1 = getDeepSignalRootId(state); // symbol
|
|
* const id2 = getDeepSignalRootId(state.a); // same symbol as id1
|
|
* getDeepSignalRootId({}) // undefined
|
|
*/
|
|
export function getDeepSignalRootId(obj: any): symbol | undefined {
|
|
return proxyMeta.get(obj)?.root;
|
|
}
|
|
|
|
// Proxy -> Map of property name -> signal function
|
|
/** Proxy instance -> map of property name -> signal function (created lazily). */
|
|
const proxyToSignals = new WeakMap();
|
|
// Raw object/array/Set -> its stable proxy
|
|
const objToProxy = new WeakMap();
|
|
// Raw array -> special `$` proxy giving index signals
|
|
const arrayToArrayOfSignals = new WeakMap();
|
|
// Objects already proxied or intentionally shallow
|
|
const ignore = new WeakSet();
|
|
// Object -> signal counter used for key enumeration invalidation
|
|
const objToIterable = new WeakMap();
|
|
const rg = /^\$/;
|
|
const descriptor = Object.getOwnPropertyDescriptor;
|
|
let peeking = false;
|
|
|
|
// (Synthetic ID helper declarations were restored further below before usage.)
|
|
/**
|
|
* Deep array interface expressed as a type intersection to avoid structural extend
|
|
* incompatibilities with the native Array while still refining callback parameter
|
|
* types to their DeepSignal forms.
|
|
*/
|
|
type DeepArray<T> = Array<T> & {
|
|
map: <U>(
|
|
callbackfn: (
|
|
value: DeepSignal<T>,
|
|
index: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => U,
|
|
thisArg?: any
|
|
) => U[];
|
|
forEach: (
|
|
callbackfn: (
|
|
value: DeepSignal<T>,
|
|
index: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => void,
|
|
thisArg?: any
|
|
) => void;
|
|
concat(...items: ConcatArray<T>[]): DeepSignalArray<T[]>;
|
|
concat(...items: (T | ConcatArray<T>)[]): DeepSignalArray<T[]>;
|
|
reverse(): DeepSignalArray<T[]>;
|
|
shift(): DeepSignal<T> | undefined;
|
|
slice(start?: number, end?: number): DeepSignalArray<T[]>;
|
|
splice(start: number, deleteCount?: number): DeepSignalArray<T[]>;
|
|
splice(
|
|
start: number,
|
|
deleteCount: number,
|
|
...items: T[]
|
|
): DeepSignalArray<T[]>;
|
|
filter<S extends T>(
|
|
predicate: (
|
|
value: DeepSignal<T>,
|
|
index: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => value is DeepSignal<S>,
|
|
thisArg?: any
|
|
): DeepSignalArray<S[]>;
|
|
filter(
|
|
predicate: (
|
|
value: DeepSignal<T>,
|
|
index: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => unknown,
|
|
thisArg?: any
|
|
): DeepSignalArray<T[]>;
|
|
reduce(
|
|
callbackfn: (
|
|
previousValue: DeepSignal<T>,
|
|
currentValue: DeepSignal<T>,
|
|
currentIndex: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => T
|
|
): DeepSignal<T>;
|
|
reduce(
|
|
callbackfn: (
|
|
previousValue: DeepSignal<T>,
|
|
currentValue: DeepSignal<T>,
|
|
currentIndex: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => DeepSignal<T>,
|
|
initialValue: T
|
|
): DeepSignal<T>;
|
|
reduce<U>(
|
|
callbackfn: (
|
|
previousValue: U,
|
|
currentValue: DeepSignal<T>,
|
|
currentIndex: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => U,
|
|
initialValue: U
|
|
): U;
|
|
reduceRight(
|
|
callbackfn: (
|
|
previousValue: DeepSignal<T>,
|
|
currentValue: DeepSignal<T>,
|
|
currentIndex: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => T
|
|
): DeepSignal<T>;
|
|
reduceRight(
|
|
callbackfn: (
|
|
previousValue: DeepSignal<T>,
|
|
currentValue: DeepSignal<T>,
|
|
currentIndex: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => DeepSignal<T>,
|
|
initialValue: T
|
|
): DeepSignal<T>;
|
|
reduceRight<U>(
|
|
callbackfn: (
|
|
previousValue: U,
|
|
currentValue: DeepSignal<T>,
|
|
currentIndex: number,
|
|
array: DeepSignalArray<T[]>
|
|
) => U,
|
|
initialValue: U
|
|
): U;
|
|
};
|
|
// --- Synthetic ID helpers & ergonomics for Set entry patching (restored) ---
|
|
let __blankNodeCounter = 0;
|
|
/** User or auto-assigned synthetic id bookkeeping for Set entry objects. */
|
|
const setObjectIds = new WeakMap<object, string>();
|
|
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 identifier for an object prior to 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;
|
|
};
|
|
/**
|
|
* Insert into a (possibly proxied) Set with a desired synthetic id; returns proxied entry (objects) or primitive.
|
|
*/
|
|
export function addWithId<T extends object>(
|
|
set: Set<T>,
|
|
entry: T,
|
|
id: string | number
|
|
): DeepSignal<T>;
|
|
export function addWithId<T>(set: Set<T>, entry: T, id: string | number): T;
|
|
export function addWithId(set: Set<any>, 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;
|
|
}
|
|
|
|
/** Determine whether a given value is a deepSignal-managed proxy (any depth). */
|
|
export const isDeepSignal = (source: any) => {
|
|
return proxyToSignals.has(source);
|
|
};
|
|
|
|
/** Predicate indicating whether a value was explicitly marked via {@link shallow}. */
|
|
export const isShallow = (source: any) => {
|
|
return ignore.has(source);
|
|
};
|
|
|
|
/**
|
|
* Create (or retrieve) a deep reactive proxy for the supplied plain object / array / Set.
|
|
* All nested objects / arrays / Sets are wrapped lazily on first access; primitives are passed through.
|
|
*
|
|
* Root identity: multiple invocations with the same object return the same proxy; each distinct root
|
|
* owns a unique symbol id available via {@link getDeepSignalRootId} and present on every emitted patch.
|
|
*
|
|
*/
|
|
export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => {
|
|
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<T>;
|
|
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 a property on a deepSignal proxy without establishing reactive tracking.
|
|
* Equivalent conceptually to a non-tracking read / untracked() pattern.
|
|
*
|
|
* @param obj deepSignal proxy object
|
|
* @param key property key to read
|
|
* @returns The raw (possibly proxied) property value without dependency collection.
|
|
*/
|
|
export const peek = <
|
|
T extends DeepSignalObject<object>,
|
|
K extends keyof RevertDeepSignalObject<T>
|
|
>(
|
|
obj: T,
|
|
key: K
|
|
): RevertDeepSignal<RevertDeepSignalObject<T>[K]> => {
|
|
peeking = true;
|
|
const value = obj[key];
|
|
try {
|
|
peeking = false;
|
|
} catch (e) {}
|
|
return value as RevertDeepSignal<RevertDeepSignalObject<T>[K]>;
|
|
};
|
|
|
|
const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW);
|
|
/**
|
|
* Mark an object so that it will not be deeply proxied when used as a property value of
|
|
* another deepSignal tree. This is a shallow escape hatch for performance or semantic reasons.
|
|
*
|
|
* NOTE: The returned object itself is not reactive; only top-level assignment of the reference
|
|
* produces patches when attached to a deepSignal structure.
|
|
*/
|
|
export function shallow<T extends object>(obj: T): Shallow<T> {
|
|
ignore.add(obj);
|
|
return obj as Shallow<T>;
|
|
}
|
|
|
|
const createProxy = (
|
|
target: object,
|
|
handlers: ProxyHandler<object>,
|
|
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 throwOnMutation = () => {
|
|
throw new Error(
|
|
"Don't mutate the signals directly (use the underlying property/value instead)."
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Unified get trap factory.
|
|
* @param isArrayOfSignals indicates we are resolving properties on the special array `$` proxy.
|
|
*/
|
|
const get =
|
|
(isArrayOfSignals: boolean) =>
|
|
(target: object, fullKey: string, receiver: object): unknown => {
|
|
if (peeking) return Reflect.get(target, fullKey, receiver);
|
|
let returnSignal = isArrayOfSignals || fullKey[0] === "$";
|
|
// Special handling for Set instances: treat as atomic & emit structural + per-entry patches
|
|
if (target instanceof Set && typeof fullKey === "string") {
|
|
const raw = target as Set<any>;
|
|
const key = fullKey;
|
|
const meta = proxyMeta.get(receiver);
|
|
// Helper to proxy a single entry
|
|
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
|
|
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;
|
|
}
|
|
queuePatch({
|
|
root: metaNow.root,
|
|
type: "set",
|
|
path: [...containerPath, synthetic],
|
|
value: entryVal,
|
|
});
|
|
} else if (key === "delete") {
|
|
const entry = args[0];
|
|
const synthetic = getSetEntryKey(entry);
|
|
queuePatch({
|
|
root: metaNow.root,
|
|
type: "delete",
|
|
path: [...containerPath, synthetic],
|
|
});
|
|
} else if (key === "clear") {
|
|
queuePatch({
|
|
root: metaNow.root,
|
|
type: "set",
|
|
path: containerPath,
|
|
value: raw,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
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<any>;
|
|
};
|
|
};
|
|
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 = Reflect.get(raw, key, raw);
|
|
if (typeof val === "function") return val.bind(raw);
|
|
return val;
|
|
}
|
|
if (!isArrayOfSignals && returnSignal && Array.isArray(target)) {
|
|
if (fullKey === "$") {
|
|
if (!arrayToArrayOfSignals.has(target))
|
|
arrayToArrayOfSignals.set(
|
|
target,
|
|
createProxy(
|
|
target,
|
|
arrayHandlers,
|
|
proxyMeta.get(receiver)?.root // propagate root id to $ array proxy
|
|
)
|
|
);
|
|
return arrayToArrayOfSignals.get(target);
|
|
}
|
|
returnSignal = fullKey === "$length";
|
|
}
|
|
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); // allocate map lazily
|
|
const signals = proxyToSignals.get(receiver);
|
|
const key = returnSignal ? fullKey.replace(rg, "") : fullKey;
|
|
if (
|
|
!signals.has(key) &&
|
|
typeof descriptor(target, key)?.get === "function"
|
|
) {
|
|
signals.set(
|
|
key,
|
|
computed(() => Reflect.get(target, key, receiver))
|
|
);
|
|
} else {
|
|
let value = Reflect.get(target, key, receiver);
|
|
if (returnSignal && typeof value === "function") return; // functions never wrapped as signals
|
|
if (typeof key === "symbol" && wellKnownSymbols.has(key)) return value;
|
|
if (!signals.has(key)) {
|
|
if (shouldProxy(value)) {
|
|
if (!objToProxy.has(value)) {
|
|
// Child object/array lazily wrapped: link to parent for path reconstruction.
|
|
const parentMeta = proxyMeta.get(receiver)!;
|
|
const childProxy = createProxy(
|
|
value,
|
|
objectHandlers,
|
|
parentMeta.root
|
|
);
|
|
const childMeta = proxyMeta.get(childProxy)!;
|
|
childMeta.parent = receiver;
|
|
childMeta.key = key as string;
|
|
objToProxy.set(value, childProxy);
|
|
}
|
|
value = objToProxy.get(value);
|
|
}
|
|
signals.set(key, signal(value));
|
|
}
|
|
}
|
|
// deep getter: function signals are callable; non-signal access returns current value.
|
|
// We intentionally return the raw function (signal) when `$`-prefixed so callers can set `.value` or invoke.
|
|
const sig = signals.get(key);
|
|
return returnSignal ? sig : sig();
|
|
};
|
|
|
|
// Handlers for standard object/array (non `$` array meta proxy)
|
|
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) {
|
|
queuePatch({
|
|
root: meta.root,
|
|
type: "set",
|
|
path: buildPath(receiver, fullKey),
|
|
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,
|
|
type: "delete",
|
|
path: buildPath(receiverProxy, key),
|
|
});
|
|
}
|
|
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);
|
|
},
|
|
};
|
|
|
|
// Handlers for special `$` proxy wrapping an array (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")
|
|
);
|
|
// Support Set so structural mutations can emit patches (Map still unsupported 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. Functions and values marked with {@link Shallow}
|
|
* are passed through untouched; arrays and plain objects become recursively deep-signal aware;
|
|
* Sets are proxied so structural & deep entry mutations emit patches.
|
|
*/
|
|
export type DeepSignal<T> = T extends Function
|
|
? T
|
|
: T extends { [shallowFlag]: true }
|
|
? T
|
|
: T extends Array<unknown>
|
|
? DeepSignalArray<T>
|
|
: T extends object
|
|
? DeepSignalObject<T>
|
|
: T;
|
|
|
|
/** Recursive mapped type converting an object graph into its deepSignal proxy shape. */
|
|
export type DeepSignalObject<T extends object> = {
|
|
[P in keyof T & string as `$${P}`]?: T[P] extends Function
|
|
? never
|
|
: ReturnType<typeof signal<T[P]>>;
|
|
} & {
|
|
[P in keyof T]: DeepSignal<T[P]>;
|
|
};
|
|
|
|
/** Extract element type from an array. */
|
|
type ArrayType<T> = T extends Array<infer I> ? I : T;
|
|
/** DeepSignal-enhanced array type (numeric indices & `$` meta accessors). */
|
|
type DeepSignalArray<T> = DeepArray<ArrayType<T>> & {
|
|
[key: number]: DeepSignal<ArrayType<T>>;
|
|
$?: { [key: number]: ReturnType<typeof signal<ArrayType<T>>> };
|
|
$length?: ReturnType<typeof signal<number>>;
|
|
};
|
|
|
|
/** Marker utility type for objects passed through without deep proxying. */
|
|
export type Shallow<T extends object> = T & { [shallowFlag]: true };
|
|
|
|
/** Framework adapter hook (declared for consumers) that returns a {@link DeepSignal} proxy. */
|
|
export declare const useDeepSignal: <T extends object>(obj: T) => DeepSignal<T>;
|
|
// @ts-ignore
|
|
/** Utility: strip `$`-prefixed synthetic signal accessors from key union. */
|
|
type FilterSignals<K> = K extends `$${string}` ? never : K;
|
|
/** Reverse of {@link DeepSignalObject}: remove signal accessors to obtain original object shape. */
|
|
type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>;
|
|
/** Reverse of {@link DeepSignalArray}: omit meta accessors. */
|
|
type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">;
|
|
|
|
/** Inverse mapped type that removes deepSignal wrapper affordances (`$` accessors). */
|
|
export type RevertDeepSignal<T> = T extends Array<unknown>
|
|
? RevertDeepSignalArray<T>
|
|
: T extends object
|
|
? RevertDeepSignalObject<T>
|
|
: 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";
|
|
|