Fabric HostComponent as EventEmitter: support add/removeEventListener (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
This commit is contained in:
Joshua Gross
2022-03-02 12:00:08 -08:00
committed by GitHub
parent 08644348b6
commit 05c283c3c3
10 changed files with 355 additions and 60 deletions

View File

@@ -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

View File

@@ -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) {
/**
@@ -111,6 +133,7 @@ class ReactFabricHostComponent {
viewConfig: ViewConfig;
currentProps: Props;
_internalInstanceHandle: Object;
_eventListeners: ?InternalEventListeners;
constructor(
tag: number,
@@ -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

View File

@@ -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,
@@ -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) {
@@ -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) {
@@ -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);
}
}
@@ -130,7 +147,6 @@ function accumulateDirectDispatches(events: ?(Array<Object> | Object)) {
}
// End of inline
type PropagationPhases = 'bubbled' | 'captured';
const ReactNativeBridgeEventPlugin = {
eventTypes: {},

View File

@@ -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

View File

@@ -1,36 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @flow
*/
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import {getFiberCurrentPropsFromNode} from './legacy-events/EventPluginUtils';
export default function getListener(
inst: Fiber,
registrationName: string,
): Function | null {
const stateNode = inst.stateNode;
if (stateNode === null) {
// Work in progress (ex: onload events in incremental mode).
return null;
}
const props = getFiberCurrentPropsFromNode(stateNode);
if (props === null) {
// Work in progress.
return null;
}
const listener = props[registrationName];
if (listener && typeof listener !== 'function') {
throw new Error(
`Expected \`${registrationName}\` listener to be a function, instead got a value of \`${typeof listener}\` type.`,
);
}
return listener;
}

View File

@@ -0,0 +1,168 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
* @flow
*/
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import type {PropagationPhases} from './legacy-events/PropagationPhases';
import {getFiberCurrentPropsFromNode} from './legacy-events/EventPluginUtils';
import {CustomEvent} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
/**
* Get a list of listeners for a specific event, in-order.
* For React Native we treat the props-based function handlers
* as the first-class citizens, and they are always executed first
* for both capture and bubbling phase.
*
* We need "phase" propagated to this point to support the HostComponent
* EventEmitter API, which does not mutate the name of the handler based
* on phase (whereas prop handlers are registered as `onMyEvent` and `onMyEvent_Capture`).
*
* Native system events emitted into React Native
* will be emitted both to the prop handler function and to imperative event
* listeners.
*
* This will either return null, a single Function without an array, or
* an array of 2+ items.
*/
export default function getListeners(
inst: Fiber,
registrationName: string,
phase: PropagationPhases,
dispatchToImperativeListeners: boolean,
): null | Function | Array<Function> {
const stateNode = inst.stateNode;
if (stateNode === null) {
return null;
}
// If null: Work in progress (ex: onload events in incremental mode).
const props = getFiberCurrentPropsFromNode(stateNode);
if (props === null) {
// Work in progress.
return null;
}
const listener = props[registrationName];
if (listener && typeof listener !== 'function') {
throw new Error(
`Expected \`${registrationName}\` listener to be a function, instead got a value of \`${typeof listener}\` type.`,
);
}
// If there are no imperative listeners, early exit.
if (
!(
dispatchToImperativeListeners &&
stateNode.canonical &&
stateNode.canonical._eventListeners
)
) {
return listener;
}
// Below this is the de-optimized path.
// If you are using _eventListeners, we do not (yet)
// expect this to be as performant as the props-only path.
// If/when this becomes a bottleneck, it can be refactored
// to avoid unnecessary closures and array allocations.
//
// Previously, there was only one possible listener for an event:
// the onEventName property in props.
// Now, it is also possible to have N listeners
// for a specific event on a node. Thus, we accumulate all of the listeners,
// including the props listener, and return a function that calls them all in
// order, starting with the handler prop and then the listeners in order.
// We return either a non-empty array or null.
const listeners = [];
if (listener) {
listeners.push(listener);
}
// TODO: for now, all of these events get an `rn:` prefix to enforce
// that the user knows they're only getting non-W3C-compliant events
// through this imperative event API.
// Events might not necessarily be noncompliant, but we currently have
// no verification that /any/ events are compliant.
// Thus, we prefix to ensure no collision with W3C event names.
const requestedPhaseIsCapture = phase === 'captured';
const mangledImperativeRegistrationName = requestedPhaseIsCapture
? 'rn:' + registrationName.replace(/Capture$/, '')
: 'rn:' + registrationName;
// Get imperative event listeners for this event
if (
stateNode.canonical._eventListeners[mangledImperativeRegistrationName] &&
stateNode.canonical._eventListeners[mangledImperativeRegistrationName]
.length > 0
) {
const eventListeners =
stateNode.canonical._eventListeners[mangledImperativeRegistrationName];
eventListeners.forEach(listenerObj => {
// Make sure phase of listener matches requested phase
const isCaptureEvent =
listenerObj.options.capture != null && listenerObj.options.capture;
if (isCaptureEvent !== requestedPhaseIsCapture) {
return;
}
// For now (this is an area of future optimization) we must wrap
// all imperative event listeners in a function to unwrap the SyntheticEvent
// and pass them an Event.
// When this API is more stable and used more frequently, we can revisit.
const listenerFnWrapper = function(syntheticEvent, ...args) {
const eventInst = new CustomEvent(mangledImperativeRegistrationName, {
detail: syntheticEvent.nativeEvent,
});
eventInst.isTrusted = true;
// setSyntheticEvent is present on the React Native Event shim.
// It is used to forward method calls on Event to the underlying SyntheticEvent.
// $FlowFixMe
eventInst.setSyntheticEvent(syntheticEvent);
listenerObj.listener(eventInst, ...args);
};
// Only call once?
// If so, we ensure that it's only called once by setting a flag
// and by removing it from eventListeners once it is called (but only
// when it's actually been executed).
if (listenerObj.options.once) {
listeners.push(function(...args) {
// Remove from the event listener once it's been called
stateNode.canonical.removeEventListener_unstable(
mangledImperativeRegistrationName,
listenerObj.listener,
listenerObj.capture,
);
// Guard against function being called more than once in
// case there are somehow multiple in-flight references to
// it being processed
if (!listenerObj.invalidated) {
listenerObj.invalidated = true;
listenerObj.listener(...args);
}
});
} else {
listeners.push(listenerFnWrapper);
}
});
}
if (listeners.length === 0) {
return null;
}
if (listeners.length === 1) {
return listeners[0];
}
return listeners;
}

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
'use strict';
// See the react-native repository for a full implementation.
// This is just a stub, currently to pass `instanceof` checks.
const CustomEvent = jest.fn();
module.exports = {default: CustomEvent};

View File

@@ -44,4 +44,7 @@ module.exports = {
get RawEventEmitter() {
return require('./RawEventEmitter').default;
},
get CustomEvent() {
return require('./CustomEvent').default;
},
};

View File

@@ -0,0 +1,10 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export type PropagationPhases = 'bubbled' | 'captured';

View File

@@ -138,6 +138,7 @@ declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'
emit: (channel: string, event: RawEventEmitterEvent) => string,
...
};
declare export var CustomEvent: CustomEvent;
}
declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInitializeCore' {