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..d599d96161 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; }; } = {}; +export 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,25 +184,41 @@ 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.'); + } + + if (eventNames.trim().split(eventNamesRegex).join('') !== eventNames) { + if (__DEV__) { + console.error(`Legacy event ${eventNames}`); + } + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + this.addEventListener(eventName, callback, thisArg); + } + return; } - 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) - list.push({ - callback: callback, - thisArg: thisArg, - }); + const eventName = eventNames; + + const list = this._getEventList(eventName, true); + if (Observable._indexOfListener(list, callback, thisArg) !== -1) { + // Already added. + return; } + + list.push({ + callback, + thisArg, + once, + }); } /** @@ -219,6 +228,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 +238,189 @@ 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]; + if (eventNames.trim().split(eventNamesRegex).join('') !== eventNames) { + if (__DEV__) { + console.error(`Legacy event ${eventNames}`); + } + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + this.removeEventListener(eventName, callback, thisArg); } + return; } - } - public static on(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - this.addEventListener(eventName, callback, thisArg); - } + const eventName = eventNames; - public static once(eventName: string, callback: (data: EventData) => void, thisArg?: any): void { - if (typeof eventName !== 'string') { - throw new TypeError('Event must be string.'); + const entries = this._observers[eventName]; + if (!entries) { + return; } - if (typeof callback !== 'function') { - throw new TypeError('callback must be function.'); - } + Observable.innerRemoveEventListener(entries, callback, thisArg); - const eventClass = this.name === 'Observable' ? '*' : this.name; - if (!_globalEventHandlers[eventClass]) { - _globalEventHandlers[eventClass] = {}; - } - if (!Array.isArray(_globalEventHandlers[eventClass][eventName])) { - _globalEventHandlers[eventClass][eventName] = []; + if (!entries.length) { + // Clear all entries of this type + delete this._observers[eventName]; } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg, once: true }); } + /** + * 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 { + 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])) { + if (eventNames.trim().split(eventNamesRegex).join('') !== eventNames) { + if (__DEV__) { + console.error(`Legacy event ${eventNames}`); + } + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + this.removeEventListener(eventName, callback, thisArg); + } 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--; - } - } - } 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]; + const eventName = eventNames; + + const entries = _globalEventHandlers?.[eventClass]?.[eventName]; + if (!entries) { + return; } - if (events.length === 0) { - // Clear all events of this type + 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 + // 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])) { + + if (eventNames.trim().split(eventNamesRegex).join('') !== eventNames) { + if (__DEV__) { + console.error(`Legacy event ${eventNames}`); + } + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + this.addEventListener(eventName, callback, thisArg); + } + return; + } + + const eventName = eventNames; + + if (!_globalEventHandlers[eventClass][eventName]) { _globalEventHandlers[eventClass][eventName] = []; } - _globalEventHandlers[eventClass][eventName].push({ callback, thisArg }); + if (Observable._indexOfListener(_globalEventHandlers[eventClass][eventName], callback, thisArg) !== -1) { + // Already added. + return; + } + + _globalEventHandlers[eventClass][eventName].push({ callback, thisArg, once }); } 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 +450,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 +504,24 @@ 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 }); + if (eventNames.trim().split(eventNamesRegex).join('') !== eventNames) { + if (__DEV__) { + console.error(`Legacy event ${eventNames}`); + } + for (const eventName of eventNames.trim().split(eventNamesRegex)) { + this._emit(eventName); + } + return; } + + const eventName = eventNames; + + 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 +534,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); diff --git a/packages/core/ui/core/view/view-common.ts b/packages/core/ui/core/view/view-common.ts index f03025fe84..ac75f14582 100644 --- a/packages/core/ui/core/view/view-common.ts +++ b/packages/core/ui/core/view/view-common.ts @@ -8,7 +8,7 @@ import { isObject } from '../../../utils/types'; import { sanitizeModuleName } from '../../../utils/common'; import { Color } from '../../../color'; import { Property, InheritedProperty } from '../properties'; -import { EventData } from '../../../data/observable'; +import { EventData, eventNamesRegex } from '../../../data/observable'; import { Trace } from '../../../trace'; import { CoreTypes } from '../../../core-types'; import { ViewHelper } from './view-helper'; @@ -123,7 +123,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { _setMinWidthNative: (value: CoreTypes.LengthType) => void; _setMinHeightNative: (value: CoreTypes.LengthType) => void; - public _gestureObservers = {}; + public readonly _gestureObservers = {} as Record>; _androidContentDescriptionUpdated?: boolean; @@ -177,7 +177,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { onLoaded() { if (!this.isLoaded) { - const hasTap = this.hasListeners('tap') || this.hasListeners('tapChange') || this.getGestureObservers(GestureTypes.tap); + const hasTap = this.hasListeners('tap') || this.hasListeners('tapChange') || !!this.getGestureObservers(GestureTypes.tap); const enableTapAnimations = TouchManager.enableGlobalTapAnimations && hasTap; if (!this.ignoreTouchAnimation && (this.touchAnimation || enableTapAnimations)) { TouchManager.addAnimations(this); @@ -286,61 +286,55 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { this._gestureObservers[type].push(gestureObserve(this, type, callback, thisArg)); } - public getGestureObservers(type: GestureTypes): Array { + public getGestureObservers(type: GestureTypes): Array | undefined { return this._gestureObservers[type]; } public addEventListener(arg: string | GestureTypes, callback: (data: EventData) => void, thisArg?: any) { - if (typeof arg === 'string') { - arg = getEventOrGestureName(arg); + if (typeof arg === 'number') { + this._observe(arg, callback as unknown as (data: GestureEventData) => void, thisArg); + return; + } - const gesture = gestureFromString(arg); - if (gesture && !this._isEvent(arg)) { - this._observe(gesture, callback as unknown as (data: GestureEventData) => void, thisArg); - } else { - const events = arg.split(','); - if (events.length > 0) { - for (let i = 0; i < events.length; i++) { - const evt = events[i].trim(); - const gst = gestureFromString(evt); - if (gst && !this._isEvent(arg)) { - this._observe(gst, callback as unknown as (data: GestureEventData) => void, thisArg); - } else { - super.addEventListener(evt, callback, thisArg); - } - } - } else { - super.addEventListener(arg, callback, thisArg); - } - } - } else if (typeof arg === 'number') { - this._observe(arg, callback as unknown as (data: GestureEventData) => void, thisArg); + // Normalize "ontap" -> "tap" + const normalizedName = getEventOrGestureName(arg); + + // Coerce "tap" -> GestureTypes.tap + // Coerce "loaded" -> undefined + const gesture: GestureTypes | undefined = gestureFromString(normalizedName); + + // If it's a gesture (and this Observable declares e.g. `static tapEvent`) + if (gesture && !this._isEvent(normalizedName)) { + this._observe(gesture, callback as unknown as (data: GestureEventData) => void, thisArg); + return; + } + + for (const eventName of normalizedName.trim().split(eventNamesRegex)) { + super.addEventListener(eventName, callback, thisArg); } } public removeEventListener(arg: string | GestureTypes, callback?: (data: EventData) => void, thisArg?: any) { - if (typeof arg === 'string') { - const gesture = gestureFromString(arg); - if (gesture && !this._isEvent(arg)) { - this._disconnectGestureObservers(gesture); - } else { - const events = arg.split(','); - if (events.length > 0) { - for (let i = 0; i < events.length; i++) { - const evt = events[i].trim(); - const gst = gestureFromString(evt); - if (gst && !this._isEvent(arg)) { - this._disconnectGestureObservers(gst); - } else { - super.removeEventListener(evt, callback, thisArg); - } - } - } else { - super.removeEventListener(arg, callback, thisArg); - } - } - } else if (typeof arg === 'number') { - this._disconnectGestureObservers(arg); + if (typeof arg === 'number') { + this._disconnectGestureObservers(arg); + return; + } + + // Normalize "ontap" -> "tap" + const normalizedName = getEventOrGestureName(arg); + + // Coerce "tap" -> GestureTypes.tap + // Coerce "loaded" -> undefined + const gesture: GestureTypes | undefined = gestureFromString(normalizedName); + + // If it's a gesture (and this Observable declares e.g. `static tapEvent`) + if (gesture && !this._isEvent(normalizedName)) { + this._disconnectGestureObservers(gesture); + return; + } + + for (const eventName of normalizedName.trim().split(eventNamesRegex)) { + super.removeEventListener(eventName, callback, thisArg); } } @@ -379,7 +373,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { const firstArgument = args[0]; const view = firstArgument instanceof ViewCommon ? firstArgument : Builder.createViewFromEntry({ moduleName: firstArgument, - }); + }); return { view, options }; } @@ -501,10 +495,12 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { private _disconnectGestureObservers(type: GestureTypes): void { const observers = this.getGestureObservers(type); - if (observers) { - for (let i = 0; i < observers.length; i++) { - observers[i].disconnect(); - } + if (!observers) { + return; + } + + for (const observer of observers) { + observer.disconnect(); } }