diff --git a/packages/react-native/src/private/webapis/dom/events/CustomEvent.js b/packages/react-native/src/private/webapis/dom/events/CustomEvent.js new file mode 100644 index 00000000000000..25d046b2709dd8 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/CustomEvent.js @@ -0,0 +1,40 @@ +/** + * 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 `CustomEvent` interface from the DOM. + * See https://dom.spec.whatwg.org/#interface-customevent. + */ + +// flowlint unsafe-getters-setters:off + +import type {EventInit} from './Event'; + +import Event from './Event'; + +export type CustomEventInit = { + ...EventInit, + detail?: mixed, +}; + +export default class CustomEvent extends Event { + _detail: mixed; + + constructor(type: string, options?: ?CustomEventInit) { + const {detail, ...eventOptions} = options ?? {}; + super(type, eventOptions); + + this._detail = detail; + } + + get detail(): mixed { + return this._detail; + } +} 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..10ccd67061e441 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/Event.js @@ -0,0 +1,236 @@ +/** + * 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'; + +export type EventInit = { + bubbles?: boolean, + cancelable?: boolean, + composed?: boolean, +}; + +export default class Event { + static +NONE: 0; + static +CAPTURING_PHASE: 1; + static +AT_TARGET: 2; + static +BUBBLING_PHASE: 3; + + +NONE: 0; + +CAPTURING_PHASE: 1; + +AT_TARGET: 2; + +BUBBLING_PHASE: 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); + } +} + +// $FlowExpectedError[cannot-write] +Object.defineProperty(Event, 'NONE', { + enumerable: true, + value: 0, +}); + +// $FlowExpectedError[cannot-write] +Object.defineProperty(Event.prototype, 'NONE', { + enumerable: true, + value: 0, +}); + +// $FlowExpectedError[cannot-write] +Object.defineProperty(Event, 'CAPTURING_PHASE', { + enumerable: true, + value: 1, +}); + +// $FlowExpectedError[cannot-write] +Object.defineProperty(Event.prototype, 'CAPTURING_PHASE', { + enumerable: true, + value: 1, +}); + +// $FlowExpectedError[cannot-write] +Object.defineProperty(Event, 'AT_TARGET', { + enumerable: true, + value: 2, +}); + +// $FlowExpectedError[cannot-write] +Object.defineProperty(Event.prototype, 'AT_TARGET', { + enumerable: true, + value: 2, +}); + +// $FlowExpectedError[cannot-write] +Object.defineProperty(Event, 'BUBBLING_PHASE', { + enumerable: true, + value: 3, +}); + +// $FlowExpectedError[cannot-write] +Object.defineProperty(Event.prototype, 'BUBBLING_PHASE', { + enumerable: true, + value: 3, +}); + +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/EventHandlerAttributes.js b/packages/react-native/src/private/webapis/dom/events/EventHandlerAttributes.js new file mode 100644 index 00000000000000..6b56bfaf05ef41 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/EventHandlerAttributes.js @@ -0,0 +1,130 @@ +/** + * 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 provides helpers for classes to implement event handler IDL + * attributes, as defined in https://html.spec.whatwg.org/multipage/webappapis.html#event-handler-idl-attributes. + * + * Expected usage: + * ``` + * import {getEventHandlerAttribute, setEventHandlerAttribute} from '../path/to/EventHandlerAttributes'; + * + * class EventTargetSubclass extends EventTarget { + * get oncustomevent(): EventListener | null { + * return getEventHandlerAttribute(this, 'customEvent'); + * } + * + * set oncustomevent(listener: EventListener | null) { + * setEventHandlerAttribute(this, 'customEvent', listener); + * } + * } + * + * const eventTargetInstance = new EventTargetSubclass(); + * + * eventTargetInstance.oncustomevent = (event: Event) => { + * console.log('custom event received'); + * }; + * eventTargetInstance.dispatchEvent(new Event('customEvent')); + * // Logs 'custom event received' to the console. + * + * eventTargetInstance.oncustomevent = null; + * eventTargetInstance.dispatchEvent(new Event('customEvent')); + * // Does not log anything to the console. + * ``` + */ + +import type EventTarget from './EventTarget'; +import type {EventCallback} from './EventTarget'; + +type EventHandler = $ReadOnly<{ + handleEvent: EventCallback, +}>; +type EventHandlerAttributeMap = Map; + +const EVENT_HANDLER_CONTENT_ATTRIBUTE_MAP_KEY = Symbol( + 'eventHandlerAttributeMap', +); + +function getEventHandlerAttributeMap( + target: EventTarget, +): ?EventHandlerAttributeMap { + // $FlowExpectedError[prop-missing] + return target[EVENT_HANDLER_CONTENT_ATTRIBUTE_MAP_KEY]; +} + +function setEventHandlerAttributeMap( + target: EventTarget, + map: ?EventHandlerAttributeMap, +) { + // $FlowExpectedError[prop-missing] + target[EVENT_HANDLER_CONTENT_ATTRIBUTE_MAP_KEY] = map; +} + +/** + * Returns the event listener registered as an event handler IDL attribute for + * the given target and type. + * + * Should be used to get the current value for `target.on{type}`. + */ +export function getEventHandlerAttribute( + target: EventTarget, + type: string, +): EventCallback | null { + const listener = getEventHandlerAttributeMap(target)?.get(type); + return listener != null ? listener.handleEvent : null; +} + +/** + * Sets the event listener registered as an event handler IDL attribute for + * the given target and type. + * + * Should be used to set a value for `target.on{type}`. + */ +export function setEventHandlerAttribute( + target: EventTarget, + type: string, + callback: ?EventCallback, +): void { + let map = getEventHandlerAttributeMap(target); + if (map != null) { + const currentListener = map.get(type); + if (currentListener) { + target.removeEventListener(type, currentListener); + map.delete(type); + } + } + + if ( + callback != null && + (typeof callback === 'function' || typeof callback === 'object') + ) { + // Register the listener as a different object in the target so it + // occupies its own slot and cannot be removed via `removeEventListener`. + const listener = { + handleEvent: callback, + }; + + try { + target.addEventListener(type, listener); + // If adding the listener fails, we don't store the value + if (map == null) { + map = new Map(); + setEventHandlerAttributeMap(target, map); + } + map.set(type, listener); + } catch (e) { + // Assigning incorrect listener does not throw in setters. + } + } + + if (map != null && map.size === 0) { + setEventHandlerAttributeMap(target, null); + } +} 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..9697800ac35c0a --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -0,0 +1,452 @@ +/** + * 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 EventCallback = (event: Event) => void; +export type EventHandler = interface { + handleEvent(event: Event): void, +}; +export type EventListener = EventCallback | EventHandler; + +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, +}; + +type ListenersMap = Map>; + +export default class EventTarget { + 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; + } + + let listenersMap = getListenersMap(this, capture); + let listenerList = listenersMap?.get(processedType); + if (listenerList == null) { + if (listenersMap == null) { + listenersMap = new Map(); + setListenersMap(this, capture, listenersMap); + } + listenerList = []; + listenersMap.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', + () => { + 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 listenersMap = getListenersMap(this, capture); + const listenerList = listenersMap?.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); + + dispatch(this, event); + + return !event.defaultPrevented; + } + + /** + * 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 { + dispatch(this, 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'.`, + ); + } +} + +function getDefaultPassiveValue( + type: string, + eventTarget: EventTarget, +): boolean { + return false; +} + +/** + * 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). + */ +function dispatch(eventTarget: EventTarget, event: Event): void { + setEventDispatchFlag(event, true); + + const eventPath = getEventPath(eventTarget, event); + setComposedPath(event, eventPath); + setTarget(event, eventTarget); + + for (let i = eventPath.length - 1; i >= 0; i--) { + if (getStopPropagationFlag(event)) { + break; + } + + const target = eventPath[i]; + setEventPhase( + event, + target === eventTarget ? Event.AT_TARGET : Event.CAPTURING_PHASE, + ); + invoke(target, 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 !== eventTarget) { + break; + } + + setEventPhase( + event, + target === eventTarget ? Event.AT_TARGET : Event.BUBBLING_PHASE, + ); + invoke(target, 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. + */ +function getEventPath( + eventTarget: EventTarget, + event: Event, +): $ReadOnlyArray { + const path = []; + let target: EventTarget | null = eventTarget; + + 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). + */ +function invoke( + eventTarget: EventTarget, + event: Event, + eventPhase: EventPhase, +) { + const listenersMap = getListenersMap( + eventTarget, + eventPhase === Event.CAPTURING_PHASE, + ); + + setCurrentTarget(event, eventTarget); + + // This is a copy so listeners added during dispatch are NOT executed. + const listenerList = listenersMap?.get(event.type)?.slice(); + if (listenerList == null) { + return; + } + + setCurrentTarget(event, eventTarget); + + for (const listener of listenerList) { + if (listener.removed) { + continue; + } + + if (listener.once) { + eventTarget.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(eventTarget, 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; + } + } +} + +function 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; + } + } +} + +const CAPTURING_LISTENERS_KEY = Symbol('capturingListeners'); +const BUBBLING_LISTENERS_KEY = Symbol('bubblingListeners'); + +function getListenersMap( + eventTarget: EventTarget, + isCapture: boolean, +): ?ListenersMap { + return isCapture + ? // $FlowExpectedError[prop-missing] + eventTarget[CAPTURING_LISTENERS_KEY] + : // $FlowExpectedError[prop-missing] + eventTarget[BUBBLING_LISTENERS_KEY]; +} + +function setListenersMap( + eventTarget: EventTarget, + isCapture: boolean, + listenersMap: ListenersMap, +): void { + if (isCapture) { + // $FlowExpectedError[prop-missing] + eventTarget[CAPTURING_LISTENERS_KEY] = listenersMap; + } else { + // $FlowExpectedError[prop-missing] + eventTarget[BUBBLING_LISTENERS_KEY] = listenersMap; + } +} + +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__/CustomEvent-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/CustomEvent-itest.js new file mode 100644 index 00000000000000..ac0eecf5e8e12d --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/CustomEvent-itest.js @@ -0,0 +1,53 @@ +/** + * 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 CustomEvent from '../CustomEvent'; +import Event from '../Event'; + +describe('CustomEvent', () => { + it('extends Event', () => { + const event = new CustomEvent('foo', { + bubbles: true, + cancelable: true, + composed: true, + }); + + expect(event.type).toBe('foo'); + expect(event.bubbles).toBe(true); + expect(event.cancelable).toBe(true); + expect(event.composed).toBe(true); + expect(event).toBeInstanceOf(Event); + }); + + it('allows passing a detail value', () => { + const detail = Symbol('detail'); + + const event = new CustomEvent('foo', {detail}); + + expect(event.detail).toBe(detail); + }); + + it('does NOT allow changing the detail value after construction', () => { + const detail = Symbol('detail'); + + const event = new CustomEvent('foo', {detail}); + + expect(() => { + 'use strict'; + // Use strict mode to throw an error instead of silently failing + // $FlowExpectedError[cannot-write] + event.detail = 'bar'; + }).toThrow(); + }); +}); 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..e05f9c8ef15475 --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/Event-itest.js @@ -0,0 +1,299 @@ +/** + * 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('provides read-only constants for event phases', () => { + 'use strict'; + // use strict mode to throw an error instead of silently failing + + expect(Event.NONE).toBe(0); + expect(Event.CAPTURING_PHASE).toBe(1); + expect(Event.AT_TARGET).toBe(2); + expect(Event.BUBBLING_PHASE).toBe(3); + + expect(() => { + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[cannot-write] + Event.NONE = 'NONE'; + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[cannot-write] + Event.CAPTURING_PHASE = 'CAPTURING_PHASE'; + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[cannot-write] + Event.AT_TARGET = 'AT_TARGET'; + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[cannot-write] + Event.BUBBLING_PHASE = 'BUBBLING_PHASE'; + }).toThrow(); + + // Also accessible through instances (via the Event prototype). + + const event = new Event('custom'); + + expect(event.NONE).toBe(0); + expect(event.CAPTURING_PHASE).toBe(1); + expect(event.AT_TARGET).toBe(2); + expect(event.BUBBLING_PHASE).toBe(3); + + expect(() => { + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[cannot-write] + event.NONE = 'NONE'; + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[cannot-write] + event.CAPTURING_PHASE = 'CAPTURING_PHASE'; + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[cannot-write] + event.AT_TARGET = 'AT_TARGET'; + }).toThrow(); + + expect(() => { + // $FlowExpectedError[incompatible-type] + // $FlowExpectedError[cannot-write] + event.BUBBLING_PHASE = 'BUBBLING_PHASE'; + }).toThrow(); + }); + + 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__/EventHandlerAttributes-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/EventHandlerAttributes-itest.js new file mode 100644 index 00000000000000..0c5b91d6eed12a --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/EventHandlerAttributes-itest.js @@ -0,0 +1,205 @@ +/** + * 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 + */ + +// flowlint unsafe-getters-setters:off + +import '../../../../../../Libraries/Core/InitializeCore.js'; + +import type {EventCallback} from '../EventTarget'; + +import Event from '../Event'; +import { + getEventHandlerAttribute, + setEventHandlerAttribute, +} from '../EventHandlerAttributes'; +import EventTarget from '../EventTarget'; + +class EventTargetSubclass extends EventTarget { + get oncustomevent(): EventCallback | null { + return getEventHandlerAttribute(this, 'customEvent'); + } + + set oncustomevent(listener: ?EventCallback) { + setEventHandlerAttribute(this, 'customEvent', listener); + } +} + +describe('EventHandlerAttributes', () => { + it('should register event listeners assigned to the attributes', () => { + const target = new EventTargetSubclass(); + + const listener = jest.fn(); + target.oncustomevent = listener; + + expect(target.oncustomevent).toBe(listener); + + const event = new Event('customEvent'); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.lastCall[0]).toBe(event); + }); + + it('should NOT register values assigned to the attributes if they are not an event listener', () => { + const target = new EventTargetSubclass(); + + const listener = Symbol(); + // $FlowExpectedError[incompatible-type] + target.oncustomevent = listener; + + expect(target.oncustomevent).toBe(null); + + const event = new Event('customEvent'); + + // This doesn't fail. + target.dispatchEvent(event); + }); + + it('should remove event listeners assigned to the attributes when reassigning them to null', () => { + const target = new EventTargetSubclass(); + + const listener = jest.fn(); + target.oncustomevent = listener; + + const event = new Event('customEvent'); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.lastCall[0]).toBe(event); + + target.oncustomevent = null; + + expect(target.oncustomevent).toBe(null); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should remove event listeners assigned to the attributes when reassigning them to a different listener', () => { + const target = new EventTargetSubclass(); + + const listener = jest.fn(); + target.oncustomevent = listener; + + const event = new Event('customEvent'); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.lastCall[0]).toBe(event); + + const newListener = jest.fn(); + target.oncustomevent = newListener; + + expect(target.oncustomevent).toBe(newListener); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(newListener).toHaveBeenCalledTimes(1); + expect(newListener.mock.lastCall[0]).toBe(event); + }); + + it('should remove event listeners assigned to the attributes when reassigning them to an incorrect listener value', () => { + const target = new EventTargetSubclass(); + + const listener = jest.fn(); + target.oncustomevent = listener; + + const event = new Event('customEvent'); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.lastCall[0]).toBe(event); + + const newListener = Symbol(); + // $FlowExpectedError[incompatible-type] + target.oncustomevent = newListener; + + expect(target.oncustomevent).toBe(null); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should interoperate with listeners registered via `addEventListener`', () => { + const target = new EventTargetSubclass(); + + let order = 0; + + const regularListener1: JestMockFn<[Event], void> = jest.fn(() => { + // $FlowExpectedError[prop-missing] + regularListener1.order = order++; + }); + target.addEventListener('customEvent', regularListener1); + + const attributeListener: JestMockFn<[Event], void> = jest.fn(() => { + // $FlowExpectedError[prop-missing] + attributeListener.order = order++; + }); + target.oncustomevent = attributeListener; + + const regularListener2: JestMockFn<[Event], void> = jest.fn(() => { + // $FlowExpectedError[prop-missing] + regularListener2.order = order++; + }); + target.addEventListener('customEvent', regularListener2); + + const event = new Event('customEvent'); + + target.dispatchEvent(event); + + expect(regularListener1).toHaveBeenCalledTimes(1); + expect(regularListener1.mock.lastCall[0]).toBe(event); + // $FlowExpectedError[prop-missing] + expect(regularListener1.order).toBe(0); + + expect(attributeListener).toHaveBeenCalledTimes(1); + expect(attributeListener.mock.lastCall[0]).toBe(event); + // $FlowExpectedError[prop-missing] + expect(attributeListener.order).toBe(1); + + expect(regularListener2).toHaveBeenCalledTimes(1); + expect(regularListener2.mock.lastCall[0]).toBe(event); + // $FlowExpectedError[prop-missing] + expect(regularListener2.order).toBe(2); + }); + + it('should not be considered the same callback when adding it again via `addEventListener`', () => { + const target = new EventTargetSubclass(); + + const listener = jest.fn(); + + target.addEventListener('customEvent', listener); + target.oncustomevent = listener; + + const event = new Event('customEvent'); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener.mock.calls[0][0]).toBe(event); + expect(listener.mock.calls[1][0]).toBe(event); + + target.removeEventListener('customEvent', listener); + + target.dispatchEvent(event); + + expect(listener).toHaveBeenCalledTimes(3); + expect(listener.mock.lastCall[0]).toBe(event); + }); +}); 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 new file mode 100644 index 00000000000000..265f17ec58f06a --- /dev/null +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-itest.js @@ -0,0 +1,1037 @@ +/** + * 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 {dispatchTrustedEvent} from '../internals/EventTargetInternals'; +import createEventTargetHierarchyWithDepth from './createEventTargetHierarchyWithDepth'; + +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; +} + +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(); + }); + }); + + 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); + }); + }); + }); +}); 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; +} 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); +}