diff --git a/packages/core/data/dom-events/dom-event.ts b/packages/core/data/dom-events/dom-event.ts index 977d8a97c..b78d2df9e 100644 --- a/packages/core/data/dom-events/dom-event.ts +++ b/packages/core/data/dom-events/dom-event.ts @@ -1,5 +1,6 @@ import type { EventData, ListenerEntry, Observable } from '../observable/index'; import type { ViewBase } from '../../ui/core/view-base'; +import { MutationSensitiveArray } from '../mutation-sensitive-array'; // This file contains some of Core's hot paths, so attention has been taken to // optimise it. Where specified, optimisations made have been informed based on @@ -13,7 +14,7 @@ const timeOrigin = Date.now(); * optional accesses, so reusing the same one and treating it as immutable * avoids unnecessary allocations on a relatively hot path of the library. */ -const emptyArray = [] as const; +const emptyArray = new MutationSensitiveArray(); export class DOMEvent implements Event { /** @@ -223,7 +224,7 @@ export class DOMEvent implements Event { * event's cancelable attribute value is false or its preventDefault() * method was not invoked, and false otherwise. */ - dispatchTo({ target, data, getGlobalEventHandlersPreHandling, getGlobalEventHandlersPostHandling }: { target: Observable; data: EventData; getGlobalEventHandlersPreHandling?: () => readonly ListenerEntry[]; getGlobalEventHandlersPostHandling?: () => readonly ListenerEntry[] }): boolean { + dispatchTo({ target, data, getGlobalEventHandlersPreHandling, getGlobalEventHandlersPostHandling }: { target: Observable; data: EventData; getGlobalEventHandlersPreHandling?: () => MutationSensitiveArray; getGlobalEventHandlersPostHandling?: () => MutationSensitiveArray }): boolean { if (this.eventPhase !== this.NONE) { throw new Error('Tried to dispatch a dispatching event'); } @@ -340,16 +341,31 @@ export class DOMEvent implements Event { return this.returnValue; } - private handleEvent({ data, isGlobal, getListenersForType, phase, removeEventListener }: { data: EventData; isGlobal: boolean; getListenersForType: () => readonly ListenerEntry[]; phase: 0 | 1 | 2 | 3; removeEventListener: (eventName: string, callback?: any, thisArg?: any, capture?: boolean) => void }) { - // Work on a copy of the array, as any callback could modify the - // original array during the loop. + private handleEvent({ data, isGlobal, getListenersForType, phase, removeEventListener }: { data: EventData; isGlobal: boolean; getListenersForType: () => MutationSensitiveArray; phase: 0 | 1 | 2 | 3; removeEventListener: (eventName: string, callback?: any, thisArg?: any, capture?: boolean) => void }) { + // We want to work on a copy of the array, as any callback could modify + // the original array during the loop. // - // Cloning the array via spread syntax is up to 180 nanoseconds faster - // per run than using Array.prototype.slice(). - const listenersForTypeCopy = [...getListenersForType()]; + // However, cloning arrays is expensive on this hot path, so we'll do it + // lazily - i.e. only take a clone if a mutation is about to happen. + // This optimisation is particularly worth doing as it's very rare that + // an event listener callback will end up modifying the listeners array. + const listenersLive: MutationSensitiveArray = getListenersForType(); - for (let i = listenersForTypeCopy.length - 1; i >= 0; i--) { - const listener = listenersForTypeCopy[i]; + // Set a listener to clone the array just before any mutations. + let listenersLazyCopy: ListenerEntry[] = listenersLive; + const doLazyCopy = () => { + // Cloning the array via spread syntax is up to 180 nanoseconds + // faster per run than using Array.prototype.slice(). + listenersLazyCopy = [...listenersLive]; + }; + listenersLive.once(doLazyCopy); + + // Make sure we remove the listener before we exit the function, + // otherwise we may wastefully clone the array. + const cleanup = () => listenersLive.removeListener(doLazyCopy); + + for (let i = listenersLazyCopy.length - 1; i >= 0; i--) { + const listener = listenersLazyCopy[i]; // Assigning variables this old-fashioned way is up to 50 // nanoseconds faster per run than ESM destructuring syntax. @@ -365,7 +381,7 @@ export class DOMEvent implements Event { // We simply use a strict equality check here because we trust that // the listeners provider will never allow two deeply-equal // listeners into the array. - if (!getListenersForType().includes(listener)) { + if (!listenersLive.includes(listener)) { continue; } @@ -395,9 +411,12 @@ export class DOMEvent implements Event { } if (this.propagationState === EventPropagationState.stopImmediate) { + cleanup(); return; } } + + cleanup(); } } diff --git a/packages/core/data/mutation-sensitive-array/index.ts b/packages/core/data/mutation-sensitive-array/index.ts new file mode 100644 index 000000000..76d0270a5 --- /dev/null +++ b/packages/core/data/mutation-sensitive-array/index.ts @@ -0,0 +1,78 @@ +/** + * A lightweight extension of Array that calls listeners just before any + * mutations. This allows you to lazily take a clone of an array (i.e. use the + * array as-is until such time as it mutates). + * + * This could equally be implemented by adding pre-mutation events into + * ObservableArray, but the whole point is to be as lightweight as possible as + * its entire purpose is to be used for performance-sensitive tasks. + */ +export class MutationSensitiveArray extends Array { + private readonly listeners: (() => void)[] = []; + + once(listener: () => void): void { + const wrapper = () => { + listener(); + this.removeListener(wrapper); + }; + this.addListener(wrapper); + } + + addListener(listener: () => void): void { + if (!this.listeners.includes(listener)) { + this.listeners.push(listener); + } + } + + removeListener(listener: () => void): void { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + } + + private invalidate(): void { + for (const listener of this.listeners) { + listener(); + } + } + + // Override each mutating Array method so that it invalidates our snapshot. + + pop(): T | undefined { + this.invalidate(); + return super.pop(); + } + push(...items: T[]): number { + this.invalidate(); + return super.push(...items); + } + reverse(): T[] { + this.invalidate(); + return super.reverse(); + } + shift(): T | undefined { + this.invalidate(); + return super.shift(); + } + sort(compareFn?: (a: T, b: T) => number): this { + this.invalidate(); + return super.sort(compareFn); + } + splice(start: number, deleteCount: number, ...rest: T[]): T[] { + this.invalidate(); + return super.splice(start, deleteCount, ...rest); + } + unshift(...items: T[]): number { + this.invalidate(); + return super.unshift(...items); + } + fill(value: T, start?: number, end?: number): this { + this.invalidate(); + return super.fill(value, start, end); + } + copyWithin(target: number, start: number, end?: number): this { + this.invalidate(); + return super.copyWithin(target, start, end); + } +} diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index 6579cd1b7..bd46fd06a 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -1,5 +1,6 @@ import type { ViewBase } from '../../ui/core/view-base'; import { DOMEvent } from '../dom-events/dom-event'; +import { MutationSensitiveArray } from '../mutation-sensitive-array'; /** * Base event data. @@ -85,7 +86,7 @@ const _wrappedValues = [new WrappedValue(null), new WrappedValue(null), new Wrap const _globalEventHandlers: { [eventClass: string]: { - [eventName: string]: ListenerEntry[]; + [eventName: string]: MutationSensitiveArray; }; } = {}; @@ -114,7 +115,7 @@ export class Observable implements EventTarget { return this._isViewBase; } - private readonly _observers: { [eventName: string]: ListenerEntry[] } = {}; + private readonly _observers: { [eventName: string]: MutationSensitiveArray } = {}; public get(name: string): any { return this[name]; @@ -313,7 +314,7 @@ export class Observable implements EventTarget { _globalEventHandlers[eventClass] = {}; } if (!Array.isArray(_globalEventHandlers[eventClass][eventName])) { - _globalEventHandlers[eventClass][eventName] = []; + _globalEventHandlers[eventClass][eventName] = new MutationSensitiveArray(); } const list = _globalEventHandlers[eventClass][eventName]; @@ -399,11 +400,11 @@ export class Observable implements EventTarget { }); } - private _getGlobalEventHandlers(data: EventData, eventType: 'First' | ''): ListenerEntry[] { + private _getGlobalEventHandlers(data: EventData, eventType: 'First' | ''): MutationSensitiveArray { const eventClass = data.object?.constructor?.name; const globalEventHandlersForOwnClass = _globalEventHandlers[eventClass]?.[`${data.eventName}${eventType}`] ?? []; const globalEventHandlersForAllClasses = _globalEventHandlers['*']?.[`${data.eventName}${eventType}`] ?? []; - return [...globalEventHandlersForOwnClass, ...globalEventHandlersForAllClasses]; + return new MutationSensitiveArray(...globalEventHandlersForOwnClass, ...globalEventHandlersForAllClasses); } /** @@ -440,14 +441,14 @@ export class Observable implements EventTarget { } } - public getEventList(eventName: string, createIfNeeded?: boolean): ListenerEntry[] | undefined { + public getEventList(eventName: string, createIfNeeded?: boolean): MutationSensitiveArray | undefined { if (!eventName) { throw new TypeError('EventName must be valid string.'); } let list = this._observers[eventName]; if (!list && createIfNeeded) { - list = []; + list = new MutationSensitiveArray(); this._observers[eventName] = list; }