Skip to content

[react-native-renderer] EventTarget-based event dispatching#36253

Merged
rubennorte merged 1 commit intofacebook:mainfrom
rubennorte:eventtarget-event-dispatching
Apr 14, 2026
Merged

[react-native-renderer] EventTarget-based event dispatching#36253
rubennorte merged 1 commit intofacebook:mainfrom
rubennorte:eventtarget-event-dispatching

Conversation

@rubennorte
Copy link
Copy Markdown
Contributor

Summary

Set up the experiment to migrate event dispatching in the React Native renderer to be based on the native EventTarget API.

Behind the enableNativeEventTargetEventDispatching flag, events are dispatched through dispatchTrustedEvent instead of the legacy plugin system.

Regular event handler props are NOT registered via addEventListener at commit time. Instead, a hook on EventTarget
(EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY) extracts handlers from canonical.currentProps at dispatch time, shifting cost from every render to only when events fire. The hook is overridden in ReactNativeElement to look up the prop name via a reverse mapping from event names (built lazily from the view config registry).

Responder events bypass EventTarget entirely. negotiateResponder walks the fiber tree directly (capture then bubble phase), calling handlers from canonical.currentProps and checking return values inline. Lifecycle events (responderGrant, responderMove, etc.) call handlers directly from props and inspect return values — onResponderGrant returning true blocks native responder, onResponderTerminationRequest returning false refuses termination. This eliminates all commit-time cost for responder events (no wrappers, no addEventListener, no responderWrappers on canonical).

How did you test this change?

Flow
Tested e2e in RN using Fantom tests (that will land after this).

@meta-cla meta-cla bot added the CLA Signed label Apr 11, 2026
@rubennorte rubennorte force-pushed the eventtarget-event-dispatching branch from 0e1c577 to 52e1d67 Compare April 11, 2026 15:19
@rubennorte rubennorte requested a review from sammy-SC April 13, 2026 11:07
Comment on lines +45 to +46
const {customBubblingEventTypes, customDirectEventTypes} =
ReactNativeViewConfigRegistry;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this usage is fine - but keep in mind these get lazily populated as view configs are accessed.

Comment on lines +146 to +148
// Process responder events before normal event dispatch.
// This handles touch negotiation (onStartShouldSetResponder, etc.)
processResponderEvent(topLevelType, targetFiber, nativeEvent);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a change in ordering at all?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is the same logic we have now.

