Skip to content

Commit 002f528

Browse files
rubennortefacebook-github-bot
authored andcommitted
Gate the public EventTarget API on ReactNativeElement and ReadOnlyText behind enableImperativeEvents (#57088)
Summary: ## Context We are migrating React Native event dispatching to the W3C `EventTarget` interface. That refactor is backwards compatible, but turning it on also incidentally exposes a brand new public API on component refs (`addEventListener`, `removeEventListener`, `dispatchEvent`). We want to decouple the event-dispatching refactor from shipping that new public API so we can roll out the refactor faster, without committing to the public surface before it is finalized. ## Changes Adds a new JS-only feature flag, `enableImperativeEvents` (default off), that controls whether the final DOM node classes `ReactNativeElement` and `ReadOnlyText` expose the public `EventTarget` API. - When `EventTarget`-based dispatching is enabled but `enableImperativeEvents` is off, `addEventListener`, `removeEventListener` and `dispatchEvent` are removed from those two final classes. Native and internal event dispatch do not rely on these public methods, so event delivery is unaffected. - When `enableImperativeEvents` is on, the classes keep the full `EventTarget` API. - Adds integration test coverage for both flag states, and pins the flag on in the existing `EventTarget` dispatching tests that exercise imperative listeners. Changelog: [Internal] Reviewed By: javache Differential Revision: D107656477
1 parent 319610d commit 002f528

7 files changed

Lines changed: 254 additions & 6 deletions

File tree

packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,16 @@ const definitions: FeatureFlagDefinitions = {
10361036
},
10371037
ossReleaseStage: 'none',
10381038
},
1039+
enableImperativeEvents: {
1040+
defaultValue: false,
1041+
metadata: {
1042+
description:
1043+
'When enabled, ReactNativeElement and ReadOnlyText expose the public EventTarget API (addEventListener, removeEventListener, dispatchEvent). When disabled, those methods are removed from those final classes.',
1044+
expectedReleaseValue: true,
1045+
purpose: 'release',
1046+
},
1047+
ossReleaseStage: 'none',
1048+
},
10391049
enableNativeEventTargetEventDispatching: {
10401050
defaultValue: false,
10411051
metadata: {

packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<77b178e216aa86a309f46cbf661d9122>>
7+
* @generated SignedSource<<9ea39238fb9e7a4fd17f5c6a4f557e8c>>
88
* @flow strict
99
* @noformat
1010
*/
@@ -33,6 +33,7 @@ export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{
3333
animatedShouldSyncValueBeforeStartCallback: Getter<boolean>,
3434
animatedShouldUseSingleOp: Getter<boolean>,
3535
deferFlatListFocusChangeRenderUpdate: Getter<boolean>,
36+
enableImperativeEvents: Getter<boolean>,
3637
enableNativeEventTargetEventDispatching: Getter<boolean>,
3738
externalElementInspectionEnabled: Getter<boolean>,
3839
fixVirtualizeListCollapseWindowSize: Getter<boolean>,
@@ -162,6 +163,11 @@ export const animatedShouldUseSingleOp: Getter<boolean> = createJavaScriptFlagGe
162163
*/
163164
export const deferFlatListFocusChangeRenderUpdate: Getter<boolean> = createJavaScriptFlagGetter('deferFlatListFocusChangeRenderUpdate', false);
164165

166+
/**
167+
* When enabled, ReactNativeElement and ReadOnlyText expose the public EventTarget API (addEventListener, removeEventListener, dispatchEvent). When disabled, those methods are removed from those final classes.
168+
*/
169+
export const enableImperativeEvents: Getter<boolean> = createJavaScriptFlagGetter('enableImperativeEvents', false);
170+
165171
/**
166172
* When enabled, the React Native renderer dispatches events through the W3C EventTarget API (addEventListener/dispatchEvent) instead of the legacy plugin-based system.
167173
*/

packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @fantom_flags enableNativeEventTargetEventDispatching:*
8+
* @fantom_flags enableImperativeEvents:*
89
* @flow strict-local
910
* @format
1011
*/
@@ -465,10 +466,13 @@ const {isOSS} = Fantom.getConstants();
465466
});
466467

467468
// --- addEventListener / removeEventListener on refs ---
468-
// These tests require EventTarget-based dispatching to be enabled,
469-
// since addEventListener is only available when the flag is on.
469+
// These tests require both `enableNativeEventTargetEventDispatching` and
470+
// `enableImperativeEvents` to be enabled, since the public `addEventListener`
471+
// API on element refs is only available when both flags are on. They are
472+
// skipped for the other flag combinations.
470473

471-
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()
474+
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
475+
ReactNativeFeatureFlags.enableImperativeEvents()
472476
? describe
473477
: describe.skip)('addEventListener / removeEventListener', () => {
474478
it('addEventListener on a ref receives dispatched events', () => {
@@ -915,7 +919,8 @@ const {isOSS} = Fantom.getConstants();
915919
},
916920
);
917921

918-
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()
922+
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
923+
ReactNativeFeatureFlags.enableImperativeEvents()
919924
? it
920925
: it.skip)(
921926
'direct (non-bubbling) events do not propagate via addEventListener',
@@ -964,7 +969,8 @@ const {isOSS} = Fantom.getConstants();
964969
},
965970
);
966971

