diff --git a/packages/react-native/Libraries/ReactNative/FabricUIManager.js b/packages/react-native/Libraries/ReactNative/FabricUIManager.js index 40de0d069c75..cc55ca8b59fa 100644 --- a/packages/react-native/Libraries/ReactNative/FabricUIManager.js +++ b/packages/react-native/Libraries/ReactNative/FabricUIManager.js @@ -92,6 +92,11 @@ export interface Spec { /* width: */ number, /* height: */ number, ]; + +setIsJSResponder: ( + node: Node | NativeElementReference, + isJSResponder: boolean, + blockNativeResponder: boolean, + ) => void; +unstable_DefaultEventPriority: number; +unstable_DiscreteEventPriority: number; +unstable_ContinuousEventPriority: number; @@ -124,6 +129,7 @@ const CACHED_PROPERTIES = [ 'dispatchCommand', 'compareDocumentPosition', 'getBoundingClientRect', + 'setIsJSResponder', 'unstable_DefaultEventPriority', 'unstable_DiscreteEventPriority', 'unstable_ContinuousEventPriority', diff --git a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js index 385f5c921941..2d8c0e42d679 100644 --- a/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js +++ b/packages/react-native/Libraries/ReactPrivate/ReactNativePrivateInterface.js @@ -8,9 +8,8 @@ * @format */ +import typeof dispatchNativeEvent from '../../src/private/renderer/events/dispatchNativeEvent'; import typeof CustomEvent from '../../src/private/webapis/dom/events/CustomEvent'; -import typeof {setEventInitTimeStamp} from '../../src/private/webapis/dom/events/internals/EventInternals'; -import typeof {dispatchTrustedEvent} from '../../src/private/webapis/dom/events/internals/EventTargetInternals'; import typeof BatchedBridge from '../BatchedBridge/BatchedBridge'; import typeof legacySendAccessibilityEvent from '../Components/AccessibilityInfo/legacySendAccessibilityEvent'; import typeof TextInputState from '../Components/TextInput/TextInputState'; @@ -135,12 +134,8 @@ module.exports = { return require('../ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance') .getInternalInstanceHandleFromPublicInstance; }, - get dispatchTrustedEvent(): dispatchTrustedEvent { - return require('../../src/private/webapis/dom/events/internals/EventTargetInternals') - .dispatchTrustedEvent; - }, - get setEventInitTimeStamp(): setEventInitTimeStamp { - return require('../../src/private/webapis/dom/events/internals/EventInternals') - .setEventInitTimeStamp; + get dispatchNativeEvent(): dispatchNativeEvent { + return require('../../src/private/renderer/events/dispatchNativeEvent') + .default; }, }; diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 407700bfb780..fdd39000be66 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -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<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -387,6 +387,7 @@ declare const RCTNetworking_default: { withCredentials: boolean, ): void } +declare const ReadOnlyNodeBase: typeof Object declare const registerCallableModule: typeof registerCallableModule_default declare const registerCallableModule_default: RegisterCallableModule declare const requireNativeComponent: typeof requireNativeComponent_default @@ -4080,7 +4081,7 @@ declare class ReadOnlyElement_default extends ReadOnlyNode_default { get tagName(): string get textContent(): string } -declare class ReadOnlyNode_default { +declare class ReadOnlyNode_default extends ReadOnlyNodeBase { static ATTRIBUTE_NODE: number static CDATA_SECTION_NODE: number static COMMENT_NODE: number @@ -5995,23 +5996,23 @@ declare type WrapperComponentProvider = ( appParameters: Object, ) => React.ComponentType export { - AccessibilityActionEvent, // f6181a2c - AccessibilityInfo, // 3e373fdc + AccessibilityActionEvent, // 5c5928b9 + AccessibilityInfo, // ccbcce2f AccessibilityProps, // 5a2836fc AccessibilityRole, // f2f2e066 AccessibilityState, // b0c2b3f7 AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // b7929377 - ActivityIndicatorProps, // 327327a4 + ActivityIndicator, // 9b127a18 + ActivityIndicatorProps, // 40142bf3 Alert, // 5bf12165 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 AlertOptions, // a0cdac0f AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // b39057e6 + Animated, // a77a3944 AppConfig, // ebddad4b AppRegistry, // f7a253e4 AppState, // 12012be5 @@ -6021,12 +6022,12 @@ export { AutoCapitalize, // c0e857a0 BackHandler, // f139fc69 BackPressEventName, // 4620fb76 - BlurEvent, // 870b9bb5 + BlurEvent, // e6151a1f BoxShadowValue, // b679703f - Button, // 12c96cf0 - ButtonProps, // 3c081e75 + Button, // 53167a86 + ButtonProps, // 0df9cb59 Clipboard, // 41addb89 - CodegenTypes, // adbc477c + CodegenTypes, // 0b8108a8 ColorSchemeName, // 31a4350e ColorValue, // 98989a8f ComponentProvider, // b5c60ddd @@ -6042,9 +6043,9 @@ export { DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // 21d003c0 - DrawerLayoutAndroidProps, // d0c886d4 - DrawerSlideEvent, // cc43db83 + DrawerLayoutAndroid, // c0fe33a6 + DrawerLayoutAndroidProps, // 6ce7fb3d + DrawerSlideEvent, // 0256d35a DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c DynamicColorIOSTuple, // 023ce58e @@ -6058,29 +6059,29 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // 91757c22 - FlatListProps, // 164888a2 - FocusEvent, // 529b43eb + FlatList, // 8c50f04a + FlatListProps, // e170f2c9 + FocusEvent, // 62fc1eb8 FontVariant, // 7c7558bb - GestureResponderEvent, // b466f6d6 - GestureResponderHandlers, // 8356843d + GestureResponderEvent, // f693e9a5 + GestureResponderHandlers, // cc70e4cb Handle, // 2d65285d - HostComponent, // 5e13ff5a - HostInstance, // 489cbe7f + HostComponent, // 16fccab5 + HostInstance, // 9b5a9ec2 I18nManager, // f9870e00 IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // 2ec3e730 - ImageBackground, // 0389b17e - ImageBackgroundProps, // 8d0275c3 - ImageErrorEvent, // b7b2ae63 - ImageLoadEvent, // 5baae813 - ImageProgressEventIOS, // adb35052 - ImageProps, // 6b9e13ae + Image, // a2358f8c + ImageBackground, // 414c60ba + ImageBackgroundProps, // 65c127f9 + ImageErrorEvent, // d3ee606e + ImageLoadEvent, // 6b547ea5 + ImageProgressEventIOS, // 4c866a82 + ImageProps, // d917cbd6 ImagePropsAndroid, // 9fd9bcbb - ImagePropsBase, // 0d040383 - ImagePropsIOS, // 318adce2 + ImagePropsBase, // c9521ea0 + ImagePropsIOS, // c4ae0c06 ImageRequireSource, // 681d683b ImageResolvedAssetSource, // f3060931 ImageSize, // 1c47cf88 @@ -6093,12 +6094,12 @@ export { InputModeOptions, // 4e8581b9 Insets, // e7fe432a InteractionManager, // c324d6e3 - KeyDownEvent, // 5309360e + KeyDownEvent, // d4971b72 KeyEvent, // 20fa4267 - KeyUpEvent, // 7c3054e1 + KeyUpEvent, // bc6bd87b Keyboard, // 49414c97 - KeyboardAvoidingView, // 014175c1 - KeyboardAvoidingViewProps, // 729e7118 + KeyboardAvoidingView, // 79591758 + KeyboardAvoidingViewProps, // 7cd981a2 KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -6111,7 +6112,7 @@ export { LayoutAnimationProperty, // 52995f01 LayoutAnimationType, // 2da0a29b LayoutAnimationTypes, // 081b3bde - LayoutChangeEvent, // c674f902 + LayoutChangeEvent, // c388ba2b LayoutConformanceProps, // 055f03b8 LayoutRectangle, // 6601b294 Linking, // 9a6a174d @@ -6123,34 +6124,34 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // 549d4c8f - ModalBaseProps, // 0c81c9b1 - ModalProps, // a7416079 + Modal, // dad0b1ce + ModalBaseProps, // cbd3c10d + ModalProps, // 8e1508c6 ModalPropsAndroid, // 515fb173 - ModalPropsIOS, // 4fbcedf6 - ModeChangeEvent, // 16790307 - MouseEvent, // 53ede3db + ModalPropsIOS, // 144bbc95 + ModeChangeEvent, // a5e9864f + MouseEvent, // fdce82bc NativeAppEventEmitter, // 08d4c47d NativeColorValue, // d2094c29 - NativeComponentRegistry, // 7fd99ba6 + NativeComponentRegistry, // 21481a60 NativeDialogManagerAndroid, // 5be8497e NativeEventEmitter, // 27f97c1a NativeEventSubscription, // de3942e7 - NativeMethods, // 03dc51c5 - NativeMethodsMixin, // 4b061b7e + NativeMethods, // a2311987 + NativeMethodsMixin, // a819ef55 NativeModules, // 4597cd36 - NativeMouseEvent, // ff25cf35 - NativePointerEvent, // 89c1f3ad + NativeMouseEvent, // 558d45b0 + NativePointerEvent, // f1763d80 NativeScrollEvent, // caad7f53 - NativeSyntheticEvent, // d2a1fe6a + NativeSyntheticEvent, // 35855c4c NativeTouchEvent, // 59b676df NativeUIEvent, // 44ac26ac Networking, // bbc5be42 OpaqueColorValue, // 25f3fa5b - PanResponder, // d803bfcf - PanResponderCallbacks, // d325aa56 + PanResponder, // 4320c1ba + PanResponderCallbacks, // ed54b109 PanResponderGestureState, // 54baf558 - PanResponderInstance, // 84d7fd52 + PanResponderInstance, // 46b4629d Permission, // 06473f4f PermissionStatus, // 4b7de97b PermissionsAndroid, // db2a401e @@ -6160,30 +6161,30 @@ export { PlatformOSType, // 0a17561e PlatformSelectSpec, // 09ed7758 PointValue, // 69db075f - PointerEvent, // ff3129ff - PressabilityConfig, // 9bb563c2 - PressabilityEventHandlers, // ade29c37 - Pressable, // e98cfef3 + PointerEvent, // fe3989a1 + PressabilityConfig, // 6dedcb61 + PressabilityEventHandlers, // 3e6c0f56 + Pressable, // aef6bb57 PressableAndroidRippleConfig, // 42bc9727 - PressableProps, // ddf6b855 + PressableProps, // 3912691c PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // 724297f7 - ProgressBarAndroidProps, // 0b510e34 + ProgressBarAndroid, // 36757db1 + ProgressBarAndroidProps, // 8bf4dfa6 PromiseTask, // 5102c862 PublicRootInstance, // 8040afd7 - PublicTextInstance, // e775d6b1 + PublicTextInstance, // cd0d8f8d PushNotificationEventName, // 84e7e150 PushNotificationIOS, // b4d1fe78 PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // 4f8857da - RefreshControlProps, // ad88b7c5 + RefreshControl, // b8659b1f + RefreshControlProps, // e747ed5d RefreshControlPropsAndroid, // 99f64c97 RefreshControlPropsIOS, // 72a36381 Registry, // e1ed403e - ResponderSyntheticEvent, // e0d1564d + ResponderSyntheticEvent, // fb10247c ReturnKeyTypeOptions, // afd47ba3 Role, // af7b889d RootTag, // 3cd10504 @@ -6191,21 +6192,21 @@ export { RootViewStyleProvider, // d4818465 Runnable, // 2cb32c54 Runnables, // d3749ae1 - SafeAreaView, // f6f8e235 + SafeAreaView, // 9589fa67 ScaledSize, // 07e417c7 - ScrollEvent, // 84e5b805 - ScrollResponderType, // a971f2ba + ScrollEvent, // 5d529218 + ScrollResponderType, // c6860ec8 ScrollToLocationParamsType, // d7ecdad1 - ScrollView, // 926eb585 - ScrollViewImperativeMethods, // 642b738d - ScrollViewProps, // 44360048 + ScrollView, // a3918d1a + ScrollViewImperativeMethods, // 7cd8d8de + ScrollViewProps, // 429fdd65 ScrollViewPropsAndroid, // 44210553 - ScrollViewPropsIOS, // d83c9733 + ScrollViewPropsIOS, // b34b696c ScrollViewScrollToOptions, // 3313411e SectionBase, // b376bddc - SectionList, // c772828c + SectionList, // 92031230 SectionListData, // 119baf83 - SectionListProps, // dd6d35bd + SectionListProps, // c0d0a46a SectionListRenderItem, // 1fad0435 SectionListRenderItemInfo, // 745e1992 Separators, // 6a45f7e3 @@ -6224,66 +6225,66 @@ export { StyleProp, // fa0e9b4a StyleSheet, // e77dd046 SubmitBehavior, // c4ddf490 - Switch, // 68d3d3c8 - SwitchChangeEvent, // 2e5bd2de - SwitchProps, // be49c609 + Switch, // 3434138b + SwitchChangeEvent, // 63e9c50b + SwitchProps, // 083b753d Systrace, // b5aa21fc TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 - Text, // b8d8cb2c + Text, // 717d25fe TextContentType, // 239b3ecc - TextInput, // ce4fd696 + TextInput, // ed3a8375 TextInputAndroidProps, // 3f09ce49 - TextInputChangeEvent, // 6821f629 - TextInputContentSizeChangeEvent, // 5fba3f54 - TextInputEndEditingEvent, // 8c22fac3 - TextInputFocusEvent, // c36e977c + TextInputChangeEvent, // 3ab11bb4 + TextInputContentSizeChangeEvent, // f71f8571 + TextInputEndEditingEvent, // e5f70633 + TextInputFocusEvent, // 020507e6 TextInputIOSProps, // 0d05a855 - TextInputKeyPressEvent, // 967178c2 - TextInputProps, // 23a015ce - TextInputSelectionChangeEvent, // a1a7622f - TextInputSubmitEditingEvent, // 48d903af - TextLayoutEvent, // 45b0a8d7 - TextProps, // fc6ffddd + TextInputKeyPressEvent, // 3924ad9b + TextInputProps, // 9b370db2 + TextInputSelectionChangeEvent, // d4d10630 + TextInputSubmitEditingEvent, // 22885c31 + TextLayoutEvent, // 73ab173e + TextProps, // 68a1c0e8 TextStyle, // bb9b7a58 ToastAndroid, // 88a8969a - Touchable, // b89b4800 - TouchableHighlight, // 3e3a1105 - TouchableHighlightProps, // 1bc11e98 - TouchableNativeFeedback, // 825bda53 - TouchableNativeFeedbackProps, // 67b5c252 - TouchableOpacity, // 8e7239e1 - TouchableOpacityProps, // 55ea2958 - TouchableWithoutFeedback, // efa59486 - TouchableWithoutFeedbackProps, // be530043 + Touchable, // da3239ee + TouchableHighlight, // 9d67503a + TouchableHighlightProps, // b2aa6f4b + TouchableNativeFeedback, // 2ed83cf4 + TouchableNativeFeedbackProps, // 1209959b + TouchableOpacity, // fcbaef78 + TouchableOpacityProps, // 13fbd043 + TouchableWithoutFeedback, // 39070327 + TouchableWithoutFeedbackProps, // b847de29 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 UIManager, // a1a7cc01 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // 04ab9769 - ViewProps, // dcb89dca - ViewPropsAndroid, // 21385d96 + View, // 02678ca8 + ViewProps, // 15e5f6b9 + ViewPropsAndroid, // bdfc84a1 ViewPropsIOS, // 58ee19bf ViewStyle, // 00a0f8fb VirtualViewMode, // 6be59722 VirtualizedList, // 68c7345e - VirtualizedListProps, // a9937e8e + VirtualizedListProps, // c7e8e7d7 VirtualizedSectionList, // 9fd9cd61 - VirtualizedSectionListProps, // e66af841 + VirtualizedSectionListProps, // 53a7e6a4 WrapperComponentProvider, // 9cf3844c codegenNativeCommands, // 628a7c0a - codegenNativeComponent, // a733b8b6 + codegenNativeComponent, // 2baac257 findNodeHandle, // 65981202 processColor, // 6e877698 registerCallableModule, // 839c8cfe - requireNativeComponent, // 72c09c3d + requireNativeComponent, // 7f7f105a useAnimatedColor, // e3511f81 useAnimatedValue, // b18adb63 useAnimatedValueXY, // c7ee2332 useColorScheme, // c216d6f7 - usePressability, // fe1f27d8 + usePressability, // b4e21b46 useWindowDimensions, // bb4b683f } diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 383b0da713aa..4a1ea7a74db7 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1043,6 +1043,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableNativeEventTargetEventDispatching: { + defaultValue: false, + metadata: { + dateAdded: '2026-04-13', + description: + 'When enabled, the React Native renderer dispatches events through the W3C EventTarget API (addEventListener/dispatchEvent) instead of the legacy plugin-based system.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, externalElementInspectionEnabled: { defaultValue: true, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index c6e47cced107..c225edc521d6 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<<1ec2592998e830300fc777070dfdc49d>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -33,6 +33,7 @@ export type ReactNativeFeatureFlagsJsOnly = $ReadOnly<{ animatedShouldUseSingleOp: Getter, deferFlatListFocusChangeRenderUpdate: Getter, disableMaintainVisibleContentPosition: Getter, + enableNativeEventTargetEventDispatching: Getter, externalElementInspectionEnabled: Getter, fixVirtualizeListCollapseWindowSize: Getter, isLayoutAnimationEnabled: Getter, @@ -162,6 +163,11 @@ export const deferFlatListFocusChangeRenderUpdate: Getter = createJavaS */ export const disableMaintainVisibleContentPosition: Getter = createJavaScriptFlagGetter('disableMaintainVisibleContentPosition', false); +/** + * When enabled, the React Native renderer dispatches events through the W3C EventTarget API (addEventListener/dispatchEvent) instead of the legacy plugin-based system. + */ +export const enableNativeEventTargetEventDispatching: Getter = createJavaScriptFlagGetter('enableNativeEventTargetEventDispatching', false); + /** * Enable the external inspection API for DevTools to communicate with the Inspector overlay. */ 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 new file mode 100644 index 000000000000..94a87f2c26b1 --- /dev/null +++ b/packages/react-native/src/private/renderer/core/__tests__/EventDispatching-benchmark-itest.js @@ -0,0 +1,265 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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:* + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {View} from 'react-native'; + +let root: ReturnType; +let ref: {current: React.ElementRef | null}; + +function createNestedViews( + depth: number, + innerRef: {current: React.ElementRef | null}, +): React.MixedElement { + if (depth === 0) { + return ( + {}} + style={{width: 10, height: 10}} + /> + ); + } + return ( + {}}> + {createNestedViews(depth - 1, innerRef)} + + ); +} + +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(); + }, + }, + ) + .test( + 'dispatch event, nested 10 deep (bubbling)', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + beforeAll: () => { + ref = React.createRef(); + }, + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViews(10, ref)); + }); + }, + 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(); + }, + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render(createNestedViews(50, ref)); + }); + }, + 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(); + }, + 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(); + }, + }, + ) + .test( + 'dispatch event with stopPropagation, nested 10 deep', + () => { + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + }, + { + 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} + + ); + } + 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(); + }, + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {}} + style={{width: 10, height: 10}} + />, + ); + }); + }, + afterEach: () => { + root.destroy(); + }, + }, + ); +} diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js new file mode 100644 index 000000000000..2090d058ccae --- /dev/null +++ b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js @@ -0,0 +1,1152 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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:* + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import type { + NativePointerEvent, + PointerEvent, +} from 'react-native/Libraries/Types/CoreEventTypes'; +import type {ReadOnlyNodeWithEventTarget} from 'react-native/src/private/webapis/dom/nodes/ReadOnlyNode'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {View} from 'react-native'; +import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags'; + +// Temporary cast until ReadOnlyNode extends EventTarget ungated. +function asEventTarget(node: ?interface {}): ReadOnlyNodeWithEventTarget { + if (node == null) { + throw new Error('Expected non-null node'); + } + // $FlowFixMe[incompatible-return] ReadOnlyNode extends EventTarget at runtime + // $FlowFixMe[incompatible-type] + return node; +} + +const {isOSS} = Fantom.getConstants(); + +(isOSS ? describe.skip : describe)( + 'EventTarget-based Event Dispatching', + () => { + it('dispatches basic press event to handler', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onPointerUp = jest.fn(); + + 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 EventTarget-based dispatching to be enabled, + // since addEventListener is only available when the flag is on. + + (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(); + }); + + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(ref.current).addEventListener('pointerup', handler); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('removeEventListener stops receiving events', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const handler = jest.fn(); + + Fantom.runTask(() => { + root.render(); + }); + + // Temporary: ReadOnlyNode extends EventTarget at runtime behind feature flag + asEventTarget(ref.current).addEventListener('pointerup', handler); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + 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('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(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('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) => { + 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( + { + 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( + 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); + }, + ); + + 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, + }, + ); + + // Only the parent capture handler should fire; everything else is stopped + expect(order).toEqual(['parent-capture']); + }); + + describe('error handling', () => { + let originalConsoleError: typeof console.error; + let mockConsoleError: JestMockFn<$FlowFixMe, $FlowFixMe>; + + beforeEach(() => { + originalConsoleError = console.error; + mockConsoleError = jest.fn(); + // $FlowFixMe[cannot-write] + console.error = mockConsoleError; + }); + + afterEach(() => { + // $FlowFixMe[cannot-write] + console.error = originalConsoleError; + }); + + 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'); + }} + /> + , + ); + }); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + // The error should have been caught and reported + expect(mockConsoleError).toHaveBeenCalled(); + // The parent bubble handler should still fire despite child's error + expect(parentHandler).toHaveBeenCalledTimes(1); + }); + }); + + (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; + }} + />, + ); + }); + + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + {category: Fantom.NativeEventCategory.Discrete}, + ); + + expect(capturedDispatchConfig).not.toBeNull(); + expect(capturedDispatchConfig.phasedRegistrationNames.bubbled).toBe( + 'onPointerUp', + ); + expect(capturedDispatchConfig.phasedRegistrationNames.captured).toBe( + 'onPointerUpCapture', + ); + }); + + 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'); + }); + }); + }, +); 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 new file mode 100644 index 000000000000..ede3410c7b1c --- /dev/null +++ b/packages/react-native/src/private/renderer/core/__tests__/ResponderEventTarget-itest.js @@ -0,0 +1,898 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * 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:* + * @flow strict-local + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +import * as Fantom from '@react-native/fantom'; +import * as React from 'react'; +import {View} from 'react-native'; + +// Helpers for common touch event payloads +function touchStart(identifier: number = 0): { + touches: Array<{...}>, + changedTouches: Array<{...}>, +} { + return { + touches: [{identifier, pageX: 0, pageY: 0, timestamp: 0}], + changedTouches: [{identifier, pageX: 0, pageY: 0, timestamp: 0}], + }; +} + +function touchMove(identifier: number = 0): { + touches: Array<{...}>, + changedTouches: Array<{...}>, +} { + return { + touches: [{identifier, pageX: 10, pageY: 10, timestamp: 100}], + changedTouches: [{identifier, pageX: 10, pageY: 10, timestamp: 100}], + }; +} + +function touchEnd( + identifier: number = 0, + remainingTouches?: Array<{...}>, +): {touches: Array<{...}>, changedTouches: Array<{...}>} { + return { + touches: remainingTouches ?? [], + changedTouches: [{identifier, pageX: 0, pageY: 0, timestamp: 200}], + }; +} + +const {isOSS} = Fantom.getConstants(); + +(isOSS ? describe.skip : describe)('Responder System', () => { + // --- Basic Grant / Release --- + + it('grants responder on touch start when onStartShouldSetResponder returns true', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={onResponderGrant} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderGrant).toHaveBeenCalledTimes(1); + + // Release responder to clean up global state + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('does not grant responder when onStartShouldSetResponder returns false', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + false} + onResponderGrant={onResponderGrant} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderGrant).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('dispatches responder release on touch end', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderGrant = jest.fn(); + const onResponderRelease = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={onResponderGrant} + onResponderRelease={onResponderRelease} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderGrant).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderRelease).toHaveBeenCalledTimes(1); + }); + + it('dispatches responderTerminate on touch cancel', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderTerminate = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => {}} + onResponderTerminate={onResponderTerminate} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + Fantom.dispatchNativeEvent( + ref, + 'onTouchCancel', + { + touches: [], + changedTouches: [{identifier: 0, pageX: 0, pageY: 0, timestamp: 100}], + }, + {category: Fantom.NativeEventCategory.Discrete}, + ); + + expect(onResponderTerminate).toHaveBeenCalledTimes(1); + }); + + // --- Incremental Touch Events --- + + it('dispatches responderMove to the current responder', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderMove = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => {}} + onResponderMove={onResponderMove} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchMove', touchMove(), { + category: Fantom.NativeEventCategory.Continuous, + }); + + expect(onResponderMove).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('dispatches responderStart and responderEnd', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderStart = jest.fn(); + const onResponderEnd = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => {}} + onResponderStart={onResponderStart} + onResponderEnd={onResponderEnd} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // responderStart is dispatched on touch start (after grant on first touch) + expect(onResponderStart).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderEnd).toHaveBeenCalledTimes(1); + }); + + // --- Capture Phase --- + + it('parent can capture responder via onStartShouldSetResponderCapture', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const parentOnResponderGrant = jest.fn(); + const childOnResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={parentOnResponderGrant}> + true} + onResponderGrant={childOnResponderGrant} + /> + , + ); + }); + + Fantom.dispatchNativeEvent(childRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Parent should get the grant because it captures + expect(parentOnResponderGrant).toHaveBeenCalledTimes(1); + expect(childOnResponderGrant).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent(childRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('child wins in bubble phase when parent does not capture', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const parentOnResponderGrant = jest.fn(); + const childOnResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={parentOnResponderGrant}> + true} + onResponderGrant={childOnResponderGrant} + /> + , + ); + }); + + Fantom.dispatchNativeEvent(childRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Child should get the grant (bubble phase, child is first) + expect(childOnResponderGrant).toHaveBeenCalledTimes(1); + expect(parentOnResponderGrant).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent(childRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + // --- Responder Transfer / Negotiation --- + + it('negotiates responder transfer: current responder can refuse termination', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const parentRef = React.createRef>(); + const childOnResponderGrant = jest.fn(); + const childOnResponderTerminate = jest.fn(); + const parentOnResponderGrant = jest.fn(); + const parentOnResponderReject = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={parentOnResponderGrant} + onResponderReject={parentOnResponderReject}> + true} + onResponderGrant={childOnResponderGrant} + onResponderTerminationRequest={() => false} + onResponderTerminate={childOnResponderTerminate} + /> + , + ); + }); + + // Child becomes responder + Fantom.dispatchNativeEvent(childRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + expect(childOnResponderGrant).toHaveBeenCalledTimes(1); + + // Parent tries to take over on move, child refuses + Fantom.dispatchNativeEvent(childRef, 'onTouchMove', touchMove(), { + category: Fantom.NativeEventCategory.Continuous, + }); + + // Child should not be terminated + expect(childOnResponderTerminate).toHaveBeenCalledTimes(0); + // Parent gets rejected + expect(parentOnResponderReject).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(childRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('negotiates responder transfer: current responder allows termination', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const childOnResponderGrant = jest.fn(); + const childOnResponderTerminate = jest.fn(); + const parentOnResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={parentOnResponderGrant}> + true} + onResponderGrant={childOnResponderGrant} + onResponderTerminationRequest={() => true} + onResponderTerminate={childOnResponderTerminate} + /> + , + ); + }); + + // Child becomes responder + Fantom.dispatchNativeEvent(childRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + expect(childOnResponderGrant).toHaveBeenCalledTimes(1); + + // Parent takes over on move, child allows + Fantom.dispatchNativeEvent(childRef, 'onTouchMove', touchMove(), { + category: Fantom.NativeEventCategory.Continuous, + }); + + // Child is terminated, parent gets grant + expect(childOnResponderTerminate).toHaveBeenCalledTimes(1); + expect(parentOnResponderGrant).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(childRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('only ancestors of the current responder can negotiate for responder', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const siblingRef = React.createRef>(); + const childOnResponderGrant = jest.fn(); + const siblingOnResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + + true} + onResponderGrant={childOnResponderGrant} + onResponderTerminationRequest={() => true} + /> + true} + onResponderGrant={siblingOnResponderGrant} + /> + , + ); + }); + + // Child becomes responder + Fantom.dispatchNativeEvent(childRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + expect(childOnResponderGrant).toHaveBeenCalledTimes(1); + + // Sibling tries to become responder via a new touch — but since it's + // not an ancestor of the current responder, negotiation starts from the + // LCA (the parent View), not the sibling itself. + // The sibling is not in the ancestor path, so it cannot claim. + Fantom.dispatchNativeEvent(siblingRef, 'onTouchStart', touchStart(1), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Sibling should NOT get the grant + expect(siblingOnResponderGrant).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent(childRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + Fantom.dispatchNativeEvent(siblingRef, 'onTouchEnd', touchEnd(1), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + // --- Responder Event touchHistory --- + + it('responder event has touchHistory property', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let hasTouchHistory = false; + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={(e: $FlowFixMe) => { + hasTouchHistory = e.touchHistory != null; + }} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(hasTouchHistory).toBe(true); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + // --- Move negotiation --- + + it('grants responder via onMoveShouldSetResponder during touch move', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={onResponderGrant} + />, + ); + }); + + // Touch start — no grant because onStartShouldSetResponder is not set + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + expect(onResponderGrant).toHaveBeenCalledTimes(0); + + // Touch move triggers onMoveShouldSetResponder + Fantom.dispatchNativeEvent(ref, 'onTouchMove', touchMove(), { + category: Fantom.NativeEventCategory.Continuous, + }); + expect(onResponderGrant).toHaveBeenCalledTimes(1); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('move events go to new responder after transfer', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + const childOnResponderMove = jest.fn(); + const parentOnResponderMove = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => {}} + onResponderMove={parentOnResponderMove}> + true} + onResponderGrant={() => {}} + onResponderTerminationRequest={() => true} + onResponderMove={childOnResponderMove} + /> + , + ); + }); + + // Child becomes responder + Fantom.dispatchNativeEvent(childRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // First move — triggers transfer to parent + Fantom.dispatchNativeEvent(childRef, 'onTouchMove', touchMove(), { + category: Fantom.NativeEventCategory.Continuous, + }); + + // Move event should go to the parent (the new responder after transfer) + expect(parentOnResponderMove).toHaveBeenCalledTimes(1); + // Child should not get the move (it was terminated) + expect(childOnResponderMove).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent(childRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('onResponderGrant returning true does not break responder lifecycle', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderRelease = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => true} + onResponderRelease={onResponderRelease} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Responder lifecycle should work normally despite grant returning true + expect(onResponderRelease).toHaveBeenCalledTimes(1); + }); + + it('responder events and EventTarget events fire independently', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const order: Array = []; + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => { + order.push('responderGrant'); + }} + onPointerUp={() => { + order.push('pointerUp'); + }} + />, + ); + }); + + // Touch start triggers both responder grant and pointer event + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Responder grant should have fired + expect(order).toContain('responderGrant'); + + // Now dispatch a pointer event separately + Fantom.dispatchNativeEvent( + ref, + 'onPointerUp', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + // Pointer event should fire independently + expect(order).toContain('pointerUp'); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + // --- event.target --- + + it('responder grant event has target set to the originally touched element', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + let grantTarget: $FlowFixMe = null; + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={(e: $FlowFixMe) => { + grantTarget = e.target; + }}> + + , + ); + }); + + Fantom.dispatchNativeEvent(childRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // target should be the child element (the originally touched element) + expect(grantTarget).not.toBeNull(); + expect(grantTarget).toBe(childRef.current); + + Fantom.dispatchNativeEvent(childRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + // --- dispatchConfig --- + + it('responder grant event has dispatchConfig.registrationName', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let capturedDispatchConfig: $FlowFixMe = null; + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={(e: $FlowFixMe) => { + capturedDispatchConfig = e.dispatchConfig; + }} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(capturedDispatchConfig).not.toBeNull(); + expect(capturedDispatchConfig.registrationName).toBe('onResponderGrant'); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('responder terminate event has dispatchConfig.registrationName', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + let capturedDispatchConfig: $FlowFixMe = null; + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => {}} + onResponderTerminate={(e: $FlowFixMe) => { + capturedDispatchConfig = e.dispatchConfig; + }} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + Fantom.dispatchNativeEvent( + ref, + 'onTouchCancel', + { + touches: [], + changedTouches: [{identifier: 0, pageX: 0, pageY: 0, timestamp: 100}], + }, + {category: Fantom.NativeEventCategory.Discrete}, + ); + + expect(capturedDispatchConfig).not.toBeNull(); + expect(capturedDispatchConfig.registrationName).toBe( + 'onResponderTerminate', + ); + }); + + // --- Error Handling --- + // When a handler throws, the error is caught per-handler so remaining + // dispatches and state transitions continue. The first error is rethrown + // after all dispatching completes. This matches the old system's behavior. + + it('error in onStartShouldSetResponder prevents responder grant', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + { + throw new Error('shouldSet error'); + }} + onResponderGrant={onResponderGrant} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Grant should not have been called since the negotiation threw + expect(onResponderGrant).toHaveBeenCalledTimes(0); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('error in onResponderGrant does not crash the system', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => { + throw new Error('grant error'); + }} + />, + ); + }); + + // Error in onResponderGrant is caught per-handler. + // The system should not crash. + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('error in onResponderMove does not release responder', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderRelease = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={() => {}} + onResponderMove={() => { + throw new Error('move error'); + }} + onResponderRelease={onResponderRelease} + />, + ); + }); + + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Error in move is caught, responder remains active + Fantom.dispatchNativeEvent(ref, 'onTouchMove', touchMove(), { + category: Fantom.NativeEventCategory.Continuous, + }); + + // Responder should still be active — release fires on touch end + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderRelease).toHaveBeenCalledTimes(1); + }); + + it('error in onResponderRelease still clears the responder', () => { + const root = Fantom.createRoot(); + const ref = React.createRef>(); + const onResponderGrant = jest.fn(); + + Fantom.runTask(() => { + root.render( + true} + onResponderGrant={onResponderGrant} + onResponderRelease={() => { + throw new Error('release error'); + }} + />, + ); + }); + + // First touch — release throws but error is caught per-handler. + // changeResponder(null) still runs, so the responder is cleared. + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Second touch — responder was cleared, so grant fires again + Fantom.dispatchNativeEvent(ref, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + expect(onResponderGrant).toHaveBeenCalledTimes(2); + + Fantom.dispatchNativeEvent(ref, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); + + it('error in onResponderTerminationRequest does not crash the system', () => { + const root = Fantom.createRoot(); + const childRef = React.createRef>(); + + Fantom.runTask(() => { + root.render( + true} onResponderGrant={() => {}}> + true} + onResponderGrant={() => {}} + onResponderTerminationRequest={() => { + throw new Error('terminationRequest error'); + }} + /> + , + ); + }); + + // Child becomes responder + Fantom.dispatchNativeEvent(childRef, 'onTouchStart', touchStart(), { + category: Fantom.NativeEventCategory.Discrete, + }); + + // Parent tries to take over on move. terminationRequest throws. + // The system should not crash. + Fantom.dispatchNativeEvent(childRef, 'onTouchMove', touchMove(), { + category: Fantom.NativeEventCategory.Continuous, + }); + + Fantom.dispatchNativeEvent(childRef, 'onTouchEnd', touchEnd(), { + category: Fantom.NativeEventCategory.Discrete, + }); + }); +}); diff --git a/packages/react-native/src/private/renderer/events/LegacySyntheticEvent.js b/packages/react-native/src/private/renderer/events/LegacySyntheticEvent.js new file mode 100644 index 000000000000..5dfbcc268827 --- /dev/null +++ b/packages/react-native/src/private/renderer/events/LegacySyntheticEvent.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +import Event, {type EventInit} from '../../webapis/dom/events/Event'; + +// flowlint unsafe-getters-setters:off + +export type DispatchConfig = + | Readonly<{registrationName: string, dependencies?: ReadonlyArray}> + | Readonly<{ + phasedRegistrationNames: Readonly<{ + bubbled: string, + captured: string, + skipBubbling?: ?boolean, + }>, + dependencies?: ReadonlyArray, + }>; + +/** + * A bridge event class that extends the W3C Event interface and carries + * the native event payload. This is used as a compatibility layer during + * the migration from the legacy SyntheticEvent system to EventTarget-based + * dispatching. + */ +export default class LegacySyntheticEvent extends Event { + _nativeEvent: {[string]: unknown}; + _propagationStopped: boolean; + _dispatchConfig: DispatchConfig | null; + + constructor( + type: string, + options: EventInit, + nativeEvent: {[string]: unknown}, + dispatchConfig?: ?DispatchConfig, + ) { + super(type, options); + this._nativeEvent = nativeEvent; + this._propagationStopped = false; + this._dispatchConfig = dispatchConfig ?? null; + } + + get nativeEvent(): {[string]: unknown} { + return this._nativeEvent; + } + + get dispatchConfig(): DispatchConfig | null { + return this._dispatchConfig; + } + + stopPropagation(): void { + super.stopPropagation(); + this._propagationStopped = true; + } + + stopImmediatePropagation(): void { + super.stopImmediatePropagation(); + this._propagationStopped = true; + } + + /** + * No-op for backward compatibility. The legacy SyntheticEvent system + * used pooling which required calling persist() to keep the event. + * With EventTarget-based dispatching, events are never pooled. + */ + persist(): void { + // No-op + } + + /** + * Backward-compatible wrapper for `defaultPrevented`. + */ + isDefaultPrevented(): boolean { + return this.defaultPrevented; + } + + /** + * Backward-compatible wrapper. Returns true if stopPropagation() + * has been called. + */ + isPropagationStopped(): boolean { + return this._propagationStopped; + } +} diff --git a/packages/react-native/src/private/renderer/events/ReactNativeEventTypeMapping.js b/packages/react-native/src/private/renderer/events/ReactNativeEventTypeMapping.js new file mode 100644 index 000000000000..21ff655ea69b --- /dev/null +++ b/packages/react-native/src/private/renderer/events/ReactNativeEventTypeMapping.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +/** + * Maps between React Native event naming conventions: + * + * - topLevelType: "topPointerUp" (native event pipeline from C++) + * - eventType: "pointerup" (EventTarget / addEventListener) + * - propName: "onPointerUp" / "onPointerUpCapture" (React props) + * + * Also provides a reverse mapping from EventTarget event types to React prop + * names, built lazily from the view config registry. + */ + +import { + customBubblingEventTypes, + customDirectEventTypes, +} from '../../../../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; + +type EventPropNames = { + bubbled: string | null, + captured: string | null, +}; + +// Cache of already-resolved event types. +const eventTypeToProps: {[string]: EventPropNames} = {}; + +/** + * Converts a topLevelType (e.g., "topPointerUp") to a DOM event type + * (e.g., "pointerup"). Strips the "top" prefix and lowercases the result. + */ +export function topLevelTypeToEventType(topLevelType: string): string { + const fourthChar = topLevelType.charCodeAt(3); + if ( + topLevelType.startsWith('top') && + fourthChar >= 65 /* A */ && + fourthChar <= 90 /* Z */ + ) { + return topLevelType.slice(3).toLowerCase(); + } + return topLevelType; +} + +function findEventPropNames(eventType: string): EventPropNames | null { + for (const topLevelType in customBubblingEventTypes) { + if (topLevelTypeToEventType(topLevelType) === eventType) { + const config = customBubblingEventTypes[topLevelType]; + const phasedRegistrationNames = config.phasedRegistrationNames; + if (phasedRegistrationNames != null) { + return { + bubbled: phasedRegistrationNames.bubbled ?? null, + captured: phasedRegistrationNames.captured ?? null, + }; + } + } + } + + for (const topLevelType in customDirectEventTypes) { + if (topLevelTypeToEventType(topLevelType) === eventType) { + const config = customDirectEventTypes[topLevelType]; + if (config.registrationName != null) { + return { + bubbled: config.registrationName, + captured: null, + }; + } + } + } + + return null; +} + +/** + * Returns the React prop name for a given EventTarget event type and phase. + * + * For example: + * getEventTypePropName("pointerup", false) → "onPointerUp" + * getEventTypePropName("pointerup", true) → "onPointerUpCapture" + * getEventTypePropName("layout", false) → "onLayout" (direct event) + * getEventTypePropName("layout", true) → null (direct events have no capture) + */ +export function getEventTypePropName( + eventType: string, + isCapture: boolean, +): string | null { + const cached = eventTypeToProps[eventType]; + if (cached !== undefined) { + return isCapture ? cached.captured : cached.bubbled; + } + const entry = findEventPropNames(eventType); + if (entry != null) { + eventTypeToProps[eventType] = entry; + return isCapture ? entry.captured : entry.bubbled; + } + return null; +} diff --git a/packages/react-native/src/private/renderer/events/ReactNativeResponder.js b/packages/react-native/src/private/renderer/events/ReactNativeResponder.js new file mode 100644 index 000000000000..da29336547c2 --- /dev/null +++ b/packages/react-native/src/private/renderer/events/ReactNativeResponder.js @@ -0,0 +1,687 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type EventTarget from '../../webapis/dom/events/EventTarget'; +import type {ReadOnlyNodeWithEventTarget} from '../../webapis/dom/nodes/ReadOnlyNode'; +import type {DispatchConfig} from './LegacySyntheticEvent'; +import type {TouchEvent} from './ResponderTouchHistoryStore'; + +import {getFabricUIManager} from '../../../../Libraries/ReactNative/FabricUIManager'; +import { + setCurrentTarget, + setTarget, +} from '../../webapis/dom/events/internals/EventInternals'; +import { + getCurrentProps, + getNativeElementReference, +} from '../../webapis/dom/nodes/internals/NodeInternals'; +import ReadOnlyElement from '../../webapis/dom/nodes/ReadOnlyElement'; +import ResponderEvent from './ResponderEvent'; +import ResponderTouchHistoryStore from './ResponderTouchHistoryStore'; + +/** + * This module is a re-implementation of the responder system from the + * React Native renderer in React: + * https://github.com/facebook/react/blob/00f063c31d60308f8e4e0fd349b89ed043b9ea54/packages/react-native-renderer/src/legacy-events/ResponderEventPlugin.js + */ + +/** + * Extract a responder event handler from props by name. + * Props are typed as {[string]: unknown} because they come from the + * reconciler; the typeof check ensures we only return actual functions. + */ +function getHandler( + node: ReadOnlyElement, + propName: string, +): ((event: ResponderEvent) => unknown) | void { + const handler = getCurrentProps(node)[propName]; + if (typeof handler === 'function') { + // $FlowFixMe[incompatible-use] props values are unknown + const typedHandler: (event: ResponderEvent) => unknown = handler; + return typedHandler; + } + return undefined; +} + +// Temporary cast until ReadOnlyNode extends EventTarget ungated. +function asEventTarget(node: ReadOnlyElement): ReadOnlyNodeWithEventTarget { + // $FlowFixMe[incompatible-type] + const eventTarget: ReadOnlyNodeWithEventTarget = node; + return eventTarget; +} + +// The currently active responder (tracked as a public instance) +let responderNode: ReadOnlyElement | null = null; + +/** + * Count of current touches. A textInput should become responder iff the + * selection changes while there is a touch on the screen. + */ +let trackedTouchCount = 0; + +function isStartish(topLevelType: string): boolean { + return topLevelType === 'topTouchStart'; +} + +function isMoveish(topLevelType: string): boolean { + return topLevelType === 'topTouchMove'; +} + +function isEndish(topLevelType: string): boolean { + return topLevelType === 'topTouchEnd' || topLevelType === 'topTouchCancel'; +} + +/** + * Return the lowest common ancestor of A and B, or null if they are in + * different trees. + */ +function getLowestCommonAncestor( + instA: ReadOnlyElement, + instB: ReadOnlyElement, +): ReadOnlyElement | null { + // Fast paths using contains (backed by native compareDocumentPosition) + if (instA.contains(instB)) { + return instA; + } + if (instB.contains(instA)) { + return instB; + } + + // Walk up from A until we find an ancestor that contains B + let current: ?ReadOnlyElement = instA.parentElement; + while (current != null) { + if (current.contains(instB)) { + return current; + } + current = current.parentElement; + } + + return null; +} + +function changeResponder( + nextNode: ReadOnlyElement | null, + blockNativeResponder: boolean, +): void { + const oldNode = responderNode; + responderNode = nextNode; + + const uiManager = getFabricUIManager(); + if (oldNode != null) { + const shadowNode = getNativeElementReference(oldNode); + if (shadowNode != null) { + uiManager?.setIsJSResponder(shadowNode, false, blockNativeResponder); + } + } + if (nextNode != null) { + const shadowNode = getNativeElementReference(nextNode); + if (shadowNode != null) { + uiManager?.setIsJSResponder(shadowNode, true, blockNativeResponder); + } + } +} + +/** + * Determine the negotiation event name for a given topLevelType. + */ +function getShouldSetEventName(topLevelType: string): string { + if (isStartish(topLevelType)) { + return 'startShouldSetResponder'; + } else if (isMoveish(topLevelType)) { + return 'moveShouldSetResponder'; + } else if (topLevelType === 'topSelectionChange') { + return 'selectionChangeShouldSetResponder'; + } else { + return 'scrollShouldSetResponder'; + } +} + +const startDependencies = ['topTouchStart']; +const moveDependencies = ['topTouchMove']; +const endDependencies = ['topTouchCancel', 'topTouchEnd']; + +const responderEventTypes: {[string]: DispatchConfig} = { + /** + * On a `touchStart`/`mouseDown`, is it desired that this element become the + * responder? + */ + startShouldSetResponder: { + phasedRegistrationNames: { + bubbled: 'onStartShouldSetResponder', + captured: 'onStartShouldSetResponderCapture', + }, + dependencies: startDependencies, + }, + + /** + * On a `scroll`, is it desired that this element become the responder? This + * is usually not needed, but should be used to retroactively infer that a + * `touchStart` had occurred during momentum scroll. During a momentum scroll, + * a touch start will be immediately followed by a scroll event if the view is + * currently scrolling. + */ + scrollShouldSetResponder: { + phasedRegistrationNames: { + bubbled: 'onScrollShouldSetResponder', + captured: 'onScrollShouldSetResponderCapture', + }, + dependencies: ['topScroll'], + }, + + /** + * On text selection change, should this element become the responder? This + * is needed for text inputs or other views with native selection, so the + * JS view can claim the responder. + */ + selectionChangeShouldSetResponder: { + phasedRegistrationNames: { + bubbled: 'onSelectionChangeShouldSetResponder', + captured: 'onSelectionChangeShouldSetResponderCapture', + }, + dependencies: ['topSelectionChange'], + }, + + /** + * On a `touchMove`/`mouseMove`, is it desired that this element become the + * responder? + */ + moveShouldSetResponder: { + phasedRegistrationNames: { + bubbled: 'onMoveShouldSetResponder', + captured: 'onMoveShouldSetResponderCapture', + }, + dependencies: moveDependencies, + }, + + /** + * Direct responder events dispatched directly to responder. Do not bubble. + */ + responderStart: { + registrationName: 'onResponderStart', + dependencies: startDependencies, + }, + responderMove: { + registrationName: 'onResponderMove', + dependencies: moveDependencies, + }, + responderEnd: { + registrationName: 'onResponderEnd', + dependencies: endDependencies, + }, + responderRelease: { + registrationName: 'onResponderRelease', + dependencies: endDependencies, + }, + responderTerminationRequest: { + registrationName: 'onResponderTerminationRequest', + dependencies: [], + }, + responderGrant: { + registrationName: 'onResponderGrant', + dependencies: [], + }, + responderReject: { + registrationName: 'onResponderReject', + dependencies: [], + }, + responderTerminate: { + registrationName: 'onResponderTerminate', + dependencies: [], + }, +}; + +/** + * Run negotiation by walking the public instance tree. Performs capture phase + * (root→target) then bubble phase (target→root), calling handlers from + * `getCurrentProps(node)`. The first handler that returns `true` wins. + */ +function negotiateResponder( + target: ReadOnlyElement, + topLevelType: string, + nativeEvent: {[string]: unknown}, +): ReadOnlyElement | null { + const shouldSetEventName = getShouldSetEventName(topLevelType); + + // Determine the negotiation dispatch target + let negotiationNode: ReadOnlyElement | null; + let skipSelf = false; + if (responderNode == null) { + negotiationNode = target; + } else { + negotiationNode = getLowestCommonAncestor(responderNode, target); + if (negotiationNode == null) { + return null; + } + if (negotiationNode === responderNode) { + skipSelf = true; + } + } + + const dispatchNode: ReadOnlyElement | null = skipSelf + ? negotiationNode.parentElement + : negotiationNode; + if (dispatchNode == null) { + return null; + } + + // Build ancestor path (root to dispatch node) + const path: Array = []; + let node: ?ReadOnlyElement = dispatchNode; + while (node != null) { + path.unshift(node); + node = node.parentElement; + } + + const dispatchConfig = responderEventTypes[shouldSetEventName]; + const event = new ResponderEvent( + shouldSetEventName, + {bubbles: true, cancelable: true}, + nativeEvent, + dispatchConfig, + ResponderTouchHistoryStore.touchHistory, + ); + setTarget(event, asEventTarget(target)); + + // Use prop names from the dispatch config + const {phasedRegistrationNames} = dispatchConfig; + if (phasedRegistrationNames == null) { + return null; + } + const bubblePropName = phasedRegistrationNames.bubbled; + const capturePropName = phasedRegistrationNames.captured; + + // Capture phase: root → target + for (let i = 0; i < path.length; i++) { + const currentNode = path[i]; + const handler = getHandler(currentNode, capturePropName); + if (handler != null) { + setCurrentTarget(event, asEventTarget(currentNode)); + if (handler(event) === true) { + setCurrentTarget(event, null); + return currentNode; + } + } + } + + // Bubble phase: target → root + for (let i = path.length - 1; i >= 0; i--) { + const currentNode = path[i]; + const handler = getHandler(currentNode, bubblePropName); + if (handler != null) { + setCurrentTarget(event, asEventTarget(currentNode)); + if (handler(event) === true) { + setCurrentTarget(event, null); + return currentNode; + } + } + } + + setCurrentTarget(event, null); + return null; +} + +/** + * Tracks the first error thrown by a lifecycle handler during dispatch. + * Matches the old system's catch-and-rethrow pattern: all handlers run to + * completion even if one throws, then the first error is rethrown. + */ +let _caughtError: unknown = null; +let _hasError: boolean = false; + +export function rethrowCaughtError(): void { + if (_hasError) { + const error = _caughtError; + _hasError = false; + _caughtError = null; + throw error; + } +} + +/** + * Dispatch a lifecycle responder event by calling the handler directly from + * props. Sets `currentTarget` before calling the handler (fixes the bug where + * Pressability's `_responderID` was null). Returns the handler's return value + * so callers can inspect it (e.g. `onResponderGrant` returning `true` blocks + * native). Errors are caught per-handler so remaining dispatches continue. + */ +function dispatchResponderEvent( + node: ReadOnlyElement, + eventName: string, + nativeEvent: {[string]: unknown}, + eventTarget: ReadOnlyElement | null, +): unknown { + const dispatchConfig = responderEventTypes[eventName]; + const {registrationName} = dispatchConfig; + if (registrationName == null) { + return undefined; + } + const handler = getHandler(node, registrationName); + if (handler == null) { + return undefined; + } + + const event = new ResponderEvent( + eventName, + {bubbles: false, cancelable: true}, + nativeEvent, + dispatchConfig, + ResponderTouchHistoryStore.touchHistory, + ); + + setTarget(event, eventTarget != null ? asEventTarget(eventTarget) : null); + setCurrentTarget(event, asEventTarget(node)); + let result: unknown; + try { + result = handler(event); + } catch (error) { + if (!_hasError) { + _hasError = true; + _caughtError = error; + } + } + setCurrentTarget(event, null); + + return result; +} + +/** + * A transfer is a negotiation between a currently set responder and the next + * element to claim responder status. + */ +function canTriggerTransfer( + topLevelType: string, + target: ReadOnlyElement | null, + nativeEvent: {[string]: unknown}, +): boolean { + return ( + target != null && + ((topLevelType === 'topScroll' && + nativeEvent.responderIgnoreScroll !== true) || + (trackedTouchCount > 0 && topLevelType === 'topSelectionChange') || + isStartish(topLevelType) || + isMoveish(topLevelType)) + ); +} + +/** + * Returns whether or not this touch end event makes it such that there are no + * longer any touches that started inside of descendants of the current + * responder. + */ +function noResponderTouches(nativeEvent: {[string]: unknown}): boolean { + const touches = nativeEvent.touches; + return !Array.isArray(touches) || touches.length === 0; +} + +/** + * + * Responder System: + * ---------------- + * + * - A global, solitary "interaction lock" on a view. + * - If a node becomes the responder, it should convey visual feedback + * immediately to indicate so, either by highlighting or moving accordingly. + * - To be the responder means, that touches are exclusively important to that + * responder view, and no other view. + * - While touches are still occurring, the responder lock can be transferred to + * a new view, but only to increasingly "higher" views (meaning ancestors of + * the current responder). + * + * Responder being granted: + * ------------------------ + * + * - Touch starts, moves, and scrolls can cause an ID to become the responder. + * - We capture/bubble `startShouldSetResponder`/`moveShouldSetResponder` to + * the "appropriate place". + * - If nothing is currently the responder, the "appropriate place" is the + * initiating event's `targetID`. + * - If something *is* already the responder, the "appropriate place" is the + * first common ancestor of the event target and the current `responderInst`. + * - Some negotiation happens: See the timing diagram below. + * - Scrolled views automatically become responder. The reasoning is that a + * platform scroll view that isn't built on top of the responder system has + * began scrolling, and the active responder must now be notified that the + * interaction is no longer locked to it - the system has taken over. + * + * - Responder being released: + * As soon as no more touches that *started* inside of descendants of the + * *current* responderInst, an `onResponderRelease` event is dispatched to the + * current responder, and the responder lock is released. + * + * TODO: + * - on "end", a callback hook for `onResponderEndShouldRemainResponder` that + * determines if the responder lock should remain. + * - If a view shouldn't "remain" the responder, any active touches should by + * default be considered "dead" and do not influence future negotiations or + * bubble paths. It should be as if those touches do not exist. + * -- For multitouch: Usually a translate-z will choose to "remain" responder + * after one out of many touches ended. For translate-y, usually the view + * doesn't wish to "remain" responder after one of many touches end. + * - Consider building this on top of a `stopPropagation` model similar to + * `W3C` events. + * - Ensure that `onResponderTerminate` is called on touch cancels, whether or + * not `onResponderTerminationRequest` returns `true` or `false`. + * + */ + +/* Negotiation Performed + +-----------------------+ + / \ +Process low level events to + Current Responder + wantsResponderID +determine who to perform negot-| (if any exists at all) | +iation/transition | Otherwise just pass through| +-------------------------------+----------------------------+------------------+ +Bubble to find first ID | | +to return true:wantsResponderID| | + | | + +-------------+ | | + | onTouchStart| | | + +------+------+ none | | + | return| | ++-----------v-------------+true| +------------------------+ | +|onStartShouldSetResponder|----->|onResponderStart (cur) |<-----------+ ++-----------+-------------+ | +------------------------+ | | + | | | +--------+-------+ + | returned true for| false:REJECT +-------->|onResponderReject + | wantsResponderID | | | +----------------+ + | (now attempt | +------------------+-----+ | + | handoff) | | onResponder | | + +------------------->| TerminationRequest| | + | +------------------+-----+ | + | | | +----------------+ + | true:GRANT +-------->|onResponderGrant| + | | +--------+-------+ + | +------------------------+ | | + | | onResponderTerminate |<-----------+ + | +------------------+-----+ | + | | | +----------------+ + | +-------->|onResponderStart| + | | +----------------+ +Bubble to find first ID | | +to return true:wantsResponderID| | + | | + +-------------+ | | + | onTouchMove | | | + +------+------+ none | | + | return| | ++-----------v-------------+true| +------------------------+ | +|onMoveShouldSetResponder |----->|onResponderMove (cur) |<-----------+ ++-----------+-------------+ | +------------------------+ | | + | | | +--------+-------+ + | returned true for| false:REJECT +-------->|onResponderRejec| + | wantsResponderID | | | +----------------+ + | (now attempt | +------------------+-----+ | + | handoff) | | onResponder | | + +------------------->| TerminationRequest| | + | +------------------+-----+ | + | | | +----------------+ + | true:GRANT +-------->|onResponderGrant| + | | +--------+-------+ + | +------------------------+ | | + | | onResponderTerminate |<-----------+ + | +------------------+-----+ | + | | | +----------------+ + | +-------->|onResponderMove | + | | +----------------+ + | | + | | + Some active touch started| | + inside current responder | +------------------------+ | + +------------------------->| onResponderEnd | | + | | +------------------------+ | + +---+---------+ | | + | onTouchEnd | | | + +---+---------+ | | + | | +------------------------+ | + +------------------------->| onResponderEnd | | + No active touches started| +-----------+------------+ | + inside current responder | | | + | v | + | +------------------------+ | + | | onResponderRelease | | + | +------------------------+ | + | | + + + */ + +/** + * Process a native event through the responder system. + */ +export function processResponderEvent( + topLevelType: string, + eventTarget: EventTarget | null, + nativeEvent: {[string]: unknown}, +): void { + // Track touch count + if (isStartish(topLevelType)) { + trackedTouchCount += 1; + } else if (isEndish(topLevelType)) { + if (trackedTouchCount >= 0) { + trackedTouchCount -= 1; + } else { + if (__DEV__) { + console.warn( + 'Ended a touch event which was not counted in `trackedTouchCount`.', + ); + } + return; + } + } + + if ( + isStartish(topLevelType) || + isMoveish(topLevelType) || + isEndish(topLevelType) + ) { + // $FlowFixMe[incompatible-type] nativeEvent has touch fields for touch top-level types + const touchEvent: TouchEvent = nativeEvent; + ResponderTouchHistoryStore.recordTouchTrack(topLevelType, touchEvent); + } + + const target: ReadOnlyElement | null = + eventTarget instanceof ReadOnlyElement ? eventTarget : null; + + // Negotiation: determine if a new responder should be set + if (canTriggerTransfer(topLevelType, target, nativeEvent) && target != null) { + const wantsResponderNode = negotiateResponder( + target, + topLevelType, + nativeEvent, + ); + + if (wantsResponderNode != null && wantsResponderNode !== responderNode) { + // A new view wants to become responder. + // onResponderGrant returning true means block native responder. + const grantResult = dispatchResponderEvent( + wantsResponderNode, + 'responderGrant', + nativeEvent, + target, + ); + const blockNativeResponder = grantResult === true; + + if (responderNode != null) { + const currentResponder = responderNode; + // Ask current responder if it will terminate. + // onResponderTerminationRequest returning false means refuse. + const terminationResult = dispatchResponderEvent( + currentResponder, + 'responderTerminationRequest', + nativeEvent, + target, + ); + const shouldSwitch = terminationResult !== false; + + if (shouldSwitch) { + dispatchResponderEvent( + currentResponder, + 'responderTerminate', + nativeEvent, + target, + ); + changeResponder(wantsResponderNode, blockNativeResponder); + } else { + dispatchResponderEvent( + wantsResponderNode, + 'responderReject', + nativeEvent, + target, + ); + } + } else { + changeResponder(wantsResponderNode, blockNativeResponder); + } + } + } + + // Dispatch lifecycle events to the active responder + if (responderNode != null) { + const activeResponder = responderNode; + if (isStartish(topLevelType)) { + dispatchResponderEvent( + activeResponder, + 'responderStart', + nativeEvent, + target, + ); + } else if (isMoveish(topLevelType)) { + dispatchResponderEvent( + activeResponder, + 'responderMove', + nativeEvent, + target, + ); + } else if (isEndish(topLevelType)) { + dispatchResponderEvent( + activeResponder, + 'responderEnd', + nativeEvent, + target, + ); + + if (topLevelType === 'topTouchCancel') { + dispatchResponderEvent( + activeResponder, + 'responderTerminate', + nativeEvent, + target, + ); + changeResponder(null, false); + } else if (noResponderTouches(nativeEvent)) { + dispatchResponderEvent( + activeResponder, + 'responderRelease', + nativeEvent, + target, + ); + changeResponder(null, false); + } + } + } +} diff --git a/packages/react-native/src/private/renderer/events/ResponderEvent.js b/packages/react-native/src/private/renderer/events/ResponderEvent.js new file mode 100644 index 000000000000..4253ef658df5 --- /dev/null +++ b/packages/react-native/src/private/renderer/events/ResponderEvent.js @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +import type {EventInit} from '../../webapis/dom/events/Event'; +import type {DispatchConfig} from './LegacySyntheticEvent'; +import type {TouchHistory} from './ResponderTouchHistoryStore'; + +import LegacySyntheticEvent from './LegacySyntheticEvent'; + +// flowlint unsafe-getters-setters:off + +/** + * Event class for responder system events. Extends LegacySyntheticEvent with + * a `touchHistory` field that tracks active touch positions. + */ +export default class ResponderEvent extends LegacySyntheticEvent { + _touchHistory: TouchHistory; + + constructor( + type: string, + options: EventInit, + nativeEvent: {[string]: unknown}, + dispatchConfig: ?DispatchConfig, + touchHistory: TouchHistory, + ) { + super(type, options, nativeEvent, dispatchConfig); + this._touchHistory = touchHistory; + } + + get touchHistory(): TouchHistory { + return this._touchHistory; + } +} diff --git a/packages/react-native/src/private/renderer/events/ResponderTouchHistoryStore.js b/packages/react-native/src/private/renderer/events/ResponderTouchHistoryStore.js new file mode 100644 index 000000000000..c0b4d12fc7f7 --- /dev/null +++ b/packages/react-native/src/private/renderer/events/ResponderTouchHistoryStore.js @@ -0,0 +1,258 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + * @format + */ + +/** + * Tracks the position and time of each active touch by `touch.identifier`. We + * should typically only see IDs in the range of 1-20 because IDs get recycled + * when touches end and start again. + */ +type TouchRecord = { + touchActive: boolean, + startPageX: number, + startPageY: number, + startTimeStamp: number, + currentPageX: number, + currentPageY: number, + currentTimeStamp: number, + previousPageX: number, + previousPageY: number, + previousTimeStamp: number, +}; + +type Touch = { + identifier: ?number, + pageX: number, + pageY: number, + timestamp: number, + ... +}; + +export type TouchEvent = { + changedTouches: Array, + touches: Array, + ... +}; + +function isStartish(topLevelType: string): boolean { + return topLevelType === 'topTouchStart'; +} + +function isMoveish(topLevelType: string): boolean { + return topLevelType === 'topTouchMove'; +} + +function isEndish(topLevelType: string): boolean { + return topLevelType === 'topTouchEnd' || topLevelType === 'topTouchCancel'; +} + +export type TouchHistory = { + touchBank: Array, + numberActiveTouches: number, + indexOfSingleActiveTouch: number, + mostRecentTimeStamp: number, +}; + +const MAX_TOUCH_BANK = 20; +const touchBank: Array = []; +const touchHistory: TouchHistory = { + touchBank, + numberActiveTouches: 0, + // If there is only one active touch, we remember its location. This prevents + // us having to loop through all of the touches all the time in the most + // common case. + indexOfSingleActiveTouch: -1, + mostRecentTimeStamp: 0, +}; + +function timestampForTouch(touch: Touch): number { + // The legacy internal implementation provides "timeStamp", which has been + // renamed to "timestamp". Let both work for now while we iron it out + return (touch as $FlowFixMe).timeStamp || touch.timestamp; +} + +function createTouchRecord(touch: Touch, timestamp: number): TouchRecord { + return { + touchActive: true, + startPageX: touch.pageX, + startPageY: touch.pageY, + startTimeStamp: timestamp, + currentPageX: touch.pageX, + currentPageY: touch.pageY, + currentTimeStamp: timestamp, + previousPageX: touch.pageX, + previousPageY: touch.pageY, + previousTimeStamp: timestamp, + }; +} + +function resetTouchRecord( + touchRecord: TouchRecord, + touch: Touch, + timestamp: number, +): void { + touchRecord.touchActive = true; + touchRecord.startPageX = touch.pageX; + touchRecord.startPageY = touch.pageY; + touchRecord.startTimeStamp = timestamp; + touchRecord.currentPageX = touch.pageX; + touchRecord.currentPageY = touch.pageY; + touchRecord.currentTimeStamp = timestamp; + touchRecord.previousPageX = touch.pageX; + touchRecord.previousPageY = touch.pageY; + touchRecord.previousTimeStamp = timestamp; +} + +function getTouchIdentifier({identifier}: Touch): number { + if (identifier == null) { + throw new Error('Touch object is missing identifier.'); + } + + if (__DEV__) { + if (identifier > MAX_TOUCH_BANK) { + console.error( + 'Touch identifier %s is greater than maximum supported %s which causes ' + + 'performance issues backfilling array locations for all of the indices.', + identifier, + MAX_TOUCH_BANK, + ); + } + } + return identifier; +} + +function recordTouchStart(touch: Touch): void { + const identifier = getTouchIdentifier(touch); + const timestamp = timestampForTouch(touch); + const touchRecord = touchBank[identifier]; + if (touchRecord) { + resetTouchRecord(touchRecord, touch, timestamp); + } else { + touchBank[identifier] = createTouchRecord(touch, timestamp); + } + touchHistory.mostRecentTimeStamp = timestamp; +} + +function recordTouchMove(touch: Touch): void { + const touchRecord = touchBank[getTouchIdentifier(touch)]; + if (touchRecord) { + touchRecord.touchActive = true; + touchRecord.previousPageX = touchRecord.currentPageX; + touchRecord.previousPageY = touchRecord.currentPageY; + touchRecord.previousTimeStamp = touchRecord.currentTimeStamp; + touchRecord.currentPageX = touch.pageX; + touchRecord.currentPageY = touch.pageY; + const timestamp = timestampForTouch(touch); + touchRecord.currentTimeStamp = timestamp; + touchHistory.mostRecentTimeStamp = timestamp; + } else { + if (__DEV__) { + console.warn( + 'Cannot record touch move without a touch start.\n' + + 'Touch Move: %s\n' + + 'Touch Bank: %s', + printTouch(touch), + printTouchBank(), + ); + } + } +} + +function recordTouchEnd(touch: Touch): void { + const touchRecord = touchBank[getTouchIdentifier(touch)]; + if (touchRecord) { + touchRecord.touchActive = false; + touchRecord.previousPageX = touchRecord.currentPageX; + touchRecord.previousPageY = touchRecord.currentPageY; + touchRecord.previousTimeStamp = touchRecord.currentTimeStamp; + touchRecord.currentPageX = touch.pageX; + touchRecord.currentPageY = touch.pageY; + const timestamp = timestampForTouch(touch); + touchRecord.currentTimeStamp = timestamp; + touchHistory.mostRecentTimeStamp = timestamp; + } else { + if (__DEV__) { + console.warn( + 'Cannot record touch end without a touch start.\n' + + 'Touch End: %s\n' + + 'Touch Bank: %s', + printTouch(touch), + printTouchBank(), + ); + } + } +} + +function printTouch(touch: Touch): string { + return JSON.stringify({ + identifier: touch.identifier, + pageX: touch.pageX, + pageY: touch.pageY, + timestamp: timestampForTouch(touch), + }); +} + +function printTouchBank(): string { + let printed = JSON.stringify(touchBank.slice(0, MAX_TOUCH_BANK)); + if (touchBank.length > MAX_TOUCH_BANK) { + printed += ' (original size: ' + touchBank.length + ')'; + } + return printed; +} + +let instrumentationCallback: ?(string, TouchEvent) => void; + +const ResponderTouchHistoryStore = { + /** + * Registers a listener which can be used to instrument every touch event. + */ + instrument(callback: (string, TouchEvent) => void): void { + instrumentationCallback = callback; + }, + + recordTouchTrack(topLevelType: string, nativeEvent: TouchEvent): void { + if (instrumentationCallback != null) { + instrumentationCallback(topLevelType, nativeEvent); + } + + if (isMoveish(topLevelType)) { + nativeEvent.changedTouches.forEach(recordTouchMove); + } else if (isStartish(topLevelType)) { + nativeEvent.changedTouches.forEach(recordTouchStart); + touchHistory.numberActiveTouches = nativeEvent.touches.length; + if (touchHistory.numberActiveTouches === 1) { + touchHistory.indexOfSingleActiveTouch = + // $FlowFixMe[incompatible-type] might be null according to type + nativeEvent.touches[0].identifier; + } + } else if (isEndish(topLevelType)) { + nativeEvent.changedTouches.forEach(recordTouchEnd); + touchHistory.numberActiveTouches = nativeEvent.touches.length; + if (touchHistory.numberActiveTouches === 1) { + for (let i = 0; i < touchBank.length; i++) { + const touchTrackToCheck = touchBank[i]; + if (touchTrackToCheck != null && touchTrackToCheck.touchActive) { + touchHistory.indexOfSingleActiveTouch = i; + break; + } + } + if (__DEV__) { + const activeRecord = touchBank[touchHistory.indexOfSingleActiveTouch]; + if (activeRecord == null || !activeRecord.touchActive) { + console.error('Cannot find single active touch.'); + } + } + } + } + }, + + touchHistory, +}; + +export default ResponderTouchHistoryStore; diff --git a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js new file mode 100644 index 000000000000..41bef5591d3e --- /dev/null +++ b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js @@ -0,0 +1,75 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type EventTarget from '../../webapis/dom/events/EventTarget'; + +import { + customBubblingEventTypes, + customDirectEventTypes, +} from '../../../../Libraries/Renderer/shims/ReactNativeViewConfigRegistry'; +import {setEventInitTimeStamp} from '../../webapis/dom/events/internals/EventInternals'; +import {dispatchTrustedEvent} from '../../webapis/dom/events/internals/EventTargetInternals'; +import LegacySyntheticEvent from './LegacySyntheticEvent'; +import {topLevelTypeToEventType} from './ReactNativeEventTypeMapping'; +import { + processResponderEvent, + rethrowCaughtError, +} from './ReactNativeResponder'; + +/** + * Dispatches a native event through the EventTarget-based dispatch system. + * This handles: + * 1. Responder negotiation (touch handling, grant/release lifecycle) + * 2. Normal event dispatch via dispatchTrustedEvent (capture/bubble phases) + * + * Called from the React renderer's dispatchEvent when + * enableNativeEventTargetEventDispatching is enabled. + */ +export default function dispatchNativeEvent( + target: EventTarget, + type: string, + payload: {[string]: unknown}, +): void { + // Process responder events before normal event dispatch. + processResponderEvent(type, target, payload); + + // Normal EventTarget dispatch + const bubbleConfig = customBubblingEventTypes[type]; + const directConfig = customDirectEventTypes[type]; + const bubbles = bubbleConfig != null; + + // Skip events that are not registered in the view config + if (bubbles || directConfig != null) { + const eventType = topLevelTypeToEventType(type); + const options: {bubbles: boolean, cancelable: boolean} = { + bubbles, + cancelable: true, + }; + + // Preserve the native event timestamp for backwards compatibility. + const nativeTimestamp = payload.timeStamp ?? payload.timestamp; + if (typeof nativeTimestamp === 'number') { + setEventInitTimeStamp(options, nativeTimestamp); + } + + const syntheticEvent = new LegacySyntheticEvent( + eventType, + options, + payload, + bubbleConfig ?? directConfig, + ); + dispatchTrustedEvent(target, syntheticEvent); + } + + // Rethrow the first error caught during responder lifecycle dispatch, + // after all dispatching is complete. This matches the old system's + // runEventsInBatch → rethrowCaughtError pattern. + rethrowCaughtError(); +} diff --git a/packages/react-native/src/private/setup/setUpDOM.js b/packages/react-native/src/private/setup/setUpDOM.js index b24ab3d3f4f7..24f2c295deba 100644 --- a/packages/react-native/src/private/setup/setUpDOM.js +++ b/packages/react-native/src/private/setup/setUpDOM.js @@ -85,4 +85,12 @@ export default function setUpDOM() { 'CustomEvent', () => require('../webapis/dom/events/CustomEvent').default, ); + + // Expose a global function that the React renderer can call to check + // if EventTarget-based event dispatching is enabled. + // We use a global function because we don't have another mechanism to pass + // feature flags from RN to React in OSS (similar to RN$enableMicrotasksInReact + // in setUpTimers.js). + global.RN$isNativeEventTargetEventDispatchingEnabled = () => + require('../featureflags/ReactNativeFeatureFlags').enableNativeEventTargetEventDispatching(); } diff --git a/packages/react-native/src/private/webapis/dom/events/EventTarget.js b/packages/react-native/src/private/webapis/dom/events/EventTarget.js index 72baf0fc1974..81b669bbd315 100644 --- a/packages/react-native/src/private/webapis/dom/events/EventTarget.js +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -15,6 +15,7 @@ import type {EventPhase} from './Event'; +import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags'; import {setPlatformObject} from '../../webidl/PlatformObjects'; import Event from './Event'; import { @@ -30,6 +31,7 @@ import { setTarget, } from './internals/EventInternals'; import { + EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY, EVENT_TARGET_GET_THE_PARENT_KEY, INTERNAL_DISPATCH_METHOD_KEY, } from './internals/EventTargetInternals'; @@ -209,6 +211,21 @@ export default class EventTarget { return !event.defaultPrevented; } + /** + * This a "protected" method to be overridden by a subclass to provide + * an additional event listener extracted from props. + * + * Called during event dispatch before explicitly registered listeners. + * Return a callback to be invoked as an event listener, or null. + */ + // $FlowExpectedError[unsupported-syntax] + [EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY]( + eventType: string, + isCapture: boolean, + ): EventCallback | null { + return null; + } + /** * This a "protected" method to be overridden by a subclass to allow event * propagation. @@ -333,24 +350,53 @@ function invoke( event: Event, eventPhase: EventPhase, ) { - const listenersByType = getListenersForPhase( - eventTarget, - eventPhase === Event.CAPTURING_PHASE, - ); + const isCapture = eventPhase === Event.CAPTURING_PHASE; setCurrentTarget(event, eventTarget); - const maybeListeners = listenersByType?.get(event.type); - if (maybeListeners == null) { - return; - } + // Build the list of listeners to invoke: + // When the flag is enabled, prop-based listeners fire first, then + // explicitly registered addEventListener listeners. + // When disabled, only addEventListener listeners are used (legacy path). + let listeners: Array; + + if (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()) { + // $FlowExpectedError[prop-missing] + const propListener: EventCallback | null = eventTarget[ + EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY + ](event.type, isCapture); - // This is a copy so listeners added during dispatch are NOT executed. - // Note that `maybeListeners.values()` is a live view of the map instead of an - // immutable copy. - const listeners = Array.from(maybeListeners.values()); + const listenersByType = getListenersForPhase(eventTarget, isCapture); + const maybeListeners = listenersByType?.get(event.type); - setCurrentTarget(event, eventTarget); + if (propListener == null && maybeListeners == null) { + return; + } + + listeners = []; + + if (propListener != null) { + listeners.push({ + callback: propListener, + passive: false, + once: false, + removed: false, + }); + } + + if (maybeListeners != null) { + for (const registration of maybeListeners.values()) { + listeners.push(registration); + } + } + } else { + const listenersByType = getListenersForPhase(eventTarget, isCapture); + const maybeListeners = listenersByType?.get(event.type); + if (maybeListeners == null) { + return; + } + listeners = Array.from(maybeListeners.values()); + } for (const listener of listeners) { if (listener.removed) { @@ -358,11 +404,7 @@ function invoke( } if (listener.once) { - eventTarget.removeEventListener( - event.type, - listener.callback, - eventPhase === Event.CAPTURING_PHASE, - ); + eventTarget.removeEventListener(event.type, listener.callback, isCapture); } if (listener.passive) { diff --git a/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js index fe47914f8a33..eca0713e4255 100644 --- a/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js +++ b/packages/react-native/src/private/webapis/dom/events/__tests__/EventTarget-benchmark-itest.js @@ -4,6 +4,7 @@ * 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:* * @flow strict-local * @format */ diff --git a/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js b/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js index 8061207d5972..a440828c5070 100644 --- a/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js +++ b/packages/react-native/src/private/webapis/dom/events/internals/EventTargetInternals.js @@ -27,6 +27,18 @@ export const EVENT_TARGET_GET_THE_PARENT_KEY: symbol = Symbol( 'EventTarget[get the parent]', ); +/** + * Use this symbol as key for a method to provide an additional event listener + * from props in an `EventTarget` subclass. + * + * During event dispatch, this method is called before processing explicitly + * registered `addEventListener` listeners. If it returns a non-null callback, + * that callback is invoked as an event listener. + */ +export const EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY: symbol = Symbol( + 'EventTarget[get listener from props]', +); + /** * This is only exposed to implement the method in `EventTarget`. * Do NOT use this directly (use the `dispatchTrustedEvent` method instead). 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 78ab5696f43c..f1e76f697058 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js +++ b/packages/react-native/src/private/webapis/dom/nodes/ReactNativeElement.js @@ -29,7 +29,10 @@ import {Commands as ViewCommands} from '../../../../../Libraries/Components/View import {create as createAttributePayload} from '../../../../../Libraries/ReactNative/ReactFabricPublicInstance/ReactNativeAttributePayload'; import warnForStyleProps from '../../../../../Libraries/ReactNative/ReactFabricPublicInstance/warnForStyleProps'; import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags'; +import {getEventTypePropName} from '../../../renderer/events/ReactNativeEventTypeMapping'; +import {EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY} from '../events/internals/EventTargetInternals'; import { + getCurrentProps, getNativeElementReference, getPublicInstanceFromInstanceHandle, setInstanceHandle, @@ -215,6 +218,27 @@ class ReactNativeElement extends ReadOnlyElement implements NativeMethods { NativeDOM.setNativeProps(node, updatePayload); } } + + // Provide event listeners from React props during EventTarget dispatch. + // This is called by EventTarget.invoke() before explicit addEventListener + // listeners, allowing prop-based handlers to be resolved at dispatch time + // without registering them via addEventListener during commit. + // $FlowExpectedError[unsupported-syntax] + [EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY]( + eventType: string, + isCapture: boolean, + ): ((event: Event) => void) | null { + const currentProps = getCurrentProps(this); + if (currentProps == null) { + return null; + } + const propName = getEventTypePropName(eventType, isCapture); + if (propName == null) { + return null; + } + const handler = currentProps[propName]; + return typeof handler === 'function' ? handler : null; + } } type ReactNativeElementT = ReactNativeElement; diff --git a/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyElement.js b/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyElement.js index ce816d9ed5fe..1f0d82c2d600 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyElement.js +++ b/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyElement.js @@ -15,7 +15,7 @@ import type HTMLCollection from '../oldstylecollections/HTMLCollection'; import DOMRect from '../../geometry/DOMRect'; import {createHTMLCollection} from '../oldstylecollections/HTMLCollection'; import { - getInstanceHandle, + getCurrentProps, getNativeElementReference, } from './internals/NodeInternals'; import {getElementSibling} from './internals/Traversal'; @@ -86,11 +86,9 @@ export default class ReadOnlyElement extends ReadOnlyNode { } get id(): string { - const instanceHandle = getInstanceHandle(this); - // TODO: migrate off this private React API - // $FlowExpectedError[incompatible-use] - const props = instanceHandle?.stateNode?.canonical?.currentProps; - return props?.id ?? props?.nativeID ?? ''; + const props = getCurrentProps(this); + const id = props.id ?? props.nativeID; + return typeof id === 'string' ? id : ''; } get lastElementChild(): ReadOnlyElement | null { diff --git a/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyNode.js b/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyNode.js index 1d5fc27d75c3..3286af0a601e 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyNode.js +++ b/packages/react-native/src/private/webapis/dom/nodes/ReadOnlyNode.js @@ -15,7 +15,10 @@ import type {InstanceHandle} from './internals/NodeInternals'; import type ReactNativeDocument from './ReactNativeDocument'; import type ReadOnlyElement from './ReadOnlyElement'; +import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags'; import {setPlatformObject} from '../../webidl/PlatformObjects'; +import EventTarget from '../events/EventTarget'; +import {EVENT_TARGET_GET_THE_PARENT_KEY} from '../events/internals/EventTargetInternals'; import {createNodeList} from '../oldstylecollections/NodeList'; import { getNativeNodeReference, @@ -26,18 +29,47 @@ import { } from './internals/NodeInternals'; import NativeDOM from './specs/NativeDOM'; -export default class ReadOnlyNode { +// $FlowFixMe[unsupported-variance-annotation] +// $FlowFixMe[incompatible-type] +const ReadOnlyNodeBase: typeof Object = + ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching() + ? EventTarget + : // $FlowFixMe[incompatible-type] + Object; + +// Ideally, this class would be exported as-is, but calling super() in a +// subclass is a very slow operation the way that Babel transforms it at +// the moment. +// +// This is a very hot code path (ReadOnlyNode is a base class for all +// ReactNativeElement instances, which are instantiated once per rendered +// host component in the tree) and we can't regress performance here. +// +// The optimization we're doing is using an old-style function constructor, +// where we're not required to use `super()`, and we make that constructor +// extend this class so it inherits all the methods and it sets the class +// hierarchy correctly. + +class ReadOnlyNode extends ReadOnlyNodeBase { constructor( instanceHandle: InstanceHandle, // This will be null for the document node itself. ownerDocument: ReactNativeDocument | null, ) { + super(); // This constructor is inlined in `ReactNativeElement` so if you modify // this make sure that their implementation stays in sync. setOwnerDocument(this, ownerDocument); setInstanceHandle(this, instanceHandle); } + // Implement the "get the parent" algorithm for EventTarget. + // This enables event propagation (capture/bubble) through the node tree. + // $FlowExpectedError[unsupported-syntax] + [EVENT_TARGET_GET_THE_PARENT_KEY](): EventTarget | null { + return this.parentNode; + } + get childNodes(): NodeList { const childNodes = getChildNodes(this); return createNodeList(childNodes); @@ -294,6 +326,43 @@ export default class ReadOnlyNode { setPlatformObject(ReadOnlyNode); +type ReadOnlyNodeT = ReadOnlyNode; + +function replaceConstructorWithoutSuper( + ReadOnlyNodeClass: Class, +): Class { + // Alternative constructor just implemented to provide a better performance than + // calling super() in the original class. + // eslint-disable-next-line no-shadow + function ReadOnlyNode( + this: ReadOnlyNodeT, + instanceHandle: InstanceHandle, + ownerDocument: ReactNativeDocument | null, + ) { + setOwnerDocument(this, ownerDocument); + setInstanceHandle(this, instanceHandle); + } + + ReadOnlyNode.prototype = ReadOnlyNodeClass.prototype; + + // Copy static properties (ELEMENT_NODE, DOCUMENT_NODE, TEXT_NODE, + // DOCUMENT_POSITION_*, etc.) so that external callers that import this + // constructor can still access them. + // $FlowFixMe[unsafe-object-assign] + // $FlowFixMe[not-an-object] + Object.assign(ReadOnlyNode, ReadOnlyNodeClass); + + // $FlowExpectedError[incompatible-type] + return ReadOnlyNode; +} + +export default replaceConstructorWithoutSuper( + ReadOnlyNode, +) as typeof ReadOnlyNode; + +// Temporary type until we ship ReadOnlyNode extending EventTarget ungated. +export type ReadOnlyNodeWithEventTarget = ReadOnlyNode & EventTarget; + export function getChildNodes( node: ReadOnlyNode, filter?: (node: ReadOnlyNode) => boolean, diff --git a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-traversal-benchmark-itest.js b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-traversal-benchmark-itest.js index 81959bacb338..562fd550bbf8 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-traversal-benchmark-itest.js +++ b/packages/react-native/src/private/webapis/dom/nodes/__tests__/ReactNativeElement-traversal-benchmark-itest.js @@ -4,6 +4,7 @@ * 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:* * @flow strict-local * @format */ diff --git a/packages/react-native/src/private/webapis/dom/nodes/internals/NodeInternals.js b/packages/react-native/src/private/webapis/dom/nodes/internals/NodeInternals.js index 4068e355ae9a..4f3c25019293 100644 --- a/packages/react-native/src/private/webapis/dom/nodes/internals/NodeInternals.js +++ b/packages/react-native/src/private/webapis/dom/nodes/internals/NodeInternals.js @@ -162,6 +162,16 @@ export function getNativeElementReference( return getNodeFromInternalInstanceHandle(instanceHandle); } +/** + * Returns the current props for a node managed by React. + * This accesses React internals (fiber.stateNode.canonical.currentProps). + */ +export function getCurrentProps(node: ReadOnlyNode): {[string]: unknown} { + const instanceHandle = getInstanceHandle(node); + // $FlowExpectedError[incompatible-use] instanceHandle is opaque + return instanceHandle?.stateNode?.canonical?.currentProps ?? {}; +} + export function getNativeTextReference( node: ReadOnlyCharacterData, ): ?NativeTextReference {