import { IIndexable, isArray, isFunction, isMap, isObject, isSet, isSymbol } from '@aurelia/kernel'; import { Collection, IConnectable } from './interfaces'; import { rtObjectFreeze, rtSafeString } from './utilities'; import { connecting, currentConnectable, _currentConnectable } from './connectable-switcher'; import { astTrackableMethodMarker } from './ast.eval'; const R$get = Reflect.get; const toStringTag = Object.prototype.toString; const proxyMap = new WeakMap(); type TrackableFunctionOptions = { deps?: string[] | ((instance: unknown) => unknown); }; type TrackableFunction = ((...args: unknown[]) => unknown) & { [astTrackableMethodMarker]?: TrackableFunctionOptions; }; /** @internal */ export const nowrapClassKey = '__au_nw__'; /** @internal */ export const nowrapPropKey = '__au_nw'; function canWrap(obj: unknown): obj is object { switch (toStringTag.call(obj)) { case '[object Object]': // enable inheritance decoration return ((obj as object).constructor as IIndexable<() => unknown>)[nowrapClassKey] !== true; case '[object Array]': case '[object Map]': case '[object Set]': // it's unlikely that methods on the following 2 objects need to be observed for changes // so while they are valid/ we don't wrap them either // case '[object Math]': // case '[object Reflect]': return true; default: return false; } } /** @internal */ export const rawKey = '__raw__'; /** @internal */ export function wrap(v: T): T { return canWrap(v) ? getProxy(v) : v; } /** @internal */ export function getProxy(obj: T): T { // deepscan-disable-next-line return proxyMap.get(obj) as T ?? createProxy(obj); } /** @internal */ export function getRaw(obj: T): T { // todo: get in a weakmap if null/undef return (obj as IIndexable)[rawKey] as T ?? obj; } /** @internal */ export function unwrap(v: T): T { // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions return canWrap(v) && (v as IIndexable)[rawKey] as T || v; } const builtInSymbols = new Set(Object.getOwnPropertyNames(Symbol).map(k => Symbol[k as keyof typeof Symbol]).filter(s => isSymbol(s) && s !== Symbol.iterator)); function doNotCollect(object: object, key: PropertyKey): boolean { if (key === 'constructor' || key === '__proto__' // probably should revert to v1 naming style for consistency with builtin? // __o__ is shorters & less chance of conflict with other libs as well || key === '$observers' || isSymbol(key) && builtInSymbols.has(key) // limit to string first // symbol can be added later // looking up from the constructor means inheritance is supported || (object.constructor as IIndexable<() => unknown>)[`${nowrapPropKey}_${rtSafeString(key)}__`] === true ) { return true; } // ensure Proxy spec compliance, value of non-configurable and non-writable property MUST be returned as is // https://262.ecma-international.org/8.0/#sec-proxy-object-internal-methods-and-internal-slots-get-p-receiver const descriptor = Reflect.getOwnPropertyDescriptor(object, key); return descriptor?.configurable === false && descriptor.writable === false; } function createProxy(obj: T): T { const handler: ProxyHandler = isArray(obj) ? arrayHandler : isMap(obj) || isSet(obj) ? collectionHandler : objectHandler; const proxiedObj = new Proxy(obj, handler); proxyMap.set(obj, proxiedObj); proxyMap.set(proxiedObj, proxiedObj); return proxiedObj as T; } function observeTrackableMethodDependencies(connectable: IConnectable, instance: unknown, options: TrackableFunctionOptions): void { if (instance == null || typeof instance !== 'object') { return; } const deps = options.deps; if (deps == null) { return; } const dependencies = isFunction(deps) ? [deps] : deps; for (const dependency of dependencies) { if (typeof dependency === 'string') { connectable.observeExpression(instance, dependency); } else { dependency(wrap(instance)); } } } const objectHandler: ProxyHandler = { get(target: IIndexable, key: PropertyKey, receiver: object): unknown { // maybe use symbol? if (key === rawKey) { return target; } const connectable = _currentConnectable; if (!connecting || connectable == null || doNotCollect(target, key)) { return R$get(target, key, receiver); } // todo: static connectable.observe(target, key); const value = R$get(target, key, receiver); if (!isFunction(value)) { return wrap(value); } const options = (value as TrackableFunction)[astTrackableMethodMarker]; if (options == null) { return wrap(value); } return function trackableMethod(this: unknown, ...args: unknown[]): unknown { const current = _currentConnectable; if (!connecting || current == null) { return (value as (...args: unknown[]) => unknown).apply(this, args); } const thisArg = this ?? receiver; const rawThisArg = isObject(thisArg) ? getRaw(thisArg) : thisArg; observeTrackableMethodDependencies(current, rawThisArg, options); const useProxy = options.deps == null; const invokedThis = useProxy && isObject(rawThisArg) ? wrap(rawThisArg) : rawThisArg; const invokedArgs = useProxy ? args.map(arg => wrap(arg)) : args.map(arg => unwrap(arg)); return (value as (...callArgs: unknown[]) => unknown).apply(invokedThis, invokedArgs); }; }, deleteProperty(target, p) { if (__DEV__) { // eslint-disable-next-line no-console console.warn(`[DEV:aurelia] deletion of a property will not always be working with Aurelia observation system, as it depends on getter/setter installation.`); } // eslint-disable-next-line @typescript-eslint/no-dynamic-delete return delete (target as IIndexable)[p]; }, }; const arrayHandler: ProxyHandler = { get(target: unknown[], key: PropertyKey, receiver: unknown): unknown { // maybe use symbol? if (key === rawKey) { return target; } if (!connecting || doNotCollect(target, key) || _currentConnectable == null) { return R$get(target, key, receiver); } switch (key) { case 'length': _currentConnectable.observe(target, 'length'); return target.length; case 'map': return wrappedArrayMap; case 'includes': return wrappedArrayIncludes; case 'indexOf': return wrappedArrayIndexOf; case 'lastIndexOf': return wrappedArrayLastIndexOf; case 'every': return wrappedArrayEvery; case 'filter': return wrappedArrayFilter; case 'find': return wrappedArrayFind; case 'findIndex': return wrappedArrayFindIndex; case 'flat': return wrappedArrayFlat; case 'flatMap': return wrappedArrayFlatMap; case 'join': return wrappedArrayJoin; case 'push': return wrappedArrayPush; case 'pop': return wrappedArrayPop; case 'reduce': return wrappedReduce; case 'reduceRight': return wrappedReduceRight; case 'reverse': return wrappedArrayReverse; case 'shift': return wrappedArrayShift; case 'unshift': return wrappedArrayUnshift; case 'slice': return wrappedArraySlice; case 'splice': return wrappedArraySplice; case 'some': return wrappedArraySome; case 'sort': return wrappedArraySort; case 'keys': return wrappedKeys; case 'values': case Symbol.iterator: return wrappedValues; case 'entries': return wrappedEntries; } _currentConnectable.observe(target, key); return wrap(R$get(target, key, receiver)); }, // for (let i in array) ... ownKeys(target: unknown[]): (string | symbol)[] { currentConnectable()?.observe(target, 'length'); return Reflect.ownKeys(target); }, }; function wrappedArrayMap(this: unknown[], cb: (v: unknown, i: number, arr: unknown[]) => unknown, thisArg?: unknown): unknown { const raw = getRaw(this); const res = raw.map((v, i) => // do we wrap `thisArg`? unwrap(cb.call(thisArg, wrap(v), i, this)) ); observeCollection(_currentConnectable, raw); return wrap(res); } function wrappedArrayEvery(this: unknown[], cb: (v: unknown, i: number, arr: unknown[]) => unknown, thisArg?: unknown): boolean { const raw = getRaw(this); const res = raw.every((v, i) => cb.call(thisArg, wrap(v), i, this)); observeCollection(_currentConnectable, raw); return res; } function wrappedArrayFilter(this: unknown[], cb: (v: unknown, i: number, arr: unknown[]) => boolean, thisArg?: unknown): unknown[] { const raw = getRaw(this); const res = raw.filter((v, i) => // do we wrap `thisArg`? unwrap(cb.call(thisArg, wrap(v), i, this)) ); observeCollection(_currentConnectable, raw); return wrap(res); } function wrappedArrayIncludes(this: unknown[], v: unknown): boolean { const raw = getRaw(this); const res = raw.includes(unwrap(v)); observeCollection(_currentConnectable, raw); return res; } function wrappedArrayIndexOf(this: unknown[], v: unknown): number { const raw = getRaw(this); const res = raw.indexOf(unwrap(v)); observeCollection(_currentConnectable, raw); return res; } function wrappedArrayLastIndexOf(this: unknown[], v: unknown): number { const raw = getRaw(this); const res = raw.lastIndexOf(unwrap(v)); observeCollection(_currentConnectable, raw); return res; } function wrappedArrayFindIndex(this: unknown[], cb: (v: unknown, i: number, arr: unknown[]) => boolean, thisArg?: unknown): number { const raw = getRaw(this); const res = raw.findIndex((v, i) => unwrap(cb.call(thisArg, wrap(v), i, this))); observeCollection(_currentConnectable, raw); return res; } function wrappedArrayFind(this: unknown[], cb: (v: unknown, i: number, arr: unknown[]) => boolean, thisArg?: unknown): unknown { const raw = getRaw(this); const res = raw.find((v, i) => cb(wrap(v), i, this), thisArg); observeCollection(_currentConnectable, raw); return wrap(res); } function wrappedArrayFlat(this: unknown[]): unknown[] { const raw = getRaw(this); observeCollection(_currentConnectable, raw); return wrap(raw.flat()); } function wrappedArrayFlatMap(this: unknown[], cb: (v: unknown, i: number, arr: unknown[]) => unknown, thisArg?: unknown): unknown[] { const raw = getRaw(this); observeCollection(_currentConnectable, raw); return getProxy(raw.flatMap((v, i) => wrap(cb.call(thisArg, wrap(v), i, this))) ); } function wrappedArrayJoin(this: unknown[], separator?: string): string { const raw = getRaw(this); observeCollection(_currentConnectable, raw); return raw.join(separator); } function wrappedArrayPop(this: unknown[]): unknown { return wrap(getRaw(this).pop()); } function wrappedArrayPush(this: unknown[], ...args: unknown[]): number { return getRaw(this).push(...args); } function wrappedArrayShift(this: unknown[]): unknown { return wrap(getRaw(this).shift()); } function wrappedArrayUnshift(this: unknown[], ...args: unknown[]): unknown { return getRaw(this).unshift(...args); } function wrappedArraySplice(this: unknown[], ...args: [number, number, ...unknown[]]): unknown { return wrap(getRaw(this).splice(...args)); } function wrappedArrayReverse(this: unknown[], ..._args: unknown[]): unknown[] { return wrap(getRaw(this).reverse()); } function wrappedArraySome(this: unknown[], cb: (v: unknown, i: number, arr: unknown[]) => boolean, thisArg?: unknown): boolean { const raw = getRaw(this); const res = raw.some((v, i) => unwrap(cb.call(thisArg, wrap(v), i, this))); observeCollection(_currentConnectable, raw); return res; } function wrappedArraySort(this: unknown[], cb?: (a: unknown, b: unknown) => number): unknown[] { const raw = getRaw(this); const res = raw.sort(cb); observeCollection(_currentConnectable, raw); return wrap(res); } function wrappedArraySlice(this: unknown[], start?: number, end?: number): unknown[] { const raw = getRaw(this); observeCollection(_currentConnectable, raw); return getProxy(raw.slice(start, end)); } function wrappedReduce(this: unknown[], cb: (curr: unknown, v: unknown, i: number, arr: unknown[]) => unknown, initValue: unknown): unknown { const raw = getRaw(this); const res = raw.reduce((curr, v, i) => cb(curr, wrap(v), i, this), initValue); observeCollection(_currentConnectable, raw); return wrap(res); } function wrappedReduceRight(this: unknown[], cb: (curr: unknown, v: unknown, i: number, arr: unknown[]) => unknown, initValue: unknown): unknown { const raw = getRaw(this); const res = raw.reduceRight((curr, v, i) => cb(curr, wrap(v), i, this), initValue); observeCollection(_currentConnectable, raw); return wrap(res); } // the below logic takes inspiration from Vue, Mobx // much thanks to them for working out this const collectionHandler: ProxyHandler<$MapOrSet> = { get(target: $MapOrSet, key: PropertyKey, receiver?): unknown { // maybe use symbol? if (key === rawKey) { return target; } const connectable = currentConnectable(); if (!connecting || doNotCollect(target, key) || connectable == null) { return R$get(target, key, receiver); } switch (key) { case 'size': connectable.observe(target, 'size'); return target.size; case 'clear': return wrappedClear; case 'delete': return wrappedDelete; case 'forEach': return wrappedForEach; case 'add': if (isSet(target)) { return wrappedAdd; } break; case 'get': if (isMap(target)) { return wrappedGet; } break; case 'set': if (isMap(target)) { return wrappedSet; } break; case 'has': return wrappedHas; case 'keys': return wrappedKeys; case 'values': return wrappedValues; case 'entries': return wrappedEntries; case Symbol.iterator: return isMap(target) ? wrappedEntries : wrappedValues; } return wrap(R$get(target, key, receiver)); }, }; type $MapOrSet = Map | Set; type CollectionMethod = (this: unknown, ...args: unknown[]) => unknown; function wrappedForEach(this: $MapOrSet, cb: CollectionMethod, thisArg?: unknown): void { const raw = getRaw(this); observeCollection(_currentConnectable, raw); return raw.forEach((v: unknown, key: unknown) => { cb.call(/* should wrap or not?? */thisArg, wrap(v), wrap(key), this); }); } function wrappedHas(this: $MapOrSet, v: unknown): boolean { const raw = getRaw(this); observeCollection(_currentConnectable, raw); return raw.has(unwrap(v)); } function wrappedGet(this: Map, k: unknown): unknown { const raw = getRaw(this); observeCollection(_currentConnectable, raw); return wrap(raw.get(unwrap(k))); } function wrappedSet(this: Map, k: unknown, v: unknown): Map { return wrap(getRaw(this).set(unwrap(k), unwrap(v))); } function wrappedAdd(this: Set, v: unknown): Set { return wrap(getRaw(this).add(unwrap(v))); } function wrappedClear(this: $MapOrSet): void { return wrap(getRaw(this).clear()); } function wrappedDelete(this: $MapOrSet, k: unknown): boolean { return wrap(getRaw(this).delete(unwrap(k))); } function wrappedKeys(this: $MapOrSet | unknown[]): IterableIterator { const raw = getRaw(this); observeCollection(_currentConnectable, raw); const iterator = raw.keys(); return { next(): IteratorResult { const next = iterator.next(); const value = next.value; const done = next.done; return done ? { value: void 0, done } : { value: wrap(value), done }; }, [Symbol.iterator]() { return this; }, }; } function wrappedValues(this: $MapOrSet | unknown[]): IterableIterator { const raw = getRaw(this); observeCollection(_currentConnectable, raw); const iterator = raw.values(); return { next(): IteratorResult { const next = iterator.next(); const value = next.value; const done = next.done; return done ? { value: void 0, done } : { value: wrap(value), done }; }, [Symbol.iterator]() { return this; }, }; } function wrappedEntries(this: $MapOrSet | unknown[]): IterableIterator { const raw = getRaw(this); observeCollection(_currentConnectable, raw); const iterator = raw.entries(); // return a wrapped iterator which returns observed versions of the // values emitted from the real iterator return { next(): IteratorResult { const next = iterator.next(); const value = next.value!; const done = next.done; return done ? { value: void 0, done } : { value: [wrap(value[0]), wrap(value[1])], done }; }, [Symbol.iterator]() { return this; }, }; } const observeCollection = (connectable: IConnectable | null, collection: Collection) => connectable?.observeCollection(collection); export const ProxyObservable = /*@__PURE__*/ rtObjectFreeze({ getProxy, getRaw, wrap, unwrap, rawKey, });