Skip to content

Commit cd5d24b

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
EventTarget-based event dispatching (facebook#56427)
Summary: Pull Request resolved: facebook#56427 Changelog: [internal] ## 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 - Add `EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY` hook to EventTarget, allowing subclasses to provide prop-based listeners at dispatch time. In `invoke()`, the prop listener is prepended to the addEventListener listeners and processed through the same loop (no duplication). - ReactNativeElement overrides this hook to look up handlers from `canonical.currentProps` using a reverse mapping from event names to prop names (built lazily from the view config in `EventNameToPropName.js`). - Add `getCurrentProps` helper in `NodeInternals.js` to encapsulate private React API access. - Conditionally extend ReadOnlyNode from EventTarget when the flag is enabled, making addEventListener/removeEventListener/dispatchEvent available on element refs. - Export `setEventInitTimeStamp` and `dispatchTrustedEvent` from `ReactNativePrivateInterface` for use by the React renderer. - Set up global `RN$isNativeEventTargetEventDispatchingEnabled` function for the React renderer to check the flag value. Gated until the OSS renderer has been synced to OSS: - Add integration tests for event dispatching (prop handlers, addEventListener, ordering, capture/bubble phases, timestamps) and responder system (grant, release, transfer, negotiation, capture). - Add event dispatching benchmark. Differential Revision: D100462547
1 parent 08f954f commit cd5d24b

File tree

14 files changed

+2246
-26
lines changed

14 files changed

+2246
-26
lines changed

packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,17 @@ const definitions: FeatureFlagDefinitions = {
10431043
},
10441044
ossReleaseStage: 'none',
10451045
},
1046+
enableNativeEventTargetEventDispatching: {
1047+
defaultValue: false,
1048+
metadata: {
1049+
dateAdded: '2026-04-13',
1050+
description:
1051+
'When enabled, the React Native renderer dispatches events through the W3C EventTarget API (addEventListener/dispatchEvent) instead of the legacy plugin-based system.',
1052+
expectedReleaseValue: true,
1053+
purpose: 'experimentation',
1054+
},
1055+
ossReleaseStage: 'none',
1056+
},
10461057
externalElementInspectionEnabled: {
10471058
defaultValue: true,
10481059
metadata: {

packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<1ec2592998e830300fc777070dfdc49d>>
7+
* @generated SignedSource<<d1f406d9418791758ce4532e16a22f73>>
88
* @flow strict
99
* @noformat
1010
*/
@@ -33,6 +33,7 @@ export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
3333
animatedShouldUseSingleOp: Getter<boolean>,
3434
deferFlatListFocusChangeRenderUpdate: Getter<boolean>,
3535
disableMaintainVisibleContentPosition: Getter<boolean>,
36+
enableNativeEventTargetEventDispatching: Getter<boolean>,
3637
externalElementInspectionEnabled: Getter<boolean>,
3738
fixVirtualizeListCollapseWindowSize: Getter<boolean>,
3839
isLayoutAnimationEnabled: Getter<boolean>,
@@ -162,6 +163,11 @@ export const deferFlatListFocusChangeRenderUpdate: Getter<boolean> = createJavaS
162163
*/
163164
export const disableMaintainVisibleContentPosition: Getter<boolean> = createJavaScriptFlagGetter('disableMaintainVisibleContentPosition', false);
164165

166+
/**
167+
* When enabled, the React Native renderer dispatches events through the W3C EventTarget API (addEventListener/dispatchEvent) instead of the legacy plugin-based system.
168+
*/
169+
export const enableNativeEventTargetEventDispatching: Getter<boolean> = createJavaScriptFlagGetter('enableNativeEventTargetEventDispatching', false);
170+
165171
/**
166172
* Enable the external inspection API for DevTools to communicate with the Inspector overlay.
167173
*/
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @fantom_flags enableNativeEventTargetEventDispatching:*
8+
* @flow strict-local
9+
* @format
10+
*/
11+
12+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
13+
14+
import * as Fantom from '@react-native/fantom';
15+
import * as React from 'react';
16+
import {View} from 'react-native';
17+
18+
let root: ReturnType<typeof Fantom.createRoot>;
19+
let ref: {current: React.ElementRef<typeof View> | null};
20+
21+
function createNestedViews(
22+
depth: number,
23+
innerRef: {current: React.ElementRef<typeof View> | null},
24+
): React.MixedElement {
25+
if (depth === 0) {
26+
return (
27+
<View
28+
ref={innerRef}
29+
collapsable={false}
30+
onPointerUp={() => {}}
31+
style={{width: 10, height: 10}}
32+
/>
33+
);
34+
}
35+
return (
36+
<View collapsable={false} onPointerUp={() => {}}>
37+
{createNestedViews(depth - 1, innerRef)}
38+
</View>
39+
);
40+
}
41+
42+
const {isOSS} = Fantom.getConstants();
43+
44+
if (isOSS) {
45+
it('is not supported in OSS yet', () => {
46+
expect(true).toBe(true);
47+
});
48+
} else {
49+
Fantom.unstable_benchmark
50+
.suite('Event Dispatching')
51+
.test(
52+
'dispatch event, flat (1 handler)',
53+
() => {
54+
Fantom.dispatchNativeEvent(
55+
ref,
56+
'onPointerUp',
57+
{x: 0, y: 0},
58+
{
59+
category: Fantom.NativeEventCategory.Discrete,
60+
},
61+
);
62+
},
63+
{
64+
beforeAll: () => {
65+
ref = React.createRef();
66+
},
67+
beforeEach: () => {
68+
root = Fantom.createRoot();
69+
Fantom.runTask(() => {
70+
root.render(
71+
<View
72+
ref={ref}
73+
collapsable={false}
74+
onPointerUp={() => {}}
75+
style={{width: 10, height: 10}}
76+
/>,
77+
);
78+
});
79+
},
80+
afterEach: () => {
81+
root.destroy();
82+
},
83+
},
84+
)
85+
.test(
86+
'dispatch event, nested 10 deep (bubbling)',
87+
() => {
88+
Fantom.dispatchNativeEvent(
89+
ref,
90+
'onPointerUp',
91+
{x: 0, y: 0},
92+
{
93+
category: Fantom.NativeEventCategory.Discrete,
94+
},
95+
);
96+
},
97+
{
98+
beforeAll: () => {
99+
ref = React.createRef();
100+
},
101+
beforeEach: () => {
102+
root = Fantom.createRoot();
103+
Fantom.runTask(() => {
104+
root.render(createNestedViews(10, ref));
105+
});
106+
},
107+
afterEach: () => {
108+
root.destroy();
109+
},
110+
},
111+
)
112+
.test(
113+
'dispatch event, nested 50 deep (bubbling)',
114+
() => {
115+
Fantom.dispatchNativeEvent(
116+
ref,
117+
'onPointerUp',
118+
{x: 0, y: 0},
119+
{
120+
category: Fantom.NativeEventCategory.Discrete,
121+
},
122+
);
123+
},
124+
{
125+
beforeAll: () => {
126+
ref = React.createRef();
127+
},
128+
beforeEach: () => {
129+
root = Fantom.createRoot();
130+
Fantom.runTask(() => {
131+
root.render(createNestedViews(50, ref));
132+
});
133+
},
134+
afterEach: () => {
135+
root.destroy();
136+
},
137+
},
138+
)
139+
.test(
140+
'dispatch event, nested 10 deep (no handlers on ancestors)',
141+
() => {
142+
Fantom.dispatchNativeEvent(
143+
ref,
144+
'onPointerUp',
145+
{x: 0, y: 0},
146+
{
147+
category: Fantom.NativeEventCategory.Discrete,
148+
},
149+
);
150+
},
151+
{
152+
beforeAll: () => {
153+
ref = React.createRef();
154+
},
155+
beforeEach: () => {
156+
root = Fantom.createRoot();
157+
Fantom.runTask(() => {
158+
let views: React.MixedElement = (
159+
<View
160+
ref={ref}
161+
collapsable={false}
162+
onPointerUp={() => {}}
163+
style={{width: 10, height: 10}}
164+
/>
165+
);
166+
for (let i = 0; i < 10; i++) {
167+
views = <View collapsable={false}>{views}</View>;
168+
}
169+
root.render(views);
170+
});
171+
},
172+
afterEach: () => {
173+
root.destroy();
174+
},
175+
},
176+
)
177+
.test(
178+
'dispatch event with stopPropagation, nested 10 deep',
179+
() => {
180+
Fantom.dispatchNativeEvent(
181+
ref,
182+
'onPointerUp',
183+
{x: 0, y: 0},
184+
{
185+
category: Fantom.NativeEventCategory.Discrete,
186+
},
187+
);
188+
},
189+
{
190+
beforeAll: () => {
191+
ref = React.createRef();
192+
},
193+
beforeEach: () => {
194+
root = Fantom.createRoot();
195+
Fantom.runTask(() => {
196+
let views: React.MixedElement = (
197+
<View
198+
ref={ref}
199+
collapsable={false}
200+
onPointerUp={e => {
201+
e.stopPropagation();
202+
}}
203+
style={{width: 10, height: 10}}
204+
/>
205+
);
206+
for (let i = 0; i < 10; i++) {
207+
views = (
208+
<View collapsable={false} onPointerUp={() => {}}>
209+
{views}
210+
</View>
211+
);
212+
}
213+
root.render(views);
214+
});
215+
},
216+
afterEach: () => {
217+
root.destroy();
218+
},
219+
},
220+
)
221+
.test(
222+
'render + dispatch, flat (handler update cost)',
223+
() => {
224+
Fantom.runTask(() => {
225+
root.render(
226+
<View
227+
ref={ref}
228+
collapsable={false}
229+
onPointerUp={() => {}}
230+
style={{width: 10, height: 10}}
231+
/>,
232+
);
233+
});
234+
Fantom.dispatchNativeEvent(
235+
ref,
236+
'onPointerUp',
237+
{x: 0, y: 0},
238+
{
239+
category: Fantom.NativeEventCategory.Discrete,
240+
},
241+
);
242+
},
243+
{
244+
beforeAll: () => {
245+
ref = React.createRef();
246+
},
247+
beforeEach: () => {
248+
root = Fantom.createRoot();
249+
Fantom.runTask(() => {
250+
root.render(
251+
<View
252+
ref={ref}
253+
collapsable={false}
254+
onPointerUp={() => {}}
255+
style={{width: 10, height: 10}}
256+
/>,
257+
);
258+
});
259+
},
260+
afterEach: () => {
261+
root.destroy();
262+
},
263+
},
264+
);
265+
}

0 commit comments

Comments
 (0)