Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement Event and EventTarget (facebook#48429)
Browse files Browse the repository at this point in the history
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).

Differential Revision: D67738145
rubennorte authored and facebook-github-bot committed Jan 2, 2025
1 parent 41ef7fd commit cb1f636
Showing 6 changed files with 1,816 additions and 0 deletions.
177 changes: 177 additions & 0 deletions packages/react-native/src/private/webapis/dom/events/Event.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* 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.",
);
}

if (options != null && typeof options !== 'object') {
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<EventTarget> {
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'];
386 changes: 386 additions & 0 deletions packages/react-native/src/private/webapis/dom/events/EventTarget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,386 @@
/**
* 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<string, Array<EventListenerRegistration>> = new Map();
#captureListeners: Map<string, Array<EventListenerRegistration>> = 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 = optionsOrUseCapture.capture ?? false;
passive =
optionsOrUseCapture.passive ??
getDefaultPassiveValue(processedType, this);
once = optionsOrUseCapture.once ?? false;
signal = optionsOrUseCapture.signal ?? null;
} 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;
}
}
}

listenerList.push({
callback,
passive,
once,
removed: false,
});

if (signal != null) {
signal.addEventListener('abort', () => {
this.removeEventListener(processedType, callback, capture);
});
}
}

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
: optionsOrUseCapture.capture ?? false;

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;
}
}

// NOTE: there is no step to remove the event listener from the signal, if
// set, as per the spec. See https://github.com/whatwg/dom/issues/1346.
}

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<EventTarget> {
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;
}
}
}

/**
* 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* 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, 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();
});

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 the given options', () => {
const eventWithDefaults = new Event('custom');

expect(eventWithDefaults.type).toBe('custom');
expect(eventWithDefaults.bubbles).toBe(false);
expect(eventWithDefaults.cancelable).toBe(false);
expect(eventWithDefaults.composed).toBe(false);

const eventWithAllOptionsSet = new Event('custom', {
bubbles: true,
cancelable: true,
composed: true,
});

expect(eventWithAllOptionsSet.type).toBe('custom');
expect(eventWithAllOptionsSet.bubbles).toBe(true);
expect(eventWithAllOptionsSet.cancelable).toBe(true);
expect(eventWithAllOptionsSet.composed).toBe(true);
});

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', () => {
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);

const previousConsoleError = console.error;
const mockConsoleError = jest.fn();
try {
// $FlowExpectedError[cannot-write]
console.error = mockConsoleError;
event.preventDefault();
} finally {
// $FlowExpectedError[cannot-write]
console.error = previousConsoleError;
}

expect(event.defaultPrevented).toBe(false);

expect(mockConsoleError).toHaveBeenCalledTimes(1);
const reportedError = mockConsoleError.mock.lastCall[0];
expect(reportedError).toBeInstanceOf(Error);
expect(reportedError.message).toBe(
'Unable to preventDefault inside passive event listener invocation.',
);
});
});
});

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<EventTarget> {
// $FlowExpectedError[prop-missing]
return event[COMPOSED_PATH_KEY];
}

export function setComposedPath(
event: Event,
composedPath: $ReadOnlyArray<EventTarget>,
): 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;
}
Original file line number Diff line number Diff line change
@@ -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);
}

0 comments on commit cb1f636

Please sign in to comment.