Skip to content

Commit

Permalink
Create module to handle event handler attributes
Browse files Browse the repository at this point in the history
Differential Revision: D67839560
  • Loading branch information
rubennorte authored and facebook-github-bot committed Jan 27, 2025
1 parent 66a29fc commit f37ce77
Show file tree
Hide file tree
Showing 3 changed files with 340 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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<string, EventHandler | null>;

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit f37ce77

Please sign in to comment.