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..5b9480383f2432 --- /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 | null, +): 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 index 1753e5ac5de207..9697800ac35c0a 100644 --- a/packages/react-native/src/private/webapis/dom/events/EventTarget.js +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -33,11 +33,11 @@ import { INTERNAL_DISPATCH_METHOD_KEY, } from './internals/EventTargetInternals'; -export type EventListener = - | ((event: Event) => void) - | interface { - handleEvent(event: Event): void, - }; +export type EventCallback = (event: Event) => void; +export type EventHandler = interface { + handleEvent(event: Event): void, +}; +export type EventListener = EventCallback | EventHandler; export type EventListenerOptions = { capture?: boolean, 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..d5f5c6e507dd09 --- /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 {EventListener} from '../EventTarget'; + +import Event from '../Event'; +import { + getEventHandlerAttribute, + setEventHandlerAttribute, +} from '../EventHandlerAttributes'; +import EventTarget from '../EventTarget'; + +class EventTargetSubclass extends EventTarget { + get oncustomevent(): EventListener | null { + return getEventHandlerAttribute(this, 'customEvent'); + } + + set oncustomevent(listener: EventListener | null) { + 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); + }); +});