Skip to content

Commit 05c283c

Browse files
authored
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
1 parent 0864434 commit 05c283c

File tree

10 files changed

+355
-60
lines changed

10 files changed

+355
-60
lines changed

packages/react-native-renderer/src/ReactFabricEventEmitter.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ import {batchedUpdates} from './legacy-events/ReactGenericBatching';
1818
import accumulateInto from './legacy-events/accumulateInto';
1919

2020
import {plugins} from './legacy-events/EventPluginRegistry';
21-
import getListener from './ReactNativeGetListener';
21+
import getListeners from './ReactNativeGetListeners';
2222
import {runEventsInBatch} from './legacy-events/EventBatching';
2323

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

26-
export {getListener, registrationNameModules as registrationNames};
26+
export {getListeners, registrationNameModules as registrationNames};
2727

2828
/**
2929
* Allows registered plugins an opportunity to extract events from top-level

packages/react-native-renderer/src/ReactFabricHostConfig.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,28 @@ export type RendererInspectionConfig = $ReadOnly<{|
9595
) => void,
9696
|}>;
9797

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+
98120
// TODO: Remove this conditional once all changes have propagated.
99121
if (registerEventHandler) {
100122
/**
@@ -111,6 +133,7 @@ class ReactFabricHostComponent {
111133
viewConfig: ViewConfig;
112134
currentProps: Props;
113135
_internalInstanceHandle: Object;
136+
_eventListeners: ?InternalEventListeners;
114137

115138
constructor(
116139
tag: number,
@@ -193,6 +216,102 @@ class ReactFabricHostComponent {
193216

194217
return;
195218
}
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+
}
196315
}
197316

198317
// eslint-disable-next-line no-unused-expressions

packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
import type {AnyNativeEvent} from './legacy-events/PluginModuleType';
1111
import type {TopLevelType} from './legacy-events/TopLevelEventTypes';
1212
import SyntheticEvent from './legacy-events/SyntheticEvent';
13+
import type {PropagationPhases} from './legacy-events/PropagationPhases';
1314

1415
// Module provided by RN:
1516
import {ReactNativeViewConfigRegistry} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface';
1617
import accumulateInto from './legacy-events/accumulateInto';
17-
import getListener from './ReactNativeGetListener';
18+
import getListeners from './ReactNativeGetListeners';
1819
import forEachAccumulated from './legacy-events/forEachAccumulated';
1920
import {HostComponent} from 'react-reconciler/src/ReactWorkTags';
21+
import isArray from 'shared/isArray';
2022

2123
const {
2224
customBubblingEventTypes,
@@ -26,10 +28,37 @@ const {
2628
// Start of inline: the below functions were inlined from
2729
// EventPropagator.js, as they deviated from ReactDOM's newer
2830
// implementations.
29-
function listenerAtPhase(inst, event, propagationPhase: PropagationPhases) {
31+
function listenersAtPhase(inst, event, propagationPhase: PropagationPhases) {
3032
const registrationName =
3133
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+
}
3362
}
3463

3564
function accumulateDirectionalDispatches(inst, phase, event) {
@@ -38,14 +67,8 @@ function accumulateDirectionalDispatches(inst, phase, event) {
3867
console.error('Dispatching inst must not be null');
3968
}
4069
}
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);
4972
}
5073

5174
function getParent(inst) {
@@ -103,14 +126,8 @@ function accumulateDispatches(
103126
): void {
104127
if (inst && event && event.dispatchConfig.registrationName) {
105128
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);
114131
}
115132
}
116133

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

132149
// End of inline
133-
type PropagationPhases = 'bubbled' | 'captured';
134150

135151
const ReactNativeBridgeEventPlugin = {
136152
eventTypes: {},

packages/react-native-renderer/src/ReactNativeEventEmitter.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ import {registrationNameModules} from './legacy-events/EventPluginRegistry';
1717
import {batchedUpdates} from './legacy-events/ReactGenericBatching';
1818
import {runEventsInBatch} from './legacy-events/EventBatching';
1919
import {plugins} from './legacy-events/EventPluginRegistry';
20-
import getListener from './ReactNativeGetListener';
20+
import getListeners from './ReactNativeGetListeners';
2121
import accumulateInto from './legacy-events/accumulateInto';
2222

2323
import {getInstanceFromNode} from './ReactNativeComponentTree';
2424

25-
export {getListener, registrationNameModules as registrationNames};
25+
export {getListeners, registrationNameModules as registrationNames};
2626

2727
/**
2828
* Version of `ReactBrowserEventEmitter` that works on the receiving side of a

packages/react-native-renderer/src/ReactNativeGetListener.js

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)