Cache parent lookups during event dispatch (#56853)#56853
Closed
rubennorte wants to merge 1 commit into
Closed
Conversation
|
@rubennorte has exported this pull request. If you are a Meta employee, you can view the originating Diff in D105337953. |
rubennorte
added a commit
to rubennorte/react-native
that referenced
this pull request
May 15, 2026
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] Differential Revision: D105337953
cd3c938 to
db32a07
Compare
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
rubennorte
added a commit
to rubennorte/react-native
that referenced
this pull request
May 15, 2026
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
baee7a3 to
67f4e57
Compare
Collaborator
|
This pull request was successfully merged by @rubennorte in e705e8d When will my fix make it into a release? | How to file a pick request? |
|
This pull request has been merged in e705e8d. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
The W3C event-dispatch pipeline (gated on
enableNativeEventTargetEventDispatching) walks the parent chain of the dispatched target on every event: once inEventTarget.getEventPathfor the capture/bubble path, plus up to two additional walks in the responder system for touch/scroll/selection events. EachparentNoderead 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 manyonLayoutevents 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.
getEventTargetParent(target)inEventTargetInternals(plus the cache slot and a sentinel for cached nulls).EventTarget.getEventPaththrough the new utility.parentElementwalks inReactNativeResponder(getLowestCommonAncestor,negotiateResponderpath build, and theskipSelfstep) through the same utility, with aninstanceof ReadOnlyElementfilter so the responder's element-only invariant is preserved.parentNodegetter onReadOnlyNodeuntouched — user-visible reads still take the canonical JSI path and detached-node reads continue to returnnull.EventDispatching-benchmark-itest.js(beforeAllmounts 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 --benchmarksfromxplat/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 (
enableNativeEventTargetEventDispatchingON):Pre-existing rebuild-per-iter scenarios with the new pipeline (no measurable change — cache is empty on each iteration):
Legacy pipeline (
enableNativeEventTargetEventDispatchingOFF) was unchanged across both runs, confirming the cache change does not leak outside the new pipeline.Changelog: [Internal]
Reviewed By: andrewdacenko
Differential Revision: D105337953