From 67f4e578c9d13d681ed6be7f7c63a745caf6ad09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Fri, 15 May 2026 10:06:17 -0700 Subject: [PATCH] Cache parent lookups during event dispatch (#56853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: 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 --- .../EventDispatching-benchmark-itest.js | 61 +++++++++++++++++++ .../renderer/events/ReactNativeResponder.js | 26 ++++++-- .../private/webapis/dom/events/EventTarget.js | 4 +- .../events/internals/EventTargetInternals.js | 21 +++++++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js index 94a87f2c26b1..4c6b1309f5f2 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js @@ -261,5 +261,66 @@ if (isOSS) { root.destroy(); }, }, + ) + .test( + 'dispatch event, nested 50 deep (bubbling), stable tree', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViews(50, ref)); + }); + }, + afterAll: () => { + root.destroy(); + }, + }, + ) + .test( + 'dispatch event, nested 50 deep (no handlers on ancestors), stable tree', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + let views: React.MixedElement = ( + {}} + style={{width: 10, height: 10}} + /> + ); + for (let i = 0; i < 50; i++) { + views = {views}; + } + root.render(views); + }); + }, + afterAll: () => { + root.destroy(); + }, + }, ); } diff --git a/packages/react-native/src/private/renderer/events/ReactNativeResponder.js b/packages/react-native/src/private/renderer/events/ReactNativeResponder.js index 63a9c6269e14..1323f08c4a0a 100644 --- a/packages/react-native/src/private/renderer/events/ReactNativeResponder.js +++ b/packages/react-native/src/private/renderer/events/ReactNativeResponder.js @@ -18,6 +18,7 @@ import { setCurrentTarget, setTarget, } from '../../webapis/dom/events/internals/EventInternals'; +import {getEventTargetParent} from '../../webapis/dom/events/internals/EventTargetInternals'; import { getCurrentProps, getNativeElementReference, @@ -78,6 +79,23 @@ function isEndish(topLevelType: string): boolean { return topLevelType === 'topTouchEnd' || topLevelType === 'topTouchCancel'; } +// Routes through the event-dispatch parent cache (shared with +// `EventTarget.getEventPath`) and stops the walk when the parent is not +// an element (e.g., when reaching the document at the top of the tree). +function getResponderParentElement( + node: ReadOnlyElement, +): ReadOnlyElement | null { + // `ReadOnlyElement` extends `EventTarget` at runtime when the new + // event-dispatching pipeline is enabled (the only case this module runs in). + // $FlowFixMe[incompatible-type] + const eventTarget: EventTarget = node; + const parent = getEventTargetParent(eventTarget); + if (parent instanceof ReadOnlyElement) { + return parent; + } + return null; +} + /** * Return the lowest common ancestor of A and B, or null if they are in * different trees. @@ -95,12 +113,12 @@ function getLowestCommonAncestor( } // Walk up from A until we find an ancestor that contains B - let current: ?ReadOnlyElement = instA.parentElement; + let current: ?ReadOnlyElement = getResponderParentElement(instA); while (current != null) { if (current.contains(instB)) { return current; } - current = current.parentElement; + current = getResponderParentElement(current); } return null; @@ -265,7 +283,7 @@ function negotiateResponder( } const dispatchNode: ReadOnlyElement | null = skipSelf - ? negotiationNode.parentElement + ? getResponderParentElement(negotiationNode) : negotiationNode; if (dispatchNode == null) { return null; @@ -276,7 +294,7 @@ function negotiateResponder( let node: ?ReadOnlyElement = dispatchNode; while (node != null) { path.unshift(node); - node = node.parentElement; + node = getResponderParentElement(node); } const dispatchConfig = responderEventTypes[shouldSetEventName]; diff --git a/packages/react-native/src/private/webapis/dom/events/EventTarget.js b/packages/react-native/src/private/webapis/dom/events/EventTarget.js index ab75d2ab9b63..92bebb55c4a5 100644 --- a/packages/react-native/src/private/webapis/dom/events/EventTarget.js +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -34,6 +34,7 @@ import { EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY, EVENT_TARGET_GET_THE_PARENT_KEY, INTERNAL_DISPATCH_METHOD_KEY, + getEventTargetParent, } from './internals/EventTargetInternals'; export type EventCallback = (event: Event) => void; @@ -341,8 +342,7 @@ function getEventPath( while (target != null) { path.push(target); - // $FlowExpectedError[prop-missing] - target = target[EVENT_TARGET_GET_THE_PARENT_KEY](); + target = getEventTargetParent(target); } return path; diff --git a/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js b/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js index fa7bed2b7e69..93bf4803feaf 100644 --- a/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js +++ b/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js @@ -47,6 +47,27 @@ export const INTERNAL_DISPATCH_METHOD_KEY: symbol = Symbol( 'EventTarget[dispatch]', ); +const EVENT_DISPATCH_PARENT_CACHE_KEY: symbol = Symbol( + 'EventTarget[dispatch parent cache]', +); + +export function getEventTargetParent(target: EventTarget): EventTarget | null { + // The slot is `undefined` until populated; a populated slot may hold + // `null` (no parent), so check against `undefined` rather than nullishness. + // $FlowExpectedError[prop-missing] symbol-keyed slot + const cached: EventTarget | null | void = + // $FlowExpectedError[prop-missing] symbol-keyed slot + target[EVENT_DISPATCH_PARENT_CACHE_KEY]; + if (cached !== undefined) { + return cached; + } + // $FlowExpectedError[prop-missing] symbol-keyed method + const parent: EventTarget | null = target[EVENT_TARGET_GET_THE_PARENT_KEY](); + // $FlowExpectedError[prop-missing] symbol-keyed slot + target[EVENT_DISPATCH_PARENT_CACHE_KEY] = parent; + return parent; +} + /** * Dispatches a trusted event to the given event target. Mirrors the * `dispatchEvent` method on `EventTarget`: returns `false` if the event