Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
332 changes: 204 additions & 128 deletions packages/react-native/ReactNativeApi.d.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1160,17 +1160,6 @@ const definitions: FeatureFlagDefinitions = {
},
ossReleaseStage: 'none',
},
enableNativeEventTargetEventDispatching: {
defaultValue: true,
metadata: {
dateAdded: '2026-04-13',
description:
'When enabled, the React Native renderer dispatches events through the W3C EventTarget API (addEventListener/dispatchEvent) instead of the legacy plugin-based system.',
expectedReleaseValue: true,
purpose: 'experimentation',
},
ossReleaseStage: 'none',
},
externalElementInspectionEnabled: {
defaultValue: true,
metadata: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<248a6701e98d6850499d84dd5c2bff5f>>
* @generated SignedSource<<715bdce4ff85be24d125b1c894da41d8>>
* @flow strict
* @noformat
*/
Expand Down Expand Up @@ -34,7 +34,6 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{
animatedKeepListenersOnDetach: Getter<boolean>,
animatedShouldSyncValueBeforeStartCallback: Getter<boolean>,
deferFlatListFocusChangeRenderUpdate: Getter<boolean>,
enableNativeEventTargetEventDispatching: Getter<boolean>,
externalElementInspectionEnabled: Getter<boolean>,
fixCrossOrientationNestedListViewability: Getter<boolean>,
fixVirtualizeListCollapseWindowSize: Getter<boolean>,
Expand Down Expand Up @@ -179,11 +178,6 @@ export const animatedShouldSyncValueBeforeStartCallback: Getter<boolean> = creat
*/
export const deferFlatListFocusChangeRenderUpdate: Getter<boolean> = createJavaScriptFlagGetter('deferFlatListFocusChangeRenderUpdate', false);

/**
* When enabled, the React Native renderer dispatches events through the W3C EventTarget API (addEventListener/dispatchEvent) instead of the legacy plugin-based system.
*/
export const enableNativeEventTargetEventDispatching: Getter<boolean> = createJavaScriptFlagGetter('enableNativeEventTargetEventDispatching', true);

/**
* Enable the external inspection API for DevTools to communicate with the Inspector overlay.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @fantom_flags enableNativeEventTargetEventDispatching:*
* @flow strict-local
* @format
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @fantom_flags enableNativeEventTargetEventDispatching:*
* @fantom_flags enableImperativeEvents:*
* @flow strict-local
* @format
Expand Down Expand Up @@ -480,13 +479,10 @@ describe('EventTarget-based Event Dispatching', () => {
});

// --- addEventListener / removeEventListener on refs ---
// These tests require both `enableNativeEventTargetEventDispatching` and
// `enableImperativeEvents` to be enabled, since the public `addEventListener`
// API on element refs is only available when both flags are on. They are
// skipped for the other flag combinations.
// These tests require `enableImperativeEvents` to be enabled, since the
// public `addEventListener` API on element refs is only available then.

(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
ReactNativeFeatureFlags.enableImperativeEvents()
(ReactNativeFeatureFlags.enableImperativeEvents() === true
? describe
: describe.skip)('addEventListener / removeEventListener', () => {
it('addEventListener on a ref receives dispatched events', () => {
Expand Down Expand Up @@ -869,74 +865,66 @@ describe('EventTarget-based Event Dispatching', () => {
}
});

(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()
? it
: it.skip)(
'event.target points to the original target and event.currentTarget changes at each step',
() => {
const root = Fantom.createRoot();
const parentRef = React.createRef<React.ElementRef<typeof View>>();
const childRef = React.createRef<React.ElementRef<typeof View>>();
const targets: Array<{target: unknown, currentTarget: unknown}> = [];
it('event.target points to the original target and event.currentTarget changes at each step', () => {
const root = Fantom.createRoot();
const parentRef = React.createRef<React.ElementRef<typeof View>>();
const childRef = React.createRef<React.ElementRef<typeof View>>();
const targets: Array<{target: unknown, currentTarget: unknown}> = [];

Fantom.runTask(() => {
root.render(
Fantom.runTask(() => {
root.render(
<View
ref={parentRef}
onPointerUpCapture={(e: $FlowFixMe) => {
targets.push({
target: e.target,
currentTarget: e.currentTarget,
});
}}
onPointerUp={(e: $FlowFixMe) => {
targets.push({
target: e.target,
currentTarget: e.currentTarget,
});
}}>
<View
ref={parentRef}
onPointerUpCapture={(e: $FlowFixMe) => {
targets.push({
target: e.target,
currentTarget: e.currentTarget,
});
}}
ref={childRef}
onPointerUp={(e: $FlowFixMe) => {
targets.push({
target: e.target,
currentTarget: e.currentTarget,
});
}}>
<View
ref={childRef}
onPointerUp={(e: $FlowFixMe) => {
targets.push({
target: e.target,
currentTarget: e.currentTarget,
});
}}
/>
</View>,
);
});

Fantom.dispatchNativeEvent(
childRef,
'onPointerUp',
{x: 0, y: 0},
{
category: Fantom.NativeEventCategory.Discrete,
},
}}
/>
</View>,
);
});

expect(targets).toHaveLength(3);
Fantom.dispatchNativeEvent(
childRef,
'onPointerUp',
{x: 0, y: 0},
{
category: Fantom.NativeEventCategory.Discrete,
},
);

// event.target is always the original target element
expect(targets[0].target).toBe(childRef.current);
expect(targets[1].target).toBe(childRef.current);
expect(targets[2].target).toBe(childRef.current);
expect(targets).toHaveLength(3);

// event.currentTarget changes at each propagation step
// Capture: parent
expect(targets[0].currentTarget).toBe(parentRef.current);
// Bubble: child, then parent
expect(targets[1].currentTarget).toBe(childRef.current);
expect(targets[2].currentTarget).toBe(parentRef.current);
},
);
// event.target is always the original target element
expect(targets[0].target).toBe(childRef.current);
expect(targets[1].target).toBe(childRef.current);
expect(targets[2].target).toBe(childRef.current);

(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
ReactNativeFeatureFlags.enableImperativeEvents()
? it
: it.skip)(
// event.currentTarget changes at each propagation step
// Capture: parent
expect(targets[0].currentTarget).toBe(parentRef.current);
// Bubble: child, then parent
expect(targets[1].currentTarget).toBe(childRef.current);
expect(targets[2].currentTarget).toBe(parentRef.current);
});

(ReactNativeFeatureFlags.enableImperativeEvents() === true ? it : it.skip)(
'direct (non-bubbling) events do not propagate via addEventListener',
() => {
const root = Fantom.createRoot();
Expand Down Expand Up @@ -983,8 +971,7 @@ describe('EventTarget-based Event Dispatching', () => {
},
);

(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
ReactNativeFeatureFlags.enableImperativeEvents()
(ReactNativeFeatureFlags.enableImperativeEvents() === true
? describe
: describe.skip)('bubbling to document element and document', () => {
it('event bubbles from child up to the document element', () => {
Expand Down Expand Up @@ -1374,9 +1361,7 @@ describe('EventTarget-based Event Dispatching', () => {
});
});

(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()
? describe
: describe.skip)('event timestamps', () => {
describe('event timestamps', () => {
it('event preserves native timestamp from nativeEvent.timeStamp', () => {
const root = Fantom.createRoot();
const ref = React.createRef<React.ElementRef<typeof View>>();
Expand Down Expand Up @@ -1661,11 +1646,8 @@ describe('EventTarget-based Event Dispatching', () => {
});

// The dispatch-behavior tests use `addEventListener` on refs and the
// document element, which is only available when both
// `enableNativeEventTargetEventDispatching` and `enableImperativeEvents`
// are enabled.
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
ReactNativeFeatureFlags.enableImperativeEvents()
// document element, which requires `enableImperativeEvents` to be enabled.
(ReactNativeFeatureFlags.enableImperativeEvents() === true
? describe
: describe.skip)('direct events (rnIsDirect) — dispatch behavior', () => {
it('dispatches onLayout to the target but never to ancestor onLayout props or bubble listeners', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @fantom_flags enableNativeEventTargetEventDispatching:*
* @flow strict-local
* @format
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ import {
* 1. Responder negotiation (touch handling, grant/release lifecycle)
* 2. Normal event dispatch via dispatchTrustedEvent (capture/bubble phases)
*
* Called from the React renderer's dispatchEvent when
* enableNativeEventTargetEventDispatching is enabled.
* Called from the React renderer's dispatchEvent.
*/
export default function dispatchNativeEvent(
target: ?EventTarget,
Expand Down
93 changes: 38 additions & 55 deletions packages/react-native/src/private/webapis/dom/events/EventTarget.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
* See https://dom.spec.whatwg.org/#interface-eventtarget.
*/

import type {AbortSignal as InternalAbortSignal} from '../abort-api/AbortSignal';
import type {EventPhase} from './Event';

import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags';
import {setPlatformObject} from '../../webidl/PlatformObjects';
import Event from './Event';
import {
Expand Down Expand Up @@ -51,7 +51,7 @@ export type AddEventListenerOptions = Readonly<{
...EventListenerOptions,
passive?: boolean,
once?: boolean,
signal?: AbortSignal,
signal?: InternalAbortSignal,
}>;

type EventListenerRegistration = {
Expand Down Expand Up @@ -403,66 +403,49 @@ function invoke(

setCurrentTarget(event, eventTarget);

if (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()) {
// Resolve the prop-based listener. Pass the event so subclasses can read
// its `type` and any pre-resolved internal slots; pass `isCapture`
// separately because it can't be derived from `event.eventPhase` (which
// is `AT_TARGET` during both passes through the target node).
// $FlowExpectedError[prop-missing]
const propListener: EventCallback | null = eventTarget[
EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY
](event, isCapture);

const listenersByType = getListenersForPhase(eventTarget, isCapture);
const maybeListeners = listenersByType?.get(event.type);

// Fast path: only a prop listener (no `addEventListener` listeners).
// This is the overwhelmingly common case for React-driven dispatch.
if (maybeListeners == null) {
if (propListener == null) {
return;
}
const currentEvent = global.event;
global.event = event;
try {
propListener.call(eventTarget, event);
} catch (error) {
handleListenerError(error, errorState);
}
global.event = currentEvent;
return;
}
// Resolve the prop-based listener. Pass the event so subclasses can read
// its `type` and any pre-resolved internal slots; pass `isCapture`
// separately because it can't be derived from `event.eventPhase` (which
// is `AT_TARGET` during both passes through the target node).
// $FlowExpectedError[prop-missing]
const propListener: EventCallback | null = eventTarget[
EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY
](event, isCapture);

// Slow path: combine prop listener + addEventListener listeners.
const listeners: Array<EventListenerRegistration> = [];
if (propListener != null) {
listeners.push({
callback: propListener,
passive: false,
once: false,
removed: false,
});
const listenersByType = getListenersForPhase(eventTarget, isCapture);
const maybeListeners = listenersByType?.get(event.type);

// Fast path: only a prop listener (no `addEventListener` listeners).
// This is the overwhelmingly common case for React-driven dispatch.
if (maybeListeners == null) {
if (propListener == null) {
return;
}
for (const registration of maybeListeners.values()) {
listeners.push(registration);
const currentEvent = global.event;
global.event = event;
try {
propListener.call(eventTarget, event);
} catch (error) {
handleListenerError(error, errorState);
}
invokeListeners(eventTarget, event, listeners, isCapture, errorState);
global.event = currentEvent;
return;
}

// Legacy path (flag OFF): only `addEventListener` listeners.
const listenersByType = getListenersForPhase(eventTarget, isCapture);
const maybeListeners = listenersByType?.get(event.type);
if (maybeListeners == null) {
return;
// Slow path: combine prop listener + addEventListener listeners.
const listeners: Array<EventListenerRegistration> = [];
if (propListener != null) {
listeners.push({
callback: propListener,
passive: false,
once: false,
removed: false,
});
}
for (const registration of maybeListeners.values()) {
listeners.push(registration);
}
invokeListeners(
eventTarget,
event,
Array.from(maybeListeners.values()),
isCapture,
errorState,
);
invokeListeners(eventTarget, event, listeners, isCapture, errorState);
}

function invokeListeners(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @fantom_flags enableNativeEventTargetEventDispatching:*
* @flow strict-local
* @format
*/
Expand Down
Loading
Loading