Skip to content

Commit

Permalink
Fabric HostComponent as EventEmitter: support add/removeEventListener…
Browse files Browse the repository at this point in the history
… (unstable only) (#23386)

* Implement addEventListener and removeEventListener on Fabric HostComponent

* add files

* re-add CustomEvent

* fix flow

* Need to get CustomEvent from an import since it won't exist on the global scope by default

* yarn prettier-all

* use a mangled name consistently to refer to imperatively registered event handlers

* yarn prettier-all

* fuzzy null check

* fix capture phase event listener logic

* early exit from getEventListeners more often

* make some optimizations to getEventListeners and the bridge plugin

* fix accumulateInto logic

* fix accumulateInto

* Simplifying getListeners at the expense of perf for the non-hot path

* feedback

* fix impl of getListeners to correctly remove function

* pass all args in to event listeners
  • Loading branch information
JoshuaGross committed Mar 2, 2022
1 parent 0864434 commit 05c283c
Show file tree
Hide file tree
Showing 10 changed files with 355 additions and 60 deletions.
4 changes: 2 additions & 2 deletions packages/react-native-renderer/src/ReactFabricEventEmitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ import {batchedUpdates} from './legacy-events/ReactGenericBatching';
import accumulateInto from './legacy-events/accumulateInto';

import {plugins} from './legacy-events/EventPluginRegistry';
import getListener from './ReactNativeGetListener';
import getListeners from './ReactNativeGetListeners';
import {runEventsInBatch} from './legacy-events/EventBatching';

import {RawEventEmitter} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';

export {getListener, registrationNameModules as registrationNames};
export {getListeners, registrationNameModules as registrationNames};

/**
* Allows registered plugins an opportunity to extract events from top-level
Expand Down
119 changes: 119 additions & 0 deletions packages/react-native-renderer/src/ReactFabricHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,28 @@ export type RendererInspectionConfig = $ReadOnly<{|
) => void,
|}>;

// TODO?: find a better place for this type to live
export type EventListenerOptions = $ReadOnly<{|
capture?: boolean,
once?: boolean,
passive?: boolean,
signal: mixed, // not yet implemented
|}>;
export type EventListenerRemoveOptions = $ReadOnly<{|
capture?: boolean,
|}>;

// TODO?: this will be changed in the future to be w3c-compatible and allow "EventListener" objects as well as functions.
export type EventListener = Function;

type InternalEventListeners = {
[string]: {|
listener: EventListener,
options: EventListenerOptions,
invalidated: boolean,
|}[],
};

// TODO: Remove this conditional once all changes have propagated.
if (registerEventHandler) {
/**
Expand All @@ -111,6 +133,7 @@ class ReactFabricHostComponent {
viewConfig: ViewConfig;
currentProps: Props;
_internalInstanceHandle: Object;
_eventListeners: ?InternalEventListeners;

constructor(
tag: number,
Expand Down Expand Up @@ -193,6 +216,102 @@ class ReactFabricHostComponent {

return;
}

// This API (addEventListener, removeEventListener) attempts to adhere to the
// w3 Level2 Events spec as much as possible, treating HostComponent as a DOM node.
//
// Unless otherwise noted, these methods should "just work" and adhere to the W3 specs.
// If they deviate in a way that is not explicitly noted here, you've found a bug!
//
// See:
// * https://www.w3.org/TR/DOM-Level-2-Events/events.html
// * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
// * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
//
// And notably, not implemented (yet?):
// * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent
//
//
// Deviations from spec/TODOs:
// (1) listener must currently be a function, we do not support EventListener objects yet.
// (2) we do not support the `signal` option / AbortSignal yet
addEventListener_unstable(
eventType: string,
listener: EventListener,
options: EventListenerOptions | boolean,
) {
if (typeof eventType !== 'string') {
throw new Error('addEventListener_unstable eventType must be a string');
}
if (typeof listener !== 'function') {
throw new Error('addEventListener_unstable listener must be a function');
}

// The third argument is either boolean indicating "captures" or an object.
const optionsObj =
typeof options === 'object' && options !== null ? options : {};
const capture =
(typeof options === 'boolean' ? options : optionsObj.capture) || false;
const once = optionsObj.once || false;
const passive = optionsObj.passive || false;
const signal = null; // TODO: implement signal/AbortSignal

const eventListeners: InternalEventListeners = this._eventListeners || {};
if (this._eventListeners == null) {
this._eventListeners = eventListeners;
}

const namedEventListeners = eventListeners[eventType] || [];
if (eventListeners[eventType] == null) {
eventListeners[eventType] = namedEventListeners;
}

namedEventListeners.push({
listener: listener,
invalidated: false,
options: {
capture: capture,
once: once,
passive: passive,
signal: signal,
},
});
}

// See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
removeEventListener_unstable(
eventType: string,
listener: EventListener,
options: EventListenerRemoveOptions | boolean,
) {
// eventType and listener must be referentially equal to be removed from the listeners
// data structure, but in "options" we only check the `capture` flag, according to spec.
// That means if you add the same function as a listener with capture set to true and false,
// you must also call removeEventListener twice with capture set to true/false.
const optionsObj =
typeof options === 'object' && options !== null ? options : {};
const capture =
(typeof options === 'boolean' ? options : optionsObj.capture) || false;

// If there are no event listeners or named event listeners, we can bail early - our
// job is already done.
const eventListeners = this._eventListeners;
if (!eventListeners) {
return;
}
const namedEventListeners = eventListeners[eventType];
if (!namedEventListeners) {
return;
}

// TODO: optimize this path to make remove cheaper
eventListeners[eventType] = namedEventListeners.filter(listenerObj => {
return !(
listenerObj.listener === listener &&
listenerObj.options.capture === capture
);
});
}
}

// eslint-disable-next-line no-unused-expressions
Expand Down
56 changes: 36 additions & 20 deletions packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
import type {AnyNativeEvent} from './legacy-events/PluginModuleType';
import type {TopLevelType} from './legacy-events/TopLevelEventTypes';
import SyntheticEvent from './legacy-events/SyntheticEvent';
import type {PropagationPhases} from './legacy-events/PropagationPhases';

// Module provided by RN:
import {ReactNativeViewConfigRegistry} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
import accumulateInto from './legacy-events/accumulateInto';
import getListener from './ReactNativeGetListener';
import getListeners from './ReactNativeGetListeners';
import forEachAccumulated from './legacy-events/forEachAccumulated';
import {HostComponent} from 'react-reconciler/src/ReactWorkTags';
import isArray from 'shared/isArray';

const {
customBubblingEventTypes,
Expand All @@ -26,10 +28,37 @@ const {
// Start of inline: the below functions were inlined from
// EventPropagator.js, as they deviated from ReactDOM's newer
// implementations.
function listenerAtPhase(inst, event, propagationPhase: PropagationPhases) {
function listenersAtPhase(inst, event, propagationPhase: PropagationPhases) {
const registrationName =
event.dispatchConfig.phasedRegistrationNames[propagationPhase];
return getListener(inst, registrationName);
return getListeners(inst, registrationName, propagationPhase, true);
}

function accumulateListenersAndInstances(inst, event, listeners) {
const listenersLength = listeners
? isArray(listeners)
? listeners.length
: 1
: 0;
if (listenersLength > 0) {
event._dispatchListeners = accumulateInto(
event._dispatchListeners,
listeners,
);

// Avoid allocating additional arrays here
if (event._dispatchInstances == null && listenersLength === 1) {
event._dispatchInstances = inst;
} else {
event._dispatchInstances = event._dispatchInstances || [];
if (!isArray(event._dispatchInstances)) {
event._dispatchInstances = [event._dispatchInstances];
}
for (let i = 0; i < listenersLength; i++) {
event._dispatchInstances.push(inst);
}
}
}
}

function accumulateDirectionalDispatches(inst, phase, event) {
Expand All @@ -38,14 +67,8 @@ function accumulateDirectionalDispatches(inst, phase, event) {
console.error('Dispatching inst must not be null');
}
}
const listener = listenerAtPhase(inst, event, phase);
if (listener) {
event._dispatchListeners = accumulateInto(
event._dispatchListeners,
listener,
);
event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
}
const listeners = listenersAtPhase(inst, event, phase);
accumulateListenersAndInstances(inst, event, listeners);
}

function getParent(inst) {
Expand Down Expand Up @@ -103,14 +126,8 @@ function accumulateDispatches(
): void {
if (inst && event && event.dispatchConfig.registrationName) {
const registrationName = event.dispatchConfig.registrationName;
const listener = getListener(inst, registrationName);
if (listener) {
event._dispatchListeners = accumulateInto(
event._dispatchListeners,
listener,
);
event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
}
const listeners = getListeners(inst, registrationName, 'bubbled', false);
accumulateListenersAndInstances(inst, event, listeners);
}
}

Expand All @@ -130,7 +147,6 @@ function accumulateDirectDispatches(events: ?(Array<Object> | Object)) {
}

// End of inline
type PropagationPhases = 'bubbled' | 'captured';

const ReactNativeBridgeEventPlugin = {
eventTypes: {},
Expand Down
4 changes: 2 additions & 2 deletions packages/react-native-renderer/src/ReactNativeEventEmitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ import {registrationNameModules} from './legacy-events/EventPluginRegistry';
import {batchedUpdates} from './legacy-events/ReactGenericBatching';
import {runEventsInBatch} from './legacy-events/EventBatching';
import {plugins} from './legacy-events/EventPluginRegistry';
import getListener from './ReactNativeGetListener';
import getListeners from './ReactNativeGetListeners';
import accumulateInto from './legacy-events/accumulateInto';

import {getInstanceFromNode} from './ReactNativeComponentTree';

export {getListener, registrationNameModules as registrationNames};
export {getListeners, registrationNameModules as registrationNames};

/**
* Version of `ReactBrowserEventEmitter` that works on the receiving side of a
Expand Down
36 changes: 0 additions & 36 deletions packages/react-native-renderer/src/ReactNativeGetListener.js

This file was deleted.

Loading

0 comments on commit 05c283c

Please sign in to comment.