Skip to content

Cache parent lookups during event dispatch (#56853)#56853

Closed
rubennorte wants to merge 1 commit into
facebook:mainfrom
rubennorte:export-D105337953
Closed

Cache parent lookups during event dispatch (#56853)#56853
rubennorte wants to merge 1 commit into
facebook:mainfrom
rubennorte:export-D105337953

Conversation

@rubennorte
Copy link
Copy Markdown
Contributor

@rubennorte rubennorte commented 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

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 15, 2026
@meta-codesync
Copy link
Copy Markdown

meta-codesync Bot commented May 15, 2026

@rubennorte has exported this pull request. If you are a Meta employee, you can view the originating Diff in D105337953.

@meta-codesync meta-codesync Bot changed the title Cache parent lookups during event dispatch Cache parent lookups during event dispatch (#56853) May 15, 2026
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
@rubennorte rubennorte force-pushed the export-D105337953 branch from cd3c938 to db32a07 Compare May 15, 2026 16:52
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
@rubennorte rubennorte force-pushed the export-D105337953 branch 2 times, most recently from baee7a3 to 67f4e57 Compare May 15, 2026 17:06
@meta-codesync meta-codesync Bot closed this in e705e8d May 15, 2026
@react-native-bot
Copy link
Copy Markdown
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?

@react-native-bot react-native-bot added the Merged This PR has been merged. label May 15, 2026
@meta-codesync
Copy link
Copy Markdown

meta-codesync Bot commented May 15, 2026

This pull request has been merged in e705e8d.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. fb-exported Merged This PR has been merged. meta-exported p: Facebook Partner: Facebook Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants