Skip to content

Commit e705e8d

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Cache parent lookups during event dispatch (#56853)
Summary: Pull Request resolved: #56853 The W3C event-dispatch pipeline (gated on `enableNativeEventTargetEventDispatching`) walks the parent chain of the dispatched target on every event: once in `EventTarget.getEventPath` for the capture/bubble path, plus up to two additional walks in the responder system for touch/scroll/selection events. Each `parentNode` read is a JSI hop into C++ that re-walks the family chain in the current shadow-tree revision, so on event-heavy screens (e.g., a list firing many `onLayout` events during mount) the per-event walk cost adds up. This change adds a per-instance parent cache that all event-dispatch consumers share. RN host trees are append-only and the shadow tree is stable during dispatch, so once a node is reachable from the dispatch path its parent is permanently stable from that pipeline's point of view. The cache stores the resolved parent in a symbol-keyed slot on the first lookup; subsequent lookups (within the same dispatch and across future dispatches on the same tree) collapse to a property load. - Add `getEventTargetParent(target)` in `EventTargetInternals` (plus the cache slot and a sentinel for cached nulls). - Route `EventTarget.getEventPath` through the new utility. - Route the `parentElement` walks in `ReactNativeResponder` (`getLowestCommonAncestor`, `negotiateResponder` path build, and the `skipSelf` step) through the same utility, with an `instanceof ReadOnlyElement` filter so the responder's element-only invariant is preserved. - Leave the `parentNode` getter on `ReadOnlyNode` untouched — user-visible reads still take the canonical JSI path and detached-node reads continue to return `null`. - Add two stable-tree scenarios to `EventDispatching-benchmark-itest.js` (`beforeAll` mounts once, the benchmarked function dispatches per iteration) so the cache win is measurable and any regression in the rebuild-per-iter scenarios is also visible. ## Benchmark results Ran `yarn fantom EventDispatching-benchmark --benchmarks` from `xplat/js/react-native-github/`, comparing the cache disabled (utility short-circuited to the canonical getter) against the cache enabled. Numbers below are p50 latency; both runs use Hermes-bytecode optimized mode (the default for `*-benchmark-itest.js`). New stable-tree scenarios with the new pipeline (`enableNativeEventTargetEventDispatching` ON): | Scenario (depth 50, stable tree) | Cache OFF | Cache ON | Improvement | | ------------------------------------------------ | --------- | -------- | ------------------- | | dispatch event, bubbling, handlers on ancestors | 0.271 ms | 0.165 ms | 39% faster (1.64×) | | dispatch event, no handlers on ancestors | 0.265 ms | 0.161 ms | 39% faster (1.65×) | Pre-existing rebuild-per-iter scenarios with the new pipeline (no measurable change — cache is empty on each iteration): | Scenario (flag ON) | Cache OFF | Cache ON | Δ | | ------------------------------------- | --------- | -------- | - | | flat (1 handler) | 0.042 ms | 0.042 ms | — | | nested 10 deep (bubbling) | 0.105 ms | 0.106 ms | — | | nested 50 deep (bubbling) | 0.378 ms | 0.381 ms | — | | nested 10 deep (no handlers) | 0.103 ms | 0.104 ms | — | | stopPropagation, nested 10 deep | 0.089 ms | 0.091 ms | — | | render + dispatch, flat | 0.082 ms | 0.083 ms | — | Legacy pipeline (`enableNativeEventTargetEventDispatching` OFF) was unchanged across both runs, confirming the cache change does not leak outside the new pipeline. Changelog: [Internal] Reviewed By: andrewdacenko Differential Revision: D105337953 fbshipit-source-id: cd157aaa34049906de00566a901a87f0e447b644
1 parent 3b9c581 commit e705e8d

4 files changed

Lines changed: 106 additions & 6 deletions

File tree

packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,5 +261,66 @@ if (isOSS) {
261261
root.destroy();
262262
},
263263
},
264+
)
265+
.test(
266+
'dispatch event, nested 50 deep (bubbling), stable tree',
267+
() => {
268+
Fantom.dispatchNativeEvent(
269+
ref,
270+
'onPointerUp',
271+
{x: 0, y: 0},
272+
{
273+
category: Fantom.NativeEventCategory.Discrete,
274+
},
275+
);
276+
},
277+
{
278+
beforeAll: () => {
279+
ref = React.createRef();
280+
root = Fantom.createRoot();
281+
Fantom.runTask(() => {
282+
root.render(createNestedViews(50, ref));
283+
});
284+
},
285+
afterAll: () => {
286+
root.destroy();
287+
},
288+
},
289+
)
290+
.test(
291+
'dispatch event, nested 50 deep (no handlers on ancestors), stable tree',
292+
() => {
293+
Fantom.dispatchNativeEvent(
294+
ref,
295+
'onPointerUp',
296+
{x: 0, y: 0},
297+
{
298+
category: Fantom.NativeEventCategory.Discrete,
299+
},
300+
);
301+
},
302+
{
303+
beforeAll: () => {
304+
ref = React.createRef();
305+
root = Fantom.createRoot();
306+
Fantom.runTask(() => {
307+
let views: React.MixedElement = (
308+
<View
309+
ref={ref}
310+
collapsable={false}
311+
onPointerUp={() => {}}
312+
style={{width: 10, height: 10}}
313+
/>
314+
);
315+
for (let i = 0; i < 50; i++) {
316+
views = <View collapsable={false}>{views}</View>;
317+
}
318+
root.render(views);
319+
});
320+
},
321+
afterAll: () => {
322+
root.destroy();
323+
},
324+
},
264325
);
265326
}

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
setCurrentTarget,
1919
setTarget,
2020
} from '../../webapis/dom/events/internals/EventInternals';
21+
import {getEventTargetParent} from '../../webapis/dom/events/internals/EventTargetInternals';
2122
import {
2223
getCurrentProps,
2324
getNativeElementReference,
@@ -78,6 +79,23 @@ function isEndish(topLevelType: string): boolean {
7879
return topLevelType === 'topTouchEnd' || topLevelType === 'topTouchCancel';
7980
}
8081

82+
// Routes through the event-dispatch parent cache (shared with
83+
// `EventTarget.getEventPath`) and stops the walk when the parent is not
84+
// an element (e.g., when reaching the document at the top of the tree).
85+
function getResponderParentElement(
86+
node: ReadOnlyElement,
87+
): ReadOnlyElement | null {
88+
// `ReadOnlyElement` extends `EventTarget` at runtime when the new
89+
// event-dispatching pipeline is enabled (the only case this module runs in).
90+
// $FlowFixMe[incompatible-type]
91+
const eventTarget: EventTarget = node;
92+
const parent = getEventTargetParent(eventTarget);
93+
if (parent instanceof ReadOnlyElement) {
94+
return parent;
95+
}
96+
return null;
97+
}
98+
8199
/**
82100
* Return the lowest common ancestor of A and B, or null if they are in
83101
* different trees.
@@ -95,12 +113,12 @@ function getLowestCommonAncestor(
95113
}
96114

97115
// Walk up from A until we find an ancestor that contains B
98-
let current: ?ReadOnlyElement = instA.parentElement;
116+
let current: ?ReadOnlyElement = getResponderParentElement(instA);
99117
while (current != null) {
100118
if (current.contains(instB)) {
101119
return current;
102120
}
103-
current = current.parentElement;
121+
current = getResponderParentElement(current);
104122
}
105123

106124
return null;
@@ -265,7 +283,7 @@ function negotiateResponder(
265283
}
266284

267285
const dispatchNode: ReadOnlyElement | null = skipSelf
268-
? negotiationNode.parentElement
286+
? getResponderParentElement(negotiationNode)
269287
: negotiationNode;
270288
if (dispatchNode == null) {
271289
return null;
@@ -276,7 +294,7 @@ function negotiateResponder(
276294
let node: ?ReadOnlyElement = dispatchNode;
277295
while (node != null) {
278296
path.unshift(node);
279-
node = node.parentElement;
297+
node = getResponderParentElement(node);
280298
}
281299

282300
const dispatchConfig = responderEventTypes[shouldSetEventName];

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY,
3535
EVENT_TARGET_GET_THE_PARENT_KEY,
3636
INTERNAL_DISPATCH_METHOD_KEY,
37+
getEventTargetParent,
3738
} from './internals/EventTargetInternals';
3839

3940
export type EventCallback = (event: Event) => void;
@@ -341,8 +342,7 @@ function getEventPath(
341342

342343
while (target != null) {
343344
path.push(target);
344-
// $FlowExpectedError[prop-missing]
345-
target = target[EVENT_TARGET_GET_THE_PARENT_KEY]();
345+
target = getEventTargetParent(target);
346346
}
347347

348348
return path;

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@ export const INTERNAL_DISPATCH_METHOD_KEY: symbol = Symbol(
4747
'EventTarget[dispatch]',
4848
);
4949

50+
const EVENT_DISPATCH_PARENT_CACHE_KEY: symbol = Symbol(
51+
'EventTarget[dispatch parent cache]',
52+
);
53+
54+
export function getEventTargetParent(target: EventTarget): EventTarget | null {
55+
// The slot is `undefined` until populated; a populated slot may hold
56+
// `null` (no parent), so check against `undefined` rather than nullishness.
57+
// $FlowExpectedError[prop-missing] symbol-keyed slot
58+
const cached: EventTarget | null | void =
59+
// $FlowExpectedError[prop-missing] symbol-keyed slot
60+
target[EVENT_DISPATCH_PARENT_CACHE_KEY];
61+
if (cached !== undefined) {
62+
return cached;
63+
}
64+
// $FlowExpectedError[prop-missing] symbol-keyed method
65+
const parent: EventTarget | null = target[EVENT_TARGET_GET_THE_PARENT_KEY]();
66+
// $FlowExpectedError[prop-missing] symbol-keyed slot
67+
target[EVENT_DISPATCH_PARENT_CACHE_KEY] = parent;
68+
return parent;
69+
}
70+
5071
/**
5172
* Dispatches a trusted event to the given event target. Mirrors the
5273
* `dispatchEvent` method on `EventTarget`: returns `false` if the event

0 commit comments

Comments
 (0)