967-
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()
972+
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
973+
ReactNativeFeatureFlags.enableImperativeEvents()
968974
? describe
969975
: describe.skip)('bubbling to document element and document', () => {
970976
it('event bubbles from child up to the document element', () => {

packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,24 @@ export const ReactNativeElement_public: typeof ReactNativeElement =
354354

355355
// $FlowExpectedError[prop-missing]
356356
ReactNativeElement_public.prototype = ReactNativeElement.prototype;
357+
358+
// The public imperative EventTarget API (`addEventListener`,
359+
// `removeEventListener`, `dispatchEvent`) is only inherited by this final class
360+
// when `enableNativeEventTargetEventDispatching` is enabled (which makes
361+
// `ReadOnlyNode` extend `EventTarget`). Until that public API is finalized, it
362+
// is gated behind `enableImperativeEvents`: when that flag is off we remove
363+
// those methods from this final class. Native/internal event dispatch does not
364+
// rely on these public methods, so removing them is safe.
365+
if (
366+
ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
367+
!ReactNativeFeatureFlags.enableImperativeEvents()
368+
) {
369+
const prototype: interface {
370+
addEventListener?: unknown,
371+
removeEventListener?: unknown,
372+
dispatchEvent?: unknown,
373+
} = ReactNativeElement.prototype;
374+
prototype.addEventListener = undefined;
375+
prototype.removeEventListener = undefined;
376+
prototype.dispatchEvent = undefined;
377+
}

packages/react-native/src/private/webapis/dom/nodes/ReadOnlyText.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
// flowlint unsafe-getters-setters:off
1212

13+
import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags';
1314
import ReadOnlyCharacterData from './ReadOnlyCharacterData';
1415
import ReadOnlyNode from './ReadOnlyNode';
1516

@@ -39,3 +40,24 @@ export const ReadOnlyText_public: typeof ReadOnlyText =
3940

4041
// $FlowExpectedError[prop-missing]
4142
ReadOnlyText_public.prototype = ReadOnlyText.prototype;
43+
44+
// The public imperative EventTarget API (`addEventListener`,
45+
// `removeEventListener`, `dispatchEvent`) is only inherited by this final class
46+
// when `enableNativeEventTargetEventDispatching` is enabled (which makes
47+
// `ReadOnlyNode` extend `EventTarget`). Until that public API is finalized, it
48+
// is gated behind `enableImperativeEvents`: when that flag is off we remove
49+
// those methods from this final class. Native/internal event dispatch does not
50+
// rely on these public methods, so removing them is safe.
51+
if (
52+
ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() &&
53+
!ReactNativeFeatureFlags.enableImperativeEvents()
54+
) {
55+
const prototype: interface {
56+
addEventListener?: unknown,
57+
removeEventListener?: unknown,
58+
dispatchEvent?: unknown,
59+
} = ReadOnlyText.prototype;
60+
prototype.addEventListener = undefined;
61+
prototype.removeEventListener = undefined;
62+
prototype.dispatchEvent = undefined;
63+
}

packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-itest.js

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* LICENSE file in the root directory of this source tree.
66
*
77
* @fantom_flags enableFabricCommitBranching:*
8+
* @fantom_flags enableNativeEventTargetEventDispatching:true
9+
* @fantom_flags enableImperativeEvents:*
810
* @flow strict-local
911
* @format
1012
*/
@@ -23,6 +25,8 @@ import {
2325
NativeText,
2426
NativeVirtualText,
2527
} from 'react-native/Libraries/Text/TextNativeComponent';
28+
import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags';
29+
import Event from 'react-native/src/private/webapis/dom/events/Event';
2630
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
2731
import ReadOnlyElement from 'react-native/src/private/webapis/dom/nodes/ReadOnlyElement';
2832
import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode';
@@ -33,6 +37,20 @@ function ensureReactNativeElement(value: unknown): ReactNativeElement {
3337
return ensureInstance(value, ReactNativeElement);
3438
}
3539

40+
// The public imperative EventTarget API is not part of the static type of this
41+
// final class (it is only present at runtime, gated by feature flags), so we
42+
// cast to an interface with optional members to inspect/use it without Flow
43+
// errors. Optional members make this a valid upcast and let us assert both
44+
// presence (`'function'`) and absence (`'undefined'`).
45+
type MaybeEventTarget = interface {
46+
addEventListener?: (type: string, callback: (event: Event) => void) => void,
47+
removeEventListener?: (
48+
type: string,
49+
callback: (event: Event) => void,
50+
) => void,
51+
dispatchEvent?: (event: Event) => boolean,
52+
};
53+
3654
/* eslint-disable no-bitwise */
3755

3856
describe('ReactNativeElement', () => {
@@ -1632,6 +1650,108 @@ describe('ReactNativeElement', () => {
16321650
});
16331651
});
16341652

1653+
describe('imperative EventTarget API', () => {
1654+
// These tests run with `enableNativeEventTargetEventDispatching:true` and
1655+
// `enableImperativeEvents:*` (see the `@fantom_flags` pragmas). The public
1656+
// EventTarget API is gated behind `enableImperativeEvents`: when it is off
1657+
// the methods are removed from this final class, when it is on they are
1658+
// available.
1659+
const {isOSS} = Fantom.getConstants();
1660+
1661+
if (!ReactNativeFeatureFlags.enableImperativeEvents()) {
1662+
describe('when `enableImperativeEvents` is off (default)', () => {
1663+
it('removes the public EventTarget methods', () => {
1664+
const ref = createRef<HostInstance>();
1665+
const root = Fantom.createRoot();
1666+
1667+
Fantom.runTask(() => {
1668+
root.render(<View ref={ref} />);
1669+
});
1670+
1671+
const element = ensureReactNativeElement(
1672+
ref.current,
1673+
) as MaybeEventTarget;
1674+
expect(typeof element.addEventListener).toBe('undefined');
1675+
expect(typeof element.removeEventListener).toBe('undefined');
1676+
expect(typeof element.dispatchEvent).toBe('undefined');
1677+
});
1678+
1679+
// Removing the public API must not affect native/prop event delivery,
1680+
// which goes through the internal (symbol-keyed) dispatch path.
1681+
(isOSS ? it.skip : it)(
1682+
'still delivers native events to prop handlers',
1683+
() => {
1684+
const ref = createRef<HostInstance>();
1685+
const onPointerUp = jest.fn();
1686+
const root = Fantom.createRoot();
1687+
1688+
Fantom.runTask(() => {
1689+
root.render(<View ref={ref} onPointerUp={onPointerUp} />);
1690+
});
1691+
1692+
expect(onPointerUp).toHaveBeenCalledTimes(0);
1693+
1694+
Fantom.dispatchNativeEvent(
1695+
ref,
1696+
'onPointerUp',
1697+
{x: 0, y: 0},
1698+
{
1699+
category: Fantom.NativeEventCategory.Discrete,
1700+
},
1701+
);
1702+
1703+
expect(onPointerUp).toHaveBeenCalledTimes(1);
1704+
},
1705+
);
1706+
});
1707+
}
1708+
1709+
if (ReactNativeFeatureFlags.enableImperativeEvents()) {
1710+
describe('when `enableImperativeEvents` is on', () => {
1711+
it('exposes the public EventTarget methods', () => {
1712+
const ref = createRef<HostInstance>();
1713+
const root = Fantom.createRoot();
1714+
1715+
Fantom.runTask(() => {
1716+
root.render(<View ref={ref} />);
1717+
});
1718+
1719+
const element = ensureReactNativeElement(
1720+
ref.current,
1721+
) as MaybeEventTarget;
1722+
expect(typeof element.addEventListener).toBe('function');
1723+
expect(typeof element.removeEventListener).toBe('function');
1724+
expect(typeof element.dispatchEvent).toBe('function');
1725+
});
1726+
1727+
it('round-trips a listener via `addEventListener` + `dispatchEvent`', () => {
1728+
const ref = createRef<HostInstance>();
1729+
const root = Fantom.createRoot();
1730+
1731+
Fantom.runTask(() => {
1732+
root.render(<View ref={ref} />);
1733+
});
1734+
1735+
const element = ensureReactNativeElement(
1736+
ref.current,
1737+
) as MaybeEventTarget;
1738+
const listener = jest.fn();
1739+
1740+
element.addEventListener?.('custom', listener);
1741+
const result = element.dispatchEvent?.(new Event('custom'));
1742+
1743+
expect(listener).toHaveBeenCalledTimes(1);
1744+
expect(result).toBe(true);
1745+
1746+
element.removeEventListener?.('custom', listener);
1747+
element.dispatchEvent?.(new Event('custom'));
1748+
1749+
expect(listener).toHaveBeenCalledTimes(1);
1750+
});
1751+
});
1752+
}
1753+
});
1754+
16351755
describe('global constructors', () => {
16361756
it('throws when constructing HTMLElement', () => {
16371757
expect(() => new HTMLElement()).toThrow(

packages/react-native/src/private/webapis/dom/nodes/__tests__/ReadOnlyText-itest.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7+
* @fantom_flags enableNativeEventTargetEventDispatching:true
8+
* @fantom_flags enableImperativeEvents:*
79
* @flow strict-local
810
* @format
911
*/
@@ -18,6 +20,7 @@ import invariant from 'invariant';
1820
import * as React from 'react';
1921
import {createRef} from 'react';
2022
import {NativeText} from 'react-native/Libraries/Text/TextNativeComponent';
23+
import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags';
2124
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
2225
import ReadOnlyNode from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode';
2326
import ReadOnlyText from 'react-native/src/private/webapis/dom/nodes/ReadOnlyText';
@@ -34,6 +37,17 @@ function ensureReactNativeElement(value: unknown): ReactNativeElement {
3437
return ensureInstance(value, ReactNativeElement);
3538
}
3639

40+
// The public imperative EventTarget API is not part of the static type of this
41+
// final class (it is only present at runtime, gated by feature flags), so we
42+
// cast to an interface with optional members to inspect it without Flow errors.
43+
// Optional members make this a valid upcast and let us assert both presence
44+
// (`'function'`) and absence (`'undefined'`).
45+
type MaybeEventTarget = interface {
46+
addEventListener?: unknown,
47+
removeEventListener?: unknown,
48+
dispatchEvent?: unknown,
49+
};
50+
3751
describe('ReadOnlyText', () => {
3852
it('should be used to create public text instances', () => {
3953
const parentNodeRef = createRef<HostInstance>();
@@ -332,6 +346,55 @@ describe('ReadOnlyText', () => {
332346
});
333347
});
334348

349+
describe('imperative EventTarget API', () => {
350+
// These tests run with `enableNativeEventTargetEventDispatching:true` and
351+
// `enableImperativeEvents:*` (see the `@fantom_flags` pragmas). The public
352+
// EventTarget API is gated behind `enableImperativeEvents`: when it is off
353+
// the methods are removed from this final class, when it is on they are
354+
// available.
355+
if (!ReactNativeFeatureFlags.enableImperativeEvents()) {
356+
it('removes the public EventTarget methods when `enableImperativeEvents` is off (default)', () => {
357+
const parentNodeRef = createRef<HostInstance>();
358+
359+
const root = Fantom.createRoot();
360+
361+
Fantom.runTask(() => {
362+
root.render(<NativeText ref={parentNodeRef}>Some text</NativeText>);
363+
});
364+
365+
const parentNode = ensureReadOnlyNode(parentNodeRef.current);
366+
const textNode = ensureReadOnlyText(
367+
parentNode.childNodes[0],
368+
) as MaybeEventTarget;
369+
370+
expect(typeof textNode.addEventListener).toBe('undefined');
371+
expect(typeof textNode.removeEventListener).toBe('undefined');
372+
expect(typeof textNode.dispatchEvent).toBe('undefined');
373+
});
374+
}
375+
376+
if (ReactNativeFeatureFlags.enableImperativeEvents()) {
377+
it('exposes the public EventTarget methods when `enableImperativeEvents` is on', () => {
378+
const parentNodeRef = createRef<HostInstance>();
379+
380+
const root = Fantom.createRoot();
381+
382+
Fantom.runTask(() => {
383+
root.render(<NativeText ref={parentNodeRef}>Some text</NativeText>);
384+
});
385+
386+
const parentNode = ensureReadOnlyNode(parentNodeRef.current);
387+
const textNode = ensureReadOnlyText(
388+
parentNode.childNodes[0],
389+
) as MaybeEventTarget;
390+
391+
expect(typeof textNode.addEventListener).toBe('function');
392+
expect(typeof textNode.removeEventListener).toBe('function');
393+
expect(typeof textNode.dispatchEvent).toBe('function');
394+
});
395+
}
396+
});
397+
335398
describe('global constructors', () => {
336399
it('throws when constructing Text', () => {
337400
expect(() => new Text()).toThrow(

0 commit comments

Comments
 (0)