Skip to content

Optimize EventTarget-based event dispatch pipeline#56738

Closed
rubennorte wants to merge 1 commit into
react:mainfrom
rubennorte:export-D104414586
Closed

Optimize EventTarget-based event dispatch pipeline#56738
rubennorte wants to merge 1 commit into
react:mainfrom
rubennorte:export-D104414586

Conversation

@rubennorte

@rubennorte rubennorte commented May 8, 2026

Copy link
Copy Markdown
Contributor

Summary:
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

@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 8, 2026
@facebook-github-tools facebook-github-tools Bot added p: Facebook Partner: Facebook Partner labels May 8, 2026
@meta-codesync

meta-codesync Bot commented May 8, 2026

Copy link
Copy Markdown

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

Summary:
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
@rubennorte rubennorte force-pushed the export-D104414586 branch from f614577 to a0aa6eb Compare May 11, 2026 10:17
@meta-codesync meta-codesync Bot closed this in 3790942 May 11, 2026
@facebook-github-tools facebook-github-tools Bot added the Merged This PR has been merged. label May 11, 2026
@meta-codesync

meta-codesync Bot commented May 11, 2026

Copy link
Copy Markdown

This pull request has been merged in 3790942.

@react-native-bot

Copy link
Copy Markdown
Collaborator

This pull request was successfully merged by @rubennorte in 3790942

When will my fix make it into a release? | How to file a pick request?

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