mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
168
packages/react-native-renderer/src/ReactNativeGetListeners.js
vendored
Normal file
168
packages/react-native-renderer/src/ReactNativeGetListeners.js
vendored
Normal 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;
|
||||
}
|
||||
14
packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/CustomEvent.js
vendored
Normal file
14
packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/CustomEvent.js
vendored
Normal 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};
|
||||
@@ -44,4 +44,7 @@ module.exports = {
|
||||
get RawEventEmitter() {
|
||||
return require('./RawEventEmitter').default;
|
||||
},
|
||||
get CustomEvent() {
|
||||
return require('./CustomEvent').default;
|
||||
},
|
||||
};
|
||||
|
||||
10
packages/react-native-renderer/src/legacy-events/PropagationPhases.js
vendored
Normal file
10
packages/react-native-renderer/src/legacy-events/PropagationPhases.js
vendored
Normal 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';
|
||||
1
scripts/flow/react-native-host-hooks.js
vendored
1
scripts/flow/react-native-host-hooks.js
vendored
@@ -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' {
|
||||
|
||||
Reference in New Issue
Block a user