diff --git a/package.json b/package.json index fc1855bc3982..cd89bbb3b25c 100644 --- a/package.json +++ b/package.json @@ -107,8 +107,8 @@ "nullthrows": "^1.1.1", "prettier": "3.6.2", "prettier-plugin-hermes-parser": "0.36.0", - "react": "19.2.3", - "react-test-renderer": "19.2.3", + "react": "19.2.7", + "react-test-renderer": "19.2.7", "rimraf": "^3.0.2", "shelljs": "^0.8.5", "signedsource": "^2.0.0", @@ -120,7 +120,7 @@ "ws": "^7.5.10" }, "resolutions": { - "react-is": "19.2.3", + "react-is": "19.2.7", "on-headers": "1.1.0", "compression": "1.8.1", "@microsoft/api-extractor/minimatch": "3.1.4", diff --git a/packages/jest-preset/package.json b/packages/jest-preset/package.json index 987afe28dacf..29f2ec3e3f26 100644 --- a/packages/jest-preset/package.json +++ b/packages/jest-preset/package.json @@ -35,6 +35,6 @@ "regenerator-runtime": "^0.13.2" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.7" } } diff --git a/packages/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js b/packages/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js index daab0e35a8e5..efe89e3067f5 100644 --- a/packages/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js +++ b/packages/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js @@ -7,7 +7,7 @@ * @noflow * @nolint * @preventMunge - * @generated SignedSource<> + * @generated SignedSource<<49a1a924bb8b9fa0ef032e4799b38449>> * * This file was sync'd from the facebook/react repository. */ @@ -18901,10 +18901,10 @@ __DEV__ && (function () { var internals = { bundleType: 1, - version: "19.2.3", + version: "19.2.7", rendererPackageName: "react-native-renderer", currentDispatcherRef: ReactSharedInternals, - reconcilerVersion: "19.2.3" + reconcilerVersion: "19.2.7" }; null !== extraDevToolsConfig && (internals.rendererConfig = extraDevToolsConfig); diff --git a/packages/react-native/Libraries/Renderer/implementations/ReactFabric-prod.js b/packages/react-native/Libraries/Renderer/implementations/ReactFabric-prod.js index 2625bf690ab0..f1317527757a 100644 --- a/packages/react-native/Libraries/Renderer/implementations/ReactFabric-prod.js +++ b/packages/react-native/Libraries/Renderer/implementations/ReactFabric-prod.js @@ -7,7 +7,7 @@ * @noflow * @nolint * @preventMunge - * @generated SignedSource<<8d29d23a1c540d7502dd188e691eb725>> + * @generated SignedSource<> * * This file was sync'd from the facebook/react repository. */ @@ -10495,10 +10495,10 @@ batchedUpdatesImpl = function (fn, a) { var roots = new Map(), internals$jscomp$inline_1245 = { bundleType: 0, - version: "19.2.3", + version: "19.2.7", rendererPackageName: "react-native-renderer", currentDispatcherRef: ReactSharedInternals, - reconcilerVersion: "19.2.3" + reconcilerVersion: "19.2.7" }; null !== extraDevToolsConfig && (internals$jscomp$inline_1245.rendererConfig = extraDevToolsConfig); diff --git a/packages/react-native/Libraries/Renderer/implementations/ReactFabric-profiling.js b/packages/react-native/Libraries/Renderer/implementations/ReactFabric-profiling.js index 544c75406ef2..c434b1423def 100644 --- a/packages/react-native/Libraries/Renderer/implementations/ReactFabric-profiling.js +++ b/packages/react-native/Libraries/Renderer/implementations/ReactFabric-profiling.js @@ -7,7 +7,7 @@ * @noflow * @nolint * @preventMunge - * @generated SignedSource<> + * @generated SignedSource<> * * This file was sync'd from the facebook/react repository. */ @@ -12298,10 +12298,10 @@ batchedUpdatesImpl = function (fn, a) { var roots = new Map(), internals$jscomp$inline_1537 = { bundleType: 0, - version: "19.2.3", + version: "19.2.7", rendererPackageName: "react-native-renderer", currentDispatcherRef: ReactSharedInternals, - reconcilerVersion: "19.2.3" + reconcilerVersion: "19.2.7" }; null !== extraDevToolsConfig && (internals$jscomp$inline_1537.rendererConfig = extraDevToolsConfig); diff --git a/packages/react-native/Libraries/Renderer/shims/ReactFabric.js b/packages/react-native/Libraries/Renderer/shims/ReactFabric.js index 15eeeb36bbaf..bc7baba8fa10 100644 --- a/packages/react-native/Libraries/Renderer/shims/ReactFabric.js +++ b/packages/react-native/Libraries/Renderer/shims/ReactFabric.js @@ -7,7 +7,9 @@ * @noformat * @nolint * @flow - * @generated SignedSource<> + * @generated SignedSource<<16b364e89f43b8a47832b0dfb98af11e>> + * + * This file was sync'd from the facebook/react repository. */ 'use strict'; diff --git a/packages/react-native/Libraries/Renderer/shims/ReactFeatureFlags.js b/packages/react-native/Libraries/Renderer/shims/ReactFeatureFlags.js index 1bf6b1b6cbaa..bf7cb70297c3 100644 --- a/packages/react-native/Libraries/Renderer/shims/ReactFeatureFlags.js +++ b/packages/react-native/Libraries/Renderer/shims/ReactFeatureFlags.js @@ -7,7 +7,9 @@ * @noformat * @nolint * @flow strict-local - * @generated SignedSource<<908f5fb85384725318e261f40e49d9a6>> + * @generated SignedSource<<1dd9e9c3f20e37ae14e485fc6ee3d9e9>> + * + * This file was sync'd from the facebook/react repository. */ 'use strict'; diff --git a/packages/react-native/Libraries/Renderer/shims/ReactNativeTypes.js b/packages/react-native/Libraries/Renderer/shims/ReactNativeTypes.js index 24cc37e31e76..0c23dc69b82f 100644 --- a/packages/react-native/Libraries/Renderer/shims/ReactNativeTypes.js +++ b/packages/react-native/Libraries/Renderer/shims/ReactNativeTypes.js @@ -7,7 +7,9 @@ * @noformat * @nolint * @flow strict - * @generated SignedSource<<4ab83fd2606d6a4d374ef914f231d9c1>> + * @generated SignedSource<<989e6e2e860dc2af7ba983849111bda8>> + * + * This file was sync'd from the facebook/react repository. */ import type { @@ -138,6 +140,36 @@ export type RenderRootOptions = { onDefaultTransitionIndicator?: () => void | (() => void), }; +/** + * Flat ReactNative renderer bundles are too big for Flow to parse efficiently. + * Provide minimal Flow typing for the high-level RN API and call it a day. + */ +export type ReactNativeType = { + findHostInstance_DEPRECATED( + componentOrHandle: ?(React.ElementRef | number), + ): ?PublicInstance, + findNodeHandle( + componentOrHandle: ?(React.ElementRef | number), + ): ?number, + isChildPublicInstance(parent: PublicInstance, child: PublicInstance): boolean, + dispatchCommand( + handle: PublicInstance, + command: string, + args: Array, + ): void, + sendAccessibilityEvent(handle: PublicInstance, eventType: string): void, + render( + element: React.MixedElement, + containerTag: number, + callback: ?() => void, + options: ?RenderRootOptions, + ): ?React.ElementRef, + unmountComponentAtNode(containerTag: number): void, + unmountComponentAtNodeAndRemoveContainer(containerTag: number): void, + +unstable_batchedUpdates: (fn: (T) => void, bookkeeping: T) => void, + ... +}; + export opaque type Node = mixed; export opaque type InternalInstanceHandle = mixed; diff --git a/packages/react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry.js b/packages/react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry.js index 436b74da7674..ace3c4aac402 100644 --- a/packages/react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry.js +++ b/packages/react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry.js @@ -7,7 +7,9 @@ * @noformat * @nolint * @flow strict-local - * @generated SignedSource<<1f7876c0dc0b05685a730513dc410236>> + * @generated SignedSource<<67d18226984338ab9301147ce0a7d414>> + * + * This file was sync'd from the facebook/react repository. */ 'use strict'; diff --git a/packages/react-native/Libraries/Renderer/shims/createReactNativeComponentClass.js b/packages/react-native/Libraries/Renderer/shims/createReactNativeComponentClass.js index a069eb91e975..3be08df89565 100644 --- a/packages/react-native/Libraries/Renderer/shims/createReactNativeComponentClass.js +++ b/packages/react-native/Libraries/Renderer/shims/createReactNativeComponentClass.js @@ -7,7 +7,9 @@ * @noformat * @nolint * @flow strict-local - * @generated SignedSource<<52163887de05f1cff05388145cf85b3b>> + * @generated SignedSource<<556d1487de0b9e4a09cbc67dd130a884>> + * + * This file was sync'd from the facebook/react repository. */ 'use strict'; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 47d4675facb7..81b662e103cf 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -150,7 +150,7 @@ "peerDependencies": { "@react-native/jest-preset": "0.87.0-main", "@types/react": "^19.1.1", - "react": "^19.2.3" + "react": "^19.2.7" }, "peerDependenciesMeta": { "@types/react": { 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__/EventDispatching-benchmark-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js index 4c6b1309f5f2..97a676b9af65 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 @@ -39,188 +39,206 @@ function createNestedViews( ); } -const {isOSS} = Fantom.getConstants(); - -if (isOSS) { - it('is not supported in OSS yet', () => { - expect(true).toBe(true); - }); -} else { - Fantom.unstable_benchmark - .suite('Event Dispatching') - .test( - 'dispatch event, flat (1 handler)', - () => { - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - }, - { - beforeAll: () => { - ref = React.createRef(); - }, - beforeEach: () => { - root = Fantom.createRoot(); - Fantom.runTask(() => { - root.render( - {}} - style={{width: 10, height: 10}} - />, - ); - }); - }, - afterEach: () => { - root.destroy(); +Fantom.unstable_benchmark + .suite('Event Dispatching') + .test( + 'dispatch event, flat (1 handler)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); }, - ) - .test( - 'dispatch event, nested 10 deep (bubbling)', - () => { - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {}} + style={{width: 10, height: 10}} + />, + ); + }); }, - { - beforeAll: () => { - ref = React.createRef(); - }, - beforeEach: () => { - root = Fantom.createRoot(); - Fantom.runTask(() => { - root.render(createNestedViews(10, ref)); - }); - }, - afterEach: () => { - root.destroy(); + afterEach: () => { + root.destroy(); + }, + }, + ) + .test( + 'dispatch event, nested 10 deep (bubbling)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); }, - ) - .test( - 'dispatch event, nested 50 deep (bubbling)', - () => { - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViews(10, ref)); + }); }, - { - beforeAll: () => { - ref = React.createRef(); - }, - beforeEach: () => { - root = Fantom.createRoot(); - Fantom.runTask(() => { - root.render(createNestedViews(50, ref)); - }); - }, - afterEach: () => { - root.destroy(); + afterEach: () => { + root.destroy(); + }, + }, + ) + .test( + 'dispatch event, nested 50 deep (bubbling)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); }, - ) - .test( - 'dispatch event, nested 10 deep (no handlers on ancestors)', - () => { - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViews(50, ref)); + }); }, - { - beforeAll: () => { - ref = React.createRef(); - }, - beforeEach: () => { - root = Fantom.createRoot(); - Fantom.runTask(() => { - let views: React.MixedElement = ( - {}} - style={{width: 10, height: 10}} - /> - ); - for (let i = 0; i < 10; i++) { - views = {views}; - } - root.render(views); - }); - }, - afterEach: () => { - root.destroy(); + afterEach: () => { + root.destroy(); + }, + }, + ) + .test( + 'dispatch event, nested 10 deep (no handlers on ancestors)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); }, - ) - .test( - 'dispatch event with stopPropagation, nested 10 deep', - () => { - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => { + let views: React.MixedElement = ( + {}} + style={{width: 10, height: 10}} + /> + ); + for (let i = 0; i < 10; i++) { + views = {views}; + } + root.render(views); + }); + }, + afterEach: () => { + root.destroy(); }, - { - beforeAll: () => { - ref = React.createRef(); + }, + ) + .test( + 'dispatch event with stopPropagation, nested 10 deep', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, }, - beforeEach: () => { - root = Fantom.createRoot(); - Fantom.runTask(() => { - let views: React.MixedElement = ( - { - e.stopPropagation(); - }} - style={{width: 10, height: 10}} - /> + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + }, + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => { + let views: React.MixedElement = ( + { + e.stopPropagation(); + }} + style={{width: 10, height: 10}} + /> + ); + for (let i = 0; i < 10; i++) { + views = ( + {}}> + {views} + ); - for (let i = 0; i < 10; i++) { - views = ( - {}}> - {views} - - ); - } - root.render(views); - }); - }, - afterEach: () => { - root.destroy(); + } + root.render(views); + }); + }, + afterEach: () => { + root.destroy(); + }, + }, + ) + .test( + 'render + dispatch, flat (handler update cost)', + () => { + Fantom.runTask(() => { + root.render( + {}} + style={{width: 10, height: 10}} + />, + ); + }); + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); }, - ) - .test( - 'render + dispatch, flat (handler update cost)', - () => { + beforeEach: () => { + root = Fantom.createRoot(); Fantom.runTask(() => { root.render( , ); }); - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - }, - { - beforeAll: () => { - ref = React.createRef(); - }, - beforeEach: () => { - root = Fantom.createRoot(); - Fantom.runTask(() => { - root.render( - {}} - style={{width: 10, height: 10}} - />, - ); - }); - }, - afterEach: () => { - root.destroy(); - }, }, - ) - .test( - 'dispatch event, nested 50 deep (bubbling), stable tree', - () => { - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + afterEach: () => { + root.destroy(); }, - { - beforeAll: () => { - ref = React.createRef(); - root = Fantom.createRoot(); - Fantom.runTask(() => { - root.render(createNestedViews(50, ref)); - }); - }, - afterAll: () => { - root.destroy(); + }, + ) + .test( + 'dispatch event, nested 50 deep (bubbling), stable tree', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViews(50, ref)); + }); }, - ) - .test( - 'dispatch event, nested 50 deep (no handlers on ancestors), stable tree', - () => { - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + afterAll: () => { + root.destroy(); }, - { - beforeAll: () => { - ref = React.createRef(); - root = Fantom.createRoot(); - Fantom.runTask(() => { - let views: React.MixedElement = ( - {}} - style={{width: 10, height: 10}} - /> - ); - for (let i = 0; i < 50; i++) { - views = {views}; - } - root.render(views); - }); - }, - afterAll: () => { - root.destroy(); + }, + ) + .test( + 'dispatch event, nested 50 deep (no handlers on ancestors), stable tree', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + root = Fantom.createRoot(); + Fantom.runTask(() => { + let views: React.MixedElement = ( + {}} + style={{width: 10, height: 10}} + /> + ); + for (let i = 0; i < 50; i++) { + views = {views}; + } + root.render(views); + }); }, - ); -} + afterAll: () => { + root.destroy(); + }, + }, + ); diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-itest.js index eec975e62174..bbb830ac5af7 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-itest.js @@ -20,112 +20,106 @@ import * as FabricUIManager from 'react-native/Libraries/ReactNative/FabricUIMan const UIManager = nullthrows(FabricUIManager.getFabricUIManager()); describe('Event Dispatching', () => { - // The OSS renderer hasn't been synced yet to have these changes - // TODO(next-major) Remove this condition - if (!Fantom.getConstants().isOSS) { - it('provides the native event timeStamp (camel case) when available', () => { - const root = Fantom.createRoot(); + it('provides the native event timeStamp (camel case) when available', () => { + const root = Fantom.createRoot(); - const ref = React.createRef>(); + const ref = React.createRef>(); - const onPointerUp = jest.fn((e: PointerEvent) => { - e.persist(); - }); + const onPointerUp = jest.fn((e: PointerEvent) => { + e.persist(); + }); - Fantom.runTask(() => { - root.render(); - }); + Fantom.runTask(() => { + root.render(); + }); - expect(onPointerUp).toHaveBeenCalledTimes(0); + expect(onPointerUp).toHaveBeenCalledTimes(0); - const NATIVE_EVENT_TIMESTAMP = 1234; + const NATIVE_EVENT_TIMESTAMP = 1234; - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0, timeStamp: NATIVE_EVENT_TIMESTAMP}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0, timeStamp: NATIVE_EVENT_TIMESTAMP}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); - expect(onPointerUp).toHaveBeenCalledTimes(1); - expect(onPointerUp.mock.calls[0][0].timeStamp).toBe( - NATIVE_EVENT_TIMESTAMP, - ); - }); + expect(onPointerUp).toHaveBeenCalledTimes(1); + expect(onPointerUp.mock.calls[0][0].timeStamp).toBe(NATIVE_EVENT_TIMESTAMP); + }); - it('provides a default timeStamp when the native event timeStamp is NOT available', () => { - const root = Fantom.createRoot(); + it('provides a default timeStamp when the native event timeStamp is NOT available', () => { + const root = Fantom.createRoot(); - const ref = React.createRef>(); + const ref = React.createRef>(); - const onPointerUp = jest.fn((e: PointerEvent) => { - e.persist(); - }); + const onPointerUp = jest.fn((e: PointerEvent) => { + e.persist(); + }); - Fantom.runTask(() => { - root.render(); - }); + Fantom.runTask(() => { + root.render(); + }); - expect(onPointerUp).toHaveBeenCalledTimes(0); + expect(onPointerUp).toHaveBeenCalledTimes(0); - const lowerBound = performance.now(); + const lowerBound = performance.now(); - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); - const upperBound = performance.now(); + const upperBound = performance.now(); - expect(onPointerUp).toHaveBeenCalledTimes(1); - expect(onPointerUp.mock.calls[0][0].timeStamp).toBeGreaterThanOrEqual( - lowerBound, - ); - expect(onPointerUp.mock.calls[0][0].timeStamp).toBeLessThanOrEqual( - upperBound, - ); - }); + expect(onPointerUp).toHaveBeenCalledTimes(1); + expect(onPointerUp.mock.calls[0][0].timeStamp).toBeGreaterThanOrEqual( + lowerBound, + ); + expect(onPointerUp.mock.calls[0][0].timeStamp).toBeLessThanOrEqual( + upperBound, + ); + }); - it('exposes dispatched events in the global scope', () => { - const root = Fantom.createRoot(); + it('exposes dispatched events in the global scope', () => { + const root = Fantom.createRoot(); - const ref = React.createRef>(); + const ref = React.createRef>(); - let globalEventIsDispatchedEvent: boolean = false; + let globalEventIsDispatchedEvent: boolean = false; - const onPointerUp = jest.fn((e: PointerEvent) => { - globalEventIsDispatchedEvent = global.event === e; - }); + const onPointerUp = jest.fn((e: PointerEvent) => { + globalEventIsDispatchedEvent = global.event === e; + }); - Fantom.runTask(() => { - root.render(); - }); + Fantom.runTask(() => { + root.render(); + }); - const globalEventValueBeforeDispatch = {type: 'some-event'}; - global.event = globalEventValueBeforeDispatch; + const globalEventValueBeforeDispatch = {type: 'some-event'}; + global.event = globalEventValueBeforeDispatch; - expect(onPointerUp).toHaveBeenCalledTimes(0); + expect(onPointerUp).toHaveBeenCalledTimes(0); - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); - expect(onPointerUp).toHaveBeenCalledTimes(1); - expect(globalEventIsDispatchedEvent).toBe(true); - expect(global.event).toBe(globalEventValueBeforeDispatch); - }); - } + expect(onPointerUp).toHaveBeenCalledTimes(1); + expect(globalEventIsDispatchedEvent).toBe(true); + expect(global.event).toBe(globalEventValueBeforeDispatch); + }); it('dispatches events with discrete priority', () => { const root = Fantom.createRoot(); 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..480fd9560878 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 */ @@ -32,51 +33,482 @@ function asEventTarget(node: ?interface {}): ReadOnlyNodeWithEventTarget { return node; } -const {isOSS} = Fantom.getConstants(); +describe('EventTarget-based Event Dispatching', () => { + it('dispatches basic press event to handler', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onPointerUp = jest.fn(); -(isOSS ? describe.skip : describe)( - 'EventTarget-based Event Dispatching', - () => { - it('dispatches basic press event to handler', () => { + Fantom.runTask(() => { + root.render(); + }); + + expect(onPointerUp).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 10, y: 20}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(onPointerUp).toHaveBeenCalledTimes(1); + }); + + it('event bubbles from child to parent', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const parentHandler = jest.fn(); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + expect(parentHandler).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(parentHandler).toHaveBeenCalledTimes(1); + }); + + it('capture phase fires before bubble phase', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const order: Array = []; + + Fantom.runTask(() => { + root.render( + { + order.push('parent-capture'); + }} + onPointerUp={() => { + order.push('parent-bubble'); + }}> + { + order.push('child-capture'); + }} + onPointerUp={() => { + order.push('child-bubble'); + }} + /> + , + ); + }); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(order).toEqual([ + 'parent-capture', + 'child-capture', + 'child-bubble', + 'parent-bubble', + ]); + }); + + it('stopPropagation prevents parent handler from firing', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const parentHandler = jest.fn(); + const childHandler = jest.fn((e: PointerEvent) => { + e.stopPropagation(); + }); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(childHandler).toHaveBeenCalledTimes(1); + expect(parentHandler).toHaveBeenCalledTimes(0); + }); + + it('event object has correct nativeEvent property', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let capturedNativeEvent: NativePointerEvent | null = null; + + const onPointerUp = jest.fn((e: PointerEvent) => { + // Capture nativeEvent inside the handler because legacy SyntheticEvent + // nullifies properties after dispatch. + capturedNativeEvent = e.nativeEvent; + }); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 42, y: 99}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(onPointerUp).toHaveBeenCalledTimes(1); + expect(capturedNativeEvent?.x).toBe(42); + expect(capturedNativeEvent?.y).toBe(99); + }); + + it('handler updates correctly when prop changes', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const firstHandler = jest.fn(); + const secondHandler = jest.fn(); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(firstHandler).toHaveBeenCalledTimes(1); + expect(secondHandler).toHaveBeenCalledTimes(0); + + // Re-render with a new handler + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(firstHandler).toHaveBeenCalledTimes(1); + expect(secondHandler).toHaveBeenCalledTimes(1); + }); + + it('handler removal stops event dispatch', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const handler = jest.fn(); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + + // Re-render without the handler + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('multiple event types on the same element dispatch correctly', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onPointerUp = jest.fn(); + const onPointerMove = jest.fn(); + + Fantom.runTask(() => { + root.render( + , + ); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(onPointerUp).toHaveBeenCalledTimes(1); + expect(onPointerMove).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerMove', + {x: 1, y: 1}, + { + category: Fantom.NativeEventCategory.Continuous, + }, + ); + + expect(onPointerUp).toHaveBeenCalledTimes(1); + expect(onPointerMove).toHaveBeenCalledTimes(1); + }); + + it('preventDefault sets defaultPrevented to true', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let defaultPrevented: ?boolean = false; + + const handler = jest.fn((e: PointerEvent) => { + e.preventDefault(); + defaultPrevented = e.defaultPrevented; + }); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + expect(defaultPrevented).toBe(true); + }); + + it('isDefaultPrevented() returns true after preventDefault()', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let result = false; + + const handler = jest.fn((e: PointerEvent) => { + e.preventDefault(); + result = e.isDefaultPrevented(); + }); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + }); + + it('isDefaultPrevented() returns false when preventDefault() was not called', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let result = true; + + const handler = jest.fn((e: PointerEvent) => { + result = e.isDefaultPrevented(); + }); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result).toBe(false); + }); + + it('isPropagationStopped() returns true after stopPropagation()', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let result = false; + + const handler = jest.fn((e: PointerEvent) => { + e.stopPropagation(); + result = e.isPropagationStopped(); + }); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + }); + + it('isPropagationStopped() returns false when stopPropagation() was not called', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let result = true; + + const handler = jest.fn((e: PointerEvent) => { + result = e.isPropagationStopped(); + }); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result).toBe(false); + }); + + it('persist() is callable and does not throw', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + + const handler = jest.fn((e: PointerEvent) => { + e.persist(); + }); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + // --- addEventListener / removeEventListener on refs --- + // 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.enableImperativeEvents() + ? describe + : describe.skip)('addEventListener / removeEventListener', () => { + it('addEventListener on a ref receives dispatched events', () => { const root = Fantom.createRoot(); const ref = React.createRef>(); - const onPointerUp = jest.fn(); + const handler = jest.fn(); Fantom.runTask(() => { - root.render(); + root.render(); }); - expect(onPointerUp).toHaveBeenCalledTimes(0); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(ref.current).addEventListener('pointerup', handler); Fantom.dispatchNativeEvent( ref, 'onPointerUp', - {x: 10, y: 20}, + {x: 0, y: 0}, { category: Fantom.NativeEventCategory.Discrete, }, ); - expect(onPointerUp).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); }); - it('event bubbles from child to parent', () => { + it('removeEventListener stops receiving events', () => { const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const parentHandler = jest.fn(); + const ref = React.createRef>(); + const handler = jest.fn(); Fantom.runTask(() => { - root.render( - - - , - ); + root.render(); }); - expect(parentHandler).toHaveBeenCalledTimes(0); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(ref.current).addEventListener('pointerup', handler); Fantom.dispatchNativeEvent( - childRef, + ref, 'onPointerUp', {x: 0, y: 0}, { @@ -84,28 +516,33 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(parentHandler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); + + asEventTarget(ref.current).removeEventListener('pointerup', handler); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); }); - it('capture phase fires before bubble phase', () => { + it('addEventListener with capture option fires during capture phase', () => { const root = Fantom.createRoot(); + const parentRef = React.createRef>(); const childRef = React.createRef>(); const order: Array = []; Fantom.runTask(() => { root.render( - { - order.push('parent-capture'); - }} - onPointerUp={() => { - order.push('parent-bubble'); - }}> + { - order.push('child-capture'); - }} onPointerUp={() => { order.push('child-bubble'); }} @@ -114,6 +551,15 @@ const {isOSS} = Fantom.getConstants(); ); }); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(parentRef.current).addEventListener( + 'pointerup', + () => { + order.push('parent-capture'); + }, + {capture: true}, + ); + Fantom.dispatchNativeEvent( childRef, 'onPointerUp', @@ -123,30 +569,26 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(order).toEqual([ - 'parent-capture', - 'child-capture', - 'child-bubble', - 'parent-bubble', - ]); + expect(order).toEqual(['parent-capture', 'child-bubble']); }); - it('stopPropagation prevents parent handler from firing', () => { + it('addEventListener receives events that bubble from children', () => { const root = Fantom.createRoot(); + const parentRef = React.createRef>(); const childRef = React.createRef>(); - const parentHandler = jest.fn(); - const childHandler = jest.fn((e: PointerEvent) => { - e.stopPropagation(); - }); + const handler = jest.fn(); Fantom.runTask(() => { root.render( - - + + , ); }); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(parentRef.current).addEventListener('pointerup', handler); + Fantom.dispatchNativeEvent( childRef, 'onPointerUp', @@ -156,68 +598,73 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(childHandler).toHaveBeenCalledTimes(1); - expect(parentHandler).toHaveBeenCalledTimes(0); + expect(handler).toHaveBeenCalledTimes(1); }); - it('event object has correct nativeEvent property', () => { + // --- Declarative (prop) vs imperative (addEventListener) ordering --- + + it('declarative prop handler fires before imperative addEventListener listener', () => { const root = Fantom.createRoot(); const ref = React.createRef>(); - let capturedNativeEvent: NativePointerEvent | null = null; + const order: Array = []; - const onPointerUp = jest.fn((e: PointerEvent) => { - // Capture nativeEvent inside the handler because legacy SyntheticEvent - // nullifies properties after dispatch. - capturedNativeEvent = e.nativeEvent; + Fantom.runTask(() => { + root.render( + { + order.push('prop'); + }} + />, + ); }); - Fantom.runTask(() => { - root.render(); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(ref.current).addEventListener('pointerup', () => { + order.push('addEventListener'); }); Fantom.dispatchNativeEvent( ref, 'onPointerUp', - {x: 42, y: 99}, + {x: 0, y: 0}, { category: Fantom.NativeEventCategory.Discrete, }, ); - expect(onPointerUp).toHaveBeenCalledTimes(1); - expect(capturedNativeEvent?.x).toBe(42); - expect(capturedNativeEvent?.y).toBe(99); + expect(order).toEqual(['prop', 'addEventListener']); }); - it('handler updates correctly when prop changes', () => { + it('declarative capture prop fires before imperative capture addEventListener', () => { const root = Fantom.createRoot(); - const ref = React.createRef>(); - const firstHandler = jest.fn(); - const secondHandler = jest.fn(); + const parentRef = React.createRef>(); + const childRef = React.createRef>(); + const order: Array = []; Fantom.runTask(() => { - root.render(); + root.render( + { + order.push('parent-prop-capture'); + }}> + + , + ); }); - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(parentRef.current).addEventListener( + 'pointerup', + () => { + order.push('parent-imperative-capture'); }, + {capture: true}, ); - expect(firstHandler).toHaveBeenCalledTimes(1); - expect(secondHandler).toHaveBeenCalledTimes(0); - - // Re-render with a new handler - Fantom.runTask(() => { - root.render(); - }); - Fantom.dispatchNativeEvent( - ref, + childRef, 'onPointerUp', {x: 0, y: 0}, { @@ -225,35 +672,34 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(firstHandler).toHaveBeenCalledTimes(1); - expect(secondHandler).toHaveBeenCalledTimes(1); + expect(order).toEqual([ + 'parent-prop-capture', + 'parent-imperative-capture', + ]); }); - it('handler removal stops event dispatch', () => { + it('stopImmediatePropagation in prop handler prevents addEventListener listeners', () => { const root = Fantom.createRoot(); const ref = React.createRef>(); - const handler = jest.fn(); + const imperativeHandler = jest.fn(); Fantom.runTask(() => { - root.render(); + root.render( + { + e.stopImmediatePropagation(); + }} + />, + ); }); - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(ref.current).addEventListener( + 'pointerup', + imperativeHandler, ); - expect(handler).toHaveBeenCalledTimes(1); - - // Re-render without the handler - Fantom.runTask(() => { - root.render(); - }); - Fantom.dispatchNativeEvent( ref, 'onPointerUp', @@ -263,25 +709,26 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(handler).toHaveBeenCalledTimes(1); + expect(imperativeHandler).toHaveBeenCalledTimes(0); }); - it('multiple event types on the same element dispatch correctly', () => { + it('stopImmediatePropagation in addEventListener does not affect prop handler', () => { const root = Fantom.createRoot(); const ref = React.createRef>(); - const onPointerUp = jest.fn(); - const onPointerMove = jest.fn(); + const propHandler = jest.fn(); Fantom.runTask(() => { - root.render( - , - ); + root.render(); }); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(ref.current).addEventListener( + 'pointerup', + (e: $FlowFixMe) => { + e.stopImmediatePropagation(); + }, + ); + Fantom.dispatchNativeEvent( ref, 'onPointerUp', @@ -291,38 +738,160 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(onPointerUp).toHaveBeenCalledTimes(1); - expect(onPointerMove).toHaveBeenCalledTimes(0); + // Prop handler fires first, so it is not affected + expect(propHandler).toHaveBeenCalledTimes(1); + }); + + it('full dispatch order: capture props, capture imperative, bubble props, bubble imperative', () => { + const root = Fantom.createRoot(); + const parentRef = React.createRef>(); + const childRef = React.createRef>(); + const order: Array = []; + + Fantom.runTask(() => { + root.render( + { + order.push('parent-prop-capture'); + }} + onPointerUp={() => { + order.push('parent-prop-bubble'); + }}> + { + order.push('child-prop-capture'); + }} + onPointerUp={() => { + order.push('child-prop-bubble'); + }} + /> + , + ); + }); + + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(parentRef.current).addEventListener( + 'pointerup', + () => { + order.push('parent-imperative-capture'); + }, + {capture: true}, + ); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(parentRef.current).addEventListener('pointerup', () => { + order.push('parent-imperative-bubble'); + }); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(childRef.current).addEventListener( + 'pointerup', + () => { + order.push('child-imperative-capture'); + }, + {capture: true}, + ); + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(childRef.current).addEventListener('pointerup', () => { + order.push('child-imperative-bubble'); + }); Fantom.dispatchNativeEvent( - ref, - 'onPointerMove', - {x: 1, y: 1}, + childRef, + 'onPointerUp', + {x: 0, y: 0}, { - category: Fantom.NativeEventCategory.Continuous, + category: Fantom.NativeEventCategory.Discrete, }, ); - expect(onPointerUp).toHaveBeenCalledTimes(1); - expect(onPointerMove).toHaveBeenCalledTimes(1); + expect(order).toEqual([ + 'parent-prop-capture', + 'parent-imperative-capture', + 'child-prop-capture', + 'child-imperative-capture', + 'child-prop-bubble', + 'child-imperative-bubble', + 'parent-prop-bubble', + 'parent-imperative-bubble', + ]); }); + }); - it('preventDefault sets defaultPrevented to true', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - let defaultPrevented: ?boolean = false; + it('event has type and bubbles properties when using EventTarget dispatching', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let eventType: unknown = null; + let eventBubbles: unknown = null; - const handler = jest.fn((e: PointerEvent) => { - e.preventDefault(); - defaultPrevented = e.defaultPrevented; - }); + const handler = jest.fn((e: PointerEvent) => { + eventType = e.type; + eventBubbles = e.bubbles; + }); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + // The legacy SyntheticEvent does not set type/bubbles as standard + // DOM Event properties. The new EventTarget-based path does. + if (eventType != null) { + expect(eventType).toBe('pointerup'); + expect(eventBubbles).toBe(true); + } + }); + + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() + ? it + : it.skip)( + 'event.target points to the original target and event.currentTarget changes at each step', + () => { + const root = Fantom.createRoot(); + const parentRef = React.createRef>(); + const childRef = React.createRef>(); + const targets: Array<{target: unknown, currentTarget: unknown}> = []; Fantom.runTask(() => { - root.render(); + root.render( + { + targets.push({ + target: e.target, + currentTarget: e.currentTarget, + }); + }} + onPointerUp={(e: $FlowFixMe) => { + targets.push({ + target: e.target, + currentTarget: e.currentTarget, + }); + }}> + { + targets.push({ + target: e.target, + currentTarget: e.currentTarget, + }); + }} + /> + , + ); }); Fantom.dispatchNativeEvent( - ref, + childRef, 'onPointerUp', {x: 0, y: 0}, { @@ -330,26 +899,92 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(handler).toHaveBeenCalledTimes(1); - expect(defaultPrevented).toBe(true); - }); - - it('isDefaultPrevented() returns true after preventDefault()', () => { + expect(targets).toHaveLength(3); + + // event.target is always the original target element + expect(targets[0].target).toBe(childRef.current); + expect(targets[1].target).toBe(childRef.current); + expect(targets[2].target).toBe(childRef.current); + + // event.currentTarget changes at each propagation step + // Capture: parent + expect(targets[0].currentTarget).toBe(parentRef.current); + // Bubble: child, then parent + expect(targets[1].currentTarget).toBe(childRef.current); + expect(targets[2].currentTarget).toBe(parentRef.current); + }, + ); + + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() && + ReactNativeFeatureFlags.enableImperativeEvents() + ? it + : it.skip)( + 'direct (non-bubbling) events do not propagate via addEventListener', + () => { const root = Fantom.createRoot(); - const ref = React.createRef>(); - let result = false; + const parentRef = React.createRef>(); + const childRef = React.createRef>(); + const childHandler = jest.fn(); + const parentImperativeHandler = jest.fn(); - const handler = jest.fn((e: PointerEvent) => { - e.preventDefault(); - result = e.isDefaultPrevented(); + Fantom.runTask(() => { + root.render( + + + , + ); }); + // Add an imperative listener on the parent for the 'layout' event. + // Since 'layout' is a direct (non-bubbling) event, this should NOT + // fire when we dispatch onLayout on the child. + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(parentRef.current).addEventListener( + 'layout', + parentImperativeHandler, + ); + + const childCallsBefore = childHandler.mock.calls.length; + + Fantom.dispatchNativeEvent( + childRef, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + // Child handler fires + expect(childHandler.mock.calls.length - childCallsBefore).toBeGreaterThan( + 0, + ); + // Parent's addEventListener listener does NOT fire because layout + // is a non-bubbling (direct) event + expect(parentImperativeHandler).toHaveBeenCalledTimes(0); + }, + ); + + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() && + ReactNativeFeatureFlags.enableImperativeEvents() + ? describe + : describe.skip)('bubbling to document element and document', () => { + it('event bubbles from child up to the document element', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const documentElementHandler = jest.fn(); + Fantom.runTask(() => { - root.render(); + root.render(); }); + asEventTarget(root.document.documentElement).addEventListener( + 'pointerup', + documentElementHandler, + ); + Fantom.dispatchNativeEvent( - ref, + childRef, 'onPointerUp', {x: 0, y: 0}, { @@ -357,25 +992,64 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(handler).toHaveBeenCalledTimes(1); - expect(result).toBe(true); + expect(documentElementHandler).toHaveBeenCalledTimes(1); }); - it('isDefaultPrevented() returns false when preventDefault() was not called', () => { + it('event bubbles from child up to the document', () => { const root = Fantom.createRoot(); - const ref = React.createRef>(); - let result = true; + const childRef = React.createRef>(); + const documentHandler = jest.fn(); - const handler = jest.fn((e: PointerEvent) => { - result = e.isDefaultPrevented(); + Fantom.runTask(() => { + root.render(); }); + asEventTarget(root.document).addEventListener( + 'pointerup', + documentHandler, + ); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(documentHandler).toHaveBeenCalledTimes(1); + }); + + it('event bubbles from a deeply nested child up to document element and document', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const documentElementHandler = jest.fn(); + const documentHandler = jest.fn(); + Fantom.runTask(() => { - root.render(); + root.render( + + + + + + + , + ); }); + asEventTarget(root.document.documentElement).addEventListener( + 'pointerup', + documentElementHandler, + ); + asEventTarget(root.document).addEventListener( + 'pointerup', + documentHandler, + ); + Fantom.dispatchNativeEvent( - ref, + childRef, 'onPointerUp', {x: 0, y: 0}, { @@ -383,26 +1057,55 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(handler).toHaveBeenCalledTimes(1); - expect(result).toBe(false); + expect(documentElementHandler).toHaveBeenCalledTimes(1); + expect(documentHandler).toHaveBeenCalledTimes(1); }); - it('isPropagationStopped() returns true after stopPropagation()', () => { + it('capture phase on document fires before capture phase on document element', () => { const root = Fantom.createRoot(); - const ref = React.createRef>(); - let result = false; + const childRef = React.createRef>(); + const order: Array = []; - const handler = jest.fn((e: PointerEvent) => { - e.stopPropagation(); - result = e.isPropagationStopped(); + Fantom.runTask(() => { + root.render( + { + order.push('child-capture'); + }} + onPointerUp={() => { + order.push('child-bubble'); + }} + />, + ); }); - Fantom.runTask(() => { - root.render(); + asEventTarget(root.document).addEventListener( + 'pointerup', + () => { + order.push('document-capture'); + }, + {capture: true}, + ); + asEventTarget(root.document.documentElement).addEventListener( + 'pointerup', + () => { + order.push('documentElement-capture'); + }, + {capture: true}, + ); + asEventTarget(root.document.documentElement).addEventListener( + 'pointerup', + () => { + order.push('documentElement-bubble'); + }, + ); + asEventTarget(root.document).addEventListener('pointerup', () => { + order.push('document-bubble'); }); Fantom.dispatchNativeEvent( - ref, + childRef, 'onPointerUp', {x: 0, y: 0}, { @@ -410,25 +1113,40 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(handler).toHaveBeenCalledTimes(1); - expect(result).toBe(true); + expect(order).toEqual([ + 'document-capture', + 'documentElement-capture', + 'child-capture', + 'child-bubble', + 'documentElement-bubble', + 'document-bubble', + ]); }); - it('isPropagationStopped() returns false when stopPropagation() was not called', () => { + it('event.target points to the original child and event.currentTarget transitions through document element and document', () => { const root = Fantom.createRoot(); - const ref = React.createRef>(); - let result = true; - - const handler = jest.fn((e: PointerEvent) => { - result = e.isPropagationStopped(); - }); + const childRef = React.createRef>(); + const targets: Array<{target: unknown, currentTarget: unknown}> = []; Fantom.runTask(() => { - root.render(); + root.render(); }); + asEventTarget(root.document.documentElement).addEventListener( + 'pointerup', + (e: $FlowFixMe) => { + targets.push({target: e.target, currentTarget: e.currentTarget}); + }, + ); + asEventTarget(root.document).addEventListener( + 'pointerup', + (e: $FlowFixMe) => { + targets.push({target: e.target, currentTarget: e.currentTarget}); + }, + ); + Fantom.dispatchNativeEvent( - ref, + childRef, 'onPointerUp', {x: 0, y: 0}, { @@ -436,24 +1154,40 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(handler).toHaveBeenCalledTimes(1); - expect(result).toBe(false); + expect(targets).toHaveLength(2); + + // event.target is always the original target element + expect(targets[0].target).toBe(childRef.current); + expect(targets[1].target).toBe(childRef.current); + + // event.currentTarget changes at each propagation step + expect(targets[0].currentTarget).toBe(root.document.documentElement); + expect(targets[1].currentTarget).toBe(root.document); }); - it('persist() is callable and does not throw', () => { + it('stopPropagation on document element prevents document handler from firing', () => { const root = Fantom.createRoot(); - const ref = React.createRef>(); - - const handler = jest.fn((e: PointerEvent) => { - e.persist(); + const childRef = React.createRef>(); + const documentHandler = jest.fn(); + const documentElementHandler = jest.fn((e: $FlowFixMe) => { + e.stopPropagation(); }); Fantom.runTask(() => { - root.render(); + root.render(); }); + asEventTarget(root.document.documentElement).addEventListener( + 'pointerup', + documentElementHandler, + ); + asEventTarget(root.document).addEventListener( + 'pointerup', + documentHandler, + ); + Fantom.dispatchNativeEvent( - ref, + childRef, 'onPointerUp', {x: 0, y: 0}, { @@ -461,67 +1195,161 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(handler).toHaveBeenCalledTimes(1); + expect(documentElementHandler).toHaveBeenCalledTimes(1); + expect(documentHandler).toHaveBeenCalledTimes(0); }); - // --- addEventListener / removeEventListener on refs --- - // These tests require EventTarget-based dispatching to be enabled, - // since addEventListener is only available when the flag is on. + it('removeEventListener on document element stops events from being received', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const documentElementHandler = jest.fn(); - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() - ? describe - : describe.skip)('addEventListener / removeEventListener', () => { - it('addEventListener on a ref receives dispatched events', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - const handler = jest.fn(); + Fantom.runTask(() => { + root.render(); + }); - Fantom.runTask(() => { - root.render(); - }); + asEventTarget(root.document.documentElement).addEventListener( + 'pointerup', + documentElementHandler, + ); - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(ref.current).addEventListener('pointerup', handler); + Fantom.dispatchNativeEvent( + childRef, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + expect(documentElementHandler).toHaveBeenCalledTimes(1); + + asEventTarget(root.document.documentElement).removeEventListener( + 'pointerup', + documentElementHandler, + ); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(documentElementHandler).toHaveBeenCalledTimes(1); + }); - expect(handler).toHaveBeenCalledTimes(1); + it('direct (non-bubbling) events do not reach the document element or document', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const childHandler = jest.fn(); + const documentElementHandler = jest.fn(); + const documentHandler = jest.fn(); + + Fantom.runTask(() => { + root.render(); }); - it('removeEventListener stops receiving events', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - const handler = jest.fn(); + asEventTarget(root.document.documentElement).addEventListener( + 'layout', + documentElementHandler, + ); + asEventTarget(root.document).addEventListener('layout', documentHandler); - Fantom.runTask(() => { - root.render(); - }); + const childCallsBefore = childHandler.mock.calls.length; - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(ref.current).addEventListener('pointerup', handler); + Fantom.dispatchNativeEvent( + childRef, + 'onLayout', + {layout: {x: 0, y: 0, width: 100, height: 50}}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + // Child handler fires + expect(childHandler.mock.calls.length - childCallsBefore).toBeGreaterThan( + 0, + ); + // Non-bubbling events don't reach the document element or the document + expect(documentElementHandler).toHaveBeenCalledTimes(0); + expect(documentHandler).toHaveBeenCalledTimes(0); + }); + }); + + it('stopPropagation in capture phase prevents all bubble-phase handlers', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const order: Array = []; + + Fantom.runTask(() => { + root.render( + { + order.push('parent-capture'); + e.stopPropagation(); + }} + onPointerUp={() => { + order.push('parent-bubble'); + }}> + { + order.push('child-capture'); + }} + onPointerUp={() => { + order.push('child-bubble'); + }} + /> + , + ); + }); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); - expect(handler).toHaveBeenCalledTimes(1); + // Only the parent capture handler should fire; everything else is stopped + expect(order).toEqual(['parent-capture']); + }); + + // When enableNativeEventTargetEventDispatching is true, EventTarget.js + // defers handler errors via setTimeout(0) in reportListenerError. This + // leaves a pending callback that Fantom's validateEmptyMessageQueue + // catches, and the error leaks into subsequent tests. Skip in that + // configuration until the error propagation mechanism is made + // synchronous (matching the legacy rethrowCaughtError pattern). + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() + ? describe.skip + : describe)('error handling', () => { + it('error in event handler does not break dispatch to subsequent listeners', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const parentHandler = jest.fn(); - asEventTarget(ref.current).removeEventListener('pointerup', handler); + Fantom.runTask(() => { + root.render( + + { + throw new Error('handler error'); + }} + /> + , + ); + }); + const dispatch = () => Fantom.dispatchNativeEvent( - ref, + childRef, 'onPointerUp', {x: 0, y: 0}, { @@ -529,1060 +1357,230 @@ const {isOSS} = Fantom.getConstants(); }, ); - expect(handler).toHaveBeenCalledTimes(1); - }); - - it('addEventListener with capture option fires during capture phase', () => { - const root = Fantom.createRoot(); - const parentRef = React.createRef>(); - const childRef = React.createRef>(); - const order: Array = []; - - Fantom.runTask(() => { - root.render( - - { - order.push('child-bubble'); - }} - /> - , - ); - }); - - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(parentRef.current).addEventListener( - 'pointerup', - () => { - order.push('parent-capture'); - }, - {capture: true}, - ); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(order).toEqual(['parent-capture', 'child-bubble']); - }); - - it('addEventListener receives events that bubble from children', () => { - const root = Fantom.createRoot(); - const parentRef = React.createRef>(); - const childRef = React.createRef>(); - const handler = jest.fn(); - - Fantom.runTask(() => { - root.render( - - - , - ); - }); - - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(parentRef.current).addEventListener('pointerup', handler); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(handler).toHaveBeenCalledTimes(1); - }); - - // --- Declarative (prop) vs imperative (addEventListener) ordering --- - - it('declarative prop handler fires before imperative addEventListener listener', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - const order: Array = []; - - Fantom.runTask(() => { - root.render( - { - order.push('prop'); - }} - />, - ); - }); - - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(ref.current).addEventListener('pointerup', () => { - order.push('addEventListener'); - }); - - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(order).toEqual(['prop', 'addEventListener']); - }); - - it('declarative capture prop fires before imperative capture addEventListener', () => { - const root = Fantom.createRoot(); - const parentRef = React.createRef>(); - const childRef = React.createRef>(); - const order: Array = []; - - Fantom.runTask(() => { - root.render( - { - order.push('parent-prop-capture'); - }}> - - , - ); - }); - - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(parentRef.current).addEventListener( - 'pointerup', - () => { - order.push('parent-imperative-capture'); - }, - {capture: true}, - ); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(order).toEqual([ - 'parent-prop-capture', - 'parent-imperative-capture', - ]); - }); - - it('stopImmediatePropagation in prop handler prevents addEventListener listeners', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - const imperativeHandler = jest.fn(); - - Fantom.runTask(() => { - root.render( - { - e.stopImmediatePropagation(); - }} - />, - ); - }); - - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(ref.current).addEventListener( - 'pointerup', - imperativeHandler, - ); - - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(imperativeHandler).toHaveBeenCalledTimes(0); - }); - - it('stopImmediatePropagation in addEventListener does not affect prop handler', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - const propHandler = jest.fn(); - - Fantom.runTask(() => { - root.render(); - }); - - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(ref.current).addEventListener( - 'pointerup', - (e: $FlowFixMe) => { - e.stopImmediatePropagation(); - }, - ); - - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - // Prop handler fires first, so it is not affected - expect(propHandler).toHaveBeenCalledTimes(1); - }); - - it('full dispatch order: capture props, capture imperative, bubble props, bubble imperative', () => { - const root = Fantom.createRoot(); - const parentRef = React.createRef>(); - const childRef = React.createRef>(); - const order: Array = []; - - Fantom.runTask(() => { - root.render( - { - order.push('parent-prop-capture'); - }} - onPointerUp={() => { - order.push('parent-prop-bubble'); - }}> - { - order.push('child-prop-capture'); - }} - onPointerUp={() => { - order.push('child-prop-bubble'); - }} - /> - , - ); - }); - - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(parentRef.current).addEventListener( - 'pointerup', - () => { - order.push('parent-imperative-capture'); - }, - {capture: true}, - ); - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(parentRef.current).addEventListener('pointerup', () => { - order.push('parent-imperative-bubble'); - }); - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(childRef.current).addEventListener( - 'pointerup', - () => { - order.push('child-imperative-capture'); - }, - {capture: true}, - ); - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(childRef.current).addEventListener('pointerup', () => { - order.push('child-imperative-bubble'); - }); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + expect(dispatch).toThrow('handler error'); - expect(order).toEqual([ - 'parent-prop-capture', - 'parent-imperative-capture', - 'child-prop-capture', - 'child-imperative-capture', - 'child-prop-bubble', - 'child-imperative-bubble', - 'parent-prop-bubble', - 'parent-imperative-bubble', - ]); - }); + // The parent bubble handler should still fire despite child's error + expect(parentHandler).toHaveBeenCalledTimes(1); }); + }); - it('event has type and bubbles properties when using EventTarget dispatching', () => { + (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() + ? describe + : describe.skip)('event timestamps', () => { + it('event preserves native timestamp from nativeEvent.timeStamp', () => { const root = Fantom.createRoot(); const ref = React.createRef>(); - let eventType: unknown = null; - let eventBubbles: unknown = null; - - const handler = jest.fn((e: PointerEvent) => { - eventType = e.type; - eventBubbles = e.bubbles; - }); + let eventTimeStamp: unknown = null; Fantom.runTask(() => { - root.render(); + root.render( + { + eventTimeStamp = e.timeStamp; + }} + />, + ); }); + const nativeTimestamp = 12345.678; Fantom.dispatchNativeEvent( ref, 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, + {x: 0, y: 0, timeStamp: nativeTimestamp}, + {category: Fantom.NativeEventCategory.Discrete}, ); - expect(handler).toHaveBeenCalledTimes(1); - // The legacy SyntheticEvent does not set type/bubbles as standard - // DOM Event properties. The new EventTarget-based path does. - if (eventType != null) { - expect(eventType).toBe('pointerup'); - expect(eventBubbles).toBe(true); - } + expect(eventTimeStamp).toBe(nativeTimestamp); }); + }); - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() - ? it - : it.skip)( - 'event.target points to the original target and event.currentTarget changes at each step', - () => { - const root = Fantom.createRoot(); - const parentRef = React.createRef>(); - const childRef = React.createRef>(); - const targets: Array<{target: unknown, currentTarget: unknown}> = []; - - Fantom.runTask(() => { - root.render( - { - targets.push({ - target: e.target, - currentTarget: e.currentTarget, - }); - }} - onPointerUp={(e: $FlowFixMe) => { - targets.push({ - target: e.target, - currentTarget: e.currentTarget, - }); - }}> - { - targets.push({ - target: e.target, - currentTarget: e.currentTarget, - }); - }} - /> - , - ); - }); + // --- dispatchConfig --- - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(targets).toHaveLength(3); - - // event.target is always the original target element - expect(targets[0].target).toBe(childRef.current); - expect(targets[1].target).toBe(childRef.current); - expect(targets[2].target).toBe(childRef.current); - - // event.currentTarget changes at each propagation step - // Capture: parent - expect(targets[0].currentTarget).toBe(parentRef.current); - // Bubble: child, then parent - expect(targets[1].currentTarget).toBe(childRef.current); - expect(targets[2].currentTarget).toBe(parentRef.current); - }, - ); - - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() - ? it - : it.skip)( - 'direct (non-bubbling) events do not propagate via addEventListener', - () => { - const root = Fantom.createRoot(); - const parentRef = React.createRef>(); - const childRef = React.createRef>(); - const childHandler = jest.fn(); - const parentImperativeHandler = jest.fn(); - - Fantom.runTask(() => { - root.render( - - - , - ); - }); - - // Add an imperative listener on the parent for the 'layout' event. - // Since 'layout' is a direct (non-bubbling) event, this should NOT - // fire when we dispatch onLayout on the child. - // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag - asEventTarget(parentRef.current).addEventListener( - 'layout', - parentImperativeHandler, - ); - - const childCallsBefore = childHandler.mock.calls.length; - - Fantom.dispatchNativeEvent( - childRef, - 'onLayout', - {layout: {x: 0, y: 0, width: 100, height: 50}}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - // Child handler fires - expect( - childHandler.mock.calls.length - childCallsBefore, - ).toBeGreaterThan(0); - // Parent's addEventListener listener does NOT fire because layout - // is a non-bubbling (direct) event - expect(parentImperativeHandler).toHaveBeenCalledTimes(0); - }, - ); - - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() - ? describe - : describe.skip)('bubbling to document element and document', () => { - it('event bubbles from child up to the document element', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const documentElementHandler = jest.fn(); - - Fantom.runTask(() => { - root.render(); - }); - - asEventTarget(root.document.documentElement).addEventListener( - 'pointerup', - documentElementHandler, - ); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(documentElementHandler).toHaveBeenCalledTimes(1); - }); - - it('event bubbles from child up to the document', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const documentHandler = jest.fn(); - - Fantom.runTask(() => { - root.render(); - }); - - asEventTarget(root.document).addEventListener( - 'pointerup', - documentHandler, - ); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(documentHandler).toHaveBeenCalledTimes(1); - }); - - it('event bubbles from a deeply nested child up to document element and document', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const documentElementHandler = jest.fn(); - const documentHandler = jest.fn(); - - Fantom.runTask(() => { - root.render( - - - - - - - , - ); - }); - - asEventTarget(root.document.documentElement).addEventListener( - 'pointerup', - documentElementHandler, - ); - asEventTarget(root.document).addEventListener( - 'pointerup', - documentHandler, - ); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(documentElementHandler).toHaveBeenCalledTimes(1); - expect(documentHandler).toHaveBeenCalledTimes(1); - }); - - it('capture phase on document fires before capture phase on document element', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const order: Array = []; - - Fantom.runTask(() => { - root.render( - { - order.push('child-capture'); - }} - onPointerUp={() => { - order.push('child-bubble'); - }} - />, - ); - }); - - asEventTarget(root.document).addEventListener( - 'pointerup', - () => { - order.push('document-capture'); - }, - {capture: true}, - ); - asEventTarget(root.document.documentElement).addEventListener( - 'pointerup', - () => { - order.push('documentElement-capture'); - }, - {capture: true}, - ); - asEventTarget(root.document.documentElement).addEventListener( - 'pointerup', - () => { - order.push('documentElement-bubble'); - }, - ); - asEventTarget(root.document).addEventListener('pointerup', () => { - order.push('document-bubble'); - }); + describe('dispatchConfig', () => { + it('includes phasedRegistrationNames on bubbling events', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let capturedDispatchConfig: $FlowFixMe = null; - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, + Fantom.runTask(() => { + root.render( + { + capturedDispatchConfig = e.dispatchConfig; + }} + />, ); - - expect(order).toEqual([ - 'document-capture', - 'documentElement-capture', - 'child-capture', - 'child-bubble', - 'documentElement-bubble', - 'document-bubble', - ]); }); - it('event.target points to the original child and event.currentTarget transitions through document element and document', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const targets: Array<{target: unknown, currentTarget: unknown}> = []; - - Fantom.runTask(() => { - root.render(); - }); - - asEventTarget(root.document.documentElement).addEventListener( - 'pointerup', - (e: $FlowFixMe) => { - targets.push({target: e.target, currentTarget: e.currentTarget}); - }, - ); - asEventTarget(root.document).addEventListener( - 'pointerup', - (e: $FlowFixMe) => { - targets.push({target: e.target, currentTarget: e.currentTarget}); - }, - ); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(targets).toHaveLength(2); - - // event.target is always the original target element - expect(targets[0].target).toBe(childRef.current); - expect(targets[1].target).toBe(childRef.current); + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + {category: Fantom.NativeEventCategory.Discrete}, + ); - // event.currentTarget changes at each propagation step - expect(targets[0].currentTarget).toBe(root.document.documentElement); - expect(targets[1].currentTarget).toBe(root.document); - }); + expect(capturedDispatchConfig).not.toBeNull(); + expect(capturedDispatchConfig.phasedRegistrationNames.bubbled).toBe( + 'onPointerUp', + ); + expect(capturedDispatchConfig.phasedRegistrationNames.captured).toBe( + 'onPointerUpCapture', + ); + }); - it('stopPropagation on document element prevents document handler from firing', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const documentHandler = jest.fn(); - const documentElementHandler = jest.fn((e: $FlowFixMe) => { - e.stopPropagation(); - }); - - Fantom.runTask(() => { - root.render(); - }); - - asEventTarget(root.document.documentElement).addEventListener( - 'pointerup', - documentElementHandler, - ); - asEventTarget(root.document).addEventListener( - 'pointerup', - documentHandler, - ); + it('includes registrationName on direct events', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let capturedDispatchConfig: $FlowFixMe = null; - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, + Fantom.runTask(() => { + root.render( + { + capturedDispatchConfig = e.dispatchConfig; + }} + />, ); - - expect(documentElementHandler).toHaveBeenCalledTimes(1); - expect(documentHandler).toHaveBeenCalledTimes(0); }); - it('removeEventListener on document element stops events from being received', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const documentElementHandler = jest.fn(); + Fantom.dispatchNativeEvent( + ref, + 'onLayout', + {x: 0, y: 0, width: 100, height: 50}, + {category: Fantom.NativeEventCategory.Discrete}, + ); - Fantom.runTask(() => { - root.render(); - }); + expect(capturedDispatchConfig).not.toBeNull(); + expect(capturedDispatchConfig.registrationName).toBe('onLayout'); + }); + }); - asEventTarget(root.document.documentElement).addEventListener( - 'pointerup', - documentElementHandler, - ); + // --- skipBubbling --- - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + describe('skipBubbling (pointerenter / pointerleave)', () => { + it('does not bubble onPointerEnter to ancestor views', () => { + const root = Fantom.createRoot(); - expect(documentElementHandler).toHaveBeenCalledTimes(1); + const childRef = React.createRef>(); - asEventTarget(root.document.documentElement).removeEventListener( - 'pointerup', - documentElementHandler, - ); + const parentSpy = jest.fn((_e: PointerEvent) => {}); + const childSpy = jest.fn((_e: PointerEvent) => {}); - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, + Fantom.runTask(() => { + root.render( + + + , ); - - expect(documentElementHandler).toHaveBeenCalledTimes(1); }); - it('direct (non-bubbling) events do not reach the document element or document', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const childHandler = jest.fn(); - const documentElementHandler = jest.fn(); - const documentHandler = jest.fn(); - - Fantom.runTask(() => { - root.render(); - }); - - asEventTarget(root.document.documentElement).addEventListener( - 'layout', - documentElementHandler, - ); - asEventTarget(root.document).addEventListener( - 'layout', - documentHandler, - ); - - const childCallsBefore = childHandler.mock.calls.length; - - Fantom.dispatchNativeEvent( - childRef, - 'onLayout', - {layout: {x: 0, y: 0, width: 100, height: 50}}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + Fantom.dispatchNativeEvent( + childRef, + 'onPointerEnter', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.ContinuousStart, + }, + ); - // Child handler fires - expect( - childHandler.mock.calls.length - childCallsBefore, - ).toBeGreaterThan(0); - // Non-bubbling events don't reach the document element or the document - expect(documentElementHandler).toHaveBeenCalledTimes(0); - expect(documentHandler).toHaveBeenCalledTimes(0); - }); + expect(childSpy).toHaveBeenCalledTimes(1); + expect(parentSpy).toHaveBeenCalledTimes(0); }); - it('stopPropagation in capture phase prevents all bubble-phase handlers', () => { + it('does not bubble onPointerLeave to ancestor views', () => { const root = Fantom.createRoot(); + const childRef = React.createRef>(); - const order: Array = []; + + const parentSpy = jest.fn((_e: PointerEvent) => {}); + const childSpy = jest.fn((_e: PointerEvent) => {}); Fantom.runTask(() => { root.render( - { - order.push('parent-capture'); - e.stopPropagation(); - }} - onPointerUp={() => { - order.push('parent-bubble'); - }}> - { - order.push('child-capture'); - }} - onPointerUp={() => { - order.push('child-bubble'); - }} - /> + + , ); }); Fantom.dispatchNativeEvent( childRef, - 'onPointerUp', + 'onPointerLeave', {x: 0, y: 0}, { - category: Fantom.NativeEventCategory.Discrete, + category: Fantom.NativeEventCategory.ContinuousEnd, }, ); - // Only the parent capture handler should fire; everything else is stopped - expect(order).toEqual(['parent-capture']); - }); - - // When enableNativeEventTargetEventDispatching is true, EventTarget.js - // defers handler errors via setTimeout(0) in reportListenerError. This - // leaves a pending callback that Fantom's validateEmptyMessageQueue - // catches, and the error leaks into subsequent tests. Skip in that - // configuration until the error propagation mechanism is made - // synchronous (matching the legacy rethrowCaughtError pattern). - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() - ? describe.skip - : describe)('error handling', () => { - it('error in event handler does not break dispatch to subsequent listeners', () => { - const root = Fantom.createRoot(); - const childRef = React.createRef>(); - const parentHandler = jest.fn(); - - Fantom.runTask(() => { - root.render( - - { - throw new Error('handler error'); - }} - /> - , - ); - }); - - const dispatch = () => - Fantom.dispatchNativeEvent( - childRef, - 'onPointerUp', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); - - expect(dispatch).toThrow('handler error'); - - // The parent bubble handler should still fire despite child's error - expect(parentHandler).toHaveBeenCalledTimes(1); - }); + expect(childSpy).toHaveBeenCalledTimes(1); + expect(parentSpy).toHaveBeenCalledTimes(0); }); - (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() - ? describe - : describe.skip)('event timestamps', () => { - it('event preserves native timestamp from nativeEvent.timeStamp', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - let eventTimeStamp: unknown = null; - - Fantom.runTask(() => { - root.render( - { - eventTimeStamp = e.timeStamp; - }} - />, - ); - }); - - const nativeTimestamp = 12345.678; - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0, timeStamp: nativeTimestamp}, - {category: Fantom.NativeEventCategory.Discrete}, - ); - - expect(eventTimeStamp).toBe(nativeTimestamp); - }); - }); - - // --- dispatchConfig --- - - describe('dispatchConfig', () => { - it('includes phasedRegistrationNames on bubbling events', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - let capturedDispatchConfig: $FlowFixMe = null; - - Fantom.runTask(() => { - root.render( - { - capturedDispatchConfig = e.dispatchConfig; - }} - />, - ); - }); + it('still fires onPointerEnterCapture on ancestors during the capture phase', () => { + const root = Fantom.createRoot(); - Fantom.dispatchNativeEvent( - ref, - 'onPointerUp', - {x: 0, y: 0}, - {category: Fantom.NativeEventCategory.Discrete}, - ); + const childRef = React.createRef>(); - expect(capturedDispatchConfig).not.toBeNull(); - expect(capturedDispatchConfig.phasedRegistrationNames.bubbled).toBe( - 'onPointerUp', - ); - expect(capturedDispatchConfig.phasedRegistrationNames.captured).toBe( - 'onPointerUpCapture', - ); + const callOrder: Array = []; + const parentCaptureSpy = jest.fn((_e: PointerEvent) => { + callOrder.push('parentCapture'); }); - - it('includes registrationName on direct events', () => { - const root = Fantom.createRoot(); - const ref = React.createRef>(); - let capturedDispatchConfig: $FlowFixMe = null; - - Fantom.runTask(() => { - root.render( - { - capturedDispatchConfig = e.dispatchConfig; - }} - />, - ); - }); - - Fantom.dispatchNativeEvent( - ref, - 'onLayout', - {x: 0, y: 0, width: 100, height: 50}, - {category: Fantom.NativeEventCategory.Discrete}, - ); - - expect(capturedDispatchConfig).not.toBeNull(); - expect(capturedDispatchConfig.registrationName).toBe('onLayout'); + const childSpy = jest.fn((_e: PointerEvent) => { + callOrder.push('child'); }); - }); - // --- skipBubbling --- - - describe('skipBubbling (pointerenter / pointerleave)', () => { - it('does not bubble onPointerEnter to ancestor views', () => { - const root = Fantom.createRoot(); - - const childRef = React.createRef>(); - - const parentSpy = jest.fn((_e: PointerEvent) => {}); - const childSpy = jest.fn((_e: PointerEvent) => {}); - - Fantom.runTask(() => { - root.render( - - - , - ); - }); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerEnter', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.ContinuousStart, - }, + Fantom.runTask(() => { + root.render( + + + , ); - - expect(childSpy).toHaveBeenCalledTimes(1); - expect(parentSpy).toHaveBeenCalledTimes(0); }); - it('does not bubble onPointerLeave to ancestor views', () => { - const root = Fantom.createRoot(); - - const childRef = React.createRef>(); - - const parentSpy = jest.fn((_e: PointerEvent) => {}); - const childSpy = jest.fn((_e: PointerEvent) => {}); - - Fantom.runTask(() => { - root.render( - - - , - ); - }); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerLeave', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.ContinuousEnd, - }, - ); - - expect(childSpy).toHaveBeenCalledTimes(1); - expect(parentSpy).toHaveBeenCalledTimes(0); - }); + Fantom.dispatchNativeEvent( + childRef, + 'onPointerEnter', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.ContinuousStart, + }, + ); - it('still fires onPointerEnterCapture on ancestors during the capture phase', () => { - const root = Fantom.createRoot(); + expect(parentCaptureSpy).toHaveBeenCalledTimes(1); + expect(childSpy).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['parentCapture', 'child']); + }); - const childRef = React.createRef>(); + it('still bubbles non-skipBubbling events (onPointerDown) to ancestor views', () => { + const root = Fantom.createRoot(); - const callOrder: Array = []; - const parentCaptureSpy = jest.fn((_e: PointerEvent) => { - callOrder.push('parentCapture'); - }); - const childSpy = jest.fn((_e: PointerEvent) => { - callOrder.push('child'); - }); + const childRef = React.createRef>(); - Fantom.runTask(() => { - root.render( - - - , - ); - }); + const parentSpy = jest.fn((_e: PointerEvent) => {}); + const childSpy = jest.fn((_e: PointerEvent) => {}); - Fantom.dispatchNativeEvent( - childRef, - 'onPointerEnter', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.ContinuousStart, - }, + Fantom.runTask(() => { + root.render( + + + , ); - - expect(parentCaptureSpy).toHaveBeenCalledTimes(1); - expect(childSpy).toHaveBeenCalledTimes(1); - expect(callOrder).toEqual(['parentCapture', 'child']); }); - it('still bubbles non-skipBubbling events (onPointerDown) to ancestor views', () => { - const root = Fantom.createRoot(); - - const childRef = React.createRef>(); - - const parentSpy = jest.fn((_e: PointerEvent) => {}); - const childSpy = jest.fn((_e: PointerEvent) => {}); - - Fantom.runTask(() => { - root.render( - - - , - ); - }); - - Fantom.dispatchNativeEvent( - childRef, - 'onPointerDown', - {x: 0, y: 0}, - { - category: Fantom.NativeEventCategory.Discrete, - }, - ); + Fantom.dispatchNativeEvent( + childRef, + 'onPointerDown', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); - expect(childSpy).toHaveBeenCalledTimes(1); - expect(parentSpy).toHaveBeenCalledTimes(1); - }); + expect(childSpy).toHaveBeenCalledTimes(1); + expect(parentSpy).toHaveBeenCalledTimes(1); }); - }, -); + }); +}); diff --git a/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js b/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js index 58672b16e954..605d90317e3f 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js @@ -46,9 +46,7 @@ function touchEnd( }; } -const {isOSS} = Fantom.getConstants(); - -(isOSS ? describe.skip : describe)('Responder System', () => { +describe('Responder System', () => { // --- Basic Grant / Release --- it('grants responder on touch start when onStartShouldSetResponder returns true', () => { 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( diff --git a/packages/rn-tester/package.json b/packages/rn-tester/package.json index 26b5b7bd97cc..631b750928e7 100644 --- a/packages/rn-tester/package.json +++ b/packages/rn-tester/package.json @@ -33,7 +33,7 @@ "nullthrows": "^1.1.1" }, "peerDependencies": { - "react": "19.2.3", + "react": "19.2.7", "react-native": "1000.0.0" }, "codegenConfig": { diff --git a/packages/virtualized-lists/package.json b/packages/virtualized-lists/package.json index d8c5de954063..5b25aaf81357 100644 --- a/packages/virtualized-lists/package.json +++ b/packages/virtualized-lists/package.json @@ -48,7 +48,7 @@ "nullthrows": "^1.1.1" }, "devDependencies": { - "react-test-renderer": "19.2.3" + "react-test-renderer": "19.2.7" }, "peerDependencies": { "@types/react": "^19.2.0", diff --git a/private/helloworld/package.json b/private/helloworld/package.json index 63cdc897f1ee..42f2864c68e5 100644 --- a/private/helloworld/package.json +++ b/private/helloworld/package.json @@ -12,7 +12,7 @@ "test": "jest" }, "dependencies": { - "react": "19.2.3", + "react": "19.2.7", "react-native": "1000.0.0" }, "devDependencies": { @@ -29,7 +29,7 @@ "eslint": "^8.19.0", "jest": "^29.7.0", "listr2": "^8.2.1", - "react-test-renderer": "19.2.3", + "react-test-renderer": "19.2.7", "rxjs": "^7.8.1" }, "engines": { diff --git a/yarn.lock b/yarn.lock index b2ce8ccc5a22..8d585205f09a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8022,28 +8022,28 @@ react-devtools-core@^6.1.5: shell-quote "^1.6.1" ws "^7" -react-is@19.2.3, react-is@^16.13.1, react-is@^18.0.0, react-is@^19.2.3: - version "19.2.3" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29" - integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA== +react-is@19.2.7, react-is@^16.13.1, react-is@^18.0.0, react-is@^19.2.7: + version "19.2.7" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.7.tgz#57668ee86a78574a542b0a539455212b2c086df2" + integrity sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A== react-refresh@^0.14.0: version "0.14.2" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== -react-test-renderer@19.2.3: - version "19.2.3" - resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.2.3.tgz#d20f5193867c98b2df9e13b4e72bb83f311f6a43" - integrity sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw== +react-test-renderer@19.2.7: + version "19.2.7" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.2.7.tgz#465cc2df3156ad997f94e93b12d6031d54828fde" + integrity sha512-U4TyPDJ9MsC8rFimXuJum8w40aPc9kbOZYO8Pc2/4A884i8hwJsMNA/JNyuOc/f2/37wHvk7HjpVl1V4re7Dig== dependencies: - react-is "^19.2.3" + react-is "^19.2.7" scheduler "^0.27.0" -react@19.2.3: - version "19.2.3" - resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8" - integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== +react@19.2.7: + version "19.2.7" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b" + integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== read-pkg-up@^2.0.0: version "2.0.0"