From d11d7d7a5bc06605f36df158569bf10ab1c95fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Mon, 27 Jan 2025 06:45:37 -0800 Subject: [PATCH 1/5] [skip ci] Implement Event and EventTarget (#48429) Summary: Changelog: [internal] This implements a (mostly) spec-compliant version of the [`Event`](https://dom.spec.whatwg.org/#interface-event) and [`EventTarget`](https://dom.spec.whatwg.org/#interface-eventtarget) Web interfaces. It does not implement legacy methods in either of the interfaces, and ignores the parts of the spec that are related to Web-specific quirks (shadow roots, re-mapping of animation events with webkit prefixes, etc.). IMPORTANT: This only creates the interfaces and does not expose them externally yet (no `Event` or `EventTarget` in the global scope). Reviewed By: yungsters Differential Revision: D67738145 --- .../src/private/webapis/dom/events/Event.js | 183 +++ .../private/webapis/dom/events/EventTarget.js | 413 +++++++ .../dom/events/__tests__/Event-itest.js | 232 ++++ .../dom/events/__tests__/EventTarget-itest.js | 1009 +++++++++++++++++ .../dom/events/internals/EventInternals.js | 120 ++ .../events/internals/EventTargetInternals.js | 52 + 6 files changed, 2009 insertions(+) create mode 100644 packages/react-native/src/private/webapis/dom/events/Event.js create mode 100644 packages/react-native/src/private/webapis/dom/events/EventTarget.js create mode 100644 packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js create mode 100644 packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js create mode 100644 packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js create mode 100644 packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js diff --git a/packages/react-native/src/private/webapis/dom/events/Event.js b/packages/react-native/src/private/webapis/dom/events/Event.js new file mode 100644 index 00000000000000..7c008be03e5c1e --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/Event.js @@ -0,0 +1,183 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * This module implements the `Event` interface from the DOM. + * See https://dom.spec.whatwg.org/#interface-event. + */ + +// flowlint unsafe-getters-setters:off + +import type EventTarget from './EventTarget'; + +import { + COMPOSED_PATH_KEY, + CURRENT_TARGET_KEY, + EVENT_PHASE_KEY, + IN_PASSIVE_LISTENER_FLAG_KEY, + IS_TRUSTED_KEY, + STOP_IMMEDIATE_PROPAGATION_FLAG_KEY, + STOP_PROPAGATION_FLAG_KEY, + TARGET_KEY, + getComposedPath, + getCurrentTarget, + getEventPhase, + getInPassiveListenerFlag, + getIsTrusted, + getTarget, + setStopImmediatePropagationFlag, + setStopPropagationFlag, +} from './internals/EventInternals'; + +type EventInit = { + bubbles?: boolean, + cancelable?: boolean, + composed?: boolean, +}; + +export default class Event { + static NONE: 0 = 0; + static CAPTURING_PHASE: 1 = 1; + static AT_TARGET: 2 = 2; + static BUBBLING_PHASE: 3 = 3; + + #bubbles: boolean; + #cancelable: boolean; + #composed: boolean; + #type: string; + + #defaultPrevented: boolean = false; + #timeStamp: number = performance.now(); + + // $FlowExpectedError[unsupported-syntax] + [COMPOSED_PATH_KEY]: boolean = []; + + // $FlowExpectedError[unsupported-syntax] + [CURRENT_TARGET_KEY]: EventTarget | null = null; + + // $FlowExpectedError[unsupported-syntax] + [EVENT_PHASE_KEY]: boolean = Event.NONE; + + // $FlowExpectedError[unsupported-syntax] + [IN_PASSIVE_LISTENER_FLAG_KEY]: boolean = false; + + // $FlowExpectedError[unsupported-syntax] + [IS_TRUSTED_KEY]: boolean = false; + + // $FlowExpectedError[unsupported-syntax] + [STOP_IMMEDIATE_PROPAGATION_FLAG_KEY]: boolean = false; + + // $FlowExpectedError[unsupported-syntax] + [STOP_PROPAGATION_FLAG_KEY]: boolean = false; + + // $FlowExpectedError[unsupported-syntax] + [TARGET_KEY]: EventTarget | null = null; + + constructor(type: string, options?: ?EventInit) { + if (arguments.length < 1) { + throw new TypeError( + "Failed to construct 'Event': 1 argument required, but only 0 present.", + ); + } + + const typeOfOptions = typeof options; + + if ( + options != null && + typeOfOptions !== 'object' && + typeOfOptions !== 'function' + ) { + throw new TypeError( + "Failed to construct 'Event': The provided value is not of type 'EventInit'.", + ); + } + + this.#type = String(type); + this.#bubbles = Boolean(options?.bubbles); + this.#cancelable = Boolean(options?.cancelable); + this.#composed = Boolean(options?.composed); + } + + get bubbles(): boolean { + return this.#bubbles; + } + + get cancelable(): boolean { + return this.#cancelable; + } + + get composed(): boolean { + return this.#composed; + } + + get currentTarget(): EventTarget | null { + return getCurrentTarget(this); + } + + get defaultPrevented(): boolean { + return this.#defaultPrevented; + } + + get eventPhase(): EventPhase { + return getEventPhase(this); + } + + get isTrusted(): boolean { + return getIsTrusted(this); + } + + get target(): EventTarget | null { + return getTarget(this); + } + + get timeStamp(): number { + return this.#timeStamp; + } + + get type(): string { + return this.#type; + } + + composedPath(): $ReadOnlyArray { + return getComposedPath(this).slice(); + } + + preventDefault(): void { + if (!this.#cancelable) { + return; + } + + if (getInPassiveListenerFlag(this)) { + console.error( + new Error( + 'Unable to preventDefault inside passive event listener invocation.', + ), + ); + return; + } + + this.#defaultPrevented = true; + } + + stopImmediatePropagation(): void { + setStopPropagationFlag(this, true); + setStopImmediatePropagationFlag(this, true); + } + + stopPropagation(): void { + setStopPropagationFlag(this, true); + } +} + +export type EventPhase = + | (typeof Event)['NONE'] + | (typeof Event)['CAPTURING_PHASE'] + | (typeof Event)['AT_TARGET'] + | (typeof Event)['BUBBLING_PHASE']; diff --git a/packages/react-native/src/private/webapis/dom/events/EventTarget.js b/packages/react-native/src/private/webapis/dom/events/EventTarget.js new file mode 100644 index 00000000000000..a3b92b5923d9b0 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -0,0 +1,413 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * This module implements the `EventTarget` and related interfaces from the DOM. + * See https://dom.spec.whatwg.org/#interface-eventtarget. + */ + +import type {EventPhase} from './Event'; + +import Event from './Event'; +import { + getStopImmediatePropagationFlag, + getStopPropagationFlag, + setComposedPath, + setCurrentTarget, + setEventPhase, + setInPassiveListenerFlag, + setIsTrusted, + setStopImmediatePropagationFlag, + setStopPropagationFlag, + setTarget, +} from './internals/EventInternals'; +import { + EVENT_TARGET_GET_THE_PARENT_KEY, + INTERNAL_DISPATCH_METHOD_KEY, +} from './internals/EventTargetInternals'; + +export type EventListener = + | ((event: Event) => void) + | interface { + handleEvent(event: Event): void, + }; + +export type EventListenerOptions = { + capture?: boolean, +}; + +export type AddEventListenerOptions = { + ...EventListenerOptions, + passive?: boolean, + once?: boolean, + signal?: AbortSignal, +}; + +type EventListenerRegistration = { + +callback: EventListener, + +passive: boolean, + +once: boolean, + removed: boolean, +}; + +function getDefaultPassiveValue( + type: string, + eventTarget: EventTarget, +): boolean { + return false; +} + +export default class EventTarget { + #listeners: Map> = new Map(); + #captureListeners: Map> = new Map(); + + addEventListener( + type: string, + callback: EventListener | null, + optionsOrUseCapture?: AddEventListenerOptions | boolean = {}, + ): void { + if (arguments.length < 2) { + throw new TypeError( + `Failed to execute 'addEventListener' on 'EventTarget': 2 arguments required, but only ${arguments.length} present.`, + ); + } + + if (callback == null) { + return; + } + + validateCallback(callback, 'addEventListener'); + + const processedType = String(type); + + let capture; + let passive; + let once; + let signal; + + if ( + optionsOrUseCapture != null && + (typeof optionsOrUseCapture === 'object' || + typeof optionsOrUseCapture === 'function') + ) { + capture = Boolean(optionsOrUseCapture.capture); + passive = + optionsOrUseCapture.passive == null + ? getDefaultPassiveValue(processedType, this) + : Boolean(optionsOrUseCapture.passive); + once = Boolean(optionsOrUseCapture.once); + signal = optionsOrUseCapture.signal; + if (signal !== undefined && !(signal instanceof AbortSignal)) { + throw new TypeError( + "Failed to execute 'addEventListener' on 'EventTarget': Failed to read the 'signal' property from 'AddEventListenerOptions': Failed to convert value to 'AbortSignal'.", + ); + } + } else { + capture = Boolean(optionsOrUseCapture); + passive = false; + once = false; + signal = null; + } + + if (signal?.aborted) { + return; + } + + const listenerMap = capture ? this.#captureListeners : this.#listeners; + let listenerList = listenerMap.get(processedType); + if (listenerList == null) { + listenerList = []; + listenerMap.set(processedType, listenerList); + } else { + for (const listener of listenerList) { + if (listener.callback === callback) { + return; + } + } + } + + const listener: EventListenerRegistration = { + callback, + passive, + once, + removed: false, + }; + listenerList.push(listener); + + const nonNullListenerList = listenerList; + + if (signal != null) { + signal.addEventListener( + 'abort', + () => { + this.#removeEventListenerRegistration(listener, nonNullListenerList); + }, + { + once: true, + }, + ); + } + } + + removeEventListener( + type: string, + callback: EventListener, + optionsOrUseCapture?: EventListenerOptions | boolean = {}, + ): void { + if (arguments.length < 2) { + throw new TypeError( + `Failed to execute 'removeEventListener' on 'EventTarget': 2 arguments required, but only ${arguments.length} present.`, + ); + } + + if (callback == null) { + return; + } + + validateCallback(callback, 'removeEventListener'); + + const processedType = String(type); + + const capture = + typeof optionsOrUseCapture === 'boolean' + ? optionsOrUseCapture + : Boolean(optionsOrUseCapture.capture); + + const listenerMap = capture ? this.#captureListeners : this.#listeners; + const listenerList = listenerMap.get(processedType); + if (listenerList == null) { + return; + } + + for (let i = 0; i < listenerList.length; i++) { + const listener = listenerList[i]; + + if (listener.callback === callback) { + listener.removed = true; + listenerList.splice(i, 1); + return; + } + } + } + + dispatchEvent(event: Event): boolean { + if (!(event instanceof Event)) { + throw new TypeError( + "Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'.", + ); + } + + if (getEventDispatchFlag(event)) { + throw new Error( + "Failed to execute 'dispatchEvent' on 'EventTarget': The event is already being dispatched.", + ); + } + + setIsTrusted(event, false); + + this.#dispatch(event); + + return !event.defaultPrevented; + } + + /** + * This internal version of `dispatchEvent` does not validate the input and + * does not reset the `isTrusted` flag, so it can be used for both trusted + * and not trusted events. + * + * Implements the "event dispatch" concept + * (see https://dom.spec.whatwg.org/#concept-event-dispatch). + */ + #dispatch(event: Event): void { + setEventDispatchFlag(event, true); + + const eventPath = this.#getEventPath(event); + setComposedPath(event, eventPath); + setTarget(event, this); + + for (let i = eventPath.length - 1; i >= 0; i--) { + if (getStopPropagationFlag(event)) { + break; + } + + const target = eventPath[i]; + setEventPhase( + event, + target === this ? Event.AT_TARGET : Event.CAPTURING_PHASE, + ); + target.#invoke(event, Event.CAPTURING_PHASE); + } + + for (const target of eventPath) { + if (getStopPropagationFlag(event)) { + break; + } + + // If the event does NOT bubble, we only dispatch the event to the + // target in the bubbling phase. + if (!event.bubbles && target !== this) { + break; + } + + setEventPhase( + event, + target === this ? Event.AT_TARGET : Event.BUBBLING_PHASE, + ); + target.#invoke(event, Event.BUBBLING_PHASE); + } + + setEventPhase(event, Event.NONE); + setCurrentTarget(event, null); + setComposedPath(event, []); + + setEventDispatchFlag(event, false); + setStopImmediatePropagationFlag(event, false); + setStopPropagationFlag(event, false); + } + + /** + * Builds the event path for an event about to be dispatched in this target + * (see https://dom.spec.whatwg.org/#event-path). + * + * The return value is also set as `composedPath` for the event. + */ + #getEventPath(event: Event): $ReadOnlyArray { + const path = []; + // eslint-disable-next-line consistent-this + let target: EventTarget | null = this; + + while (target != null) { + path.push(target); + // $FlowExpectedError[prop-missing] + target = target[EVENT_TARGET_GET_THE_PARENT_KEY](); + } + + return path; + } + + /** + * Implements the event listener invoke concept + * (see https://dom.spec.whatwg.org/#concept-event-listener-invoke). + */ + #invoke(event: Event, eventPhase: EventPhase) { + const listenerMap = + eventPhase === Event.CAPTURING_PHASE + ? this.#captureListeners + : this.#listeners; + + setCurrentTarget(event, this); + + // This is a copy so listeners added during dispatch are NOT executed. + const listenerList = listenerMap.get(event.type)?.slice(); + if (listenerList == null) { + return; + } + + for (const listener of listenerList) { + if (listener.removed) { + continue; + } + + if (listener.once) { + this.removeEventListener( + event.type, + listener.callback, + eventPhase === Event.CAPTURING_PHASE, + ); + } + + if (listener.passive) { + setInPassiveListenerFlag(event, true); + } + + const currentEvent = global.event; + global.event = event; + + const callback = listener.callback; + + try { + if (typeof callback === 'function') { + callback.call(this, event); + // $FlowExpectedError[method-unbinding] + } else if (typeof callback.handleEvent === 'function') { + callback.handleEvent(event); + } + } catch (error) { + // TODO: replace with `reportError` when it's available. + console.error(error); + } + + if (listener.passive) { + setInPassiveListenerFlag(event, false); + } + + global.event = currentEvent; + + if (getStopImmediatePropagationFlag(event)) { + break; + } + } + } + + #removeEventListenerRegistration( + registration: EventListenerRegistration, + listenerList: Array, + ): void { + for (let i = 0; i < listenerList.length; i++) { + const listener = listenerList[i]; + + if (listener === registration) { + listener.removed = true; + listenerList.splice(i, 1); + return; + } + } + } + + /** + * This a "protected" method to be overridden by a subclass to allow event + * propagation. + * + * Should implement the "get the parent" algorithm + * (see https://dom.spec.whatwg.org/#get-the-parent). + */ + // $FlowExpectedError[unsupported-syntax] + [EVENT_TARGET_GET_THE_PARENT_KEY](): EventTarget | null { + return null; + } + + /** + * This is "protected" method to dispatch trusted events. + */ + // $FlowExpectedError[unsupported-syntax] + [INTERNAL_DISPATCH_METHOD_KEY](event: Event): void { + this.#dispatch(event); + } +} + +function validateCallback(callback: EventListener, methodName: string): void { + if (typeof callback !== 'function' && typeof callback !== 'object') { + throw new TypeError( + `Failed to execute '${methodName}' on 'EventTarget': parameter 2 is not of type 'Object'.`, + ); + } +} + +const EVENT_DISPATCH_FLAG = Symbol('Event.dispatch'); + +function getEventDispatchFlag(event: Event): boolean { + // $FlowExpectedError[prop-missing] + return event[EVENT_DISPATCH_FLAG]; +} + +function setEventDispatchFlag(event: Event, value: boolean): void { + // $FlowExpectedError[prop-missing] + event[EVENT_DISPATCH_FLAG] = value; +} diff --git a/packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js new file mode 100644 index 00000000000000..db907c12b01a82 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js @@ -0,0 +1,232 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + * @fantom_flags enableAccessToHostTreeInFabric:true + */ + +import '../../../../../../Libraries/Core/InitializeCore.js'; + +import Event from '../Event'; +import {setInPassiveListenerFlag} from '../internals/EventInternals'; + +describe('Event', () => { + it('should throw an error if type is not passed', () => { + expect(() => { + // $FlowExpectedError[incompatible-call] + return new Event(); + }).toThrow( + "Failed to construct 'Event': 1 argument required, but only 0 present.", + ); + }); + + it('should throw an error if the given options is not an object, function, null or undefined', () => { + expect(() => { + // $FlowExpectedError[incompatible-call] + return new Event('custom', 1); + }).toThrow( + "Failed to construct 'Event': The provided value is not of type 'EventInit'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + return new Event('custom', '1'); + }).toThrow( + "Failed to construct 'Event': The provided value is not of type 'EventInit'.", + ); + + expect(() => { + return new Event('custom', null); + }).not.toThrow(); + + expect(() => { + return new Event('custom', undefined); + }).not.toThrow(); + + expect(() => { + return new Event('custom', {}); + }).not.toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-exact] + // $FlowExpectedError[prop-missing] + return new Event('custom', class {}); + }).not.toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-exact] + return new Event('custom', () => {}); + }).not.toThrow(); + }); + + it('should have default values for as a non-dispatched event', () => { + const event = new Event('custom'); + + expect(event.currentTarget).toBe(null); + expect(event.defaultPrevented).toBe(false); + expect(event.eventPhase).toBe(Event.NONE); + expect(event.isTrusted).toBe(false); + expect(event.target).toBe(null); + expect(event.composedPath()).toEqual([]); + }); + + it('should initialize the event with default values', () => { + const event = new Event('custom'); + + expect(event.type).toBe('custom'); + expect(event.bubbles).toBe(false); + expect(event.cancelable).toBe(false); + expect(event.composed).toBe(false); + }); + + it('should initialize the event with the given options', () => { + const eventWithAllOptionsSet = new Event('custom', { + bubbles: true, + cancelable: true, + composed: true, + }); + + expect(eventWithAllOptionsSet.bubbles).toBe(true); + expect(eventWithAllOptionsSet.cancelable).toBe(true); + expect(eventWithAllOptionsSet.composed).toBe(true); + + const bubblingEvent = new Event('custom', { + bubbles: true, + }); + + expect(bubblingEvent.bubbles).toBe(true); + expect(bubblingEvent.cancelable).toBe(false); + expect(bubblingEvent.composed).toBe(false); + + const cancelableEvent = new Event('custom', { + cancelable: true, + }); + + expect(cancelableEvent.bubbles).toBe(false); + expect(cancelableEvent.cancelable).toBe(true); + expect(cancelableEvent.composed).toBe(false); + + const composedEvent = new Event('custom', { + composed: true, + }); + + expect(composedEvent.bubbles).toBe(false); + expect(composedEvent.cancelable).toBe(false); + expect(composedEvent.composed).toBe(true); + }); + + it('should coerce values to the right types', () => { + // $FlowExpectedError[incompatible-call] + const eventWithAllOptionsSet = new Event(undefined, { + // $FlowExpectedError[incompatible-call] + bubbles: 1, + // $FlowExpectedError[incompatible-call] + cancelable: 'true', + // $FlowExpectedError[incompatible-call] + composed: {}, + }); + + expect(eventWithAllOptionsSet.type).toBe('undefined'); + expect(eventWithAllOptionsSet.bubbles).toBe(true); + expect(eventWithAllOptionsSet.cancelable).toBe(true); + expect(eventWithAllOptionsSet.composed).toBe(true); + }); + + it('should not allow writing the options after construction', () => { + 'use strict'; + // use strict mode to throw an error instead of silently failing + + const event = new Event('custom'); + + expect(() => { + // $FlowExpectedError[cannot-write] + event.bubbles = false; + }).toThrow(); + + expect(() => { + // $FlowExpectedError[cannot-write] + event.cancelable = false; + }).toThrow(); + + expect(() => { + // $FlowExpectedError[cannot-write] + event.composed = false; + }).toThrow(); + }); + + it('should set the timestamp with the current high resolution time', () => { + const lowerBoundTimestamp = performance.now(); + const event = new Event('type'); + const upperBoundTimestamp = performance.now(); + + expect(event.timeStamp).toBeGreaterThanOrEqual(lowerBoundTimestamp); + expect(event.timeStamp).toBeLessThanOrEqual(upperBoundTimestamp); + }); + + describe('preventDefault', () => { + let originalConsoleError; + let consoleErrorMock; + + beforeEach(() => { + originalConsoleError = console.error; + consoleErrorMock = jest.fn(); + // $FlowExpectedError[cannot-write] + console.error = consoleErrorMock; + }); + + afterEach(() => { + // $FlowExpectedError[cannot-write] + console.error = originalConsoleError; + }); + + it('does nothing with non-cancelable events', () => { + const event = new Event('custom', { + cancelable: false, + }); + + expect(event.defaultPrevented).toBe(false); + + event.preventDefault(); + + expect(event.defaultPrevented).toBe(false); + }); + + it('cancels cancelable events', () => { + const event = new Event('custom', { + cancelable: true, + }); + + expect(event.defaultPrevented).toBe(false); + + event.preventDefault(); + + expect(event.defaultPrevented).toBe(true); + }); + + it('does not cancel events with the "in passive listener" flag set, and logs an error', () => { + const event = new Event('custom', { + cancelable: true, + }); + + expect(event.defaultPrevented).toBe(false); + + setInPassiveListenerFlag(event, true); + + event.preventDefault(); + + expect(event.defaultPrevented).toBe(false); + + expect(consoleErrorMock).toHaveBeenCalledTimes(1); + const reportedError = consoleErrorMock.mock.lastCall[0]; + expect(reportedError).toBeInstanceOf(Error); + expect(reportedError.message).toBe( + 'Unable to preventDefault inside passive event listener invocation.', + ); + }); + }); +}); diff --git a/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js new file mode 100644 index 00000000000000..c38c434e3a64bd --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js @@ -0,0 +1,1009 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + * @fantom_flags enableAccessToHostTreeInFabric:true + */ + +import '../../../../../../Libraries/Core/InitializeCore.js'; + +import Event from '../Event'; +import EventTarget from '../EventTarget'; +import { + EVENT_TARGET_GET_THE_PARENT_KEY, + dispatchTrustedEvent, +} from '../internals/EventTargetInternals'; + +let listenerCallOrder = 0; + +function resetListenerCallOrder() { + listenerCallOrder = 0; +} + +type EventRecordingListener = JestMockFn<[Event], void> & { + eventData?: { + callOrder: number, + composedPath: $ReadOnlyArray, + currentTarget: Event['currentTarget'], + eventPhase: Event['eventPhase'], + target: Event['target'], + }, + ... +}; + +function createListener( + implementation?: Event => void, +): EventRecordingListener { + // $FlowExpectedError[prop-missing] + const listener: EventRecordingListener = jest.fn((event: Event) => { + listener.eventData = { + callOrder: listenerCallOrder++, + composedPath: event.composedPath(), + currentTarget: event.currentTarget, + eventPhase: event.eventPhase, + target: event.target, + }; + + if (implementation) { + implementation(event); + } + }); + + return listener; +} + +function createEventTargetHierarchyWithDepth( + depth: number, +): Array { + const targets = []; + + for (let i = 0; i < depth; i++) { + const target = new EventTarget(); + const parentTarget = targets[targets.length - 1]; + + // $FlowExpectedError[prop-missing] + target[EVENT_TARGET_GET_THE_PARENT_KEY] = () => parentTarget; + + targets.push(target); + } + + return targets; +} + +describe('EventTarget', () => { + describe('addEventListener', () => { + it('should throw an error if event or callback are NOT passed', () => { + const eventTarget = new EventTarget(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener(); + }).toThrow( + "Failed to execute 'addEventListener' on 'EventTarget': 2 arguments required, but only 0 present.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom'); + }).toThrow( + "Failed to execute 'addEventListener' on 'EventTarget': 2 arguments required, but only 1 present.", + ); + + expect(() => { + eventTarget.addEventListener('custom', () => {}); + }).not.toThrow(); + }); + + it('should throw an error if the callback is NOT a function or an object', () => { + const eventTarget = new EventTarget(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom', 'foo'); + }).toThrow( + "Failed to execute 'addEventListener' on 'EventTarget': parameter 2 is not of type 'Object'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom', Symbol('test')); + }).toThrow( + "Failed to execute 'addEventListener' on 'EventTarget': parameter 2 is not of type 'Object'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom', true); + }).toThrow( + "Failed to execute 'addEventListener' on 'EventTarget': parameter 2 is not of type 'Object'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom', 5); + }).toThrow( + "Failed to execute 'addEventListener' on 'EventTarget': parameter 2 is not of type 'Object'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom', {}); + }).not.toThrow(); + + // It should work even if the `handleEvent` property is not a function. + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom', { + handleEvent: 5, + }); + }).not.toThrow(); + + expect(() => { + eventTarget.addEventListener('custom', { + handleEvent: () => {}, + }); + }).not.toThrow(); + + expect(() => { + eventTarget.addEventListener('custom', () => {}); + }).not.toThrow(); + }); + + it('should throw an error if the passed `signal` is not an instance of `AbortSignal`', () => { + const eventTarget = new EventTarget(); + + const abortController = new AbortController(); + + expect(() => { + eventTarget.addEventListener('custom', () => {}, { + signal: undefined, + }); + }).not.toThrow(); + + expect(() => { + eventTarget.addEventListener('custom', () => {}, { + signal: abortController.signal, + }); + }).not.toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom', () => {}, { + signal: null, + }); + }).toThrow( + "Failed to execute 'addEventListener' on 'EventTarget': Failed to read the 'signal' property from 'AddEventListenerOptions': Failed to convert value to 'AbortSignal'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.addEventListener('custom', () => {}, { + signal: {}, + }); + }).toThrow( + "Failed to execute 'addEventListener' on 'EventTarget': Failed to read the 'signal' property from 'AddEventListenerOptions': Failed to convert value to 'AbortSignal'.", + ); + }); + }); + + describe('removeEventListener', () => { + it('should throw an error if event or callback are NOT passed', () => { + const eventTarget = new EventTarget(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.removeEventListener(); + }).toThrow( + "Failed to execute 'removeEventListener' on 'EventTarget': 2 arguments required, but only 0 present.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.removeEventListener('eventName'); + }).toThrow( + "Failed to execute 'removeEventListener' on 'EventTarget': 2 arguments required, but only 1 present.", + ); + + expect(() => { + eventTarget.removeEventListener('eventName', () => {}); + }).not.toThrow(); + }); + + it('should throw an error if the callback is NOT a function or an object', () => { + const eventTarget = new EventTarget(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.removeEventListener('eventName', 'foo'); + }).toThrow( + "Failed to execute 'removeEventListener' on 'EventTarget': parameter 2 is not of type 'Object'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.removeEventListener('eventName', Symbol('test')); + }).toThrow( + "Failed to execute 'removeEventListener' on 'EventTarget': parameter 2 is not of type 'Object'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.removeEventListener('eventName', true); + }).toThrow( + "Failed to execute 'removeEventListener' on 'EventTarget': parameter 2 is not of type 'Object'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.removeEventListener('eventName', 5); + }).toThrow( + "Failed to execute 'removeEventListener' on 'EventTarget': parameter 2 is not of type 'Object'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.removeEventListener('eventName', {}); + }).not.toThrow(); + + // It should work even if the `handleEvent` property is not a function. + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.removeEventListener('eventName', { + handleEvent: 5, + }); + }).not.toThrow(); + + expect(() => { + eventTarget.removeEventListener('eventName', { + handleEvent: () => {}, + }); + }).not.toThrow(); + + expect(() => { + eventTarget.removeEventListener('eventName', () => {}); + }).not.toThrow(); + }); + }); + + describe('internal `dispatchTrustedEvent`', () => { + it('should set the `isTrusted` flag to `true`', () => { + const eventTarget = new EventTarget(); + + const listener = createListener(); + + eventTarget.addEventListener('custom', listener); + + const event = new Event('custom'); + + dispatchTrustedEvent(eventTarget, event); + + expect(event.isTrusted).toBe(true); + }); + }); + + describe('dispatchEvent', () => { + it('should throw an error if event is NOT passed', () => { + const eventTarget = new EventTarget(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.dispatchEvent(); + }).toThrow( + "Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'.", + ); + + expect(() => { + eventTarget.dispatchEvent(new Event('eventName')); + }).not.toThrow(); + }); + + it('should throw an error if the passed value is NOT an `Event` instance', () => { + const eventTarget = new EventTarget(); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.dispatchEvent('foo'); + }).toThrow( + "Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'.", + ); + + expect(() => { + // $FlowExpectedError[incompatible-call] + eventTarget.dispatchEvent(true); + }).toThrow( + "Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is not of type 'Event'.", + ); + }); + + it('works with listeners as functions and as objects with a `handleEvent` method', () => { + const eventTarget = new EventTarget(); + + const listenerFunction = createListener(); + const handleEventMethod = createListener(); + const listenerObject = { + handleEvent: handleEventMethod, + }; + + eventTarget.addEventListener('custom', listenerFunction); + eventTarget.addEventListener('custom', listenerObject); + + const event = new Event('custom'); + + eventTarget.dispatchEvent(event); + + expect(listenerFunction.mock.lastCall[0]).toBe(event); + expect(handleEventMethod.mock.lastCall[0]).toBe(event); + }); + + it('sets the global `event` value to the event while it is in dispatch', () => { + const eventTarget = new EventTarget(); + + let globalEventDuringDispatch; + let globalEventBeforeDispatch = Symbol('some value'); + + global.event = globalEventBeforeDispatch; + + const listener = createListener(() => { + globalEventDuringDispatch = global.event; + }); + + eventTarget.addEventListener('custom', listener); + + const event = new Event('custom'); + + eventTarget.dispatchEvent(event); + + expect(globalEventDuringDispatch).toBe(event); + expect(global.event).toBe(globalEventBeforeDispatch); + }); + + it('sets the global `event` value to the right event, when they are dispatched recursively', () => { + const eventTarget1 = new EventTarget(); + const eventTarget2 = new EventTarget(); + + let globalEventBeforeDispatch = Symbol('some value'); + + global.event = globalEventBeforeDispatch; + + const event1 = new Event('custom'); + const event2 = new Event('other'); + + let globalEventInListener1A; + const listener1A = createListener(() => { + globalEventInListener1A = global.event; + + eventTarget2.dispatchEvent(event2); + }); + + let globalEventInListener1B; + const listener1B = createListener(() => { + globalEventInListener1B = global.event; + }); + + let globalEventInListener2; + const listener2 = createListener(() => { + globalEventInListener2 = global.event; + }); + + eventTarget1.addEventListener('custom', listener1A); + eventTarget1.addEventListener('custom', listener1B); + + eventTarget2.addEventListener('other', listener2); + + eventTarget1.dispatchEvent(event1); + + expect(global.event).toBe(globalEventBeforeDispatch); + expect(globalEventInListener1A).toBe(event1); + expect(globalEventInListener1B).toBe(event1); + expect(globalEventInListener2).toBe(event2); + }); + + it('sets the `isTrusted` flag to `false`', () => { + const eventTarget = new EventTarget(); + + const listener = createListener(); + + eventTarget.addEventListener('custom', listener); + + const event = new Event('custom'); + + expect(event.isTrusted).toBe(false); + + dispatchTrustedEvent(eventTarget, event); + + expect(event.isTrusted).toBe(true); + + eventTarget.dispatchEvent(event); + + expect(event.isTrusted).toBe(false); + }); + + it('should call listeners in the same target in the order in which they were added', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const firstListener = createListener(); + const secondListener = createListener(); + const thirdListener = createListener(); + + node.addEventListener('custom', firstListener); + node.addEventListener('custom', secondListener); + node.addEventListener('custom', thirdListener); + + // Dispatch + + const event = new Event('custom'); + + node.dispatchEvent(event); + + expect(firstListener.eventData?.callOrder).toBe(0); + expect(secondListener.eventData?.callOrder).toBe(1); + expect(thirdListener.eventData?.callOrder).toBe(2); + }); + + describe('bubbling', () => { + it('should call listeners in the capturing phase, target phase and bubbling phase when dispatching events that bubble', () => { + const [parentTarget, childTarget, grandchildTarget] = + createEventTargetHierarchyWithDepth(3); + + // Listener setup + + resetListenerCallOrder(); + + const capturingListenerOnParent = createListener(); + const capturingListenerOnChild = createListener(); + const capturingListenerOnGrandchild = createListener(); + const bubblingListenerOnParent = createListener(); + const bubblingListenerOnChild = createListener(); + const bubblingListenerOnGrandchild = createListener(); + + parentTarget.addEventListener( + 'custom', + capturingListenerOnParent, + true, + ); + parentTarget.addEventListener('custom', bubblingListenerOnParent); + + childTarget.addEventListener('custom', capturingListenerOnChild, true); + childTarget.addEventListener('custom', bubblingListenerOnChild); + + grandchildTarget.addEventListener( + 'custom', + capturingListenerOnGrandchild, + true, + ); + grandchildTarget.addEventListener( + 'custom', + bubblingListenerOnGrandchild, + ); + + // Dispatch + + const event = new Event('custom', {bubbles: true}); + + const result = grandchildTarget.dispatchEvent(event); + + expect(result).toBe(true); + + expect(capturingListenerOnParent.eventData).toEqual({ + callOrder: 0, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: parentTarget, + eventPhase: Event.CAPTURING_PHASE, + target: grandchildTarget, + }); + expect(capturingListenerOnParent.mock.contexts[0]).toBe(parentTarget); + + expect(capturingListenerOnChild.eventData).toEqual({ + callOrder: 1, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: childTarget, + eventPhase: Event.CAPTURING_PHASE, + target: grandchildTarget, + }); + expect(capturingListenerOnChild.mock.contexts[0]).toBe(childTarget); + + expect(capturingListenerOnGrandchild.eventData).toEqual({ + callOrder: 2, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: grandchildTarget, + eventPhase: Event.AT_TARGET, + target: grandchildTarget, + }); + expect(capturingListenerOnGrandchild.mock.contexts[0]).toBe( + grandchildTarget, + ); + + expect(bubblingListenerOnGrandchild.eventData).toEqual({ + callOrder: 3, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: grandchildTarget, + eventPhase: Event.AT_TARGET, + target: grandchildTarget, + }); + expect(bubblingListenerOnGrandchild.mock.contexts[0]).toBe( + grandchildTarget, + ); + + expect(bubblingListenerOnChild.eventData).toEqual({ + callOrder: 4, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: childTarget, + eventPhase: Event.BUBBLING_PHASE, + target: grandchildTarget, + }); + expect(bubblingListenerOnChild.mock.contexts[0]).toBe(childTarget); + + expect(bubblingListenerOnParent.eventData).toEqual({ + callOrder: 5, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: parentTarget, + eventPhase: Event.BUBBLING_PHASE, + target: grandchildTarget, + }); + expect(bubblingListenerOnParent.mock.contexts[0]).toBe(parentTarget); + }); + + it('should call listeners in the capturing phase and target phase, but NOT in the bubbling phase when dispatching events that do NOT bubble', () => { + const [parentTarget, childTarget, grandchildTarget] = + createEventTargetHierarchyWithDepth(3); + + // Listener setup + + resetListenerCallOrder(); + + const capturingListenerOnParent = createListener(); + const capturingListenerOnChild = createListener(); + const capturingListenerOnGrandchild = createListener(); + const bubblingListenerOnParent = createListener(); + const bubblingListenerOnChild = createListener(); + const bubblingListenerOnGrandchild = createListener(); + + parentTarget.addEventListener( + 'custom', + capturingListenerOnParent, + true, + ); + parentTarget.addEventListener('custom', bubblingListenerOnParent); + + childTarget.addEventListener('custom', capturingListenerOnChild, true); + childTarget.addEventListener('custom', bubblingListenerOnChild); + + grandchildTarget.addEventListener( + 'custom', + capturingListenerOnGrandchild, + true, + ); + grandchildTarget.addEventListener( + 'custom', + bubblingListenerOnGrandchild, + ); + + // Dispatch + + const event = new Event('custom', {bubbles: false}); + + const result = grandchildTarget.dispatchEvent(event); + + expect(result).toBe(true); + + expect(capturingListenerOnParent.eventData).toEqual({ + callOrder: 0, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: parentTarget, + eventPhase: Event.CAPTURING_PHASE, + target: grandchildTarget, + }); + expect(capturingListenerOnParent.mock.contexts[0]).toBe(parentTarget); + + expect(capturingListenerOnChild.eventData).toEqual({ + callOrder: 1, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: childTarget, + eventPhase: Event.CAPTURING_PHASE, + target: grandchildTarget, + }); + expect(capturingListenerOnChild.mock.contexts[0]).toBe(childTarget); + + expect(capturingListenerOnGrandchild.eventData).toEqual({ + callOrder: 2, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: grandchildTarget, + eventPhase: Event.AT_TARGET, + target: grandchildTarget, + }); + expect(capturingListenerOnGrandchild.mock.contexts[0]).toBe( + grandchildTarget, + ); + + expect(bubblingListenerOnGrandchild.eventData).toEqual({ + callOrder: 3, + composedPath: [grandchildTarget, childTarget, parentTarget], + currentTarget: grandchildTarget, + eventPhase: Event.AT_TARGET, + target: grandchildTarget, + }); + expect(bubblingListenerOnGrandchild.mock.contexts[0]).toBe( + grandchildTarget, + ); + + // NO bubbling phase calls + expect(bubblingListenerOnChild).not.toHaveBeenCalled(); + expect(bubblingListenerOnParent).not.toHaveBeenCalled(); + }); + + it('should restore event properties after dispatch', () => { + const [parentTarget, childTarget, grandchildTarget] = + createEventTargetHierarchyWithDepth(3); + + // Listener setup + + resetListenerCallOrder(); + + const capturingListenerOnParent = createListener(); + const capturingListenerOnChild = createListener(); + const capturingListenerOnGrandchild = createListener(); + const bubblingListenerOnParent = createListener(); + const bubblingListenerOnChild = createListener(); + const bubblingListenerOnGrandchild = createListener(event => { + event.preventDefault(); + }); + + parentTarget.addEventListener( + 'custom', + capturingListenerOnParent, + true, + ); + parentTarget.addEventListener('custom', bubblingListenerOnParent); + + childTarget.addEventListener('custom', capturingListenerOnChild, true); + childTarget.addEventListener('custom', bubblingListenerOnChild); + + grandchildTarget.addEventListener( + 'custom', + capturingListenerOnGrandchild, + true, + ); + grandchildTarget.addEventListener( + 'custom', + bubblingListenerOnGrandchild, + ); + + // Dispatch + + const event = new Event('custom', {bubbles: true, cancelable: true}); + + grandchildTarget.dispatchEvent(event); + + // Should be restored + expect(event.composedPath()).toEqual([]); + expect(event.currentTarget).toBe(null); + expect(event.eventPhase).toBe(Event.NONE); + + // Should be preserved + expect(event.target).toBe(grandchildTarget); + expect(event.defaultPrevented).toBe(true); + }); + }); + + describe('stopPropagation', () => { + it('should continue calling listeners in the same target, but NOT on parents', () => { + const [parentTarget, childTarget] = + createEventTargetHierarchyWithDepth(2); + + // Listener setup + + resetListenerCallOrder(); + + const parentListener = createListener(); + + const firstListener = createListener(); + const secondListener = createListener(event => { + event.stopPropagation(); + }); + const thirdListener = createListener(); + + parentTarget.addEventListener('custom', parentListener); + + childTarget.addEventListener('custom', firstListener); + childTarget.addEventListener('custom', secondListener); + childTarget.addEventListener('custom', thirdListener); + + // Dispatch + + const event = new Event('custom'); + + childTarget.dispatchEvent(event); + + resetListenerCallOrder(); + + expect(firstListener).toHaveBeenCalled(); + expect(secondListener).toHaveBeenCalled(); + expect(thirdListener).toHaveBeenCalled(); + expect(parentListener).not.toHaveBeenCalled(); + }); + }); + + describe('stopImmediatePropagation', () => { + it('should stop calling listeners on the same target as well', () => { + const [parentTarget, childTarget] = + createEventTargetHierarchyWithDepth(2); + + // Listener setup + + resetListenerCallOrder(); + + const parentListener = createListener(); + + const firstListener = createListener(); + const secondListener = createListener(event => { + event.stopImmediatePropagation(); + }); + const thirdListener = createListener(); + + parentTarget.addEventListener('custom', parentListener); + + childTarget.addEventListener('custom', firstListener); + childTarget.addEventListener('custom', secondListener); + childTarget.addEventListener('custom', thirdListener); + + // Dispatch + + const event = new Event('custom'); + + childTarget.dispatchEvent(event); + + resetListenerCallOrder(); + + expect(firstListener).toHaveBeenCalled(); + expect(secondListener).toHaveBeenCalled(); + expect(thirdListener).not.toHaveBeenCalled(); + + expect(parentListener).not.toHaveBeenCalled(); + }); + }); + + describe('preventDefault', () => { + it('should cancel cancelable events', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const listener = createListener(event => { + event.preventDefault(); + }); + + node.addEventListener('custom', listener); + + // Dispatch + + const event = new Event('custom', {cancelable: true}); + + const result = node.dispatchEvent(event); + + expect(result).toBe(false); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should NOT cancel cancelable event in passive listeners', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const listener = createListener(event => { + event.preventDefault(); + }); + + node.addEventListener('custom', listener, {passive: true}); + + // Dispatch + + const event = new Event('custom', {cancelable: true}); + + const result = node.dispatchEvent(event); + + expect(result).toBe(true); + + expect(event.defaultPrevented).toBe(false); + }); + }); + + describe('events with `once`', () => { + it('should remove the listener after the first call', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const listener = createListener(); + + node.addEventListener('custom', listener, {once: true}); + + // Dispatch + + const event = new Event('custom'); + + node.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + + node.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + describe('events with `signal`', () => { + it('should remove the listener when the signal is aborted before registration', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const listener = createListener(); + + const abortController = new AbortController(); + + abortController.abort(); + + node.addEventListener('custom', listener, { + signal: abortController.signal, + }); + + // Dispatch + + const event = new Event('custom'); + + node.dispatchEvent(event); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should remove the listener when the signal is aborted after registration', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const listener = createListener(); + + const abortController = new AbortController(); + + node.addEventListener('custom', listener, { + signal: abortController.signal, + }); + + // Dispatch + + const event = new Event('custom'); + + node.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + + abortController.abort(); + + node.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + + describe('dispatching an event while the same event is being dispatched', () => { + it('should throw an error', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const event = new Event('custom'); + + let errorWhenRedispatching: ?Error; + + const listener = createListener(() => { + try { + node.dispatchEvent(event); + } catch (error) { + errorWhenRedispatching = error; + } + }); + + node.addEventListener('custom', listener); + + node.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(errorWhenRedispatching).toBeInstanceOf(Error); + expect(errorWhenRedispatching?.message).toBe( + "Failed to execute 'dispatchEvent' on 'EventTarget': The event is already being dispatched.", + ); + }); + }); + + describe('adding listeners during dispatch', () => { + it('should NOT call listeners for a target and phase that were added during the dispatch of the event for that target and phase', () => { + const [parentTarget, childTarget] = + createEventTargetHierarchyWithDepth(2); + + // Listener setup + + resetListenerCallOrder(); + + const newParentBubblingListener = createListener(); + + const newChildBubblingListener = createListener(); + const newChildCapturingListener = createListener(); + + const childCapturingListener = createListener(() => { + // These should be called + childTarget.addEventListener('custom', newChildBubblingListener); + parentTarget.addEventListener('custom', newParentBubblingListener); + + // This should NOT be called + childTarget.addEventListener( + 'custom', + newChildCapturingListener, + true, + ); + }); + + childTarget.addEventListener('custom', childCapturingListener, true); + + // Dispatch + + const event = new Event('custom', {bubbles: true}); + + childTarget.dispatchEvent(event); + + expect(childCapturingListener).toHaveBeenCalled(); + expect(newChildCapturingListener).not.toHaveBeenCalled(); + expect(newChildBubblingListener).toHaveBeenCalled(); + expect(newParentBubblingListener).toHaveBeenCalled(); + }); + }); + + describe('removing listeners during dispatch', () => { + it('should NOT call them', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const listener = createListener(() => { + node.removeEventListener('custom', listenerThatWillBeRemoved); + }); + const listenerThatWillBeRemoved = createListener(); + + node.addEventListener('custom', listener); + node.addEventListener('custom', listenerThatWillBeRemoved); + + // Dispatch + + const event = new Event('custom'); + + node.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listenerThatWillBeRemoved).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js b/packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js new file mode 100644 index 00000000000000..7192ca4e57c08a --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js @@ -0,0 +1,120 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * This method contains internal implementation details for the `Event` module + * and it is defined in a separate module to keep the exports in `Event` clean + * (only with public exports). + */ + +import type Event, {EventPhase} from '../Event'; +import type EventTarget from '../EventTarget'; + +export const COMPOSED_PATH_KEY: symbol = Symbol('composedPath'); +export const CURRENT_TARGET_KEY: symbol = Symbol('currentTarget'); +export const EVENT_PHASE_KEY: symbol = Symbol('eventPhase'); +export const IN_PASSIVE_LISTENER_FLAG_KEY: symbol = Symbol( + 'inPassiveListenerFlag', +); +export const IS_TRUSTED_KEY: symbol = Symbol('isTrusted'); +export const STOP_IMMEDIATE_PROPAGATION_FLAG_KEY: symbol = Symbol( + 'stopPropagationFlag', +); +export const STOP_PROPAGATION_FLAG_KEY: symbol = Symbol('stopPropagationFlag'); +export const TARGET_KEY: symbol = Symbol('target'); + +export function getCurrentTarget(event: Event): EventTarget | null { + // $FlowExpectedError[prop-missing] + return event[CURRENT_TARGET_KEY]; +} + +export function setCurrentTarget( + event: Event, + currentTarget: EventTarget | null, +): void { + // $FlowExpectedError[prop-missing] + event[CURRENT_TARGET_KEY] = currentTarget; +} + +export function getComposedPath(event: Event): $ReadOnlyArray { + // $FlowExpectedError[prop-missing] + return event[COMPOSED_PATH_KEY]; +} + +export function setComposedPath( + event: Event, + composedPath: $ReadOnlyArray, +): void { + // $FlowExpectedError[prop-missing] + event[COMPOSED_PATH_KEY] = composedPath; +} + +export function getEventPhase(event: Event): EventPhase { + // $FlowExpectedError[prop-missing] + return event[EVENT_PHASE_KEY]; +} + +export function setEventPhase(event: Event, eventPhase: EventPhase): void { + // $FlowExpectedError[prop-missing] + event[EVENT_PHASE_KEY] = eventPhase; +} + +export function getInPassiveListenerFlag(event: Event): boolean { + // $FlowExpectedError[prop-missing] + return event[IN_PASSIVE_LISTENER_FLAG_KEY]; +} + +export function setInPassiveListenerFlag(event: Event, value: boolean): void { + // $FlowExpectedError[prop-missing] + event[IN_PASSIVE_LISTENER_FLAG_KEY] = value; +} + +export function getIsTrusted(event: Event): boolean { + // $FlowExpectedError[prop-missing] + return event[IS_TRUSTED_KEY]; +} + +export function setIsTrusted(event: Event, isTrusted: boolean): void { + // $FlowExpectedError[prop-missing] + event[IS_TRUSTED_KEY] = isTrusted; +} + +export function getStopImmediatePropagationFlag(event: Event): boolean { + // $FlowExpectedError[prop-missing] + return event[STOP_IMMEDIATE_PROPAGATION_FLAG_KEY]; +} + +export function setStopImmediatePropagationFlag( + event: Event, + value: boolean, +): void { + // $FlowExpectedError[prop-missing] + event[STOP_IMMEDIATE_PROPAGATION_FLAG_KEY] = value; +} + +export function getStopPropagationFlag(event: Event): boolean { + // $FlowExpectedError[prop-missing] + return event[STOP_PROPAGATION_FLAG_KEY]; +} + +export function setStopPropagationFlag(event: Event, value: boolean): void { + // $FlowExpectedError[prop-missing] + event[STOP_PROPAGATION_FLAG_KEY] = value; +} + +export function getTarget(event: Event): EventTarget | null { + // $FlowExpectedError[prop-missing] + return event[TARGET_KEY]; +} + +export function setTarget(event: Event, target: EventTarget | null): void { + // $FlowExpectedError[prop-missing] + event[TARGET_KEY] = target; +} diff --git a/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js b/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js new file mode 100644 index 00000000000000..8061207d597263 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js @@ -0,0 +1,52 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * This method contains internal implementation details for the `EventTarget` + * module and it is defined in a separate module to keep the exports in + * the original module clean (only with public exports). + */ + +import type Event from '../Event'; +import type EventTarget from '../EventTarget'; + +import {setIsTrusted} from './EventInternals'; + +/** + * Use this symbol as key for a method to implement the "get the parent" + * algorithm in an `EventTarget` subclass. + */ +export const EVENT_TARGET_GET_THE_PARENT_KEY: symbol = Symbol( + 'EventTarget[get the parent]', +); + +/** + * This is only exposed to implement the method in `EventTarget`. + * Do NOT use this directly (use the `dispatchTrustedEvent` method instead). + */ +export const INTERNAL_DISPATCH_METHOD_KEY: symbol = Symbol( + 'EventTarget[dispatch]', +); + +/** + * Dispatches a trusted event to the given event target. + * + * This should only be used by the runtime to dispatch native events to + * JavaScript. + */ +export function dispatchTrustedEvent( + eventTarget: EventTarget, + event: Event, +): void { + setIsTrusted(event, true); + + // $FlowExpectedError[prop-missing] + return eventTarget[INTERNAL_DISPATCH_METHOD_KEY](event); +} From e13643031da53cacf3aa6a3b37ed35d66880f329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Mon, 27 Jan 2025 06:45:37 -0800 Subject: [PATCH 2/5] [skip ci] Add benchmark for EventTarget (#48886) Summary: Changelog: [internal] Creates a benchmarks to measure the performance of `EventTarget`. Reviewed By: javache Differential Revision: D67750677 --- .../__tests__/EventTarget-benchmark-itest.js | 110 ++++++++++++++++++ .../dom/events/__tests__/EventTarget-itest.js | 24 +--- .../createEventTargetHierarchyWithDepth.js | 38 ++++++ 3 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js create mode 100644 packages/react-native/src/private/webapis/dom/events/__tests__/createEventTargetHierarchyWithDepth.js diff --git a/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js new file mode 100644 index 00000000000000..dea8de92266b8a --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js @@ -0,0 +1,110 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import '../../../../../../Libraries/Core/InitializeCore.js'; + +import Event from '../Event'; +import EventTarget from '../EventTarget'; +import createEventTargetHierarchyWithDepth from './createEventTargetHierarchyWithDepth'; +import {unstable_benchmark} from '@react-native/fantom'; + +let event: Event; +let eventTarget: EventTarget; + +unstable_benchmark + .suite('EventTarget') + .add( + 'dispatchEvent, no bubbling, no listeners', + () => { + eventTarget.dispatchEvent(event); + }, + { + beforeAll: () => { + event = new Event('custom'); + eventTarget = new EventTarget(); + }, + }, + ) + .add( + 'dispatchEvent, no bubbling, single listener', + () => { + eventTarget.dispatchEvent(event); + }, + { + beforeAll: () => { + event = new Event('custom'); + eventTarget = new EventTarget(); + eventTarget.addEventListener('custom', () => {}); + }, + }, + ) + .add( + 'dispatchEvent, no bubbling, multiple listeners', + () => { + eventTarget.dispatchEvent(event); + }, + { + beforeAll: () => { + event = new Event('custom'); + eventTarget = new EventTarget(); + for (let i = 0; i < 100; i++) { + eventTarget.addEventListener('custom', () => {}); + } + }, + }, + ) + .add( + 'dispatchEvent, bubbling, no listeners', + () => { + eventTarget.dispatchEvent(event); + }, + { + beforeAll: () => { + event = new Event('custom', {bubbles: true}); + const targets = createEventTargetHierarchyWithDepth(100); + eventTarget = targets[targets.length - 1]; + }, + }, + ) + .add( + 'dispatchEvent, bubbling, single listener per target', + () => { + eventTarget.dispatchEvent(event); + }, + { + beforeAll: () => { + event = new Event('custom', {bubbles: true}); + const targets = createEventTargetHierarchyWithDepth(100); + eventTarget = targets[targets.length - 1]; + for (const target of targets) { + target.addEventListener('custom', () => {}); + } + }, + }, + ) + .add( + 'dispatchEvent, bubbling, multiple listeners per target', + () => { + eventTarget.dispatchEvent(event); + }, + { + beforeAll: () => { + event = new Event('custom', {bubbles: true}); + const targets = createEventTargetHierarchyWithDepth(100); + eventTarget = targets[targets.length - 1]; + for (const target of targets) { + for (let i = 0; i < 100; i++) { + target.addEventListener('custom', () => {}); + } + } + }, + }, + ); diff --git a/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js index c38c434e3a64bd..73076c1691f12e 100644 --- a/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js @@ -14,10 +14,8 @@ import '../../../../../../Libraries/Core/InitializeCore.js'; import Event from '../Event'; import EventTarget from '../EventTarget'; -import { - EVENT_TARGET_GET_THE_PARENT_KEY, - dispatchTrustedEvent, -} from '../internals/EventTargetInternals'; +import {dispatchTrustedEvent} from '../internals/EventTargetInternals'; +import createEventTargetHierarchyWithDepth from './createEventTargetHierarchyWithDepth'; let listenerCallOrder = 0; @@ -57,24 +55,6 @@ function createListener( return listener; } -function createEventTargetHierarchyWithDepth( - depth: number, -): Array { - const targets = []; - - for (let i = 0; i < depth; i++) { - const target = new EventTarget(); - const parentTarget = targets[targets.length - 1]; - - // $FlowExpectedError[prop-missing] - target[EVENT_TARGET_GET_THE_PARENT_KEY] = () => parentTarget; - - targets.push(target); - } - - return targets; -} - describe('EventTarget', () => { describe('addEventListener', () => { it('should throw an error if event or callback are NOT passed', () => { diff --git a/packages/react-native/src/private/webapis/dom/events/__tests__/createEventTargetHierarchyWithDepth.js b/packages/react-native/src/private/webapis/dom/events/__tests__/createEventTargetHierarchyWithDepth.js new file mode 100644 index 00000000000000..950d2c5682ea22 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/createEventTargetHierarchyWithDepth.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import EventTarget from '../EventTarget'; +import {EVENT_TARGET_GET_THE_PARENT_KEY} from '../internals/EventTargetInternals'; + +/** + * Returns a tree of EventTargets with the given depth (starting from the root). + */ +export default function createEventTargetHierarchyWithDepth( + depth: number, +): Array { + if (depth < 1) { + throw new Error('Depth must be greater or equal to 1'); + } + + const targets = []; + + for (let i = 0; i < depth; i++) { + const target = new EventTarget(); + const parentTarget = targets[targets.length - 1]; + + // $FlowExpectedError[prop-missing] + target[EVENT_TARGET_GET_THE_PARENT_KEY] = () => parentTarget; + + targets.push(target); + } + + return targets; +} From f7911fc3c2df63e7ce8f9d557e9ba4682a9926bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Mon, 27 Jan 2025 06:45:37 -0800 Subject: [PATCH 3/5] [skip ci] Add regression test for EventTarget (#48431) Summary: Changelog: [internal] Adds a regression test to make sure we implement the correct spec-compliant behavior for a possible bug in ~~the Web spec~~ __Chrome__: https://github.com/whatwg/dom/issues/1346 Edit: the bug is in the Chrome implementation, not in the spec. Reviewed By: javache Differential Revision: D67758702 --- .../dom/events/__tests__/EventTarget-itest.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js index 73076c1691f12e..265f17ec58f06a 100644 --- a/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js @@ -985,5 +985,53 @@ describe('EventTarget', () => { expect(listenerThatWillBeRemoved).not.toHaveBeenCalled(); }); }); + + describe('re-attaching a previous listener with a pending signal', () => { + // This is a regression test for https://github.com/whatwg/dom/issues/1346 + it('should NOT remove the new subscription when the signal for the old subscription is aborted', () => { + const [node] = createEventTargetHierarchyWithDepth(1); + + // Listener setup + + resetListenerCallOrder(); + + const listener = createListener(); + + const abortController = new AbortController(); + + node.addEventListener('custom', listener, { + signal: abortController.signal, + }); + + // Dispatch + + const event = new Event('custom'); + + node.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + + node.removeEventListener('custom', listener); + + node.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + + // Added without a signal + node.addEventListener('custom', listener); + + node.dispatchEvent(event); + + // Listener is called + expect(listener).toHaveBeenCalledTimes(2); + + abortController.abort(); + + node.dispatchEvent(event); + + // Listener is called + expect(listener).toHaveBeenCalledTimes(3); + }); + }); }); }); From 3dea2355126fc6542223afe1b079476e84f23bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Mon, 27 Jan 2025 06:45:37 -0800 Subject: [PATCH 4/5] [skip ci] Make EventTarget compatible with the existing implementation of ReadOnlyNode (#48427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Changelog: [internal] The `ReactNativeElement` class was refactored for performance reasons, and the current implementation does **NOT** call `super()`, and it inlines the parent constructor instead. When it eventually extends `EventTarget`, things won't work as expected because the existing `EventTarget` implementation has constructor dependencies. This refactors the current implementation of `EventTarget` to eliminate those constructor side-effects, and eliminates the constructor altogether. This breaks encapsulation, but it has some positive side-effects on performance: 1. Creating `EventTarget` instances is faster because it has no constructor logic. 2. Improves memory by not creating maps to hold the event listeners if no event listeners are ever added to the target (which is very common). 3. Improves the overall runtime performance of the methods in the class by migrating away from private methods (which are known to be slow on the babel transpiled version we're currently using). Extra: it also simplifies making window/the global scope implement the EventTarget interface :) ## Benchmark results Before: | Latency average (ns) | Latency median (ns) | Samples | Task name | Throughput average (ops/s) | Throughput median (ops/s) | | ---|--- |--- |--- |---|---| | 8234.22 ± 0.27% | 8132.00 | 121445 | dispatchEvent, no bubbling, no listeners | 122323 ± 0.02% | 122971 | | 9001.22 ± 0.41% | 8883.00 | 111097 | dispatchEvent, no bubbling, single listener | 111981 ± 0.02% | 112575 | | 51777.94 ± 0.58% | 51247.00 | 19314 | dispatchEvent, no bubbling, multiple listeners | 19393 ± 0.04% | 19513 | | 8256.65 ± 0.29% | 8152.00 | 121115 | dispatchEvent, bubbling, no listeners | 122031 ± 0.02% | 122669 | | 9064.32 ± 0.44% | 8933.00 | 110323 | dispatchEvent, bubbling, single listener per target | 111265 ± 0.02% | 111944 | | 51879.66 ± 0.27% | 51447.00 | 19276 | dispatchEvent, bubbling, multiple listeners per target | 19325 ± 0.04% | 19437 | After: | Latency average (ns) | Latency median (ns) | Samples | Task name | Throughput average (ops/s) | Throughput median (ops/s)| | ---------------------|---------------------|---------|--------------------------------------------------------|----------------------------|--------------------------| | 5664.62 ± 0.50% | 5588.00 | 176535 | dispatchEvent, no bubbling, no listeners | 178219 ± 0.02% | 178955 | | 7232.86 ± 0.50% | 7131.00 | 138258 | dispatchEvent, no bubbling, single listener | 139540 ± 0.02% | 140233 | | 50957.51 ± 0.71% | 50336.00 | 19625 | dispatchEvent, no bubbling, multiple listeners | 19751 ± 0.04% | 19866 | | 5692.36 ± 0.50% | 5618.00 | 175675 | dispatchEvent, bubbling, no listeners | 177315 ± 0.02% | 177999 | | 7277.82 ± 0.38% | 7181.00 | 137404 | dispatchEvent, bubbling, single listener per target | 138560 ± 0.02% | 139256 | | 50493.64 ± 0.28% | 50105.00 | 19805 | dispatchEvent, bubbling, multiple listeners per target | 19855 ± 0.04% | 19958 | Reviewed By: yungsters Differential Revision: D67758408 --- .../private/webapis/dom/events/EventTarget.js | 71 +++++++++++++------ 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/react-native/src/private/webapis/dom/events/EventTarget.js b/packages/react-native/src/private/webapis/dom/events/EventTarget.js index a3b92b5923d9b0..e7ae4438f8e997 100644 --- a/packages/react-native/src/private/webapis/dom/events/EventTarget.js +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -57,6 +57,11 @@ type EventListenerRegistration = { removed: boolean, }; +type ListenersMap = Map>; + +const CAPTURING_LISTENERS_KEY = Symbol('capturingListeners'); +const BUBBLING_LISTENERS_KEY = Symbol('bubblingListeners'); + function getDefaultPassiveValue( type: string, eventTarget: EventTarget, @@ -65,9 +70,6 @@ function getDefaultPassiveValue( } export default class EventTarget { - #listeners: Map> = new Map(); - #captureListeners: Map> = new Map(); - addEventListener( type: string, callback: EventListener | null, @@ -120,11 +122,15 @@ export default class EventTarget { return; } - const listenerMap = capture ? this.#captureListeners : this.#listeners; - let listenerList = listenerMap.get(processedType); + let listenersMap = this._getListenersMap(capture); + let listenerList = listenersMap?.get(processedType); if (listenerList == null) { + if (listenersMap == null) { + listenersMap = new Map(); + this._setListenersMap(capture, listenersMap); + } listenerList = []; - listenerMap.set(processedType, listenerList); + listenersMap.set(processedType, listenerList); } else { for (const listener of listenerList) { if (listener.callback === callback) { @@ -147,7 +153,7 @@ export default class EventTarget { signal.addEventListener( 'abort', () => { - this.#removeEventListenerRegistration(listener, nonNullListenerList); + this._removeEventListenerRegistration(listener, nonNullListenerList); }, { once: true, @@ -180,8 +186,8 @@ export default class EventTarget { ? optionsOrUseCapture : Boolean(optionsOrUseCapture.capture); - const listenerMap = capture ? this.#captureListeners : this.#listeners; - const listenerList = listenerMap.get(processedType); + const listenersMap = this._getListenersMap(capture); + const listenerList = listenersMap?.get(processedType); if (listenerList == null) { return; } @@ -212,7 +218,7 @@ export default class EventTarget { setIsTrusted(event, false); - this.#dispatch(event); + this._dispatch(event); return !event.defaultPrevented; } @@ -225,10 +231,10 @@ export default class EventTarget { * Implements the "event dispatch" concept * (see https://dom.spec.whatwg.org/#concept-event-dispatch). */ - #dispatch(event: Event): void { + _dispatch(event: Event): void { setEventDispatchFlag(event, true); - const eventPath = this.#getEventPath(event); + const eventPath = this._getEventPath(event); setComposedPath(event, eventPath); setTarget(event, this); @@ -242,7 +248,7 @@ export default class EventTarget { event, target === this ? Event.AT_TARGET : Event.CAPTURING_PHASE, ); - target.#invoke(event, Event.CAPTURING_PHASE); + target._invoke(event, Event.CAPTURING_PHASE); } for (const target of eventPath) { @@ -260,7 +266,7 @@ export default class EventTarget { event, target === this ? Event.AT_TARGET : Event.BUBBLING_PHASE, ); - target.#invoke(event, Event.BUBBLING_PHASE); + target._invoke(event, Event.BUBBLING_PHASE); } setEventPhase(event, Event.NONE); @@ -278,7 +284,7 @@ export default class EventTarget { * * The return value is also set as `composedPath` for the event. */ - #getEventPath(event: Event): $ReadOnlyArray { + _getEventPath(event: Event): $ReadOnlyArray { const path = []; // eslint-disable-next-line consistent-this let target: EventTarget | null = this; @@ -296,20 +302,21 @@ export default class EventTarget { * Implements the event listener invoke concept * (see https://dom.spec.whatwg.org/#concept-event-listener-invoke). */ - #invoke(event: Event, eventPhase: EventPhase) { - const listenerMap = - eventPhase === Event.CAPTURING_PHASE - ? this.#captureListeners - : this.#listeners; + _invoke(event: Event, eventPhase: EventPhase) { + const listenersMap = this._getListenersMap( + eventPhase === Event.CAPTURING_PHASE, + ); setCurrentTarget(event, this); // This is a copy so listeners added during dispatch are NOT executed. - const listenerList = listenerMap.get(event.type)?.slice(); + const listenerList = listenersMap?.get(event.type)?.slice(); if (listenerList == null) { return; } + setCurrentTarget(event, this); + for (const listener of listenerList) { if (listener.removed) { continue; @@ -356,7 +363,7 @@ export default class EventTarget { } } - #removeEventListenerRegistration( + _removeEventListenerRegistration( registration: EventListenerRegistration, listenerList: Array, ): void { @@ -371,6 +378,24 @@ export default class EventTarget { } } + _getListenersMap(isCapture: boolean): ?ListenersMap { + return isCapture + ? // $FlowExpectedError[prop-missing] + this[CAPTURING_LISTENERS_KEY] + : // $FlowExpectedError[prop-missing] + this[BUBBLING_LISTENERS_KEY]; + } + + _setListenersMap(isCapture: boolean, listenersMap: ListenersMap): void { + if (isCapture) { + // $FlowExpectedError[prop-missing] + this[CAPTURING_LISTENERS_KEY] = listenersMap; + } else { + // $FlowExpectedError[prop-missing] + this[BUBBLING_LISTENERS_KEY] = listenersMap; + } + } + /** * This a "protected" method to be overridden by a subclass to allow event * propagation. @@ -388,7 +413,7 @@ export default class EventTarget { */ // $FlowExpectedError[unsupported-syntax] [INTERNAL_DISPATCH_METHOD_KEY](event: Event): void { - this.#dispatch(event); + this._dispatch(event); } } From f2161c850f743039f3c9762092666c20373c8a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Mon, 27 Jan 2025 06:45:37 -0800 Subject: [PATCH 5/5] [skip ci] Further optimizations for event dispatching (#48426) Summary: Changelog: [internal] This improves the performance of DOM `Event` interface implementation by migrating away from private fields. Reviewed By: yungsters Differential Revision: D67751821 --- .../src/private/webapis/dom/events/Event.js | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/react-native/src/private/webapis/dom/events/Event.js b/packages/react-native/src/private/webapis/dom/events/Event.js index 7c008be03e5c1e..d2e311ea31fb96 100644 --- a/packages/react-native/src/private/webapis/dom/events/Event.js +++ b/packages/react-native/src/private/webapis/dom/events/Event.js @@ -48,13 +48,13 @@ export default class Event { static AT_TARGET: 2 = 2; static BUBBLING_PHASE: 3 = 3; - #bubbles: boolean; - #cancelable: boolean; - #composed: boolean; - #type: string; + _bubbles: boolean; + _cancelable: boolean; + _composed: boolean; + _type: string; - #defaultPrevented: boolean = false; - #timeStamp: number = performance.now(); + _defaultPrevented: boolean = false; + _timeStamp: number = performance.now(); // $FlowExpectedError[unsupported-syntax] [COMPOSED_PATH_KEY]: boolean = []; @@ -99,22 +99,22 @@ export default class Event { ); } - this.#type = String(type); - this.#bubbles = Boolean(options?.bubbles); - this.#cancelable = Boolean(options?.cancelable); - this.#composed = Boolean(options?.composed); + this._type = String(type); + this._bubbles = Boolean(options?.bubbles); + this._cancelable = Boolean(options?.cancelable); + this._composed = Boolean(options?.composed); } get bubbles(): boolean { - return this.#bubbles; + return this._bubbles; } get cancelable(): boolean { - return this.#cancelable; + return this._cancelable; } get composed(): boolean { - return this.#composed; + return this._composed; } get currentTarget(): EventTarget | null { @@ -122,7 +122,7 @@ export default class Event { } get defaultPrevented(): boolean { - return this.#defaultPrevented; + return this._defaultPrevented; } get eventPhase(): EventPhase { @@ -138,11 +138,11 @@ export default class Event { } get timeStamp(): number { - return this.#timeStamp; + return this._timeStamp; } get type(): string { - return this.#type; + return this._type; } composedPath(): $ReadOnlyArray { @@ -150,7 +150,7 @@ export default class Event { } preventDefault(): void { - if (!this.#cancelable) { + if (!this._cancelable) { return; } @@ -163,7 +163,7 @@ export default class Event { return; } - this.#defaultPrevented = true; + this._defaultPrevented = true; } stopImmediatePropagation(): void {