diff --git a/apps/automated/src/data/observable-tests.ts b/apps/automated/src/data/observable-tests.ts index 5bfa1cefb7..9bc36cb9e2 100644 --- a/apps/automated/src/data/observable-tests.ts +++ b/apps/automated/src/data/observable-tests.ts @@ -273,7 +273,65 @@ export var test_Observable_removeEventListener_SingleEvent_MultipleCallbacks = f TKUnit.assert(receivedCount === 3, 'Observable.removeEventListener not working properly with multiple listeners.'); }; -export var test_Observable_removeEventListener_MutlipleEvents_SingleCallback = function () { +export var test_Observable_identity = function () { + const obj = new Observable(); + + let receivedCount = 0; + const callback = () => receivedCount++; + const eventName = Observable.propertyChangeEvent; + + // The identity of an event listener is determined by the tuple of + // [eventType, callback, thisArg], and influences addition and removal. + + // If you try to add the same callback for a given event name twice, without + // distinguishing by its thisArg, the second addition will no-op. + obj.addEventListener(eventName, callback); + obj.addEventListener(eventName, callback); + obj.set('testName', 1); + TKUnit.assert(receivedCount === 1, 'Expected Observable to fire exactly once upon a property change, having passed the same callback into addEventListener() twice'); + obj.removeEventListener(eventName, callback); + TKUnit.assert(!obj.hasListeners(eventName), 'Expected removeEventListener(eventName, callback) to remove all matching callbacks regardless of thisArg'); + receivedCount = 0; + + // All truthy thisArgs are distinct, so we have three distinct identities here + // and they should all get added. + obj.addEventListener(eventName, callback); + obj.addEventListener(eventName, callback, 1); + obj.addEventListener(eventName, callback, 2); + obj.set('testName', 2); + TKUnit.assert(receivedCount === 3, 'Expected Observable to fire exactly three times upon a property change, having passed the same callback into addEventListener() three times, with the latter two distinguished by each having a different truthy thisArg'); + obj.removeEventListener(eventName, callback); + TKUnit.assert(!obj.hasListeners(eventName), 'Expected removeEventListener(eventName, callback) to remove all matching callbacks regardless of thisArg'); + receivedCount = 0; + + // If you specify thisArg when removing an event listener, it should remove + // just the event listener with the corresponding thisArg. + obj.addEventListener(eventName, callback, 1); + obj.addEventListener(eventName, callback, 2); + obj.set('testName', 3); + TKUnit.assert(receivedCount === 2, 'Expected Observable to fire exactly three times upon a property change, having passed the same callback into addEventListener() three times, with the latter two distinguished by each having a different truthy thisArg'); + obj.removeEventListener(eventName, callback, 2); + TKUnit.assert(obj.hasListeners(eventName), 'Expected removeEventListener(eventName, callback, thisArg) to remove just the event listener that matched the callback and thisArg'); + obj.removeEventListener(eventName, callback, 1); + TKUnit.assert(!obj.hasListeners(eventName), 'Expected removeEventListener(eventName, callback, thisArg) to remove the remaining event listener that matched the callback and thisArg'); + receivedCount = 0; + + // All falsy thisArgs are treated alike, so these all have the same identity + // and only the first should get added. + obj.addEventListener(eventName, callback); + obj.addEventListener(eventName, callback, 0); + obj.addEventListener(eventName, callback, false); + obj.addEventListener(eventName, callback, null); + obj.addEventListener(eventName, callback, undefined); + obj.addEventListener(eventName, callback, ''); + obj.set('testName', 4); + TKUnit.assert(receivedCount === 1, 'Expected Observable to fire exactly once upon a property change, having passed the same callback into addEventListener() multiple times, each time with a different falsy (and therefore indistinct) thisArg'); + obj.removeEventListener(eventName, callback); + TKUnit.assert(!obj.hasListeners(eventName), 'Expected removeEventListener(eventName, callback) to remove all matching callbacks regardless of thisArg'); + receivedCount = 0; +}; + +export var test_Observable_removeEventListener_MultipleEvents_SingleCallback = function () { var obj = new TestObservable(); var receivedCount = 0; diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index 60e6c81da3..53d801e172 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -38,7 +38,7 @@ export interface PropertyChangeData extends EventData { interface ListenerEntry { callback: (data: EventData) => void; - thisArg: any; + thisArg?: any; once?: true; } @@ -58,7 +58,7 @@ export class WrappedValue { /** * Property which holds the real value. */ - public wrapped: any + public wrapped: any, ) {} /** @@ -81,14 +81,16 @@ export class WrappedValue { } } -const _wrappedValues = [new WrappedValue(null), new WrappedValue(null), new WrappedValue(null), new WrappedValue(null), new WrappedValue(null)]; +const _wrappedValues = [new WrappedValue(null), new WrappedValue(null), new WrappedValue(null), new WrappedValue(null), new WrappedValue(null)] as const; const _globalEventHandlers: { [eventClass: string]: { - [eventName: string]: ListenerEntry[]; + [eventName: string]: Array; }; } = {}; +const eventNamesRegex = /\s*,\s*/; + /** * Observable is used when you want to be notified when a change occurs. Use on/off methods to add/remove listener. * Please note that should you be using the `new Observable({})` constructor, it is **obsolete** since v3.0, @@ -106,7 +108,7 @@ export class Observable { */ public _isViewBase: boolean; - private readonly _observers: { [eventName: string]: ListenerEntry[] } = {}; + private readonly _observers: { [eventName: string]: Array } = {}; /** * Gets the value of the specified property. @@ -166,16 +168,7 @@ export class Observable { * @param thisArg An optional parameter which when set will be used as "this" in callback method call. */ public once(event: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof event !== 'string') { - throw new TypeError('Event must be string.'); - } - - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); - } - - const list = this._getEventList(event, true); - list.push({ callback, thisArg, once: true }); + this.addEventListener(event, callback, thisArg, true); } /** @@ -191,23 +184,29 @@ export class Observable { * @param callback A function to be called when some of the specified event(s) is raised. * @param thisArg An optional parameter which when set will be used as "this" in callback method call. */ - public addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any): void { + public addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any, once?: boolean): void { + once = once || undefined; + thisArg = thisArg || undefined; + if (typeof eventNames !== 'string') { - throw new TypeError('Events name(s) must be string.'); + throw new TypeError('Event name(s) must be a string.'); } if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); + throw new TypeError('Callback, if provided, must be a function.'); } - const events = eventNames.split(','); - for (let i = 0, l = events.length; i < l; i++) { - const event = events[i].trim(); - const list = this._getEventList(event, true); - // TODO: Performance optimization - if we do not have the thisArg specified, do not wrap the callback in additional object (ObserveEntry) + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + const list = this._getEventList(eventName, true); + if (Observable._indexOfListener(list, callback, thisArg) !== -1) { + // Already added. + continue; + } + list.push({ - callback: callback, - thisArg: thisArg, + callback, + thisArg, + once, }); } } @@ -219,6 +218,8 @@ export class Observable { * @param thisArg An optional parameter which when set will be used to refine search of the correct callback which will be removed as event listener. */ public removeEventListener(eventNames: string, callback?: (data: EventData) => void, thisArg?: any): void { + thisArg = thisArg || undefined; + if (typeof eventNames !== 'string') { throw new TypeError('Events name(s) must be string.'); } @@ -227,137 +228,160 @@ export class Observable { throw new TypeError('callback must be function.'); } - const events = eventNames.split(','); - for (let i = 0, l = events.length; i < l; i++) { - const event = events[i].trim(); - if (callback) { - const list = this._getEventList(event, false); - if (list) { - const index = Observable._indexOfListener(list, callback, thisArg); - if (index >= 0) { - list.splice(index, 1); - } - if (list.length === 0) { - delete this._observers[event]; - } - } - } else { - this._observers[event] = undefined; - delete this._observers[event]; + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + const entries = this._observers[eventName]; + if (!entries) { + continue; + } + + Observable.innerRemoveEventListener(entries, callback, thisArg); + + if (!entries.length) { + // Clear all entries of this type + delete this._observers[eventName]; } } } - public static on(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - this.addEventListener(eventName, callback, thisArg); + /** + * Please avoid using the static event-handling APIs as they will be removed + * in future. + * @deprecated + */ + public static on(eventName: string, callback: (data: EventData) => void, thisArg?: any, once?: boolean): void { + this.addEventListener(eventName, callback, thisArg, once); } + /** + * Please avoid using the static event-handling APIs as they will be removed + * in future. + * @deprecated + */ public static once(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof eventName !== 'string') { - throw new TypeError('Event must be string.'); - } - - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); - } - - const eventClass = this.name === 'Observable' ? '*' : this.name; - if (!_globalEventHandlers[eventClass]) { - _globalEventHandlers[eventClass] = {}; - } - if (!Array.isArray(_globalEventHandlers[eventClass][eventName])) { - _globalEventHandlers[eventClass][eventName] = []; - } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg, once: true }); + this.addEventListener(eventName, callback, thisArg, true); } + /** + * Please avoid using the static event-handling APIs as they will be removed + * in future. + * @deprecated + */ public static off(eventName: string, callback?: (data: EventData) => void, thisArg?: any): void { this.removeEventListener(eventName, callback, thisArg); } - public static removeEventListener(eventName: string, callback?: (data: EventData) => void, thisArg?: any): void { - if (typeof eventName !== 'string') { - throw new TypeError('Event must be string.'); + private static innerRemoveEventListener(entries: Array, callback?: (data: EventData) => void, thisArg?: any): void { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + // If we have a `thisArg`, refine on both `callback` and `thisArg`. + if (thisArg && (entry.callback !== callback || entry.thisArg !== thisArg)) { + continue; + } + + // If we don't have a `thisArg`, refine only on `callback`. + if (callback && entry.callback !== callback) { + continue; + } + + // If we have neither `thisArg` nor `callback`, just remove all events + // of this type regardless. + + entries.splice(i, 1); + i--; + } + } + + /** + * Please avoid using the static event-handling APIs as they will be removed + * in future. + * @deprecated + */ + public static removeEventListener(eventNames: string, callback?: (data: EventData) => void, thisArg?: any): void { + thisArg = thisArg || undefined; + + if (typeof eventNames !== 'string') { + throw new TypeError('Event name(s) must be a string.'); } if (callback && typeof callback !== 'function') { - throw new TypeError('callback must be function.'); + throw new TypeError('Callback, if provided, must be function.'); } const eventClass = this.name === 'Observable' ? '*' : this.name; - // Short Circuit if no handlers exist.. - if (!_globalEventHandlers[eventClass] || !Array.isArray(_globalEventHandlers[eventClass][eventName])) { - return; - } - - const events = _globalEventHandlers[eventClass][eventName]; - if (thisArg) { - for (let i = 0; i < events.length; i++) { - if (events[i].callback === callback && events[i].thisArg === thisArg) { - events.splice(i, 1); - i--; - } + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + const entries = _globalEventHandlers?.[eventClass]?.[eventName]; + if (!entries) { + continue; } - } else if (callback) { - for (let i = 0; i < events.length; i++) { - if (events[i].callback === callback) { - events.splice(i, 1); - i--; - } - } - } else { - // Clear all events of this type - delete _globalEventHandlers[eventClass][eventName]; - } - if (events.length === 0) { - // Clear all events of this type - delete _globalEventHandlers[eventClass][eventName]; - } + Observable.innerRemoveEventListener(entries, callback, thisArg); + + if (!entries.length) { + // Clear all entries of this type + delete _globalEventHandlers[eventClass][eventName]; + } - // Clear the primary class grouping if no events are left - const keys = Object.keys(_globalEventHandlers[eventClass]); - if (keys.length === 0) { - delete _globalEventHandlers[eventClass]; + // Clear the primary class grouping if no list are left + const keys = Object.keys(_globalEventHandlers[eventClass]); + if (keys.length === 0) { + delete _globalEventHandlers[eventClass]; + } } } - public static addEventListener(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof eventName !== 'string') { - throw new TypeError('Event must be string.'); + /** + * Please avoid using the static event-handling APIs as they will be removed + * in future. + * @deprecated + */ + public static addEventListener(eventNames: string, callback: (data: EventData) => void, thisArg?: any, once?: boolean): void { + once = once || undefined; + thisArg = thisArg || undefined; + + if (typeof eventNames !== 'string') { + throw new TypeError('Event name(s) must be a string.'); } if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); + throw new TypeError('Callback must be a function.'); } const eventClass = this.name === 'Observable' ? '*' : this.name; if (!_globalEventHandlers[eventClass]) { _globalEventHandlers[eventClass] = {}; } - if (!Array.isArray(_globalEventHandlers[eventClass][eventName])) { - _globalEventHandlers[eventClass][eventName] = []; + + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + if (!_globalEventHandlers[eventClass][eventName]) { + _globalEventHandlers[eventClass][eventName] = []; + } + if (Observable._indexOfListener(_globalEventHandlers[eventClass][eventName], callback, thisArg) !== -1) { + // Already added. + return; + } + + _globalEventHandlers[eventClass][eventName].push({ callback, thisArg, once }); } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg }); } private _globalNotify(eventClass: string, eventType: string, data: T): void { // Check for the Global handlers for JUST this class if (_globalEventHandlers[eventClass]) { - const event = data.eventName + eventType; - const events = _globalEventHandlers[eventClass][event]; - if (events) { - Observable._handleEvent(events, data); + const eventName = data.eventName + eventType; + const entries = _globalEventHandlers[eventClass][eventName]; + if (entries) { + Observable._handleEvent(entries, data); } } - // Check for he Global handlers for ALL classes + // Check for the Global handlers for ALL classes if (_globalEventHandlers['*']) { - const event = data.eventName + eventType; - const events = _globalEventHandlers['*'][event]; - if (events) { - Observable._handleEvent(events, data); + const eventName = data.eventName + eventType; + const entries = _globalEventHandlers['*'][eventName]; + if (entries) { + Observable._handleEvent(entries, data); } } } @@ -387,29 +411,27 @@ export class Observable { } private static _handleEvent(observers: Array, data: T): void { - if (!observers) { + if (!observers.length) { return; } + for (let i = observers.length - 1; i >= 0; i--) { const entry = observers[i]; - if (entry) { - if (entry.once) { - observers.splice(i, 1); - } - - let returnValue: any; - if (entry.thisArg) { - returnValue = entry.callback.apply(entry.thisArg, [data]); - } else { - returnValue = entry.callback(data); - } - - // This ensures errors thrown inside asynchronous functions do not get swallowed - if (returnValue && returnValue instanceof Promise) { - returnValue.catch((err) => { - console.error(err); - }); - } + if (!entry) { + continue; + } + + if (entry.once) { + observers.splice(i, 1); + } + + const returnValue = entry.thisArg ? entry.callback.apply(entry.thisArg, [data]) : entry.callback(data); + + // This ensures errors thrown inside asynchronous functions do not get swallowed + if (returnValue instanceof Promise) { + returnValue.catch((err) => { + console.error(err); + }); } } } @@ -443,17 +465,14 @@ export class Observable { } public _emit(eventNames: string): void { - const events = eventNames.split(','); - - for (let i = 0, l = events.length; i < l; i++) { - const event = events[i].trim(); - this.notify({ eventName: event, object: this }); + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + this.notify({ eventName, object: this }); } } - private _getEventList(eventName: string, createIfNeeded?: boolean): Array { + private _getEventList(eventName: string, createIfNeeded?: boolean): Array | undefined { if (!eventName) { - throw new TypeError('EventName must be valid string.'); + throw new TypeError('eventName must be a valid string.'); } let list = >this._observers[eventName]; @@ -466,20 +485,9 @@ export class Observable { } private static _indexOfListener(list: Array, callback: (data: EventData) => void, thisArg?: any): number { - for (let i = 0; i < list.length; i++) { - const entry = list[i]; - if (thisArg) { - if (entry.callback === callback && entry.thisArg === thisArg) { - return i; - } - } else { - if (entry.callback === callback) { - return i; - } - } - } + thisArg = thisArg || undefined; - return -1; + return list.findIndex((entry) => entry.callback === callback && entry.thisArg === thisArg); } } diff --git a/packages/core/ui/core/bindable/index.ts b/packages/core/ui/core/bindable/index.ts index a737f80f20..5e13cb6112 100644 --- a/packages/core/ui/core/bindable/index.ts +++ b/packages/core/ui/core/bindable/index.ts @@ -635,8 +635,9 @@ export class Binding { try { if (isEventOrGesture(options.property, optionsInstance) && types.isFunction(value)) { - // calling off method with null as handler will remove all handlers for options.property event - optionsInstance.off(options.property, null, optionsInstance.bindingContext); + // Calling Observable.prototype.off() with just the event name will + // remove all handlers under that event name. + optionsInstance.off(options.property); optionsInstance.on(options.property, value, optionsInstance.bindingContext); } else if (optionsInstance instanceof Observable) { optionsInstance.set(options.property, value);