diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 98dfa7b6eab0..940f56d10e73 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1036,6 +1036,16 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableImperativeEvents: { + defaultValue: false, + metadata: { + description: + 'When enabled, ReactNativeElement and ReadOnlyText expose the public EventTarget API (addEventListener, removeEventListener, dispatchEvent). When disabled, those methods are removed from those final classes.', + expectedReleaseValue: true, + purpose: 'release', + }, + ossReleaseStage: 'none', + }, enableNativeEventTargetEventDispatching: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index cf32422016e7..fe98367b4bcf 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<<77b178e216aa86a309f46cbf661d9122>> + * @generated SignedSource<<9ea39238fb9e7a4fd17f5c6a4f557e8c>> * @flow strict * @noformat */ @@ -33,6 +33,7 @@ export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{ animatedShouldSyncValueBeforeStartCallback: Getter, animatedShouldUseSingleOp: Getter, deferFlatListFocusChangeRenderUpdate: Getter, + enableImperativeEvents: Getter, enableNativeEventTargetEventDispatching: Getter, externalElementInspectionEnabled: Getter, fixVirtualizeListCollapseWindowSize: Getter, @@ -162,6 +163,11 @@ export const animatedShouldUseSingleOp: Getter = createJavaScriptFlagGe */ export const deferFlatListFocusChangeRenderUpdate: Getter = createJavaScriptFlagGetter('deferFlatListFocusChangeRenderUpdate', false); +/** + * When enabled, ReactNativeElement and ReadOnlyText expose the public EventTarget API (addEventListener, removeEventListener, dispatchEvent). When disabled, those methods are removed from those final classes. + */ +export const enableImperativeEvents: Getter = createJavaScriptFlagGetter('enableImperativeEvents', false); + /** * When enabled, the React Native renderer dispatches events through the W3C EventTarget API (addEventListener/dispatchEvent) instead of the legacy plugin-based system. */ 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 10c73183b1fd..76de52d98711 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 @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @fantom_flags enableNativeEventTargetEventDispatching:* + * @fantom_flags enableImperativeEvents:* * @flow strict-local * @format */ @@ -465,10 +466,13 @@ const {isOSS} = Fantom.getConstants(); }); // --- addEventListener / removeEventListener on refs --- - // These tests require EventTarget-based dispatching to be enabled, - // since addEventListener is only available when the flag is on. + // 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. - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() && + ReactNativeFeatureFlags.enableImperativeEvents() ? describe : describe.skip)('addEventListener / removeEventListener', () => { it('addEventListener on a ref receives dispatched events', () => { @@ -915,7 +919,8 @@ const {isOSS} = Fantom.getConstants(); }, ); - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() && + ReactNativeFeatureFlags.enableImperativeEvents() ? it : it.skip)( 'direct (non-bubbling) events do not propagate via addEventListener', @@ -964,7 +969,8 @@ const {isOSS} = Fantom.getConstants(); }, ); - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() && + ReactNativeFeatureFlags.enableImperativeEvents() ? describe : describe.skip)('bubbling to document element and document', () => { it('event bubbles from child up to the document element', () => { diff --git a/packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js b/packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js index e1bc584eed4a..9fef664f4004 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js +++ b/packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js @@ -354,3 +354,24 @@ export const ReactNativeElement_public: typeof ReactNativeElement = // $FlowExpectedError[prop-missing] ReactNativeElement_public.prototype = ReactNativeElement.prototype; + +// The public imperative EventTarget API (`addEventListener`, +// `removeEventListener`, `dispatchEvent`) is only inherited by this final class +// when `enableNativeEventTargetEventDispatching` is enabled (which makes +// `ReadOnlyNode` extend `EventTarget`). Until that public API is finalized, it +// is gated behind `enableImperativeEvents`: when that flag is off we remove +// those methods from this final class. Native/internal event dispatch does not +// rely on these public methods, so removing them is safe. +if ( + ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() && + !ReactNativeFeatureFlags.enableImperativeEvents() +) { + const prototype: interface { + addEventListener?: unknown, + removeEventListener?: unknown, + dispatchEvent?: unknown, + } = ReactNativeElement.prototype; + prototype.addEventListener = undefined; + prototype.removeEventListener = undefined; + prototype.dispatchEvent = undefined; +} diff --git a/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyText.js b/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyText.js index 01dcbbae5955..278b227964f6 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyText.js +++ b/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyText.js @@ -10,6 +10,7 @@ // flowlint unsafe-getters-setters:off +import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags'; import ReadOnlyCharacterData from './ReadOnlyCharacterData'; import ReadOnlyNode from './ReadOnlyNode'; @@ -39,3 +40,24 @@ export const ReadOnlyText_public: typeof ReadOnlyText = // $FlowExpectedError[prop-missing] ReadOnlyText_public.prototype = ReadOnlyText.prototype; + +// The public imperative EventTarget API (`addEventListener`, +// `removeEventListener`, `dispatchEvent`) is only inherited by this final class +// when `enableNativeEventTargetEventDispatching` is enabled (which makes +// `ReadOnlyNode` extend `EventTarget`). Until that public API is finalized, it +// is gated behind `enableImperativeEvents`: when that flag is off we remove +// those methods from this final class. Native/internal event dispatch does not +// rely on these public methods, so removing them is safe. +if ( + ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() && + !ReactNativeFeatureFlags.enableImperativeEvents() +) { + const prototype: interface { + addEventListener?: unknown, + removeEventListener?: unknown, + dispatchEvent?: unknown, + } = ReadOnlyText.prototype; + prototype.addEventListener = undefined; + prototype.removeEventListener = undefined; + prototype.dispatchEvent = undefined; +} diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js index 6bd94995fda4..d0757fcdba75 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. * * @fantom_flags enableFabricCommitBranching:* + * @fantom_flags enableNativeEventTargetEventDispatching:true + * @fantom_flags enableImperativeEvents:* * @flow strict-local * @format */ @@ -23,6 +25,8 @@ import { NativeText, NativeVirtualText, } from 'react-native/Libraries/Text/TextNativeComponent'; +import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; +import Event from 'react-native/src/private/webapis/dom/events/Event'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement'; import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode'; @@ -33,6 +37,20 @@ function ensureReactNativeElement(value: unknown): ReactNativeElement { return ensureInstance(value, ReactNativeElement); } +// The public imperative EventTarget API is not part of the static type of this +// final class (it is only present at runtime, gated by feature flags), so we +// cast to an interface with optional members to inspect/use it without Flow +// errors. Optional members make this a valid upcast and let us assert both +// presence (`'function'`) and absence (`'undefined'`). +type MaybeEventTarget = interface { + addEventListener?: (type: string, callback: (event: Event) => void) => void, + removeEventListener?: ( + type: string, + callback: (event: Event) => void, + ) => void, + dispatchEvent?: (event: Event) => boolean, +}; + /* eslint-disable no-bitwise */ describe('ReactNativeElement', () => { @@ -1632,6 +1650,108 @@ describe('ReactNativeElement', () => { }); }); + describe('imperative EventTarget API', () => { + // These tests run with `enableNativeEventTargetEventDispatching:true` and + // `enableImperativeEvents:*` (see the `@fantom_flags` pragmas). The public + // EventTarget API is gated behind `enableImperativeEvents`: when it is off + // the methods are removed from this final class, when it is on they are + // available. + const {isOSS} = Fantom.getConstants(); + + if (!ReactNativeFeatureFlags.enableImperativeEvents()) { + describe('when `enableImperativeEvents` is off (default)', () => { + it('removes the public EventTarget methods', () => { + const ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const element = ensureReactNativeElement( + ref.current, + ) as MaybeEventTarget; + expect(typeof element.addEventListener).toBe('undefined'); + expect(typeof element.removeEventListener).toBe('undefined'); + expect(typeof element.dispatchEvent).toBe('undefined'); + }); + + // Removing the public API must not affect native/prop event delivery, + // which goes through the internal (symbol-keyed) dispatch path. + (isOSS ? it.skip : it)( + 'still delivers native events to prop handlers', + () => { + const ref = createRef(); + const onPointerUp = jest.fn(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect(onPointerUp).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(onPointerUp).toHaveBeenCalledTimes(1); + }, + ); + }); + } + + if (ReactNativeFeatureFlags.enableImperativeEvents()) { + describe('when `enableImperativeEvents` is on', () => { + it('exposes the public EventTarget methods', () => { + const ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const element = ensureReactNativeElement( + ref.current, + ) as MaybeEventTarget; + expect(typeof element.addEventListener).toBe('function'); + expect(typeof element.removeEventListener).toBe('function'); + expect(typeof element.dispatchEvent).toBe('function'); + }); + + it('round-trips a listener via `addEventListener` + `dispatchEvent`', () => { + const ref = createRef(); + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + const element = ensureReactNativeElement( + ref.current, + ) as MaybeEventTarget; + const listener = jest.fn(); + + element.addEventListener?.('custom', listener); + const result = element.dispatchEvent?.(new Event('custom')); + + expect(listener).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + + element.removeEventListener?.('custom', listener); + element.dispatchEvent?.(new Event('custom')); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); + } + }); + describe('global constructors', () => { it('throws when constructing HTMLElement', () => { expect(() => new HTMLElement()).toThrow( diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReadOnlyText-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReadOnlyText-itest.js index 8f7b258ad920..2e48cafc5511 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReadOnlyText-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReadOnlyText-itest.js @@ -4,6 +4,8 @@ * 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:true + * @fantom_flags enableImperativeEvents:* * @flow strict-local * @format */ @@ -18,6 +20,7 @@ import invariant from 'invariant'; import * as React from 'react'; import {createRef} from 'react'; import {NativeText} from 'react-native/Libraries/Text/TextNativeComponent'; +import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode'; import ReadOnlyText from 'react-native/src/private/webapis/dom/nodes/ReadOnlyText'; @@ -34,6 +37,17 @@ function ensureReactNativeElement(value: unknown): ReactNativeElement { return ensureInstance(value, ReactNativeElement); } +// The public imperative EventTarget API is not part of the static type of this +// final class (it is only present at runtime, gated by feature flags), so we +// cast to an interface with optional members to inspect it without Flow errors. +// Optional members make this a valid upcast and let us assert both presence +// (`'function'`) and absence (`'undefined'`). +type MaybeEventTarget = interface { + addEventListener?: unknown, + removeEventListener?: unknown, + dispatchEvent?: unknown, +}; + describe('ReadOnlyText', () => { it('should be used to create public text instances', () => { const parentNodeRef = createRef(); @@ -332,6 +346,55 @@ describe('ReadOnlyText', () => { }); }); + describe('imperative EventTarget API', () => { + // These tests run with `enableNativeEventTargetEventDispatching:true` and + // `enableImperativeEvents:*` (see the `@fantom_flags` pragmas). The public + // EventTarget API is gated behind `enableImperativeEvents`: when it is off + // the methods are removed from this final class, when it is on they are + // available. + if (!ReactNativeFeatureFlags.enableImperativeEvents()) { + it('removes the public EventTarget methods when `enableImperativeEvents` is off (default)', () => { + const parentNodeRef = createRef(); + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(Some text); + }); + + const parentNode = ensureReadOnlyNode(parentNodeRef.current); + const textNode = ensureReadOnlyText( + parentNode.childNodes[0], + ) as MaybeEventTarget; + + expect(typeof textNode.addEventListener).toBe('undefined'); + expect(typeof textNode.removeEventListener).toBe('undefined'); + expect(typeof textNode.dispatchEvent).toBe('undefined'); + }); + } + + if (ReactNativeFeatureFlags.enableImperativeEvents()) { + it('exposes the public EventTarget methods when `enableImperativeEvents` is on', () => { + const parentNodeRef = createRef(); + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(Some text); + }); + + const parentNode = ensureReadOnlyNode(parentNodeRef.current); + const textNode = ensureReadOnlyText( + parentNode.childNodes[0], + ) as MaybeEventTarget; + + expect(typeof textNode.addEventListener).toBe('function'); + expect(typeof textNode.removeEventListener).toBe('function'); + expect(typeof textNode.dispatchEvent).toBe('function'); + }); + } + }); + describe('global constructors', () => { it('throws when constructing Text', () => { expect(() => new Text()).toThrow(