Skip to content

Commit 3790942

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Optimize EventTarget-based event dispatch pipeline (#56738)
Summary: Pull Request resolved: #56738 Reduces dispatch latency on the new W3C `EventTarget`-based event pipeline (gated behind `enableNativeEventTargetEventDispatching`) by eliminating redundant work that compounds per ancestor on every dispatch. Four surgical changes, all backwards-compatible with the existing public API surface (`EventTarget` / `Event` / `LegacySyntheticEvent` / `dispatchNativeEvent` shapes are unchanged; the protected `EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY` contract evolves additively): 1. **Fast path in `EventTarget.invoke()`** when only a prop-listener is present and there are no `addEventListener` listeners — call the prop listener inline without allocating an array or running `for..of`. The mixed-listeners slow path moved to a small `invokeListeners()` helper. 2. **Pre-resolve React prop names once per dispatch** in `dispatchNativeEvent`. The view-config we already look up exposes the bubbled / captured prop names directly; stash them on the event via internal symbol slots (`BUBBLED_PROP_NAME_KEY` / `CAPTURED_PROP_NAME_KEY`) so per-ancestor `EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY` lookups can read them in O(1) instead of doing a `getEventTypePropName(eventType, isCapture)` hash lookup each time. `ReactNativeElement` reads them with a fallback to the mapping table for events not constructed via `dispatchNativeEvent`. The protected method now receives `(event, isCapture)` instead of `(eventType, isCapture)` — `event.type` is `eventType`, and `isCapture` can't be derived from `event.eventPhase` (which is `AT_TARGET` during both passes through the target node per the W3C "event dispatch" algorithm). 3. **Alias `[EVENT_TARGET_GET_THE_PARENT_KEY]` to the `parentNode` getter** on `ReadOnlyNode.prototype` (instead of a trampoline method that just returns `this.parentNode`). Removes one extra function call per ancestor on the dispatch hot path. 4. **Early-return `processResponderEvent`** for non-touch events (`pointerup`, `pointermove`, `layout`, etc.) when no responder is currently set. Trivially safe; saves the touch counting + `ResponderTouchHistoryStore` + `canTriggerTransfer` work that always short-circuits anyway in that case. Also adds one new scenario to `EventTarget-benchmark-itest.js` (`'dispatchEvent, bubbling (100), prop listener per target only'`) that isolates the per-target prop-listener cost in pure JS — useful for future micro-validation of `invoke()` changes. ### Benchmark results (`EventDispatching-benchmark-itest.js`, opt mode, FLAG ON, median ns/op) | Scenario | Before | After | Speedup | |------------------------------------------------|--------|--------|--------| | dispatch event, flat (1 handler) | 44,226 | 41,653 | 5.8 % | | dispatch event, nested 10 deep (bubbling) | 112,489 | 100,050 | 11.1 % | | dispatch event, nested 50 deep (bubbling) | 405,799 | 359,259 | 11.5 % | | dispatch event, nested 10 (no handlers) | 105,378 | 98,067 | 6.9 % | | dispatch event with stopPropagation, nested 10 | 91,868 | 86,831 | 5.5 % | | render + dispatch, flat | 83,766 | 80,781 | 3.6 % | Improvements scale with tree depth as the per-ancestor savings compound. The legacy plugin path (FLAG OFF) is unchanged within run-to-run noise on every scenario. The remaining gap to the legacy path at depth 50 (~2.74×) is dominated by the per-ancestor `NativeDOM.getParentNode` TurboModule call (~5.4 % of total profile inclusive). Closing that requires a non-surgical change (e.g., a bulk native parent-walk API) that is out of scope here. Changelog: [Internal] Reviewed By: javache Differential Revision: D104414586 fbshipit-source-id: 88513ca40fc3b303931548aac3187ca32f4be9ca
1 parent 5dea3b5 commit 3790942

7 files changed

Lines changed: 208 additions & 33 deletions

File tree

packages/react-native/src/private/renderer/events/ReactNativeResponder.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,24 @@ to return true:wantsResponderID| |
550550
| |
551551
+ + */
552552

553+
/**
554+
* Returns whether a top-level event is one that the responder system needs to
555+
* observe. Used as a fast-path in `processResponderEvent`: most events
556+
* (`pointerup`, `pointermove`, `layout`, etc.) are not responder-relevant and
557+
* — when no responder is currently set — produce no work.
558+
*/
559+
function isResponderRelevantTopLevelType(topLevelType: string): boolean {
560+
return match (topLevelType) {
561+
| 'topTouchStart'
562+
| 'topTouchMove'
563+
| 'topTouchEnd'
564+
| 'topTouchCancel'
565+
| 'topScroll'
566+
| 'topSelectionChange' => true,
567+
_ => false,
568+
};
569+
}
570+
553571
/**
554572
* Process a native event through the responder system.
555573
*/
@@ -558,6 +576,15 @@ export function processResponderEvent(
558576
eventTarget: EventTarget | null,
559577
nativeEvent: {[string]: unknown},
560578
): void {
579+
// Fast path: if this event isn't one the responder system cares about and
580+
// there is no active responder, exit immediately. This is the dominant case
581+
// for non-touch events (pointer*, layout, etc.) on apps without an active
582+
// gesture, and saves the touch counting + ResponderTouchHistoryStore +
583+
// canTriggerTransfer work.
584+
if (responderNode == null && !isResponderRelevantTopLevelType(topLevelType)) {
585+
return;
586+
}
587+
561588
// Track touch count
562589
if (isStartish(topLevelType)) {
563590
trackedTouchCount += 1;

packages/react-native/src/private/renderer/events/dispatchNativeEvent.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import {
1414
customBubblingEventTypes,
1515
customDirectEventTypes,
1616
} from '../../../../Libraries/Renderer/shims/ReactNativeViewConfigRegistry';
17-
import {setEventInitTimeStamp} from '../../webapis/dom/events/internals/EventInternals';
17+
import {
18+
setBubbledPropName,
19+
setCapturedPropName,
20+
setEventInitTimeStamp,
21+
} from '../../webapis/dom/events/internals/EventInternals';
1822
import {dispatchTrustedEvent} from '../../webapis/dom/events/internals/EventTargetInternals';
1923
import LegacySyntheticEvent from './LegacySyntheticEvent';
2024
import {topLevelTypeToEventType} from './ReactNativeEventTypeMapping';
@@ -73,6 +77,26 @@ export default function dispatchNativeEvent(
7377
payload,
7478
bubbleConfig ?? directConfig,
7579
);
80+
81+
// Pre-resolve the React prop names ("onFoo" / "onFooCapture") once per
82+
// dispatch and stash them on the event so per-ancestor
83+
// `EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY` lookups can read them
84+
// directly, avoiding the per-call `getEventTypePropName` hash lookup.
85+
if (bubbleConfig != null) {
86+
const phasedRegistrationNames = bubbleConfig.phasedRegistrationNames;
87+
setBubbledPropName(
88+
syntheticEvent,
89+
phasedRegistrationNames.bubbled ?? null,
90+
);
91+
setCapturedPropName(
92+
syntheticEvent,
93+
phasedRegistrationNames.captured ?? null,
94+
);
95+
} else if (directConfig != null) {
96+
setBubbledPropName(syntheticEvent, directConfig.registrationName ?? null);
97+
setCapturedPropName(syntheticEvent, null);
98+
}
99+
76100
dispatchTrustedEvent(target, syntheticEvent);
77101
}
78102

packages/react-native/src/private/webapis/dom/events/EventTarget.js

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,14 @@ export default class EventTarget {
217217
*
218218
* Called during event dispatch before explicitly registered listeners.
219219
* Return a callback to be invoked as an event listener, or null.
220+
*
221+
* `event.type` is the event type. `isCapture` distinguishes the capture pass
222+
* from the bubble pass — it cannot be derived from `event.eventPhase`,
223+
* which is `AT_TARGET` during both passes through the target node.
220224
*/
221225
// $FlowExpectedError[unsupported-syntax]
222226
[EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY](
223-
eventType: string,
227+
event: Event,
224228
isCapture: boolean,
225229
): EventCallback | null {
226230
return null;
@@ -354,27 +358,39 @@ function invoke(
354358

355359
setCurrentTarget(event, eventTarget);
356360

357-
// Build the list of listeners to invoke:
358-
// When the flag is enabled, prop-based listeners fire first, then
359-
// explicitly registered addEventListener listeners.
360-
// When disabled, only addEventListener listeners are used (legacy path).
361-
let listeners: Array<EventListenerRegistration>;
362-
363361
if (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()) {
362+
// Resolve the prop-based listener. Pass the event so subclasses can read
363+
// its `type` and any pre-resolved internal slots; pass `isCapture`
364+
// separately because it can't be derived from `event.eventPhase` (which
365+
// is `AT_TARGET` during both passes through the target node).
364366
// $FlowExpectedError[prop-missing]
365367
const propListener: EventCallback | null = eventTarget[
366368
EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY
367-
](event.type, isCapture);
369+
](event, isCapture);
368370

369371
const listenersByType = getListenersForPhase(eventTarget, isCapture);
370372
const maybeListeners = listenersByType?.get(event.type);
371373

372-
if (propListener == null && maybeListeners == null) {
374+
// Fast path: only a prop listener (no `addEventListener` listeners).
375+
// This is the overwhelmingly common case for React-driven dispatch.
376+
if (maybeListeners == null) {
377+
if (propListener == null) {
378+
return;
379+
}
380+
const currentEvent = global.event;
381+
global.event = event;
382+
try {
383+
propListener.call(eventTarget, event);
384+
} catch (error) {
385+
// TODO: replace with `reportError` when it's available.
386+
console.error(error);
387+
}
388+
global.event = currentEvent;
373389
return;
374390
}
375391

376-
listeners = [];
377-
392+
// Slow path: combine prop listener + addEventListener listeners.
393+
const listeners: Array<EventListenerRegistration> = [];
378394
if (propListener != null) {
379395
listeners.push({
380396
callback: propListener,
@@ -383,21 +399,33 @@ function invoke(
383399
removed: false,
384400
});
385401
}
386-
387-
if (maybeListeners != null) {
388-
for (const registration of maybeListeners.values()) {
389-
listeners.push(registration);
390-
}
402+
for (const registration of maybeListeners.values()) {
403+
listeners.push(registration);
391404
}
392-
} else {
393-
const listenersByType = getListenersForPhase(eventTarget, isCapture);
394-
const maybeListeners = listenersByType?.get(event.type);
395-
if (maybeListeners == null) {
396-
return;
397-
}
398-
listeners = Array.from(maybeListeners.values());
405+
invokeListeners(eventTarget, event, listeners, isCapture);
406+
return;
407+
}
408+
409+
// Legacy path (flag OFF): only `addEventListener` listeners.
410+
const listenersByType = getListenersForPhase(eventTarget, isCapture);
411+
const maybeListeners = listenersByType?.get(event.type);
412+
if (maybeListeners == null) {
413+
return;
399414
}
415+
invokeListeners(
416+
eventTarget,
417+
event,
418+
Array.from(maybeListeners.values()),
419+
isCapture,
420+
);
421+
}
400422

423+
function invokeListeners(
424+
eventTarget: EventTarget,
425+
event: Event,
426+
listeners: Array<EventListenerRegistration>,
427+
isCapture: boolean,
428+
): void {
401429
for (const listener of listeners) {
402430
if (listener.removed) {
403431
continue;

packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,29 @@ import createEventTargetHierarchyWithDepth from './createEventTargetHierarchyWit
1515
import {unstable_benchmark} from '@react-native/fantom';
1616
import Event from 'react-native/src/private/webapis/dom/events/Event';
1717
import EventTarget from 'react-native/src/private/webapis/dom/events/EventTarget';
18+
import {EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY} from 'react-native/src/private/webapis/dom/events/internals/EventTargetInternals';
1819

1920
let event: Event;
2021
let eventTarget: EventTarget;
2122
let eventTargets: ReadonlyArray<EventTarget>;
2223

24+
// Simulates the prop-listener pattern from `ReactNativeElement` without
25+
// requiring React or any `parentNode` TurboModule traversal: each target
26+
// returns a no-op callback from `EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY`.
27+
// Used to isolate the per-target cost of `invoke()` when only declarative
28+
// (prop) listeners are present, which is the common path on real components.
29+
function createPropListenerOnlyHierarchy(
30+
depth: number,
31+
): ReadonlyArray<EventTarget> {
32+
const targets = createEventTargetHierarchyWithDepth(depth);
33+
const noop = () => {};
34+
for (const target of targets) {
35+
// $FlowExpectedError[prop-missing] $FlowExpectedError[invalid-computed-prop]
36+
target[EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY] = () => noop;
37+
}
38+
return targets;
39+
}
40+
2341
unstable_benchmark
2442
.suite('EventTarget', {
2543
minIterations: 1000,
@@ -175,4 +193,17 @@ unstable_benchmark
175193
eventTargets = createEventTargetHierarchyWithDepth(100);
176194
},
177195
},
196+
)
197+
.test(
198+
'dispatchEvent, bubbling (100), prop listener per target only',
199+
() => {
200+
eventTarget.dispatchEvent(event);
201+
},
202+
{
203+
beforeAll: () => {
204+
event = new Event('custom', {bubbles: true});
205+
const targets = createPropListenerOnlyHierarchy(100);
206+
eventTarget = targets[targets.length - 1];
207+
},
208+
},
178209
);

packages/react-native/src/private/webapis/dom/events/internals/EventInternals.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,42 @@ export const TARGET_KEY: symbol = Symbol('target');
3535
// platform using the original timestamps.
3636
export const EVENT_INIT_TIMESTAMP_KEY: symbol = Symbol('eventInitTimestamp');
3737

38+
// Internal slots used by the React Native renderer to pass pre-resolved
39+
// React prop names ("onPointerUp" / "onPointerUpCapture") through the event
40+
// to subclasses of `EventTarget` (i.e. `ReactNativeElement`). This avoids
41+
// recomputing `getEventTypePropName(event.type, isCapture)` per ancestor per
42+
// phase during dispatch, which is hot.
43+
export const BUBBLED_PROP_NAME_KEY: symbol = Symbol('bubbledPropName');
44+
export const CAPTURED_PROP_NAME_KEY: symbol = Symbol('capturedPropName');
45+
46+
export function getBubbledPropName(event: Event): string | null | void {
47+
// $FlowExpectedError[prop-missing]
48+
return event[BUBBLED_PROP_NAME_KEY];
49+
}
50+
51+
export function setBubbledPropName(
52+
event: Event,
53+
propName: string | null,
54+
): void {
55+
// $FlowExpectedError[prop-missing]
56+
// $FlowExpectedError[invalid-computed-prop]
57+
event[BUBBLED_PROP_NAME_KEY] = propName;
58+
}
59+
60+
export function getCapturedPropName(event: Event): string | null | void {
61+
// $FlowExpectedError[prop-missing]
62+
return event[CAPTURED_PROP_NAME_KEY];
63+
}
64+
65+
export function setCapturedPropName(
66+
event: Event,
67+
propName: string | null,
68+
): void {
69+
// $FlowExpectedError[prop-missing]
70+
// $FlowExpectedError[invalid-computed-prop]
71+
event[CAPTURED_PROP_NAME_KEY] = propName;
72+
}
73+
3874
export function getCurrentTarget(event: Event): EventTarget | null {
3975
// $FlowExpectedError[prop-missing]
4076
return event[CURRENT_TARGET_KEY];

packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
MeasureOnSuccessCallback,
2222
NativeMethods,
2323
} from '../../../types/HostInstance';
24+
import type Event from '../events/Event';
2425
import type {InstanceHandle} from './internals/NodeInternals';
2526
import type ReactNativeDocument from './ReactNativeDocument';
2627

@@ -30,6 +31,10 @@ import {create as createAttributePayload} from '../../../../../Libraries/ReactNa
3031
import warnForStyleProps from '../../../../../Libraries/ReactNative/ReactFabricPublicInstance/warnForStyleProps';
3132
import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags';
3233
import {getEventTypePropName} from '../../../renderer/events/ReactNativeEventTypeMapping';
34+
import {
35+
getBubbledPropName,
36+
getCapturedPropName,
37+
} from '../events/internals/EventInternals';
3338
import {EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY} from '../events/internals/EventTargetInternals';
3439
import {
3540
getCurrentProps,
@@ -223,16 +228,29 @@ class ReactNativeElement extends ReadOnlyElement implements NativeMethods {
223228
// This is called by EventTarget.invoke() before explicit addEventListener
224229
// listeners, allowing prop-based handlers to be resolved at dispatch time
225230
// without registering them via addEventListener during commit.
231+
//
232+
// Fast path: when `event` is a `LegacySyntheticEvent` (always the case for
233+
// events created by `dispatchNativeEvent`), the React prop names have been
234+
// pre-resolved on the event during construction. Reading them directly
235+
// avoids the `getEventTypePropName(eventType, isCapture)` hash lookup per
236+
// ancestor per phase.
226237
// $FlowExpectedError[unsupported-syntax]
227238
[EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY](
228-
eventType: string,
239+
event: Event,
229240
isCapture: boolean,
230241
): ((event: Event) => void) | null {
231242
const currentProps = getCurrentProps(this);
232243
if (currentProps == null) {
233244
return null;
234245
}
235-
const propName = getEventTypePropName(eventType, isCapture);
246+
let propName = isCapture
247+
? getCapturedPropName(event)
248+
: getBubbledPropName(event);
249+
if (propName === undefined) {
250+
// The event wasn't created via `dispatchNativeEvent` (e.g.,
251+
// user-dispatched). Fall back to the mapping table.
252+
propName = getEventTypePropName(event.type, isCapture);
253+
}
236254
if (propName == null) {
237255
return null;
238256
}

packages/react-native/src/private/webapis/dom/nodes/ReadOnlyNode.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,6 @@ class ReadOnlyNode extends ReadOnlyNodeBase {
6363
setInstanceHandle(this, instanceHandle);
6464
}
6565

66-
// Implement the "get the parent" algorithm for EventTarget.
67-
// This enables event propagation (capture/bubble) through the node tree.
68-
// $FlowExpectedError[unsupported-syntax]
69-
[EVENT_TARGET_GET_THE_PARENT_KEY](): EventTarget | null {
70-
return this.parentNode;
71-
}
72-
7366
get childNodes(): NodeList<ReadOnlyNode> {
7467
const childNodes = getChildNodes(this);
7568
return createNodeList(childNodes);
@@ -326,6 +319,24 @@ class ReadOnlyNode extends ReadOnlyNodeBase {
326319

327320
setPlatformObject(ReadOnlyNode);
328321

322+
// Implement the "get the parent" algorithm for EventTarget by aliasing
323+
// `[EVENT_TARGET_GET_THE_PARENT_KEY]` directly to the `parentNode` getter
324+
// function on the prototype. This enables event propagation (capture/bubble)
325+
// through the node tree and avoids the extra function call that a
326+
// trampoline method (e.g. `() { return this.parentNode; }`) would add per
327+
// ancestor on the hot event-dispatch path.
328+
{
329+
const parentNodeGetter = Object.getOwnPropertyDescriptor(
330+
ReadOnlyNode.prototype,
331+
'parentNode',
332+
)?.get;
333+
if (parentNodeGetter != null) {
334+
// $FlowExpectedError[prop-missing]
335+
// $FlowExpectedError[invalid-computed-prop]
336+
ReadOnlyNode.prototype[EVENT_TARGET_GET_THE_PARENT_KEY] = parentNodeGetter;
337+
}
338+
}
339+
329340
type ReadOnlyNodeT = ReadOnlyNode;
330341

331342
function replaceConstructorWithoutSuper(

0 commit comments

Comments
 (0)