Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -1033,8 +1022,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: {
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<<71e307d568421d49e3943f58c4fcb163>>
* @generated SignedSource<<81820f6c8f87bd90ed861ad9ddb153bb>>
* @flow strict
* @noformat
*/
Expand Down Expand Up @@ -33,7 +33,6 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{
animatedForceNativeDriver: Getter<boolean>,
animatedShouldSyncValueBeforeStartCallback: Getter<boolean>,
deferFlatListFocusChangeRenderUpdate: Getter<boolean>,
enableDirectEventsInEventTarget: Getter<boolean>,
enableImperativeEvents: Getter<boolean>,
enableNativeEventTargetEventDispatching: Getter<boolean>,
externalElementInspectionEnabled: Getter<boolean>,
Expand Down Expand Up @@ -162,11 +161,6 @@ export const animatedShouldSyncValueBeforeStartCallback: Getter<boolean> = creat
*/
export const deferFlatListFocusChangeRenderUpdate: Getter<boolean> = 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<boolean> = 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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* LICENSE file in the root directory of this source tree.
*
* @fantom_flags enableNativeEventTargetEventDispatching:*
* @fantom_flags enableDirectEventsInEventTarget:*
* @flow strict-local
* @format
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
*
* @fantom_flags enableNativeEventTargetEventDispatching:*
* @fantom_flags enableImperativeEvents:*
* @fantom_flags enableDirectEventsInEventTarget:*
* @flow strict-local
* @format
*/
Expand Down Expand Up @@ -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<React.ElementRef<typeof View>>();
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<React.ElementRef<typeof View>>();
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<React.ElementRef<typeof View>>();
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,
);
});
});
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
customBubblingEventTypes,
customDirectEventTypes,
} from '../../../../Libraries/Renderer/shims/ReactNativeViewConfigRegistry';
import * as ReactNativeFeatureFlags from '../../featureflags/ReactNativeFeatureFlags';
import {
setBubbledPropName,
setCapturedPropName,
Expand Down Expand Up @@ -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: {
Expand All @@ -78,7 +75,7 @@ export default function dispatchNativeEvent(
} = {
bubbles,
cancelable: true,
rnIsDirect,
rnIsDirect: isDirect,
};

// Preserve the native event timestamp for backwards compatibility.
Expand Down
Loading