From 05c283c3c31184d68c6a54dfd6a044790b89a08a Mon Sep 17 00:00:00 2001 From: Joshua Gross Date: Wed, 2 Mar 2022 12:00:08 -0800 Subject: [PATCH] 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 --- .../src/ReactFabricEventEmitter.js | 4 +- .../src/ReactFabricHostConfig.js | 119 +++++++++++++ .../src/ReactNativeBridgeEventPlugin.js | 56 +++--- .../src/ReactNativeEventEmitter.js | 4 +- .../src/ReactNativeGetListener.js | 36 ---- .../src/ReactNativeGetListeners.js | 168 ++++++++++++++++++ .../Libraries/ReactPrivate/CustomEvent.js | 14 ++ .../ReactNativePrivateInterface.js | 3 + .../src/legacy-events/PropagationPhases.js | 10 ++ scripts/flow/react-native-host-hooks.js | 1 + 10 files changed, 355 insertions(+), 60 deletions(-) delete mode 100644 packages/react-native-renderer/src/ReactNativeGetListener.js create mode 100644 packages/react-native-renderer/src/ReactNativeGetListeners.js create mode 100644 packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/CustomEvent.js create mode 100644 packages/react-native-renderer/src/legacy-events/PropagationPhases.js diff --git a/packages/react-native-renderer/src/ReactFabricEventEmitter.js b/packages/react-native-renderer/src/ReactFabricEventEmitter.js index d8492156fdc7c..c3b89e91779a3 100644 --- a/packages/react-native-renderer/src/ReactFabricEventEmitter.js +++ b/packages/react-native-renderer/src/ReactFabricEventEmitter.js @@ -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 diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 727b782efd768..81295f6e50f68 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -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 diff --git a/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js b/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js index 212b181c08294..2d74776e4200c 100644 --- a/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js +++ b/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js @@ -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)) { } // End of inline -type PropagationPhases = 'bubbled' | 'captured'; const ReactNativeBridgeEventPlugin = { eventTypes: {}, diff --git a/packages/react-native-renderer/src/ReactNativeEventEmitter.js b/packages/react-native-renderer/src/ReactNativeEventEmitter.js index 2ba35aed39b9f..91816a53f82da 100644 --- a/packages/react-native-renderer/src/ReactNativeEventEmitter.js +++ b/packages/react-native-renderer/src/ReactNativeEventEmitter.js @@ -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 diff --git a/packages/react-native-renderer/src/ReactNativeGetListener.js b/packages/react-native-renderer/src/ReactNativeGetListener.js deleted file mode 100644 index 4f76fddd29e7b..0000000000000 --- a/packages/react-native-renderer/src/ReactNativeGetListener.js +++ /dev/null @@ -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; -} diff --git a/packages/react-native-renderer/src/ReactNativeGetListeners.js b/packages/react-native-renderer/src/ReactNativeGetListeners.js new file mode 100644 index 0000000000000..2ab198c504383 --- /dev/null +++ b/packages/react-native-renderer/src/ReactNativeGetListeners.js @@ -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 { + 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; +} diff --git a/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/CustomEvent.js b/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/CustomEvent.js new file mode 100644 index 0000000000000..0b0b79c5ddddf --- /dev/null +++ b/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/CustomEvent.js @@ -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}; diff --git a/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js b/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js index 3eaf3a5a38057..dbfb9910c943e 100644 --- a/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js +++ b/packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js @@ -44,4 +44,7 @@ module.exports = { get RawEventEmitter() { return require('./RawEventEmitter').default; }, + get CustomEvent() { + return require('./CustomEvent').default; + }, }; diff --git a/packages/react-native-renderer/src/legacy-events/PropagationPhases.js b/packages/react-native-renderer/src/legacy-events/PropagationPhases.js new file mode 100644 index 0000000000000..7d05d30be8c47 --- /dev/null +++ b/packages/react-native-renderer/src/legacy-events/PropagationPhases.js @@ -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'; diff --git a/scripts/flow/react-native-host-hooks.js b/scripts/flow/react-native-host-hooks.js index c7a2fc8dc3212..896964f063643 100644 --- a/scripts/flow/react-native-host-hooks.js +++ b/scripts/flow/react-native-host-hooks.js @@ -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' {