Comment on lines +28 to +38
export function topLevelTypeToEventName(topLevelType: string): string {
const fourthChar = topLevelType.charCodeAt(3);
if (
topLevelType.startsWith('top') &&
fourthChar >= 65 /* A */ &&
fourthChar <= 90 /* Z */
) {
return topLevelType.slice(3).toLowerCase();
}
return topLevelType;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have this (and similar logic between onPress and topPress) in way too many places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should just remove the top prefix from the native dispatch and then clean up all checks downstream.

Comment on lines +40 to +43
// Lazily built map from registration name (e.g. "onPointerUp", "onPointerUpCapture")
// to {eventName, capture}. Built from the view config registry on first access.
let registrationNameToEventConfig: {
[registrationName: string]: {eventName: string, capture: boolean},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably a pre-existing issue, but what if different view configs define the same event name differently?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's a pre-existing issue. I guess the first one would win.

*
* Returns null if the prop name is not a registered event handler.
*/
export function propNameToEventConfig(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I did a refactor after this and forgot to clean it up.

## Context

React Native's event system currently dispatches events through a legacy
plugin-based system. This change sets up an experiment to migrate to
the W3C EventTarget API (addEventListener/removeEventListener/dispatchEvent),
which enables imperative event handling on refs and aligns with web standards.

The experiment is gated behind the `enableNativeEventTargetEventDispatching`
feature flag (defaults to off).

## Changes

Event dispatching:
- Behind the flag, events are dispatched through `dispatchTrustedEvent`
  instead of the legacy plugin system.
- `LegacySyntheticEvent` extends the W3C `Event` class and carries the
  native event payload, providing backwards-compatible methods
  (`persist`, `isDefaultPrevented`, `isPropagationStopped`).
- Native event timestamps are preserved using `setEventInitTimeStamp`,
  matching the legacy behavior of reading `nativeEvent.timeStamp` or
  `nativeEvent.timestamp`.
- `topLevelTypeToEventName` converts top-level types (e.g. "topPress")
  to DOM event names (e.g. "press") for EventTarget dispatch.

Declarative (prop-based) event handlers:
- Props are NOT registered via addEventListener at commit time. Instead,
  a hook on EventTarget (`EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY`)
  extracts handlers from `canonical.currentProps` at dispatch time.
- `ReactNativeEventTypeMapping` provides `topLevelTypeToEventName` to
  convert between the naming conventions.

Responder system:
- Responder events bypass EventTarget entirely. `negotiateResponder`
  walks the fiber tree directly (capture then bubble), calling handlers
  from `canonical.currentProps`. The first handler returning `true` wins.
- Lifecycle events call handlers directly and inspect return values:
  `onResponderGrant` returning `true` blocks native responder,
  `onResponderTerminationRequest` returning `false` refuses termination.

Type fixes:
- `AnyNativeEvent` corrected from `Event | KeyboardEvent | MouseEvent |
  TouchEvent` to `{[string]: mixed}` to match the actual C++ native
  event objects, removing several FlowFixMe suppressions.
- `dispatchEvent` entry point validates `nativeEvent` is a non-null
  object, defaulting to `{}`.
- Flow libdef updated with `dispatchTrustedEvent` and
  `setEventInitTimeStamp` declarations.
@rubennorte rubennorte force-pushed the eventtarget-event-dispatching branch from 52e1d67 to 9b35af5 Compare April 13, 2026 16:11

// These globals are set by React Native (e.g. in setUpDOM.js, setUpTimers.js)
// and provide access to RN's feature flags. We use global functions because we
// don't have another mechanism to pass feature flags from RN to React in OSS.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't this using ReactFeatureFlags? We could hook that early in the init path to use the feature flag value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no easy way to hook that up ensuring that the flags are consistent, which is why I went for this option. If the function is defined, we know the flags will be consistent.

@@ -0,0 +1,572 @@
/**
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any changes here you want to highlight from the previous implementation in ResponderEventPlugin?

Any way we could share more of the logic or maintain history? There's a lot of complexity here which would be useful to understand the history of in the future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a 1:1 port of the old implementation, so the old history should still apply. Maybe the comments should help understanding the complexity?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no source control history as far as I can see

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean that going past this change they'll be able to see it in the other file if that was necessary. I don't think there's a clean way to preserve history here.

@rubennorte rubennorte merged commit 5682442 into facebook:main Apr 14, 2026
235 of 236 checks passed
@rubennorte rubennorte deleted the eventtarget-event-dispatching branch April 14, 2026 11:43
github-actions bot pushed a commit that referenced this pull request Apr 14, 2026
## Summary

Set up the experiment to migrate event dispatching in the React Native
renderer to be based on the native EventTarget API.

Behind the `enableNativeEventTargetEventDispatching` flag, events are
dispatched through `dispatchTrustedEvent` instead of the legacy plugin
system.

Regular event handler props are NOT registered via addEventListener at
commit time. Instead, a hook on EventTarget
(`EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY`) extracts handlers from
`canonical.currentProps` at dispatch time, shifting cost from every
render to only when events fire. The hook is overridden in
ReactNativeElement to look up the prop name via a reverse mapping from
event names (built lazily from the view config registry).

Responder events bypass EventTarget entirely. `negotiateResponder` walks
the fiber tree directly (capture then bubble phase), calling handlers
from `canonical.currentProps` and checking return values inline.
Lifecycle events (`responderGrant`, `responderMove`, etc.) call handlers
directly from props and inspect return values — `onResponderGrant`
returning `true` blocks native responder,
`onResponderTerminationRequest` returning `false` refuses termination.
This eliminates all commit-time cost for responder events (no wrappers,
no addEventListener, no `responderWrappers` on canonical).

## How did you test this change?

Flow
Tested e2e in RN using Fantom tests (that will land after this).

DiffTrain build for [5682442](5682442)
rubennorte added a commit that referenced this pull request Apr 14, 2026
## Summary

We found a bug in the logic in
#36253 and we realized it's very
inconvenient to iterate on the implementation when it's in this
repository, as we're forced to then synchronize it to RN to test
changes.

This moves the entire implementation to RN for simplicity and also to
simplify some clean ups in the future (like removing `top` prefixes from
native event types).

## How did you test this change?

The changes are gated. Will test e2e in RN.
github-actions bot pushed a commit that referenced this pull request Apr 14, 2026
## Summary

We found a bug in the logic in
#36253 and we realized it's very
inconvenient to iterate on the implementation when it's in this
repository, as we're forced to then synchronize it to RN to test
changes.

This moves the entire implementation to RN for simplicity and also to
simplify some clean ups in the future (like removing `top` prefixes from
native event types).

## How did you test this change?

The changes are gated. Will test e2e in RN.

DiffTrain build for [0418c8a](0418c8a)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants