export type Reactive = T | (() => T); export const isString = (val: unknown): val is string => typeof val === 'string'; const isIntegerKey = (key: unknown): boolean => isString(key) && key !== 'NaN' && key[0] !== '-' && '' + parseInt(key, 10) === key; let defered = false, _deferSet = new Set<() => void>(); export const _defer = (fn: () => void) => { if(!defered) { defered = true; queueMicrotask(() => { _deferSet.forEach(e => e()); _deferSet.clear(); defered = false; }); } _deferSet.add(fn); } let activeEffect: (() => void) | null = null, _isTracking = true; const SYMBOLS = { PROXY: Symbol('is a proxy'), ITERATE: Symbol('iterating'), RAW: Symbol('raw value'), } as const; function reactiveReadArray(array: T[]): T[] { const _raw = raw(array) if (_raw === array) return _raw; track(_raw, SYMBOLS.ITERATE); return _raw.map(wrapReactive); } function shallowReadArray(arr: T[]): T[] { track((arr = raw(arr)), SYMBOLS.ITERATE); return arr; } function iterator(self: unknown[], method: keyof Array, wrapValue: (value: any) => unknown) { const arr = shallowReadArray(self); const iter = (arr[method] as any)() as IterableIterator & { _next: IterableIterator['next'] }; if (arr !== self && !isShallow(self)) { iter._next = iter.next; iter.next = () => { const result = iter._next(); if (!result.done) result.value = wrapValue(result.value); return result; } } return iter; } function wrapReactive(obj: any): any { return obj && typeof obj === 'object' ? reactive(obj as Proxy) : obj; } const arrayProto = Array.prototype function apply(self: unknown[], method: keyof Array, fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown, wrappedRetFn?: (result: any) => unknown, args?: IArguments) { const arr = shallowReadArray(self); const needsWrap = arr !== self; const methodFn = arr[method] as Function; if (methodFn !== arrayProto[method as any]) { const result = methodFn.apply(self, args); return needsWrap ? toReactive(result) : result; } let wrappedFn = fn; if (arr !== self) { if (needsWrap) { wrappedFn = function (this: unknown, item, index) { return fn.call(this, wrapReactive(item), index, self); }; } else if (fn.length > 2) { wrappedFn = function (this: unknown, item, index) { return fn.call(this, item, index, self); }; } } const result = methodFn.call(arr, wrappedFn, thisArg); return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result; } function reduce(self: unknown[], method: keyof Array, fn: (acc: unknown, item: unknown, index: number, array: unknown[]) => unknown, args: unknown[]) { const arr = shallowReadArray(self); let wrappedFn = fn; if (arr !== self && fn.length > 3) { wrappedFn = function (this: unknown, acc, item, index) { return fn.call(this, acc, wrapReactive(item), index, self) }; } else { wrappedFn = function (this: unknown, acc, item, index) { return fn.call(this, acc, wrapReactive(item), index, self) ;} } return (arr[method] as any)(wrappedFn, ...args); } function searchProxy(self: unknown[], method: keyof Array, args: unknown[]) { const arr = raw(self) as any; track(arr, SYMBOLS.ITERATE); const res = arr[method](...args); if ((res === -1 || res === false) && isProxy(args[0])) { args[0] = raw(args[0]); return arr[method](...args); } return res; } function noTracking(self: unknown[], method: keyof Array, args: unknown[] = []) { _isTracking = false; const res = (raw(self) as any)[method].apply(self, args); _isTracking = true; return res; } const arraySubstitute = { // <-- is required to allow __proto__ without getting an error __proto__: null, // <-- Required to remove the object prototype removing the object default functions from the substitution [Symbol.iterator]() { return iterator(this, Symbol.iterator, item => wrapReactive(item)) }, concat(...args: unknown[]) { return reactiveReadArray(this).concat(...args.map(x => (Array.isArray(x) ? reactiveReadArray(x) : x))) }, entries() { return iterator(this, 'entries', (value: [number, unknown]) => { value[1] = wrapReactive(value[1]); return value; }) }, every(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply(this, 'every', fn, thisArg, undefined, arguments) }, filter(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply( this, 'filter', fn, thisArg, v => v.map((item: unknown) => wrapReactive(item)), arguments, ) }, find(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { return apply( this, 'find', fn, thisArg, item => wrapReactive(item), arguments, ) }, findIndex(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { return apply(this, 'findIndex', fn, thisArg, undefined, arguments) }, findLast(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { return apply( this, 'findLast', fn, thisArg, item => wrapReactive(item), arguments) }, findLastIndex(fn: (item: unknown, index: number, array: unknown[]) => boolean, thisArg?: unknown) { return apply(this, 'findLastIndex', fn, thisArg, undefined, arguments) }, forEach(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply(this, 'forEach', fn, thisArg, undefined, arguments) }, includes(...args: unknown[]) { return searchProxy(this, 'includes', args) }, indexOf(...args: unknown[]) { return searchProxy(this, 'indexOf', args) }, join(separator?: string) { return reactiveReadArray(this).join(separator) }, lastIndexOf(...args: unknown[]) { return searchProxy(this, 'lastIndexOf', args) }, map(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply(this, 'map', fn, thisArg, undefined, arguments) }, pop() { return noTracking(this, 'pop') }, push(...args: unknown[]) { return noTracking(this, 'push', args) }, reduce(fn: ( acc: unknown, item: unknown, index: number, array: unknown[], ) => unknown, ...args: unknown[]) { return reduce(this, 'reduce', fn, args) }, reduceRight(fn: ( acc: unknown, item: unknown, index: number, array: unknown[], ) => unknown, ...args: unknown[]) { return reduce(this, 'reduceRight', fn, args) }, shift() { return noTracking(this, 'shift') }, some(fn: (item: unknown, index: number, array: unknown[]) => unknown, thisArg?: unknown) { return apply(this, 'some', fn, thisArg, undefined, arguments) }, splice(...args: unknown[]) { return noTracking(this, 'splice', args) }, toReversed() { return reactiveReadArray(this).toReversed() }, toSorted(comparer?: (a: unknown, b: unknown) => number) { return reactiveReadArray(this).toSorted(comparer) }, toSpliced(...args: unknown[]) { return (reactiveReadArray(this).toSpliced as any)(...args) }, unshift(...args: unknown[]) { return noTracking(this, 'unshift', args) }, values() { return iterator(this, 'values', item => wrapReactive(item)) }, /* */ }; // Store object to proxy correspondance const _reactiveCache = new WeakMap(); // Store a Weak map of all the tracked object. // For each object, we have a map of its properties, allowing us to effectively listen to absolutely everything on the object // For a given property, we have a set of "effect" (function called on value update) type Dependency = Set<() => void>; const _tracker = new WeakMap>(); function trigger(target: object, key?: string | symbol | null, value?: unknown) { const dependencies = _tracker.get(target); if(!dependencies) return; const run = (dep?: Dependency) => { dep?.forEach(_defer); }; const isArray = Array.isArray(target); const arrayIndex = isIntegerKey(key); //When the array length is modified, call not only the length and ITERATE dependencies but also the added/removed items dependencies if(isArray && key === 'length') { // Run for 'length' key, SYMBOL.ITERATE and any index key after the new length (for reduction) dependencies.forEach((v, k: any) => (k === 'length' || k === SYMBOLS.ITERATE || (isIntegerKey(k) && k >= (value as number))) && run(v)); } else { key !== undefined && run(dependencies.get(key)); arrayIndex && run(dependencies.get(SYMBOLS.ITERATE)); } } function track(target: object, key: string | symbol | null) { if(!activeEffect || !_isTracking) return; let dependencies = _tracker.get(target); if(!dependencies) { dependencies = new Map(); _tracker.set(target, dependencies); } let set = dependencies.get(key); if(!set) { set = new Set(); dependencies.set(key, set); } set.add(activeEffect); //if(set) console.log('Tracking %o with key "%s"', target, key, set.size); } export type Proxy = T & { [SYMBOLS.PROXY]?: boolean; [SYMBOLS.RAW]?: T; }; export function isProxy(target: Proxy): boolean { return target[SYMBOLS.PROXY]; } export function reactive(obj: T | Proxy): T | Proxy { if((obj as Proxy)[SYMBOLS.PROXY]) return obj; if(_reactiveCache.has(obj)) return _reactiveCache.get(obj)!; const prototype = Object.getPrototypeOf(obj); const isArray = Array.isArray(obj); const proxy = new Proxy(obj, { get: (target, key, receiver) => { if(key === SYMBOLS.PROXY) return true; else if(key === SYMBOLS.RAW) return obj; if(key in arraySubstitute) return arraySubstitute[key]!; const value = Reflect.get(target, key, receiver); track(target, key); //If the value is an object, mark it as reactive dynamically if(value && typeof value === 'object') return reactive(value as Proxy); return value; }, set: (target, key, value, receiver) => { if(key === SYMBOLS.PROXY || key === SYMBOLS.RAW) return false; const result = Reflect.set(target, key, raw(value), receiver); trigger(target, key, value); return result; }, deleteProperty: (target, key) => { const has = key in target; const result = Reflect.deleteProperty(target, key); if(result && has) trigger(target, key); return result; }, has: (target, key) => { const result = Reflect.has(target, key); track(target, key); return result; }, ownKeys: (target) => { const result = Reflect.ownKeys(target); track(target, SYMBOLS.ITERATE); return result; } }) as Proxy; _reactiveCache.set(obj, proxy); return proxy; } export function raw(obj: T): T { return typeof obj === 'object' ? ((obj as Proxy)[SYMBOLS.RAW] as Proxy | undefined) ?? obj : obj; } export function reactivity(reactiveProperty: Reactive, effect: (processed: T) => void) { // Function wrapping to keep the context safe and secured. // Also useful to retrigger the tracking system if the reactive property provides new properties (via conditions for example) const secureEffect = () => effect(typeof reactiveProperty === 'function' ? (reactiveProperty as () => T)() : reactiveProperty); const secureContext = () => { activeEffect = secureContext; try { return secureEffect(); } finally { activeEffect = null; } }; return secureContext(); }