packages/runtime/src/proxy-observation.ts
import { IIndexable, isArray, isMap, isSet } from '@aurelia/kernel';
import { Collection, IConnectable } from './interfaces';
import { rtObjectFreeze, rtSafeString } from './utilities';
import { connecting, currentConnectable, _connectable } from './connectable-switcher';
const R$get = Reflect.get;
const toStringTag = Object.prototype.toString;
const proxyMap = new WeakMap<object, object>();
/** @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;
}
}
export const rawKey = '__raw__';
export function wrap<T>(v: T): T {
return canWrap(v) ? getProxy(v) : v;
}
export function getProxy<T extends object>(obj: T): T {
// deepscan-disable-next-line
return proxyMap.get(obj) as T ?? createProxy(obj);
}
export function getRaw<T extends object>(obj: T): T {
// todo: get in a weakmap if null/undef
return (obj as IIndexable)[rawKey] as T ?? obj;
}
export function unwrap<T>(v: T): T {
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
return canWrap(v) && (v as IIndexable)[rawKey] as T || v;
}
function doNotCollect(object: object, key: PropertyKey): boolean {
return 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'
|| key === Symbol.toPrimitive
|| key === Symbol.toStringTag
// 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;
}
function createProxy<T extends object>(obj: T): T {
const handler: ProxyHandler<object> = 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;
}
const objectHandler: ProxyHandler<object> = {
get(target: IIndexable, key: PropertyKey, receiver: object): 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);
}
// todo: static
connectable.observe(target, key);
return wrap(R$get(target, key, receiver));
},
};
const arrayHandler: ProxyHandler<unknown[]> = {
get(target: unknown[], key: PropertyKey, receiver: unknown): unknown {
// maybe use symbol?
if (key === rawKey) {
return target;
}
if (!connecting || doNotCollect(target, key) || _connectable == null) {
return R$get(target, key, receiver);
}
switch (key) {
case 'length':
_connectable.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;
}
_connectable.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(_connectable, 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(_connectable, 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(_connectable, raw);
return wrap(res);
}
function wrappedArrayIncludes(this: unknown[], v: unknown): boolean {
const raw = getRaw(this);
const res = raw.includes(unwrap(v));
observeCollection(_connectable, raw);
return res;
}
function wrappedArrayIndexOf(this: unknown[], v: unknown): number {
const raw = getRaw(this);
const res = raw.indexOf(unwrap(v));
observeCollection(_connectable, raw);
return res;
}
function wrappedArrayLastIndexOf(this: unknown[], v: unknown): number {
const raw = getRaw(this);
const res = raw.lastIndexOf(unwrap(v));
observeCollection(_connectable, 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(_connectable, 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(_connectable, raw);
return wrap(res);
}
function wrappedArrayFlat(this: unknown[]): unknown[] {
const raw = getRaw(this);
observeCollection(_connectable, 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(_connectable, 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(_connectable, 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[] {
const raw = getRaw(this);
const res = raw.reverse();
observeCollection(_connectable, raw);
return wrap(res);
}
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(_connectable, raw);
return res;
}
function wrappedArraySort(this: unknown[], cb?: (a: unknown, b: unknown) => number): unknown[] {
const raw = getRaw(this);
const res = raw.sort(cb);
observeCollection(_connectable, raw);
return wrap(res);
}
function wrappedArraySlice(this: unknown[], start?: number, end?: number): unknown[] {
const raw = getRaw(this);
observeCollection(_connectable, 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(_connectable, 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(_connectable, 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<unknown, unknown> | Set<unknown>;
type CollectionMethod = (this: unknown, ...args: unknown[]) => unknown;
function wrappedForEach(this: $MapOrSet, cb: CollectionMethod, thisArg?: unknown): void {
const raw = getRaw(this);
observeCollection(_connectable, 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(_connectable, raw);
return raw.has(unwrap(v));
}
function wrappedGet(this: Map<unknown, unknown>, k: unknown): unknown {
const raw = getRaw(this);
observeCollection(_connectable, raw);
return wrap(raw.get(unwrap(k)));
}
function wrappedSet(this: Map<unknown, unknown>, k: unknown, v: unknown): Map<unknown, unknown> {
return wrap(getRaw(this).set(unwrap(k), unwrap(v)));
}
function wrappedAdd(this: Set<unknown>, v: unknown): Set<unknown> {
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<unknown> {
const raw = getRaw(this);
observeCollection(_connectable, raw);
const iterator = raw.keys();
return {
next(): IteratorResult<unknown, unknown> {
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<unknown> {
const raw = getRaw(this);
observeCollection(_connectable, raw);
const iterator = raw.values();
return {
next(): IteratorResult<unknown, unknown> {
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<unknown> {
const raw = getRaw(this);
observeCollection(_connectable, raw);
const iterator = raw.entries();
// return a wrapped iterator which returns observed versions of the
// values emitted from the real iterator
return {
next(): IteratorResult<unknown, unknown> {
const next = iterator.next();
const value = next.value;
const done = next.done;
return done
? { value: void 0, done }
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
: { 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,
});