From d400bebba089cec035a49d5b0bec1bcf0c09b7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 08:29:50 -0700 Subject: [PATCH 1/2] Document why EventTarget feature flags stay at ossReleaseStage 'none' (#57527) Summary: Add TODO comments to the `enableImperativeEvents` and `enableNativeEventTargetEventDispatching` feature flags explaining why they haven't progressed yet (the OSS renderer cannot be upgraded with the necessary changes until React 19.3.0 is released). This is a comment-only change with no functional impact. Changelog: [Internal] Reviewed By: huntie Differential Revision: D111695477 --- .../scripts/featureflags/ReactNativeFeatureFlags.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 54ffc48eefc..0feb8d5d21f 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1033,8 +1033,10 @@ const definitions: FeatureFlagDefinitions = { expectedReleaseValue: true, purpose: 'release', }, + // TODO: This should be "canary" now but the OSS renderer cannot be upgraded with the necessary changes until React 19.3.0 is released. ossReleaseStage: 'none', }, + // TODO: This feature flag should be shipped and clean up now, but the OSS renderer cannot be upgraded with the necessary changes until React 19.3.0 is released. enableNativeEventTargetEventDispatching: { defaultValue: false, metadata: { From 46a68e1d0d696eec177a2e9ccfc280f5d5579950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Tue, 14 Jul 2026 08:29:50 -0700 Subject: [PATCH 2/2] Remove enableDirectEventsInEventTarget feature flag (#57528) Summary: Cleans up the `enableDirectEventsInEventTarget` JS feature flag and inlines its usages as `true`. Direct events (those that neither bubble nor capture, such as `onLayout`) now always take the fast target-only dispatch path when EventTarget-based event dispatching is enabled, without a separate gate. - Removed the flag definition and regenerated the feature flags. - Inlined the flag in `dispatchNativeEvent` so `rnIsDirect` is derived directly from the event config. - Updated integration/benchmark tests: dropped the flag pragmas, made the fast-path test always run, and removed the now-dead legacy-path test. Changelog: [Internal] Reviewed By: huntie Differential Revision: D111695476 --- .../ReactNativeFeatureFlags.config.js | 11 -- .../featureflags/ReactNativeFeatureFlags.js | 8 +- .../EventDispatching-benchmark-itest.js | 1 - .../__tests__/EventTargetDispatching-itest.js | 124 ++++++------------ .../renderer/events/dispatchNativeEvent.js | 13 +- 5 files changed, 46 insertions(+), 111 deletions(-) diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 0feb8d5d21f..848f7222d71 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1014,17 +1014,6 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, - enableDirectEventsInEventTarget: { - defaultValue: false, - metadata: { - dateAdded: '2026-07-06', - description: - 'When enabled (together with enableNativeEventTargetEventDispatching), direct events (those that neither bubble nor capture, such as onLayout) are dispatched only to the target node via a fast path that skips construction and traversal of the ancestor event path.', - expectedReleaseValue: true, - purpose: 'experimentation', - }, - ossReleaseStage: 'none', - }, enableImperativeEvents: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index a9c69041434..0b7d259dcf6 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<71e307d568421d49e3943f58c4fcb163>> + * @generated SignedSource<<81820f6c8f87bd90ed861ad9ddb153bb>> * @flow strict * @noformat */ @@ -33,7 +33,6 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{ animatedForceNativeDriver: Getter, animatedShouldSyncValueBeforeStartCallback: Getter, deferFlatListFocusChangeRenderUpdate: Getter, - enableDirectEventsInEventTarget: Getter, enableImperativeEvents: Getter, enableNativeEventTargetEventDispatching: Getter, externalElementInspectionEnabled: Getter, @@ -162,11 +161,6 @@ export const animatedShouldSyncValueBeforeStartCallback: Getter = creat */ export const deferFlatListFocusChangeRenderUpdate: Getter = createJavaScriptFlagGetter('deferFlatListFocusChangeRenderUpdate', false); -/** - * When enabled (together with enableNativeEventTargetEventDispatching), direct events (those that neither bubble nor capture, such as onLayout) are dispatched only to the target node via a fast path that skips construction and traversal of the ancestor event path. - */ -export const enableDirectEventsInEventTarget: Getter = createJavaScriptFlagGetter('enableDirectEventsInEventTarget', false); - /** * When enabled, ReactNativeElement and ReadOnlyText expose the public EventTarget API (addEventListener, removeEventListener, dispatchEvent). When disabled, those methods are removed from those final classes. */ diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js index da6c8f3087d..2b2614bca94 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js @@ -5,7 +5,6 @@ * LICENSE file in the root directory of this source tree. * * @fantom_flags enableNativeEventTargetEventDispatching:* - * @fantom_flags enableDirectEventsInEventTarget:* * @flow strict-local * @format */ diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js index 8f6edc9e6d5..9f3fd6984f4 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js @@ -6,7 +6,6 @@ * * @fantom_flags enableNativeEventTargetEventDispatching:* * @fantom_flags enableImperativeEvents:* - * @fantom_flags enableDirectEventsInEventTarget:* * @flow strict-local * @format */ @@ -1730,94 +1729,51 @@ const {isOSS} = Fantom.getConstants(); ).toBe(0); }); - (ReactNativeFeatureFlags.enableDirectEventsInEventTarget() - ? it - : it.skip)( - 'restricts the event path to the target (AT_TARGET only, no ancestor capture listeners)', - () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const ancestorCapture = jest.fn(); - let observedPhase: number | null = null; - let observedPathLength: number | null = null; - let observedCurrentIsTarget: boolean | null = null; - - const handler = jest.fn((e: $FlowFixMe) => { - observedPhase = e.eventPhase; - observedPathLength = e.composedPath().length; - observedCurrentIsTarget = e.currentTarget === childRef.current; - }); - - Fantom.runTask(() => { - root.render(createNestedViewsWithLayout(5, childRef, () => {})); - }); - - asEventTarget(childRef.current).addEventListener('layout', handler); - asEventTarget(root.document.documentElement).addEventListener( - 'layout', - ancestorCapture, - {capture: true}, - ); - Fantom.flushAllNativeEvents(); - - const ancestorCaptureBefore = ancestorCapture.mock.calls.length; - - Fantom.dispatchNativeEvent( - childRef, - 'onLayout', - {layout: {x: 0, y: 0, width: 100, height: 50}}, - {category: Fantom.NativeEventCategory.Discrete}, - ); + it('restricts the event path to the target (AT_TARGET only, no ancestor capture listeners)', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const ancestorCapture = jest.fn(); + let observedPhase: number | null = null; + let observedPathLength: number | null = null; + let observedCurrentIsTarget: boolean | null = null; + + const handler = jest.fn((e: $FlowFixMe) => { + observedPhase = e.eventPhase; + observedPathLength = e.composedPath().length; + observedCurrentIsTarget = e.currentTarget === childRef.current; + }); - expect(handler).toHaveBeenCalled(); - expect(observedPhase).toBe(Event.AT_TARGET); - // Event path is just the target node. - expect(observedPathLength).toBe(1); - expect(observedCurrentIsTarget).toBe(true); - // With the fast path, the capture phase does not traverse ancestors. - expect( - ancestorCapture.mock.calls.length - ancestorCaptureBefore, - ).toBe(0); - }, - ); + Fantom.runTask(() => { + root.render(createNestedViewsWithLayout(5, childRef, () => {})); + }); - (ReactNativeFeatureFlags.enableDirectEventsInEventTarget() - ? it.skip - : it)( - 'without the fast path, the capture phase still traverses ancestors for direct events', - () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const ancestorCapture = jest.fn(); - - Fantom.runTask(() => { - root.render(createNestedViewsWithLayout(5, childRef, () => {})); - }); - - asEventTarget(root.document.documentElement).addEventListener( - 'layout', - ancestorCapture, - {capture: true}, - ); - Fantom.flushAllNativeEvents(); + asEventTarget(childRef.current).addEventListener('layout', handler); + asEventTarget(root.document.documentElement).addEventListener( + 'layout', + ancestorCapture, + {capture: true}, + ); + Fantom.flushAllNativeEvents(); - const ancestorCaptureBefore = ancestorCapture.mock.calls.length; + const ancestorCaptureBefore = ancestorCapture.mock.calls.length; - Fantom.dispatchNativeEvent( - childRef, - 'onLayout', - {layout: {x: 0, y: 0, width: 100, height: 50}}, - {category: Fantom.NativeEventCategory.Discrete}, - ); + Fantom.dispatchNativeEvent( + childRef, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + {category: Fantom.NativeEventCategory.Discrete}, + ); - // The DOM dispatch algorithm runs the capture phase over every - // ancestor even for non-bubbling events, so the ancestor capture - // listener fires. - expect( - ancestorCapture.mock.calls.length - ancestorCaptureBefore, - ).toBe(1); - }, - ); + expect(handler).toHaveBeenCalled(); + expect(observedPhase).toBe(Event.AT_TARGET); + // Event path is just the target node. + expect(observedPathLength).toBe(1); + expect(observedCurrentIsTarget).toBe(true); + // With the fast path, the capture phase does not traverse ancestors. + expect(ancestorCapture.mock.calls.length - ancestorCaptureBefore).toBe( + 0, + ); + }); }); }, ); diff --git a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js index bc43624f1d8..f7333082cea 100644 --- a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js +++ b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js @@ -14,7 +14,6 @@ import { customBubblingEventTypes, customDirectEventTypes, } from '../../../../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; -import * as ReactNativeFeatureFlags from '../../featureflags/ReactNativeFeatureFlags'; import { setBubbledPropName, setCapturedPropName, @@ -62,13 +61,11 @@ export default function dispatchNativeEvent( bubbleConfig.phasedRegistrationNames.skipBubbling !== true; // A "direct" event is one registered only in the direct-event config - // (e.g. `onLayout`): it neither bubbles nor captures. When the feature - // flag is enabled, tag it so the EventTarget dispatch takes the fast - // target-only path. Note that bubbling events with `skipBubbling` (e.g. - // `onPointerEnter`) still have a capture phase and are NOT direct. + // (e.g. `onLayout`): it neither bubbles nor captures. Tag it so the + // EventTarget dispatch takes the fast target-only path. Note that + // bubbling events with `skipBubbling` (e.g. `onPointerEnter`) still have + // a capture phase and are NOT direct. const isDirect = bubbleConfig == null && directConfig != null; - const rnIsDirect = - isDirect && ReactNativeFeatureFlags.enableDirectEventsInEventTarget(); const eventType = topLevelTypeToEventType(type); const options: { @@ -78,7 +75,7 @@ export default function dispatchNativeEvent( } = { bubbles, cancelable: true, - rnIsDirect, + rnIsDirect: isDirect, }; // Preserve the native event timestamp for backwards compatibility.