@samitouri / QOS-React-2 / commits / 05c283c3c3

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

Joshua Gross committed Mar 2, 2022 at 12:00 UTC 05c283c3c31184d68c6a54dfd6a044790b89a08a
10 files changed +355 -60
packages/react-native-renderer/src/ReactFabricEventEmitter.js
+2 -2
@@ -18,12 +18,12 @@ import {batchedUpdates} from './legacy-events/ReactGenericBatching';
18 import accumulateInto from './legacy-events/accumulateInto';
19
20 import {plugins} from './legacy-events/EventPluginRegistry';
21 -import getListener from './ReactNativeGetListener';
21 +import getListeners from './ReactNativeGetListeners';
22 import {runEventsInBatch} from './legacy-events/EventBatching';
23
24 import {RawEventEmitter} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
25
26 -export {getListener, registrationNameModules as registrationNames};
26 +export {getListeners, registrationNameModules as registrationNames};
27
28 /**
29 * Allows registered plugins an opportunity to extract events from top-level
packages/react-native-renderer/src/ReactFabricHostConfig.js
+119
@@ -95,6 +95,28 @@ export type RendererInspectionConfig = $ReadOnly<{|
95 ) => void,
96 |}>;
97
98 +// TODO?: find a better place for this type to live
99 +export type EventListenerOptions = $ReadOnly<{|
100 + capture?: boolean,
101 + once?: boolean,
102 + passive?: boolean,
103 + signal: mixed, // not yet implemented
104 +|}>;
105 +export type EventListenerRemoveOptions = $ReadOnly<{|
106 + capture?: boolean,
107 +|}>;
108 +
109 +// TODO?: this will be changed in the future to be w3c-compatible and allow "EventListener" objects as well as functions.
110 +export type EventListener = Function;
111 +
112 +type InternalEventListeners = {
113 + [string]: {|
114 + listener: EventListener,
115 + options: EventListenerOptions,
116 + invalidated: boolean,
117 + |}[],
118 +};
119 +
120 // TODO: Remove this conditional once all changes have propagated.
121 if (registerEventHandler) {
122 /**
@@ -111,6 +133,7 @@ class ReactFabricHostComponent {
133 viewConfig: ViewConfig;
134 currentProps: Props;
135 _internalInstanceHandle: Object;
136 + _eventListeners: ?InternalEventListeners;
137
138 constructor(
139 tag: number,
@@ -193,6 +216,102 @@ class ReactFabricHostComponent {
216
217 return;
218 }
219 +
220 + // This API (addEventListener, removeEventListener) attempts to adhere to the
221 + // w3 Level2 Events spec as much as possible, treating HostComponent as a DOM node.
222 + //
223 + // Unless otherwise noted, these methods should "just work" and adhere to the W3 specs.
224 + // If they deviate in a way that is not explicitly noted here, you've found a bug!
225 + //
226 + // See:
227 + // * https://www.w3.org/TR/DOM-Level-2-Events/events.html
228 + // * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
229 + // * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
230 + //
231 + // And notably, not implemented (yet?):
232 + // * https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent
233 + //
234 + //
235 + // Deviations from spec/TODOs:
236 + // (1) listener must currently be a function, we do not support EventListener objects yet.
237 + // (2) we do not support the `signal` option / AbortSignal yet
238 + addEventListener_unstable(
239 + eventType: string,
240 + listener: EventListener,
241 + options: EventListenerOptions | boolean,
242 + ) {
243 + if (typeof eventType !== 'string') {
244 + throw new Error('addEventListener_unstable eventType must be a string');
245 + }
246 + if (typeof listener !== 'function') {
247 + throw new Error('addEventListener_unstable listener must be a function');
248 + }
249 +
250 + // The third argument is either boolean indicating "captures" or an object.
251 + const optionsObj =
252 + typeof options === 'object' && options !== null ? options : {};
253 + const capture =
254 + (typeof options === 'boolean' ? options : optionsObj.capture) || false;
255 + const once = optionsObj.once || false;
256 + const passive = optionsObj.passive || false;
257 + const signal = null; // TODO: implement signal/AbortSignal
258 +
259 + const eventListeners: InternalEventListeners = this._eventListeners || {};
260 + if (this._eventListeners == null) {
261 + this._eventListeners = eventListeners;
262 + }
263 +
264 + const namedEventListeners = eventListeners[eventType] || [];
265 + if (eventListeners[eventType] == null) {
266 + eventListeners[eventType] = namedEventListeners;
267 + }
268 +
269 + namedEventListeners.push({
270 + listener: listener,
271 + invalidated: false,
272 + options: {
273 + capture: capture,
274 + once: once,
275 + passive: passive,
276 + signal: signal,
277 + },
278 + });
279 + }
280 +
281 + // See https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener
282 + removeEventListener_unstable(
283 + eventType: string,
284 + listener: EventListener,
285 + options: EventListenerRemoveOptions | boolean,
286 + ) {
287 + // eventType and listener must be referentially equal to be removed from the listeners
288 + // data structure, but in "options" we only check the `capture` flag, according to spec.
289 + // That means if you add the same function as a listener with capture set to true and false,
290 + // you must also call removeEventListener twice with capture set to true/false.
291 + const optionsObj =
292 + typeof options === 'object' && options !== null ? options : {};
293 + const capture =
294 + (typeof options === 'boolean' ? options : optionsObj.capture) || false;
295 +
296 + // If there are no event listeners or named event listeners, we can bail early - our
297 + // job is already done.
298 + const eventListeners = this._eventListeners;
299 + if (!eventListeners) {
300 + return;
301 + }
302 + const namedEventListeners = eventListeners[eventType];
303 + if (!namedEventListeners) {
304 + return;
305 + }
306 +
307 + // TODO: optimize this path to make remove cheaper
308 + eventListeners[eventType] = namedEventListeners.filter(listenerObj => {
309 + return !(
310 + listenerObj.listener === listener &&
311 + listenerObj.options.capture === capture
312 + );
313 + });
314 + }
315 }
316
317 // eslint-disable-next-line no-unused-expressions
packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js
+36 -20
@@ -10,13 +10,15 @@
10 import type {AnyNativeEvent} from './legacy-events/PluginModuleType';
11 import type {TopLevelType} from './legacy-events/TopLevelEventTypes';
12 import SyntheticEvent from './legacy-events/SyntheticEvent';
13 +import type {PropagationPhases} from './legacy-events/PropagationPhases';
14
15 // Module provided by RN:
16 import {ReactNativeViewConfigRegistry} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
17 import accumulateInto from './legacy-events/accumulateInto';
17 -import getListener from './ReactNativeGetListener';
18 +import getListeners from './ReactNativeGetListeners';
19 import forEachAccumulated from './legacy-events/forEachAccumulated';
20 import {HostComponent} from 'react-reconciler/src/ReactWorkTags';
21 +import isArray from 'shared/isArray';
22
23 const {
24 customBubblingEventTypes,
@@ -26,10 +28,37 @@ const {
28 // Start of inline: the below functions were inlined from
29 // EventPropagator.js, as they deviated from ReactDOM's newer
30 // implementations.
29 -function listenerAtPhase(inst, event, propagationPhase: PropagationPhases) {
31 +function listenersAtPhase(inst, event, propagationPhase: PropagationPhases) {
32 const registrationName =
33 event.dispatchConfig.phasedRegistrationNames[propagationPhase];
32 - return getListener(inst, registrationName);
34 + return getListeners(inst, registrationName, propagationPhase, true);
35 +}
36 +
37 +function accumulateListenersAndInstances(inst, event, listeners) {
38 + const listenersLength = listeners
39 + ? isArray(listeners)
40 + ? listeners.length
41 + : 1
42 + : 0;
43 + if (listenersLength > 0) {
44 + event._dispatchListeners = accumulateInto(
45 + event._dispatchListeners,
46 + listeners,
47 + );
48 +
49 + // Avoid allocating additional arrays here
50 + if (event._dispatchInstances == null && listenersLength === 1) {
51 + event._dispatchInstances = inst;
52 + } else {
53 + event._dispatchInstances = event._dispatchInstances || [];
54 + if (!isArray(event._dispatchInstances)) {
55 + event._dispatchInstances = [event._dispatchInstances];
56 + }
57 + for (let i = 0; i < listenersLength; i++) {
58 + event._dispatchInstances.push(inst);
59 + }
60 + }
61 + }
62 }
63
64 function accumulateDirectionalDispatches(inst, phase, event) {
@@ -38,14 +67,8 @@ function accumulateDirectionalDispatches(inst, phase, event) {
67 console.error('Dispatching inst must not be null');
68 }
69 }
41 - const listener = listenerAtPhase(inst, event, phase);
42 - if (listener) {
43 - event._dispatchListeners = accumulateInto(
44 - event._dispatchListeners,
45 - listener,
46 - );
47 - event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
48 - }
70 + const listeners = listenersAtPhase(inst, event, phase);
71 + accumulateListenersAndInstances(inst, event, listeners);
72 }
73
74 function getParent(inst) {
@@ -103,14 +126,8 @@ function accumulateDispatches(
126 ): void {
127 if (inst && event && event.dispatchConfig.registrationName) {
128 const registrationName = event.dispatchConfig.registrationName;
106 - const listener = getListener(inst, registrationName);
107 - if (listener) {
108 - event._dispatchListeners = accumulateInto(
109 - event._dispatchListeners,
110 - listener,
111 - );
112 - event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
113 - }
129 + const listeners = getListeners(inst, registrationName, 'bubbled', false);
130 + accumulateListenersAndInstances(inst, event, listeners);
131 }
132 }
133
@@ -130,7 +147,6 @@ function accumulateDirectDispatches(events: ?(Array<Object> | Object)) {
147 }
148
149 // End of inline
133 -type PropagationPhases = 'bubbled' | 'captured';
150
151 const ReactNativeBridgeEventPlugin = {
152 eventTypes: {},
packages/react-native-renderer/src/ReactNativeEventEmitter.js
+2 -2
@@ -17,12 +17,12 @@ import {registrationNameModules} from './legacy-events/EventPluginRegistry';
17 import {batchedUpdates} from './legacy-events/ReactGenericBatching';
18 import {runEventsInBatch} from './legacy-events/EventBatching';
19 import {plugins} from './legacy-events/EventPluginRegistry';
20 -import getListener from './ReactNativeGetListener';
20 +import getListeners from './ReactNativeGetListeners';
21 import accumulateInto from './legacy-events/accumulateInto';
22
23 import {getInstanceFromNode} from './ReactNativeComponentTree';
24
25 -export {getListener, registrationNameModules as registrationNames};
25 +export {getListeners, registrationNameModules as registrationNames};
26
27 /**
28 * Version of `ReactBrowserEventEmitter` that works on the receiving side of a
packages/react-native-renderer/src/ReactNativeGetListener.js deleted
-36
@@ -1,36 +0,0 @@
1 -/**
2 - * Copyright (c) Facebook, Inc. and its affiliates.
3 - *
4 - * This source code is licensed under the MIT license found in the
5 - * LICENSE file in the root directory of this source tree.
6 - * @flow
7 - */
8 -
9 -import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
10 -
11 -import {getFiberCurrentPropsFromNode} from './legacy-events/EventPluginUtils';
12 -
13 -export default function getListener(
14 - inst: Fiber,
15 - registrationName: string,
16 -): Function | null {
17 - const stateNode = inst.stateNode;
18 - if (stateNode === null) {
19 - // Work in progress (ex: onload events in incremental mode).
20 - return null;
21 - }
22 - const props = getFiberCurrentPropsFromNode(stateNode);
23 - if (props === null) {
24 - // Work in progress.
25 - return null;
26 - }
27 - const listener = props[registrationName];
28 -
29 - if (listener && typeof listener !== 'function') {
30 - throw new Error(
31 - `Expected \`${registrationName}\` listener to be a function, instead got a value of \`${typeof listener}\` type.`,
32 - );
33 - }
34 -
35 - return listener;
36 -}
packages/react-native-renderer/src/ReactNativeGetListeners.js new
+168
@@ -0,0 +1,168 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + * @flow
7 + */
8 +
9 +import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
10 +import type {PropagationPhases} from './legacy-events/PropagationPhases';
11 +
12 +import {getFiberCurrentPropsFromNode} from './legacy-events/EventPluginUtils';
13 +import {CustomEvent} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
14 +
15 +/**
16 + * Get a list of listeners for a specific event, in-order.
17 + * For React Native we treat the props-based function handlers
18 + * as the first-class citizens, and they are always executed first
19 + * for both capture and bubbling phase.
20 + *
21 + * We need "phase" propagated to this point to support the HostComponent
22 + * EventEmitter API, which does not mutate the name of the handler based
23 + * on phase (whereas prop handlers are registered as `onMyEvent` and `onMyEvent_Capture`).
24 + *
25 + * Native system events emitted into React Native
26 + * will be emitted both to the prop handler function and to imperative event
27 + * listeners.
28 + *
29 + * This will either return null, a single Function without an array, or
30 + * an array of 2+ items.
31 + */
32 +export default function getListeners(
33 + inst: Fiber,
34 + registrationName: string,
35 + phase: PropagationPhases,
36 + dispatchToImperativeListeners: boolean,
37 +): null | Function | Array<Function> {
38 + const stateNode = inst.stateNode;
39 +
40 + if (stateNode === null) {
41 + return null;
42 + }
43 +
44 + // If null: Work in progress (ex: onload events in incremental mode).
45 + const props = getFiberCurrentPropsFromNode(stateNode);
46 + if (props === null) {
47 + // Work in progress.
48 + return null;
49 + }
50 +
51 + const listener = props[registrationName];
52 +
53 + if (listener && typeof listener !== 'function') {
54 + throw new Error(
55 + `Expected \`${registrationName}\` listener to be a function, instead got a value of \`${typeof listener}\` type.`,
56 + );
57 + }
58 +
59 + // If there are no imperative listeners, early exit.
60 + if (
61 + !(
62 + dispatchToImperativeListeners &&
63 + stateNode.canonical &&
64 + stateNode.canonical._eventListeners
65 + )
66 + ) {
67 + return listener;
68 + }
69 +
70 + // Below this is the de-optimized path.
71 + // If you are using _eventListeners, we do not (yet)
72 + // expect this to be as performant as the props-only path.
73 + // If/when this becomes a bottleneck, it can be refactored
74 + // to avoid unnecessary closures and array allocations.
75 + //
76 + // Previously, there was only one possible listener for an event:
77 + // the onEventName property in props.
78 + // Now, it is also possible to have N listeners
79 + // for a specific event on a node. Thus, we accumulate all of the listeners,
80 + // including the props listener, and return a function that calls them all in
81 + // order, starting with the handler prop and then the listeners in order.
82 + // We return either a non-empty array or null.
83 + const listeners = [];
84 + if (listener) {
85 + listeners.push(listener);
86 + }
87 +
88 + // TODO: for now, all of these events get an `rn:` prefix to enforce
89 + // that the user knows they're only getting non-W3C-compliant events
90 + // through this imperative event API.
91 + // Events might not necessarily be noncompliant, but we currently have
92 + // no verification that /any/ events are compliant.
93 + // Thus, we prefix to ensure no collision with W3C event names.
94 + const requestedPhaseIsCapture = phase === 'captured';
95 + const mangledImperativeRegistrationName = requestedPhaseIsCapture
96 + ? 'rn:' + registrationName.replace(/Capture$/, '')
97 + : 'rn:' + registrationName;
98 +
99 + // Get imperative event listeners for this event
100 + if (
101 + stateNode.canonical._eventListeners[mangledImperativeRegistrationName] &&
102 + stateNode.canonical._eventListeners[mangledImperativeRegistrationName]
103 + .length > 0
104 + ) {
105 + const eventListeners =
106 + stateNode.canonical._eventListeners[mangledImperativeRegistrationName];
107 +
108 + eventListeners.forEach(listenerObj => {
109 + // Make sure phase of listener matches requested phase
110 + const isCaptureEvent =
111 + listenerObj.options.capture != null && listenerObj.options.capture;
112 + if (isCaptureEvent !== requestedPhaseIsCapture) {
113 + return;
114 + }
115 +
116 + // For now (this is an area of future optimization) we must wrap
117 + // all imperative event listeners in a function to unwrap the SyntheticEvent
118 + // and pass them an Event.
119 + // When this API is more stable and used more frequently, we can revisit.
120 + const listenerFnWrapper = function(syntheticEvent, ...args) {
121 + const eventInst = new CustomEvent(mangledImperativeRegistrationName, {
122 + detail: syntheticEvent.nativeEvent,
123 + });
124 + eventInst.isTrusted = true;
125 + // setSyntheticEvent is present on the React Native Event shim.
126 + // It is used to forward method calls on Event to the underlying SyntheticEvent.
127 + // $FlowFixMe
128 + eventInst.setSyntheticEvent(syntheticEvent);
129 +
130 + listenerObj.listener(eventInst, ...args);
131 + };
132 +
133 + // Only call once?
134 + // If so, we ensure that it's only called once by setting a flag
135 + // and by removing it from eventListeners once it is called (but only
136 + // when it's actually been executed).
137 + if (listenerObj.options.once) {
138 + listeners.push(function(...args) {
139 + // Remove from the event listener once it's been called
140 + stateNode.canonical.removeEventListener_unstable(
141 + mangledImperativeRegistrationName,
142 + listenerObj.listener,
143 + listenerObj.capture,
144 + );
145 +
146 + // Guard against function being called more than once in
147 + // case there are somehow multiple in-flight references to
148 + // it being processed
149 + if (!listenerObj.invalidated) {
150 + listenerObj.invalidated = true;
151 + listenerObj.listener(...args);
152 + }
153 + });
154 + } else {
155 + listeners.push(listenerFnWrapper);
156 + }
157 + });
158 + }
159 +
160 + if (listeners.length === 0) {
161 + return null;
162 + }
163 + if (listeners.length === 1) {
164 + return listeners[0];
165 + }
166 +
167 + return listeners;
168 +}
packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/CustomEvent.js new
+14
@@ -0,0 +1,14 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +'use strict';
9 +
10 +// See the react-native repository for a full implementation.
11 +// This is just a stub, currently to pass `instanceof` checks.
12 +const CustomEvent = jest.fn();
13 +
14 +module.exports = {default: CustomEvent};
packages/react-native-renderer/src/__mocks__/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js
+3
@@ -44,4 +44,7 @@ module.exports = {
44 get RawEventEmitter() {
45 return require('./RawEventEmitter').default;
46 },
47 + get CustomEvent() {
48 + return require('./CustomEvent').default;
49 + },
50 };
packages/react-native-renderer/src/legacy-events/PropagationPhases.js new
+10
@@ -0,0 +1,10 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +export type PropagationPhases = 'bubbled' | 'captured';
scripts/flow/react-native-host-hooks.js
+1
@@ -138,6 +138,7 @@ declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'
138 emit: (channel: string, event: RawEventEmitterEvent) => string,
139 ...
140 };
141 + declare export var CustomEvent: CustomEvent;
142 }
143
144 declare module 'react-native/Libraries/ReactPrivate/ReactNativePrivateInitializeCore' {