diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 3a34d698f1a8..792f79594b2b 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1014,6 +1014,17 @@ 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 573f62436a0e..806a8ad0fcfd 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<<2be1f76084eb85288987229421d7585f>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -33,6 +33,7 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{ animatedForceNativeDriver: Getter, animatedShouldSyncValueBeforeStartCallback: Getter, deferFlatListFocusChangeRenderUpdate: Getter, + enableDirectEventsInEventTarget: Getter, enableImperativeEvents: Getter, enableNativeEventTargetEventDispatching: Getter, externalElementInspectionEnabled: Getter, @@ -161,6 +162,11 @@ 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 4c6b1309f5f2..da6c8f3087da 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,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @fantom_flags enableNativeEventTargetEventDispatching:* + * @fantom_flags enableDirectEventsInEventTarget:* * @flow strict-local * @format */ @@ -39,6 +40,29 @@ function createNestedViews( ); } +// `onLayout` is a direct (non-bubbling) event, so only the leaf needs a +// handler; ancestors never receive the layout event. +function createNestedViewsWithLayout( + depth: number, + innerRef: {current: React.ElementRef | null}, +): React.MixedElement { + if (depth === 0) { + return ( + {}} + style={{width: 10, height: 10}} + /> + ); + } + return ( + + {createNestedViewsWithLayout(depth - 1, innerRef)} + + ); +} + const {isOSS} = Fantom.getConstants(); if (isOSS) { @@ -322,5 +346,92 @@ if (isOSS) { root.destroy(); }, }, + ) + .test( + 'dispatch onLayout event, flat (1 handler)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {}} + style={{width: 10, height: 10}} + />, + ); + }); + // Flush the layout events emitted during the initial layout pass so + // they are not measured as part of the dispatched event below. + Fantom.flushAllNativeEvents(); + }, + afterAll: () => { + root.destroy(); + }, + }, + ) + .test( + 'dispatch onLayout event, nested 10 deep (direct, no bubbling)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViewsWithLayout(10, ref)); + }); + Fantom.flushAllNativeEvents(); + }, + afterAll: () => { + root.destroy(); + }, + }, + ) + .test( + 'dispatch onLayout event, nested 50 deep (direct, no bubbling)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViewsWithLayout(50, ref)); + }); + Fantom.flushAllNativeEvents(); + }, + afterAll: () => { + root.destroy(); + }, + }, ); } 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 e232633f432c..8f6edc9e6d5b 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,6 +6,7 @@ * * @fantom_flags enableNativeEventTargetEventDispatching:* * @fantom_flags enableImperativeEvents:* + * @fantom_flags enableDirectEventsInEventTarget:* * @flow strict-local * @format */ @@ -22,6 +23,7 @@ import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {View} from 'react-native'; import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; +import Event from 'react-native/src/private/webapis/dom/events/Event'; // Temporary cast until ReadOnlyNode extends EventTarget ungated. function asEventTarget(node: ?interface {}): ReadOnlyNodeWithEventTarget { @@ -33,6 +35,28 @@ function asEventTarget(node: ?interface {}): ReadOnlyNodeWithEventTarget { return node; } +function createNestedViewsWithLayout( + depth: number, + innerRef: {current: React.ElementRef | null}, + onLayout: () => void, +): React.MixedElement { + if (depth === 0) { + return ( + + ); + } + return ( + + {createNestedViewsWithLayout(depth - 1, innerRef, onLayout)} + + ); +} + const {isOSS} = Fantom.getConstants(); (isOSS ? describe.skip : describe)( @@ -1626,5 +1650,174 @@ const {isOSS} = Fantom.getConstants(); expect(parentSpy.mock.calls.length - parentCallsBefore).toBe(0); }); }); + + describe('direct events (rnIsDirect) — Event construction validation', () => { + it('allows constructing a direct event that does not bubble', () => { + const event = new Event('layout', {rnIsDirect: true}); + expect(event.rnIsDirect).toBe(true); + expect(event.bubbles).toBe(false); + }); + + it('defaults rnIsDirect to false', () => { + const event = new Event('layout'); + expect(event.rnIsDirect).toBe(false); + }); + + it('throws when rnIsDirect and bubbles are both true', () => { + expect(() => { + // eslint-disable-next-line no-new + new Event('layout', {rnIsDirect: true, bubbles: true}); + }).toThrow( + "Failed to construct 'Event': 'rnIsDirect' cannot be true when 'bubbles' is also true.", + ); + }); + }); + + // 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() + ? describe + : describe.skip)('direct events (rnIsDirect) — dispatch behavior', () => { + it('dispatches onLayout to the target but never to ancestor onLayout props or bubble listeners', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const childHandler = jest.fn(); + const parentPropHandler = jest.fn(); + const parentImperativeBubble = jest.fn(); + + Fantom.runTask(() => { + root.render( + + + + + + + , + ); + }); + + asEventTarget(root.document.documentElement).addEventListener( + 'layout', + parentImperativeBubble, + ); + + // Drain mount-time layout events. + Fantom.flushAllNativeEvents(); + + const childBefore = childHandler.mock.calls.length; + const parentBefore = parentPropHandler.mock.calls.length; + const parentBubbleBefore = parentImperativeBubble.mock.calls.length; + + Fantom.dispatchNativeEvent( + childRef, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + {category: Fantom.NativeEventCategory.Discrete}, + ); + + // The target's own onLayout fires exactly once. + expect(childHandler.mock.calls.length - childBefore).toBe(1); + // A direct (non-bubbling) event never reaches an ancestor's onLayout + // prop or an ancestor's bubble-phase listener, regardless of the fast + // path. + expect(parentPropHandler.mock.calls.length - parentBefore).toBe(0); + expect( + parentImperativeBubble.mock.calls.length - parentBubbleBefore, + ).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}, + ); + + 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); + }, + ); + + (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(); + + const ancestorCaptureBefore = ancestorCapture.mock.calls.length; + + 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); + }, + ); + }); }, ); diff --git a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js index 69efbb30fea4..bc43624f1d8d 100644 --- a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js +++ b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js @@ -14,6 +14,7 @@ import { customBubblingEventTypes, customDirectEventTypes, } from '../../../../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; +import * as ReactNativeFeatureFlags from '../../featureflags/ReactNativeFeatureFlags'; import { setBubbledPropName, setCapturedPropName, @@ -60,10 +61,24 @@ export default function dispatchNativeEvent( bubbleConfig != null && 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. + const isDirect = bubbleConfig == null && directConfig != null; + const rnIsDirect = + isDirect && ReactNativeFeatureFlags.enableDirectEventsInEventTarget(); + const eventType = topLevelTypeToEventType(type); - const options: {bubbles: boolean, cancelable: boolean} = { + const options: { + bubbles: boolean, + cancelable: boolean, + rnIsDirect?: boolean, + } = { bubbles, cancelable: true, + rnIsDirect, }; // Preserve the native event timestamp for backwards compatibility. diff --git a/packages/react-native/src/private/webapis/dom/events/Event.js b/packages/react-native/src/private/webapis/dom/events/Event.js index 4b161026426e..829ff605c777 100644 --- a/packages/react-native/src/private/webapis/dom/events/Event.js +++ b/packages/react-native/src/private/webapis/dom/events/Event.js @@ -42,6 +42,11 @@ export interface EventInit { readonly bubbles?: boolean; readonly cancelable?: boolean; readonly composed?: boolean; + // React Native-specific. When true, the event is a "direct" event: it is + // dispatched only to the target node (single AT_TARGET phase) and never + // captures or bubbles through ancestors. A direct event must not bubble, so + // `bubbles` cannot also be true. + readonly rnIsDirect?: boolean; } export default class Event { @@ -58,6 +63,7 @@ export default class Event { _bubbles: boolean; _cancelable: boolean; _composed: boolean; + _rnIsDirect: boolean; _type: string; _defaultPrevented: boolean = false; @@ -110,6 +116,16 @@ export default class Event { this._bubbles = Boolean(options?.bubbles); this._cancelable = Boolean(options?.cancelable); this._composed = Boolean(options?.composed); + this._rnIsDirect = Boolean(options?.rnIsDirect); + + // A direct event is dispatched only to its target and never propagates, so + // it cannot bubble. Reject the contradictory combination at construction + // time. + if (this._rnIsDirect && this._bubbles) { + throw new TypeError( + "Failed to construct 'Event': 'rnIsDirect' cannot be true when 'bubbles' is also true.", + ); + } // For internal construction of events using a custom timestamp (instead of // event object creation), for use cases like dispatching events from the @@ -132,6 +148,15 @@ export default class Event { return this._composed; } + /** + * React Native-specific. When true, this event is dispatched only to its + * target (single `AT_TARGET` phase) and never captures or bubbles through + * ancestors. + */ + get rnIsDirect(): boolean { + return this._rnIsDirect; + } + get currentTarget(): EventTarget | null { return getCurrentTarget(this); } diff --git a/packages/react-native/src/private/webapis/dom/events/EventTarget.js b/packages/react-native/src/private/webapis/dom/events/EventTarget.js index 9c7ef58f5fd6..a8ea92586fce 100644 --- a/packages/react-native/src/private/webapis/dom/events/EventTarget.js +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -366,6 +366,17 @@ function getEventPath( eventTarget: EventTarget, event: Event, ): ReadonlyArray { + // React Native-specific fast path: a "direct" event is dispatched only to its + // target, as a single AT_TARGET phase, with just the target in the event + // path. This skips the O(depth) ancestor walk (and the capture-phase + // traversal over every ancestor) that the DOM dispatch algorithm otherwise + // performs even for non-bubbling events. The feature flag gating lives at the + // dispatch site (see `dispatchNativeEvent`), which only sets `rnIsDirect` when + // the flag is enabled. + if (event.rnIsDirect) { + return [eventTarget]; + } + const path = []; let target: EventTarget | null = eventTarget;