import { createIndexMap, type AccessorType, type ISubscriberCollection, type ICollectionSubscriberCollection, type IObserver, type ICollectionObserver, type IndexMap, type ISubscriber, atObserver, } from './interfaces'; import { CollectionLengthObserver, } from './collection-length-observer'; import { subscriberCollection, } from './subscriber-collection'; import { rtDef, rtDefineHiddenProp, rtDefineMetadata, rtGetMetadata } from './utilities'; import { addCollectionBatch, batching } from './subscriber-batch'; import { isFunction } from '@aurelia/kernel'; export interface ArrayObserver extends ICollectionObserver<'array'>, ICollectionSubscriberCollection { getIndexObserver(index: number): ArrayIndexObserver; } export interface ArrayIndexObserver extends IObserver, ISubscriberCollection { readonly owner: ICollectionObserver<'array'>; } export const getArrayObserver = /*@__PURE__*/ (() => { const observerLookup = new WeakMap(); // https://tc39.github.io/ecma262/#sec-sortcompare function sortCompare(x: unknown, y: unknown): number { if (x === y) { return 0; } x = x === null ? 'null' : (x as {}).toString(); y = y === null ? 'null' : (y as {}).toString(); return (x as {}) < (y as {}) ? -1 : 1; } function preSortCompare(x: unknown, y: unknown): number { if (x === void 0) { if (y === void 0) { return 0; } else { return 1; } } if (y === void 0) { return -1; } return 0; } function insertionSort(arr: unknown[], indexMap: IndexMap, from: number, to: number, compareFn: (a: unknown, b: unknown) => number): void { let velement, ielement, vtmp, itmp, order; let i, j; for (i = from + 1; i < to; i++) { velement = arr[i]; ielement = indexMap[i]; for (j = i - 1; j >= from; j--) { vtmp = arr[j]; itmp = indexMap[j]; order = compareFn(vtmp, velement); if (order > 0) { arr[j + 1] = vtmp; indexMap[j + 1] = itmp; } else { break; } } arr[j + 1] = velement; indexMap[j + 1] = ielement; } } function quickSort(arr: unknown[], indexMap: IndexMap, from: number, to: number, compareFn: (a: unknown, b: unknown) => number): void { let thirdIndex = 0, i = 0; let v0, v1, v2; let i0, i1, i2; let c01, c02, c12; let vtmp, itmp; let vpivot, ipivot, lowEnd, highStart; let velement, ielement, order, vtopElement; // eslint-disable-next-line no-constant-condition while (true) { if (to - from <= 10) { insertionSort(arr, indexMap, from, to, compareFn); return; } thirdIndex = from + ((to - from) >> 1); v0 = arr[from]; i0 = indexMap[from]; v1 = arr[to - 1]; i1 = indexMap[to - 1]; v2 = arr[thirdIndex]; i2 = indexMap[thirdIndex]; c01 = compareFn(v0, v1); if (c01 > 0) { vtmp = v0; itmp = i0; v0 = v1; i0 = i1; v1 = vtmp; i1 = itmp; } c02 = compareFn(v0, v2); if (c02 >= 0) { vtmp = v0; itmp = i0; v0 = v2; i0 = i2; v2 = v1; i2 = i1; v1 = vtmp; i1 = itmp; } else { c12 = compareFn(v1, v2); if (c12 > 0) { vtmp = v1; itmp = i1; v1 = v2; i1 = i2; v2 = vtmp; i2 = itmp; } } arr[from] = v0; indexMap[from] = i0; arr[to - 1] = v2; indexMap[to - 1] = i2; vpivot = v1; ipivot = i1; lowEnd = from + 1; highStart = to - 1; arr[thirdIndex] = arr[lowEnd]; indexMap[thirdIndex] = indexMap[lowEnd]; arr[lowEnd] = vpivot; indexMap[lowEnd] = ipivot; partition: for (i = lowEnd + 1; i < highStart; i++) { velement = arr[i]; ielement = indexMap[i]; order = compareFn(velement, vpivot); if (order < 0) { arr[i] = arr[lowEnd]; indexMap[i] = indexMap[lowEnd]; arr[lowEnd] = velement; indexMap[lowEnd] = ielement; lowEnd++; } else if (order > 0) { do { highStart--; // eslint-disable-next-line eqeqeq if (highStart == i) { break partition; } vtopElement = arr[highStart]; order = compareFn(vtopElement, vpivot); } while (order > 0); arr[i] = arr[highStart]; indexMap[i] = indexMap[highStart]; arr[highStart] = velement; indexMap[highStart] = ielement; if (order < 0) { velement = arr[i]; ielement = indexMap[i]; arr[i] = arr[lowEnd]; indexMap[i] = indexMap[lowEnd]; arr[lowEnd] = velement; indexMap[lowEnd] = ielement; lowEnd++; } } } if (to - highStart < lowEnd - from) { quickSort(arr, indexMap, highStart, to, compareFn); to = lowEnd; } else { quickSort(arr, indexMap, from, lowEnd, compareFn); from = highStart; } } } // eslint-disable-next-line @typescript-eslint/no-explicit-any const proto = Array.prototype as { [K in keyof any[]]: any[][K] & { observing?: boolean } }; const methods: ['push', 'unshift', 'pop', 'shift', 'splice', 'reverse', 'sort'] = ['push', 'unshift', 'pop', 'shift', 'splice', 'reverse', 'sort']; let observe: undefined | Pick; // let native: undefined | Pick; function overrideArrayPrototypes() { const $push = proto.push; const $unshift = proto.unshift; const $pop = proto.pop; const $shift = proto.shift; const $splice = proto.splice; const $reverse = proto.reverse; const $sort = proto.sort; // native = { push: $push, unshift: $unshift, pop: $pop, shift: $shift, splice: $splice, reverse: $reverse, sort: $sort }; observe = { // https://tc39.github.io/ecma262/#sec-array.prototype.push push: function (this: unknown[], ...args: unknown[]): ReturnType { const o = observerLookup.get(this); if (o === void 0) { return $push.apply(this, args); } const len = this.length; const argCount = args.length; if (argCount === 0) { return len; } this.length = o.indexMap.length = len + argCount; let i = len; while (i < this.length) { this[i] = args[i - len]; o.indexMap[i] = - 2; i++; } o.notify(); return this.length; }, // https://tc39.github.io/ecma262/#sec-array.prototype.unshift unshift: function (this: unknown[], ...args: unknown[]): ReturnType { const o = observerLookup.get(this); if (o === void 0) { return $unshift.apply(this, args); } const argCount = args.length; const inserts = new Array(argCount); let i = 0; while (i < argCount) { inserts[i++] = - 2; } $unshift.apply(o.indexMap, inserts); const len = $unshift.apply(this, args); o.notify(); return len; }, // https://tc39.github.io/ecma262/#sec-array.prototype.pop pop: function (this: unknown[]): ReturnType { const o = observerLookup.get(this); if (o === void 0) { return $pop.call(this); } const indexMap = o.indexMap; const element = $pop.call(this); // only mark indices as deleted if they actually existed in the original array const index = indexMap.length - 1; if (indexMap[index] > -1) { indexMap.deletedIndices.push(indexMap[index]); indexMap.deletedItems.push(element); } $pop.call(indexMap); o.notify(); return element; }, // https://tc39.github.io/ecma262/#sec-array.prototype.shift shift: function (this: unknown[]): ReturnType { const o = observerLookup.get(this); if (o === void 0) { return $shift.call(this); } const indexMap = o.indexMap; const element = $shift.call(this); // only mark indices as deleted if they actually existed in the original array if (indexMap[0] > -1) { indexMap.deletedIndices.push(indexMap[0]); indexMap.deletedItems.push(element); } $shift.call(indexMap); o.notify(); return element; }, // https://tc39.github.io/ecma262/#sec-array.prototype.splice splice: function (this: unknown[], ...args: [number, number, ...unknown[]]): ReturnType { const start: number = args[0]; const deleteCount: number | undefined = args[1]; const o = observerLookup.get(this); if (o === void 0) { return $splice.apply(this, args); } const len = this.length; const relativeStart = start | 0; const actualStart = relativeStart < 0 ? Math.max((len + relativeStart), 0) : Math.min(relativeStart, len); const indexMap = o.indexMap; const argCount = args.length; const actualDeleteCount = argCount === 0 ? 0 : argCount === 1 ? len - actualStart : deleteCount; let i = actualStart; if (actualDeleteCount > 0) { const to = i + actualDeleteCount; while (i < to) { // only mark indices as deleted if they actually existed in the original array if (indexMap[i] > -1) { indexMap.deletedIndices.push(indexMap[i]); indexMap.deletedItems.push(this[i]); } i++; } } i = 0; if (argCount > 2) { const itemCount = argCount - 2; const inserts = new Array(itemCount); while (i < itemCount) { inserts[i++] = - 2; } $splice.call(indexMap, start, deleteCount, ...inserts); } else { $splice.apply(indexMap, args); } const deleted = $splice.apply(this, args); // only notify when there's deletion, or addition if (actualDeleteCount > 0 || i > 0) { o.notify(); } return deleted; }, // https://tc39.github.io/ecma262/#sec-array.prototype.reverse reverse: function (this: unknown[]): ReturnType { const o = observerLookup.get(this); if (o === void 0) { $reverse.call(this); return this; } const len = this.length; const middle = (len / 2) | 0; let lower = 0; while (lower !== middle) { const upper = len - lower - 1; const lowerValue = this[lower]; const lowerIndex = o.indexMap[lower]; const upperValue = this[upper]; const upperIndex = o.indexMap[upper]; this[lower] = upperValue; o.indexMap[lower] = upperIndex; this[upper] = lowerValue; o.indexMap[upper] = lowerIndex; lower++; } o.notify(); return this; }, // https://tc39.github.io/ecma262/#sec-array.prototype.sort // https://github.com/v8/v8/blob/master/src/js/array.js sort: function (this: unknown[], compareFn?: (a: unknown, b: unknown) => number): unknown[] { const o = observerLookup.get(this); if (o === void 0) { $sort.call(this, compareFn); return this; } let len = this.length; if (len < 2) { return this; } quickSort(this, o.indexMap, 0, len, preSortCompare); let i = 0; while (i < len) { if (this[i] === void 0) { break; } i++; } if (compareFn === void 0 || !isFunction(compareFn)/* spec says throw a TypeError, should we do that too? */) { compareFn = sortCompare; } quickSort(this, o.indexMap, 0, i, compareFn); // todo(fred): it shouldn't notify if the sort produce a stable array: // where every item has the same index before/after // though this is inefficient we loop a few times like this let shouldNotify = false; for (i = 0, len = o.indexMap.length; len > i; ++i) { if (o.indexMap[i] !== i) { shouldNotify = true; break; } } if (shouldNotify || batching) { o.notify(); } return this; } }; for (const method of methods) { rtDef(observe[method], 'observing', { value: true }); } } let enableArrayObservationCalled = false; const observationEnabledKey = '__au_arr_on__'; function enableArrayObservation(): void { if (observe === undefined) { overrideArrayPrototypes(); } // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions if (!(rtGetMetadata(observationEnabledKey, Array) ?? false)) { rtDefineMetadata(true, Array, observationEnabledKey); for (const method of methods) { if (proto[method].observing !== true) { rtDefineHiddenProp(proto, method, observe![method]); } } } } // function disableArrayObservation(): void { // for (const method of methods) { // if (proto[method].observing === true) { // rtDefineHiddenProp(proto, method, native![method]); // } // } // } interface ArrayObserverImpl extends ICollectionObserver<'array'>, ICollectionSubscriberCollection { } class ArrayObserverImpl { static { subscriberCollection(ArrayObserverImpl, null!); } public type: AccessorType = atObserver; private readonly indexObservers: Record; private lenObs?: CollectionLengthObserver; public constructor(array: unknown[]) { if (!enableArrayObservationCalled) { enableArrayObservationCalled = true; enableArrayObservation(); } this.indexObservers = {}; this.collection = array; this.indexMap = createIndexMap(array.length); this.lenObs = void 0; observerLookup.set(array, this); } public notify(): void { const subs = this.subs; subs.notifyDirty(); const indexMap = this.indexMap; if (batching) { addCollectionBatch(subs, this.collection, indexMap); return; } const arr = this.collection; const length = arr.length; this.indexMap = createIndexMap(length); subs.notifyCollection(arr, indexMap); } public getLengthObserver(): CollectionLengthObserver { return this.lenObs ??= new CollectionLengthObserver(this); } public getIndexObserver(index: number): ArrayIndexObserverImpl { // It's unnecessary to destroy/recreate index observer all the time, // so just create once, and add/remove instead return this.indexObservers[index] ??= new ArrayIndexObserverImpl(this, index); } } interface ArrayIndexObserverImpl extends ArrayIndexObserver, ISubscriberCollection { } class ArrayIndexObserverImpl implements ArrayIndexObserver { static { subscriberCollection(ArrayIndexObserverImpl, null!); } public doNotCache: boolean = true; public value: unknown; public constructor( public readonly owner: ArrayObserverImpl, public readonly index: number ) { this.value = this.getValue(); } public getValue(): unknown { return this.owner.collection[this.index]; } public setValue(newValue: unknown): void { if (newValue === this.getValue()) { return; } const arrayObserver = this.owner; const index = this.index; const indexMap = arrayObserver.indexMap; if (indexMap[index] > -1) { indexMap.deletedIndices.push(indexMap[index]); } indexMap[index] = -2; // do not need to update current value here // as it will be updated inside handle collection change arrayObserver.collection[index] = newValue; arrayObserver.notify(); } public handleDirty() { if (this.value !== this.getValue()) { this.subs.notifyDirty(); } } /** * From interface `ICollectionSubscriber` */ public handleCollectionChange(_arr: unknown[], indexMap: IndexMap): void { const index = this.index; const noChange = indexMap[index] === index; if (noChange) { return; } const prevValue = this.value; const currValue = this.value = this.getValue(); if (prevValue !== currValue) { this.subs.notify(currValue, prevValue); } } public subscribe(subscriber: ISubscriber): void { if (this.subs.add(subscriber) && this.subs.count === 1) { this.owner.subscribe(this); } } public unsubscribe(subscriber: ISubscriber): void { if (this.subs.remove(subscriber) && this.subs.count === 0) { this.owner.unsubscribe(this); } } } return function getArrayObserver(array: unknown[]): ArrayObserver { let observer = observerLookup.get(array); if (observer === void 0) { observerLookup.set(array, observer = new ArrayObserverImpl(array)); enableArrayObservation(); } return observer; }; })();