From be44bfd7a6a8dd5681b7fd359aeb1fa60aa4bc32 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:39:30 +0000 Subject: [PATCH 1/3] Remove unsafe Composer and tab navigator type assertions Seatbelt-Operation: 2facc752-58f4-4853-995b-5cf055d3ff11 --- .../Composer/implementation/index.native.tsx | 4 +- .../Composer/implementation/index.tsx | 23 +- src/components/Composer/types.ts | 27 +- src/hooks/useHtmlPaste/index.ts | 14 +- src/hooks/useHtmlPaste/types.ts | 2 +- src/hooks/useOdometerDraftHydrator.ts | 4 +- src/hooks/useResetIOUType.ts | 8 +- src/libs/ComponentUtils/index.native.ts | 6 +- src/libs/ComponentUtils/index.ts | 4 +- src/libs/InputUtils/index.ts | 9 +- src/libs/InputUtils/types.ts | 2 +- src/libs/Navigation/OnyxTabNavigator.tsx | 563 ++++++------- src/libs/ReportActionComposeFocusManager.ts | 2 +- src/libs/actions/InputFocus/index.ts | 4 +- src/libs/actions/InputFocus/index.web.ts | 10 +- src/libs/focusComposerWithDelay/types.ts | 4 +- .../index.ios.ts | 2 +- .../types.ts | 4 +- src/libs/updateMultilineInputRange/types.ts | 3 +- .../home/ForYouSection/ConciergePromptBox.tsx | 4 +- .../ReportActionCompose/ComposerContext.ts | 6 +- .../ComposerWithSuggestions.tsx | 104 ++- .../ReportActionComposeUtils.ts | 2 +- .../ReportActionCompose/Suggestions.tsx | 5 +- .../getScrollPosition/types.ts | 4 +- .../updateNativeTextInputValue/index.ios.ts | 2 +- .../ReportActionCompose/useComposerSubmit.ts | 6 +- .../useComposerSuggestions.ts | 3 +- .../ReportActionCompose/useEditMessage.ts | 2 +- .../report/ReportActionItemMessageEdit.tsx | 2 +- .../iou/request/DistanceRequestStartPage.tsx | 10 +- src/pages/iou/request/IOURequestStartPage.tsx | 9 +- tests/ui/ComposerImperativeRefTest.tsx | 773 ++++++++++++++++++ tests/ui/DistanceRequestStartPageTest.tsx | 23 +- tests/ui/IOURequestStartPageManualTabTest.tsx | 28 +- tests/ui/OnyxTabNavigatorTest.tsx | 318 ++++++- tests/ui/hooks/useHtmlPasteTest/index.tsx | 143 +++- .../ComposerSelectionNotificationTest.tsx | 102 +++ tests/unit/InputUtilsTest.ts | 86 ++ tests/unit/focusComposerWithDelayTest.ts | 55 ++ .../useEditMessageComposerCommands.test.ts | 120 +++ .../unit/useComposerSubmitClearValueTest.tsx | 121 +++ .../unit/useResetIOUTypeTabSelectionTest.tsx | 123 +++ 43 files changed, 2302 insertions(+), 444 deletions(-) create mode 100644 tests/ui/ComposerImperativeRefTest.tsx create mode 100644 tests/unit/ComposerSelectionNotificationTest.tsx create mode 100644 tests/unit/InputUtilsTest.ts create mode 100644 tests/unit/hooks/useEditMessageComposerCommands.test.ts create mode 100644 tests/unit/useComposerSubmitClearValueTest.tsx create mode 100644 tests/unit/useResetIOUTypeTabSelectionTest.tsx diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 31653968860b..008e6cb17f4e 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -1,4 +1,4 @@ -import type {ComposerProps, ComposerRef} from '@components/Composer/types'; +import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -97,7 +97,7 @@ function Composer({ // get a ref to the inner textInput element e.g. if we do // this.textInput = el} /> this will not // return a ref to the component, but rather the HTML element by default - ref(textInputRef.current as ComposerRef); + ref(textInputRef.current); }, // eslint-disable-next-line react-hooks/exhaustive-deps [isInLandscapeMode], diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 8e5d2d2cd711..25db92af5235 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -1,4 +1,4 @@ -import type {ComposerProps, ComposerRef} from '@components/Composer/types'; +import type {ComposerProps, WebComposerRef} from '@components/Composer/types'; import {useSession} from '@components/OnyxListItemProvider'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; @@ -20,13 +20,15 @@ import Parser from '@libs/Parser'; import CONST from '@src/CONST'; import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; -import type {TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; +import type {TextInput, TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter, StyleSheet} from 'react-native'; +type WebMarkdownInput = HTMLDivElement & Pick & {value?: string}; + const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL, CONST.QA_EXPENSIFY_URL]; @@ -69,7 +71,7 @@ function Composer({ const addAuthTokenToImageURL = useCallback((url: string) => addEncryptedAuthTokenToURL(url, encryptedAuthToken), [encryptedAuthToken]); const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const StyleUtils = useStyleUtils(); - const textInputRef = useRef(null); + const textInputRef = useRef(null); const [selection, setSelection] = useState< | { start: number; @@ -156,7 +158,7 @@ function Composer({ } if (textInputRef.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { - const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; + const eventTarget = event.target instanceof HTMLElement ? event.target : null; // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste const isTargetInput = eventTarget?.nodeName === CONST.ELEMENT_NAME.INPUT || eventTarget?.nodeName === CONST.ELEMENT_NAME.TEXTAREA || eventTarget?.contentEditable === 'true'; @@ -301,14 +303,14 @@ function Composer({ end: 0, }, }, - } as TextInputSelectionChangeEvent; + }; onSelectionChange(selectionEvent); setSelection({start: 0, end: 0}); onClear(currentText); }, [onClear, onSelectionChange]); - useImperativeHandle(ref, () => { + useImperativeHandle(ref, (): WebComposerRef => { const textInput = textInputRef.current; if (!textInput) { throw new Error('textInput is not available. This should never happen and indicates a developer error.'); @@ -321,16 +323,17 @@ function Composer({ // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly blur: () => textInput.blur(), focus: () => textInput.focus(), + isFocused: () => textInput.isFocused(), get scrollTop() { return textInput.scrollTop; }, - } as ComposerRef; + }; }, [clear]); const handleKeyPress = useCallback( (e: TextInputKeyPressEvent) => { // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed - if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { + if (!onKeyPress || (e.nativeEvent instanceof KeyboardEvent && isEnterWhileComposition(e.nativeEvent))) { return; } @@ -366,7 +369,9 @@ function Composer({ autoComplete="off" autoCorrect={!isMobileSafari()} placeholderTextColor={theme.placeholderText} - ref={textInputRef} + ref={(element: AnimatedMarkdownTextInputRef | null) => { + textInputRef.current = element instanceof HTMLDivElement ? element : null; + }} selection={selection} style={[inputStyleMemo]} markdownStyle={markdownStyle} diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index e00420b8025d..7727d6d8b2c5 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -2,9 +2,8 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {MarkdownTextInput} from '@expensify/react-native-live-markdown'; import type {Ref} from 'react'; -import type {StyleProp, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; +import type {StyleProp, TextInput, TextInputProps, TextInputSelectionChangeEvent, TextStyle} from 'react-native'; type TextSelection = { start: number; @@ -12,14 +11,22 @@ type TextSelection = { positionX?: number; positionY?: number; }; -type CustomSelectionChangeEvent = TextInputSelectionChangeEvent & { - positionX?: number; - positionY?: number; +type CustomSelectionChangeEvent = { + nativeEvent: { + selection: TextInputSelectionChangeEvent['nativeEvent']['selection'] & Pick; + }; +}; + +type WebComposerRef = Pick & { + readonly scrollTop: number; + setSelection?: never; + setNativeProps?: never; + preventFocusOnFirstResponderOnce?: never; }; -type ComposerRef = MarkdownTextInput & HTMLInputElement & HTMLTextAreaElement; +type ComposerRef = TextInput | WebComposerRef; -type ComposerProps = Omit & +type ComposerProps = Omit & ForwardedFSClassProps & { /** Indicate whether input is multiline */ multiline?: boolean; @@ -35,9 +42,9 @@ type ComposerProps = Omit & /** * Callback when the input was cleared using the .clear ref method. - * The text parameter will be the value of the text that was cleared. + * The text parameter is absent if live-markdown has not initialized its value. */ - onClear?: (text: string) => void; + onClear?: (text: string | undefined) => void; /** Callback method handle when the input is changed */ onChangeText?: (numberOfLines: string) => void; @@ -77,4 +84,4 @@ type ComposerProps = Omit & ref?: Ref; }; -export type {TextSelection, ComposerProps, CustomSelectionChangeEvent, ComposerRef}; +export type {TextSelection, ComposerProps, CustomSelectionChangeEvent, ComposerRef, WebComposerRef}; diff --git a/src/hooks/useHtmlPaste/index.ts b/src/hooks/useHtmlPaste/index.ts index 770ebdf84a4e..289a5255b70a 100644 --- a/src/hooks/useHtmlPaste/index.ts +++ b/src/hooks/useHtmlPaste/index.ts @@ -55,13 +55,17 @@ const useHtmlPaste: UseHtmlPaste = (textInputRef, preHtmlPasteCallback, isActive const paste = useCallback( (text: string) => { try { - const textInputHTMLElement = textInputRef.current as HTMLElement; - if (textInputHTMLElement?.hasAttribute('contenteditable')) { + const textInputHTMLElement = textInputRef.current; + if (!(textInputHTMLElement instanceof HTMLElement)) { + return; + } + if (textInputHTMLElement.hasAttribute('contenteditable')) { insertAtCaret(textInputHTMLElement, text, maxLength); + } else if (textInputHTMLElement instanceof HTMLInputElement || textInputHTMLElement instanceof HTMLTextAreaElement) { + const availableLength = maxLength - (textInputHTMLElement.value?.length ?? 0); + textInputHTMLElement.setRangeText(text.slice(0, availableLength)); } else { - const htmlInput = textInputRef.current as unknown as HTMLInputElement; - const availableLength = maxLength - (htmlInput.value?.length ?? 0); - htmlInput.setRangeText(text.slice(0, availableLength)); + return; } requestAnimationFrame(() => { diff --git a/src/hooks/useHtmlPaste/types.ts b/src/hooks/useHtmlPaste/types.ts index 8add6a319001..72548e306788 100644 --- a/src/hooks/useHtmlPaste/types.ts +++ b/src/hooks/useHtmlPaste/types.ts @@ -2,7 +2,7 @@ import type {ComponentRef, RefObject} from 'react'; import type {TextInput} from 'react-native'; type UseHtmlPaste = ( - textInputRef: RefObject<(HTMLTextAreaElement & ComponentRef) | ComponentRef | null>, + textInputRef: RefObject<(HTMLElement & Pick) | ComponentRef | null>, preHtmlPasteCallback?: (event: ClipboardEvent) => boolean, isActive?: boolean, maxLength?: number, // Maximum length of the text input value after pasting diff --git a/src/hooks/useOdometerDraftHydrator.ts b/src/hooks/useOdometerDraftHydrator.ts index 6df590e0dd70..a4d2b46258eb 100644 --- a/src/hooks/useOdometerDraftHydrator.ts +++ b/src/hooks/useOdometerDraftHydrator.ts @@ -27,7 +27,7 @@ function useOdometerDraftHydrator({ transactionRequestType, isLoadingTransaction = false, isLoadingSelectedTab = false, -}: UseOdometerDraftHydratorParams): (newIOUType: IOURequestType) => void { +}: UseOdometerDraftHydratorParams): (newIOUType: IOURequestType | undefined) => void { const [odometerDraft] = useOnyx(ONYXKEYS.ODOMETER_DRAFT); useEffect(() => { @@ -54,7 +54,7 @@ function useOdometerDraftHydrator({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [transactionRequestType, odometerDraft, isLoadingTransaction, isLoadingSelectedTab]); - return (newIOUType: IOURequestType) => { + return (newIOUType: IOURequestType | undefined) => { if (newIOUType !== CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { return; } diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index 961a50ba8900..62dd29de808a 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -70,7 +70,7 @@ function useResetIOUType({ policy, isTrackDistanceExpense = false, skipKeyboardDismissForPerDiem = false, -}: UseResetIOUTypeParams): (newIOUType: IOURequestType) => void { +}: UseResetIOUTypeParams): (newIOUType: IOURequestType | undefined) => void { const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [hasOnlyPersonalPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasOnlyPersonalPoliciesSelector}); const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); @@ -96,7 +96,7 @@ function useResetIOUType({ }); const defaultParticipants = resolvedDefaultParticipants.length > 0 ? resolvedDefaultParticipants : undefined; - const resetIOUTypeIfChanged = (newIOUType: IOURequestType) => { + const resetIOUTypeIfChanged = (newIOUType: IOURequestType | undefined) => { if (transaction?.iouRequestType === newIOUType) { return; } @@ -145,9 +145,9 @@ function useResetIOUType({ hydrateOdometerOnLanding(newIOUType); }; - const tabSelectedTypeRef = useRef(null); + const tabSelectedTypeRef = useRef(null); - const onTabSelected = (newIouType: IOURequestType) => { + const onTabSelected = (newIouType: IOURequestType | undefined) => { tabSelectedTypeRef.current = newIouType; resetIOUTypeIfChanged(newIouType); }; diff --git a/src/libs/ComponentUtils/index.native.ts b/src/libs/ComponentUtils/index.native.ts index 01f45648d21f..3424d0a38f65 100644 --- a/src/libs/ComponentUtils/index.native.ts +++ b/src/libs/ComponentUtils/index.native.ts @@ -1,4 +1,6 @@ -import type {Component} from 'react'; +import type {ComposerRef} from '@components/Composer/types'; + +import type {HostInstance} from 'react-native'; import type {AnimatedRef} from 'react-native-reanimated'; import {dispatchCommand} from 'react-native-reanimated'; @@ -11,7 +13,7 @@ const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'none'; * Clears a text input on the UI thread using a custom clear command * that bypasses the event count check. */ -function forceClearInput(animatedInputRef: AnimatedRef) { +function forceClearInput(animatedInputRef: AnimatedRef) { 'worklet'; dispatchCommand(animatedInputRef, 'clear'); diff --git a/src/libs/ComponentUtils/index.ts b/src/libs/ComponentUtils/index.ts index ba4a031a5560..2ce5b9c1cecb 100644 --- a/src/libs/ComponentUtils/index.ts +++ b/src/libs/ComponentUtils/index.ts @@ -1,3 +1,5 @@ +import type {ComposerRef} from '@components/Composer/types'; + import ObjectUtils from '@src/types/utils/ObjectUtils'; import type {HostInstance} from 'react-native'; @@ -10,7 +12,7 @@ import type AccessibilityRoleForm from './types'; */ const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'form'; -function forceClearInput(animatedInputRef: AnimatedRef) { +function forceClearInput(animatedInputRef: AnimatedRef) { 'worklet'; const input = animatedInputRef.current; diff --git a/src/libs/InputUtils/index.ts b/src/libs/InputUtils/index.ts index b6a0a91be289..3f5c49e3cc18 100644 --- a/src/libs/InputUtils/index.ts +++ b/src/libs/InputUtils/index.ts @@ -1,20 +1,21 @@ import type {MoveSelectionToEnd, ScrollInput} from './types'; const scrollToBottom: ScrollInput = (input) => { - if (!('scrollTop' in input)) { + // Native inputs also expose scroll offsets, but only DOM offsets are writable. + if (!(input instanceof HTMLElement)) { return; } // eslint-disable-next-line no-param-reassign - (input as HTMLInputElement).scrollTop = input.scrollHeight; + input.scrollTop = input.scrollHeight; }; const scrollToRight: ScrollInput = (input) => { - if (!('scrollLeft' in input)) { + if (!(input instanceof HTMLElement)) { return; } // Scroll to the far right // eslint-disable-next-line no-param-reassign - (input as HTMLInputElement).scrollLeft = input.scrollWidth; + input.scrollLeft = input.scrollWidth; }; const moveSelectionToEnd: MoveSelectionToEnd = (input) => { diff --git a/src/libs/InputUtils/types.ts b/src/libs/InputUtils/types.ts index 684d2790efef..494133ffe4d4 100644 --- a/src/libs/InputUtils/types.ts +++ b/src/libs/InputUtils/types.ts @@ -1,7 +1,7 @@ import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; -type ScrollInput = (input: HTMLInputElement | ComponentRef) => void; +type ScrollInput = (input: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement | ComponentRef) => void; type MoveSelectionToEnd = (input: HTMLInputElement | ComponentRef) => void; export type {ScrollInput, MoveSelectionToEnd}; diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 181e9a3170d4..4477ad3f7575 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -18,13 +18,12 @@ import Tab from '@userActions/Tab'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {SelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import KeyboardUtils from '@src/utils/keyboard'; import type {MaterialTopTabNavigationEventMap} from '@react-navigation/material-top-tabs'; -import type {EventArg, EventMapCore, NavigationProp, NavigationState, ParamListBase, ScreenListeners} from '@react-navigation/native'; +import type {EventArg, NavigationProp, ParamListBase, ScreenListeners, TabNavigationState} from '@react-navigation/native'; import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; import {TabActions, useRoute} from '@react-navigation/native'; @@ -36,15 +35,17 @@ import type {RegisterTabSwitchGuard, TabSwitchGuard} from './TabSwitchGuardConte import {backBehavior, defaultScreenOptions, pagerContainerStyle} from './OnyxTabNavigatorConfig'; import TabSwitchGuardContext from './TabSwitchGuardContext'; -type OnyxTabNavigatorProps = ChildrenProps & { +type OnyxTabParamList = Record; + +type OnyxTabNavigatorProps = ChildrenProps & { /** ID of the tab component to be saved in onyx */ id: string; - defaultSelectedTab?: TTabName; - onTabSelected?: (newTabName: TTabName) => void; + defaultSelectedTab?: Extract; + onTabSelected?: (newTabName: Extract | undefined) => void; tabBar: (props: TabSelectorProps) => React.ReactNode; - screenListeners?: ScreenListeners; + screenListeners?: ScreenListeners>, MaterialTopTabNavigationEventMap>; /** Callback to register the focus trap container elements of the current active tab. * Use this in the parent component to get the focus trap container element of the active tab, @@ -83,8 +84,6 @@ type OnyxTabNavigatorProps = Child shouldReapplyInterruptedTabPress?: boolean; }; -const TopTab = createMaterialTopTabNavigator(); - const DISMISS_KEYBOARD_BEFORE_TAB_SWITCH_TIMEOUT_MS = CONST.MAX_TRANSITION_DURATION_MS; /** @@ -132,10 +131,9 @@ const getTabNames = (children: React.ReactNode): string[] => { return; } - const element = child as React.ReactElement<{name?: string}>; - - if (typeof element.props.name === 'string') { - result.push(element.props.name); + const props = child.props; + if (props && typeof props === 'object' && 'name' in props && typeof props.name === 'string') { + result.push(props.name); } }); @@ -145,294 +143,307 @@ const getTabNames = (children: React.ReactNode): string[] => { // This takes all the same props as MaterialTopTabsNavigator: https://reactnavigation.org/docs/material-top-tab-navigator/#props, // except ID is now required, and it gets a `selectedTab` from Onyx // It also takes 2 more optional callbacks to manage the focus trap container elements of the tab bar and the active tab -function OnyxTabNavigator({ - id, - defaultSelectedTab, - tabBar: TabBar, - children, - onTabBarFocusTrapContainerElementChanged, - onActiveTabFocusTrapContainerElementChanged, - onTabSelected = () => {}, - screenListeners, - shouldShowLabelWhenInactive = true, - lazyLoadEnabled = false, - onTabSelect, - equalWidth = false, - shouldDismissKeyboardBeforeTabSwitch = false, - shouldReapplyInterruptedTabPress = false, - ...rest -}: OnyxTabNavigatorProps) { - const styles = useThemeStyles(); - const isFirstMountRef = useRef(true); - // Mapping of tab name to focus trap container element - const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); - const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${id}`); - - const pressedTabRef = useRef<{from: string | undefined; to: string; armedAt: number} | undefined>(undefined); - // Frame handle of a queued re-apply jump, so it can be dropped when it is superseded by a newer press or by unmount. - const reapplyFrameRef = useRef(undefined); +function createOnyxTabNavigator(names: ReadonlyArray>) { + const TopTab = createMaterialTopTabNavigator, string>(); + + function OnyxTabNavigator({ + id, + defaultSelectedTab, + tabBar: TabBar, + children, + onTabBarFocusTrapContainerElementChanged, + onActiveTabFocusTrapContainerElementChanged, + onTabSelected = () => {}, + screenListeners, + shouldShowLabelWhenInactive = true, + lazyLoadEnabled = false, + onTabSelect, + equalWidth = false, + shouldDismissKeyboardBeforeTabSwitch = false, + shouldReapplyInterruptedTabPress = false, + ...rest + }: OnyxTabNavigatorProps) { + const styles = useThemeStyles(); + const isFirstMountRef = useRef(true); + // Mapping of tab name to focus trap container element + const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); + const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${id}`); + + const pressedTabRef = useRef<{from: string | undefined; to: string; armedAt: number} | undefined>(undefined); + // Frame handle of a queued re-apply jump, so it can be dropped when it is superseded by a newer press or by unmount. + const reapplyFrameRef = useRef(undefined); + + const tabNames = getTabNames(children); + + const storedTabName = names.find((name) => name === selectedTab); + const validInitialTab = storedTabName && tabNames.includes(storedTabName) ? storedTabName : defaultSelectedTab; + + const LazyPlaceholder = useCallback(() => { + return ( + + + + ); + }, [styles.fullScreenLoading, styles.w100]); + + // This callback is used to register the focus trap container element of each available tab screen + const setTabFocusTrapContainerElement = (tabName: string, containerElement: HTMLElement | null) => { + setFocusTrapContainerElementMapping((prevMapping) => { + const resultMapping = {...prevMapping}; + if (containerElement) { + resultMapping[tabName] = containerElement; + } else { + delete resultMapping[tabName]; + } + return resultMapping; + }); + }; - const tabNames = getTabNames(children); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + // Tab-switch discard guards, keyed by tab name. Tab screens register via `useDiscardChangesConfirmation`. + const guardsRef = useRef>(new Map()); + const isDiscardModalOpenRef = useRef(false); + // Set while a tab switch is waiting on the keyboard, so repeated taps can't queue competing jumps. + const isTabSwitchPendingRef = useRef(false); + + const registerTabGuard: RegisterTabSwitchGuard = (guard) => { + guardsRef.current.set(guard.tabName, guard); + return () => { + // Only clear if this exact guard is still registered, so a re-registration from another mount isn't wiped. + if (guardsRef.current.get(guard.tabName) !== guard) { + return; + } + guardsRef.current.delete(guard.tabName); + }; + }; - const validInitialTab = selectedTab && tabNames.includes(selectedTab) ? selectedTab : defaultSelectedTab; + // A function, not a cached value: each call site checks the keyboard's current state, not one captured earlier. + // Gated on Keyboard.isVisible() rather than platform, so an opted-in caller also defers on iOS, not just Android. + const hasKeyboardToDismiss = () => shouldDismissKeyboardBeforeTabSwitch && Keyboard.isVisible(); - const LazyPlaceholder = useCallback(() => { - return ( - - - - ); - }, [styles.fullScreenLoading, styles.w100]); - - // This callback is used to register the focus trap container element of each available tab screen - const setTabFocusTrapContainerElement = (tabName: string, containerElement: HTMLElement | null) => { - setFocusTrapContainerElementMapping((prevMapping) => { - const resultMapping = {...prevMapping}; - if (containerElement) { - resultMapping[tabName] = containerElement; - } else { - delete resultMapping[tabName]; + const runAfterKeyboardDismiss = async (callback: () => void) => { + if (!hasKeyboardToDismiss()) { + callback(); + return; } - return resultMapping; - }); - }; - const {translate} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); - // Tab-switch discard guards, keyed by tab name. Tab screens register via `useDiscardChangesConfirmation`. - const guardsRef = useRef>(new Map()); - const isDiscardModalOpenRef = useRef(false); - // Set while a tab switch is waiting on the keyboard, so repeated taps can't queue competing jumps. - const isTabSwitchPendingRef = useRef(false); - - const registerTabGuard: RegisterTabSwitchGuard = (guard) => { - guardsRef.current.set(guard.tabName, guard); - return () => { - // Only clear if this exact guard is still registered, so a re-registration from another mount isn't wiped. - if (guardsRef.current.get(guard.tabName) !== guard) { - return; + isTabSwitchPendingRef.current = true; + + // Shared try/catch so neither branch can leave `isTabSwitchPendingRef` stuck `true` if the jump callback + // throws. No `finally`: the React Compiler (Babel) can't lower a `try` with a `finally` clause. + try { + // dismissKeyboardAndExecute only actually waits for the keyboard to close on Android; elsewhere it + // runs the callback immediately, so iOS/web use the dismiss()-based wait instead. + if (getPlatform() === CONST.PLATFORM.ANDROID) { + await KeyboardUtils.dismissKeyboardAndExecute(callback); + } else { + await dismissKeyboardBeforeTabSwitch(); + callback(); + } + } catch (error: unknown) { + Log.warn('[OnyxTabNavigator] Failed to switch tabs after dismissing the keyboard', {error}); } - guardsRef.current.delete(guard.tabName); + isTabSwitchPendingRef.current = false; }; - }; - - // A function, not a cached value: each call site checks the keyboard's current state, not one captured earlier. - // Gated on Keyboard.isVisible() rather than platform, so an opted-in caller also defers on iOS, not just Android. - const hasKeyboardToDismiss = () => shouldDismissKeyboardBeforeTabSwitch && Keyboard.isVisible(); - const runAfterKeyboardDismiss = async (callback: () => void) => { - if (!hasKeyboardToDismiss()) { - callback(); - return; - } - - isTabSwitchPendingRef.current = true; - - // Shared try/catch so neither branch can leave `isTabSwitchPendingRef` stuck `true` if the jump callback - // throws. No `finally`: the React Compiler (Babel) can't lower a `try` with a `finally` clause. - try { - // dismissKeyboardAndExecute only actually waits for the keyboard to close on Android; elsewhere it - // runs the callback immediately, so iOS/web use the dismiss()-based wait instead. - if (getPlatform() === CONST.PLATFORM.ANDROID) { - await KeyboardUtils.dismissKeyboardAndExecute(callback); - } else { - await dismissKeyboardBeforeTabSwitch(); - callback(); + // Records a tab switch that is about to be dispatched, so the `state` listener can re-apply it if it gets bounced back. + const armTabSwitch = (from: string | undefined, to: string | undefined) => { + if (!shouldReapplyInterruptedTabPress) { + return; } - } catch (error: unknown) { - Log.warn('[OnyxTabNavigator] Failed to switch tabs after dismissing the keyboard', {error}); - } - isTabSwitchPendingRef.current = false; - }; - - // Records a tab switch that is about to be dispatched, so the `state` listener can re-apply it if it gets bounced back. - const armTabSwitch = (from: string | undefined, to: string | undefined) => { - if (!shouldReapplyInterruptedTabPress) { - return; - } - // A newer switch supersedes a re-apply that hasn't been dispatched yet, otherwise the queued jump would override it. - cancelQueuedTabSwitch(reapplyFrameRef.current); - pressedTabRef.current = to && to !== from ? {from, to, armedAt: Date.now()} : undefined; - }; - - const reapplyTabSwitchIfInterrupted = (navigation: NavigationProp, newSelectedTab: string | undefined) => { - const pressedTab = pressedTabRef.current; - if (!pressedTab || !newSelectedTab) { - return; - } - const isSwitchInFlight = Date.now() - pressedTab.armedAt <= CONST.ANIMATED_TRANSITION; - if (!isSwitchInFlight || newSelectedTab !== pressedTab.to) { - pressedTabRef.current = undefined; - } - if (isSwitchInFlight && newSelectedTab === pressedTab.from) { + // A newer switch supersedes a re-apply that hasn't been dispatched yet, otherwise the queued jump would override it. cancelQueuedTabSwitch(reapplyFrameRef.current); - reapplyFrameRef.current = requestAnimationFrame(() => { - reapplyFrameRef.current = undefined; - navigation.dispatch(TabActions.jumpTo(pressedTab.to)); - }); - } - }; + pressedTabRef.current = to && to !== from ? {from, to, armedAt: Date.now()} : undefined; + }; - const handleTabPress = (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { - if (isDiscardModalOpenRef.current || isTabSwitchPendingRef.current) { - event.preventDefault(); - return; - } - const navState = navigation.getState(); - const currentRouteName = navState.routes.at(navState.index)?.name; - const guard = currentRouteName ? guardsRef.current.get(currentRouteName) : undefined; - const targetRoute = navState.routes.find((tabRoute) => tabRoute.key === event.target); - if (!targetRoute || targetRoute.name === currentRouteName) { - return; - } - // The tab only changes once the keyboard is fully gone. A fire-and-forget dismiss loses the race: the tab - // switches mid hide-animation and the incoming tab lays out while the keyboard still occupies the screen. - const jumpToTargetTab = () => { - runAfterKeyboardDismiss(() => { - // This jump replaces the press we prevented above (both callers below call `preventDefault`), so it - // needs the same bounce-back protection the outer `tabPress` listener applies to a normal press. - armTabSwitch(currentRouteName, targetRoute.name); - navigation.dispatch(TabActions.jumpTo(targetRoute.name)); - }); + const reapplyTabSwitchIfInterrupted = (navigation: NavigationProp, newSelectedTab: string | undefined) => { + const pressedTab = pressedTabRef.current; + if (!pressedTab || !newSelectedTab) { + return; + } + const isSwitchInFlight = Date.now() - pressedTab.armedAt <= CONST.ANIMATED_TRANSITION; + if (!isSwitchInFlight || newSelectedTab !== pressedTab.to) { + pressedTabRef.current = undefined; + } + if (isSwitchInFlight && newSelectedTab === pressedTab.from) { + cancelQueuedTabSwitch(reapplyFrameRef.current); + reapplyFrameRef.current = requestAnimationFrame(() => { + reapplyFrameRef.current = undefined; + navigation.dispatch(TabActions.jumpTo(pressedTab.to)); + }); + } }; - if (!guard || !guard.getHasUnsavedChanges()) { - if (!hasKeyboardToDismiss()) { - // Nothing to wait for, so leave the jump to react-navigation and keep it synchronous. + + const handleTabPress = (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { + if (isDiscardModalOpenRef.current || isTabSwitchPendingRef.current) { + event.preventDefault(); return; } - // No unsaved changes means no modal is shown, but the input can still be focused with the keyboard up. Take - // the jump over from react-navigation so it waits for the keyboard instead of switching straight away. - event.preventDefault(); - jumpToTargetTab(); - return; - } - event.preventDefault(); - isDiscardModalOpenRef.current = true; - ComposerFocusManager.blurActiveInput(); - showConfirmModal({ - ...getDiscardChangesModalConfig(translate), - shouldIgnoreBackHandlerDuringTransition: true, - }).then((result) => { - isDiscardModalOpenRef.current = false; - if (result.action !== ModalActions.CONFIRM) { - guard.onCancel?.(); + const navState = navigation.getState(); + const currentRouteName = navState.routes.at(navState.index)?.name; + const guard = currentRouteName ? guardsRef.current.get(currentRouteName) : undefined; + const targetRoute = navState.routes.find((tabRoute) => tabRoute.key === event.target); + if (!targetRoute || targetRoute.name === currentRouteName) { return; } - // User confirmed: always jump to the target tab, even if onDiscard fails, rather than stranding them with no feedback. - Promise.resolve() - .then(() => guard.onDiscard()) - .catch((error: unknown) => { - Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); - Growl.error(translate('common.genericErrorMessage')); - }) - .then(() => { - jumpToTargetTab(); + // The tab only changes once the keyboard is fully gone. A fire-and-forget dismiss loses the race: the tab + // switches mid hide-animation and the incoming tab lays out while the keyboard still occupies the screen. + const jumpToTargetTab = () => { + runAfterKeyboardDismiss(() => { + // This jump replaces the press we prevented above (both callers below call `preventDefault`), so it + // needs the same bounce-back protection the outer `tabPress` listener applies to a normal press. + armTabSwitch(currentRouteName, targetRoute.name); + navigation.dispatch(TabActions.jumpTo(targetRoute.name)); }); - }); - }; + }; + if (!guard || !guard.getHasUnsavedChanges()) { + if (!hasKeyboardToDismiss()) { + // Nothing to wait for, so leave the jump to react-navigation and keep it synchronous. + return; + } + // No unsaved changes means no modal is shown, but the input can still be focused with the keyboard up. Take + // the jump over from react-navigation so it waits for the keyboard instead of switching straight away. + event.preventDefault(); + jumpToTargetTab(); + return; + } + event.preventDefault(); + isDiscardModalOpenRef.current = true; + ComposerFocusManager.blurActiveInput(); + showConfirmModal({ + ...getDiscardChangesModalConfig(translate), + shouldIgnoreBackHandlerDuringTransition: true, + }).then((result) => { + isDiscardModalOpenRef.current = false; + if (result.action !== ModalActions.CONFIRM) { + guard.onCancel?.(); + return; + } + // User confirmed: always jump to the target tab, even if onDiscard fails, rather than stranding them with no feedback. + Promise.resolve() + .then(() => guard.onDiscard()) + .catch((error: unknown) => { + Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); + Growl.error(translate('common.genericErrorMessage')); + }) + .then(() => { + jumpToTargetTab(); + }); + }); + }; - /** - * This is a TabBar wrapper component that includes the focus trap container element callback. - * In `TabSelector.tsx` component, the callback prop to register focus trap container element is supported out of the box - */ - const TabBarWithFocusTrapInclusion = useCallback( - (props: TabSelectorProps) => { - return ( - - ); - }, - [TabBar, onTabBarFocusTrapContainerElementChanged, shouldShowLabelWhenInactive, equalWidth], - ); + /** + * This is a TabBar wrapper component that includes the focus trap container element callback. + * In `TabSelector.tsx` component, the callback prop to register focus trap container element is supported out of the box + */ + const TabBarWithFocusTrapInclusion = useCallback( + (props: TabSelectorProps) => { + return ( + + ); + }, + [TabBar, onTabBarFocusTrapContainerElementChanged, shouldShowLabelWhenInactive, equalWidth], + ); - // Keep the generic type casts outside the nested screenListeners callback because OXC cannot hoist - // type-parameter references while outlining that callback. - const persistSelectedTab = Tab.setSelectedTab as (tabID: string, tabName: string) => void; - const notifyTabSelected = onTabSelected as (newTabName: string | undefined) => void; + // Drop a queued re-apply jump on unmount so it isn't dispatched into a navigator that no longer exists. + useEffect(() => () => cancelQueuedTabSwitch(reapplyFrameRef.current), []); - // Drop a queued re-apply jump on unmount so it isn't dispatched into a navigator that no longer exists. - useEffect(() => () => cancelQueuedTabSwitch(reapplyFrameRef.current), []); + // If the selected tab changes, we need to update the focus trap container element of the active tab + useEffect(() => { + onActiveTabFocusTrapContainerElementChanged?.(selectedTab ? focusTrapContainerElementMapping[selectedTab] : null); + }, [selectedTab, focusTrapContainerElementMapping, onActiveTabFocusTrapContainerElementChanged]); - // If the selected tab changes, we need to update the focus trap container element of the active tab - useEffect(() => { - onActiveTabFocusTrapContainerElementChanged?.(selectedTab ? focusTrapContainerElementMapping[selectedTab] : null); - }, [selectedTab, focusTrapContainerElementMapping, onActiveTabFocusTrapContainerElementChanged]); + if (isLoadingOnyxValue(selectedTabResult)) { + return null; + } - if (isLoadingOnyxValue(selectedTabResult)) { - return null; + return ( + + + }) => { + const callerListeners = screenListeners ?? {}; + return { + ...callerListeners, + state: (e) => { + callerListeners.state?.(e); + const state = e.data.state; + const index = state.index; + const routeNames = state.routeNames; + if (isFirstMountRef.current) { + onTabSelect?.({index}); + isFirstMountRef.current = false; + } + const newSelectedTab = routeNames.at(index); + reapplyTabSwitchIfInterrupted(navigation, newSelectedTab); + if (selectedTab === newSelectedTab) { + return; + } + if (newSelectedTab) { + Tab.setSelectedTab(id, newSelectedTab); + } + onTabSelected(newSelectedTab); + }, + tabPress: (e) => { + // Let a caller's own tabPress run first; if it blocked the switch, don't also run the guard. + callerListeners.tabPress?.(e); + if (e.defaultPrevented) { + return; + } + handleTabPress(navigation, e); + if (e.defaultPrevented) { + return; + } + const navState = navigation.getState(); + const pressedTabName = navState.routes.find((tabRoute) => tabRoute.key === e.target)?.name; + const currentTabName = navState.routes.at(navState.index)?.name; + armTabSwitch(currentTabName, pressedTabName); + }, + }; + }} + screenOptions={{ + ...defaultScreenOptions, + swipeEnabled: false, + lazy: lazyLoadEnabled, + lazyPlaceholder: LazyPlaceholder, + }} + > + {children} + + + + ); } - return ( - - - }) => { - const callerListeners = screenListeners ?? {}; - return { - ...callerListeners, - state: (e) => { - callerListeners.state?.(e); - const event = e as unknown as EventMapCore['state']; - const state = event.data.state; - const index = state.index; - const routeNames = state.routeNames; - if (isFirstMountRef.current) { - onTabSelect?.({index}); - isFirstMountRef.current = false; - } - const newSelectedTab = routeNames.at(index); - reapplyTabSwitchIfInterrupted(navigation, newSelectedTab); - if (selectedTab === newSelectedTab) { - return; - } - if (newSelectedTab) { - persistSelectedTab(id, newSelectedTab); - } - notifyTabSelected(newSelectedTab); - }, - tabPress: (e) => { - // Let a caller's own tabPress run first; if it blocked the switch, don't also run the guard. - callerListeners.tabPress?.(e); - if (e.defaultPrevented) { - return; - } - handleTabPress(navigation, e); - if (e.defaultPrevented) { - return; - } - const navState = navigation.getState(); - const pressedTabName = navState.routes.find((tabRoute) => tabRoute.key === e.target)?.name; - const currentTabName = navState.routes.at(navState.index)?.name; - armTabSwitch(currentTabName, pressedTabName); - }, - }; - }} - screenOptions={{ - ...defaultScreenOptions, - swipeEnabled: false, - lazy: lazyLoadEnabled, - lazyPlaceholder: LazyPlaceholder, - }} - > - {children} - - - - ); + return {OnyxTabNavigator, TopTab}; } +const {OnyxTabNavigator, TopTab} = createOnyxTabNavigator([ + ...Object.values(CONST.TAB_REQUEST), + CONST.TAB.NEW_CHAT, + CONST.TAB.NEW_ROOM, + ...Object.values(CONST.TAB.SPLIT), + CONST.TAB.SHARE.SHARE, + CONST.TAB.SHARE.SUBMIT, + CONST.TAB.RECEIPT_PARTNERS.ALL, + CONST.TAB.RECEIPT_PARTNERS.LINKED, + CONST.TAB.RECEIPT_PARTNERS.OUTSTANDING, +]); + /** * We should use this wrapper for each tab screen. This will help register the focus trap container element of each tab screen. * In the OnyxTabNavigator component, depending on the selected tab, we will further register the correct container element of the current active tab to the parent focus trap. @@ -470,4 +481,6 @@ function TabScreenWithFocusTrapWrapper({children}: {children?: React.ReactNode}) export default OnyxTabNavigator; -export {TabScreenWithFocusTrapWrapper, TopTab}; +export {createOnyxTabNavigator, TabScreenWithFocusTrapWrapper, TopTab}; + +export type {OnyxTabNavigatorProps}; diff --git a/src/libs/ReportActionComposeFocusManager.ts b/src/libs/ReportActionComposeFocusManager.ts index 89b48a80f6b4..479a4de97d6a 100644 --- a/src/libs/ReportActionComposeFocusManager.ts +++ b/src/libs/ReportActionComposeFocusManager.ts @@ -24,7 +24,7 @@ const editComposerRef = React.createRef(); /** * There can be 2 composers present at the same time. This ref is for the side panel. */ -const sidePanelComposerRef: RefObject | null> = React.createRef>(); +const sidePanelComposerRef: RefObject | ComposerRef | null> = React.createRef | ComposerRef>(); // There are two types of focus callbacks: priority and general // Priority callback would take priority if it existed diff --git a/src/libs/actions/InputFocus/index.ts b/src/libs/actions/InputFocus/index.ts index 658ac2977d78..86a335b1c47c 100644 --- a/src/libs/actions/InputFocus/index.ts +++ b/src/libs/actions/InputFocus/index.ts @@ -1,8 +1,10 @@ +import type {ComposerRef} from '@components/Composer/types'; + import type {Modal} from '@src/types/onyx'; // eslint-disable-next-line @typescript-eslint/no-unused-vars function inputFocusChange(focus: boolean) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars -function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: Modal, onyxFocused: boolean) {} +function composerFocusKeepFocusOn(ref: Pick | null, isFocused: boolean, modal: Modal, onyxFocused: boolean) {} export {composerFocusKeepFocusOn, inputFocusChange}; diff --git a/src/libs/actions/InputFocus/index.web.ts b/src/libs/actions/InputFocus/index.web.ts index c3010a15909e..4f35ad5dc741 100644 --- a/src/libs/actions/InputFocus/index.web.ts +++ b/src/libs/actions/InputFocus/index.web.ts @@ -1,3 +1,5 @@ +import type {ComposerRef} from '@components/Composer/types'; + import * as Browser from '@libs/Browser'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; @@ -11,10 +13,14 @@ function inputFocusChange(focus: boolean) { Onyx.set(ONYXKEYS.INPUT_FOCUSED, focus); } -let refSave: HTMLElement | undefined; -function composerFocusKeepFocusOn(ref: HTMLElement, isFocused: boolean, modal: Modal, onyxFocused: boolean) { +let refSave: Pick | null | undefined; +function composerFocusKeepFocusOn(ref: Pick | null, isFocused: boolean, modal: Modal, onyxFocused: boolean) { if (isFocused && !onyxFocused) { inputFocusChange(true); + // Preserve the failed focus invocation after Onyx records focus. + if (ref === null) { + throw new TypeError("Cannot read properties of null (reading 'focus')"); + } ref.focus(); } if (isFocused) { diff --git a/src/libs/focusComposerWithDelay/types.ts b/src/libs/focusComposerWithDelay/types.ts index df263b3df7a6..b55a6effeded 100644 --- a/src/libs/focusComposerWithDelay/types.ts +++ b/src/libs/focusComposerWithDelay/types.ts @@ -1,3 +1,5 @@ +import type {WebComposerRef} from '@components/Composer/types'; + import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; @@ -10,6 +12,6 @@ type Selection = { type FocusComposerWithDelay = (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => Promise; -type InputType = ComponentRef | HTMLTextAreaElement; +type InputType = ComponentRef | HTMLTextAreaElement | WebComposerRef; export type {Selection, FocusComposerWithDelay, InputType}; diff --git a/src/libs/preventTextInputFocusOnFirstResponderOnce/index.ios.ts b/src/libs/preventTextInputFocusOnFirstResponderOnce/index.ios.ts index 702bcb283ed7..8fd1e53331dc 100644 --- a/src/libs/preventTextInputFocusOnFirstResponderOnce/index.ios.ts +++ b/src/libs/preventTextInputFocusOnFirstResponderOnce/index.ios.ts @@ -5,7 +5,7 @@ import type {PreventTextInputFocusOnFirstResponderOnce} from './types'; * first responder in the UIResponder chain. (iOS only, no-op on Android and web) */ const preventTextInputFocusOnFirstResponderOnce: PreventTextInputFocusOnFirstResponderOnce = (composerRef) => { - composerRef.current?.preventFocusOnFirstResponderOnce(); + composerRef.current?.preventFocusOnFirstResponderOnce?.(); }; export default preventTextInputFocusOnFirstResponderOnce; diff --git a/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts b/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts index 6a7c3a15b2e4..ac83e2225d47 100644 --- a/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts +++ b/src/libs/preventTextInputFocusOnFirstResponderOnce/types.ts @@ -1,7 +1,9 @@ +import type {ComposerRef} from '@components/Composer/types'; + import type {ComponentRef, RefObject} from 'react'; import type {TextInput} from 'react-native'; -type PreventTextInputFocusOnFirstResponderOnce = (composerRef: RefObject | null>) => void; +type PreventTextInputFocusOnFirstResponderOnce = (composerRef: RefObject | ComposerRef | null>) => void; // eslint-disable-next-line import/prefer-default-export export type {PreventTextInputFocusOnFirstResponderOnce}; diff --git a/src/libs/updateMultilineInputRange/types.ts b/src/libs/updateMultilineInputRange/types.ts index 348332586466..0f4471cee551 100644 --- a/src/libs/updateMultilineInputRange/types.ts +++ b/src/libs/updateMultilineInputRange/types.ts @@ -1,8 +1,9 @@ +import type {WebComposerRef} from '@components/Composer/types'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; -type UpdateMultilineInputRange = (input: HTMLInputElement | BaseTextInputRef | ComponentRef | null, shouldAutoFocus?: boolean) => void; +type UpdateMultilineInputRange = (input: HTMLInputElement | BaseTextInputRef | ComponentRef | WebComposerRef | null, shouldAutoFocus?: boolean) => void; export default UpdateMultilineInputRange; diff --git a/src/pages/home/ForYouSection/ConciergePromptBox.tsx b/src/pages/home/ForYouSection/ConciergePromptBox.tsx index 8e5d886182ff..4da89cf44f38 100644 --- a/src/pages/home/ForYouSection/ConciergePromptBox.tsx +++ b/src/pages/home/ForYouSection/ConciergePromptBox.tsx @@ -45,7 +45,7 @@ import type {AnchorPosition} from '@src/styles'; import type {FileObject} from '@src/types/utils/Attachment'; import type {ComponentRef} from 'react'; -import type {HostInstance, TextInputKeyPressEvent} from 'react-native'; +import type {TextInputKeyPressEvent} from 'react-native'; import {useIsFocused} from '@react-navigation/core'; import React, {useEffect, useRef, useState} from 'react'; @@ -130,7 +130,7 @@ function ConciergePromptBox({isMenuVisible, setIsMenuVisible, isCopyLoading}: Co const [longPlaceholderHeight, setLongPlaceholderHeight] = useState(null); const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); const actionButtonRef = useRef | HTMLDivElement | null>(null); - const animatedRef = useAnimatedRef(); + const animatedRef = useAnimatedRef(); const containerRef = useRef>(null); diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts index 5c9625313f73..3ebff162ecc1 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts +++ b/src/pages/inbox/report/ReportActionCompose/ComposerContext.ts @@ -1,6 +1,6 @@ import type {Emoji} from '@assets/emojis/types'; -import type {TextSelection} from '@components/Composer/types'; +import type {CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import type Mention from '@components/MentionSuggestions/types'; import type {ReportActionEditMessageState} from '@pages/inbox/report/ReportActionEditMessageContext'; @@ -10,7 +10,7 @@ import type {ReportAction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import type {ComponentRef, RefObject} from 'react'; -import type {BlurEvent, TextInputSelectionChangeEvent, View} from 'react-native'; +import type {BlurEvent, View} from 'react-native'; import {createContext, useContext} from 'react'; @@ -19,7 +19,7 @@ import type useDebouncedCommentMaxLengthValidation from './useDebouncedCommentMa type SuggestionsRef = { resetSuggestions: () => void; - onSelectionChange?: (event: TextInputSelectionChangeEvent) => void; + onSelectionChange?: (event: CustomSelectionChangeEvent) => void; triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined; updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void; setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 128ef3727bc5..4a73e48c6713 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -55,7 +55,7 @@ import type {FileObject} from '@src/types/utils/Attachment'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {Ref, RefObject} from 'react'; -import type {BlurEvent, HostInstance, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, TextInputContentSizeChangeEvent, TextInputKeyPressEvent} from 'react-native'; +import type {BlurEvent, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, TextInputContentSizeChangeEvent, TextInputKeyPressEvent} from 'react-native'; import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; import lodashDebounce from 'lodash/debounce'; @@ -82,7 +82,11 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; -type ComposerWithSuggestionsRef = ComposerRef & { +type ComposerWithSuggestionsRef = Pick & { + readonly setSelection: ComposerRef['setSelection']; + readonly setNativeProps: ComposerRef['setNativeProps']; + readonly preventFocusOnFirstResponderOnce: ComposerRef['preventFocusOnFirstResponderOnce']; + readonly scrollTop: number | undefined; focus: (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => void; replaceSelectionWithText: OnEmojiSelected; getCurrentText: () => string; @@ -109,7 +113,7 @@ type ComposerWithSuggestionsProps = Partial & onValueChange: (value: string) => void; /** Callback when the composer got cleared on the UI thread */ - onClear?: (text: string) => void; + onClear?: (text: string | undefined) => void; isComposerFullSize: boolean; setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; @@ -384,7 +388,7 @@ function ComposerWithSuggestions({ isTransitioningToPreExistingReport.current = false; }, []); - const animatedRef = useAnimatedRef(); + const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref */ @@ -953,37 +957,73 @@ function ComposerWithSuggestions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useImperativeHandle( - ref, - () => - new Proxy( - {}, - { - get: (_target, prop) => { - if (prop === 'focus') { - return focus; - } - if (prop === 'replaceSelectionWithText') { - return replaceSelectionWithText; - } - if (prop === 'getCurrentText') { - return getCurrentText; - } - if (prop === 'clearWorklet') { - return clearWorklet; - } - if (prop === 'resetHeight') { - return resetHeight; - } - - return composerRef.current?.[prop as keyof ComposerRef]; - }, + useImperativeHandle(ref, (): ComposerWithSuggestionsRef => { + const handle: ComposerWithSuggestionsRef = { + focus, + replaceSelectionWithText, + getCurrentText, + clearWorklet, + resetHeight, + clear: () => { + if (!composerRef.current) { + throw new TypeError('Composer ref is not available'); + } + composerRef.current.clear(); + }, + blur: () => { + if (!composerRef.current) { + throw new TypeError('Composer ref is not available'); + } + composerRef.current.blur(); + }, + isFocused: () => { + if (!composerRef.current) { + throw new TypeError('Composer ref is not available'); + } + return composerRef.current.isFocused(); + }, + setSelection: undefined, + setNativeProps: undefined, + preventFocusOnFirstResponderOnce: undefined, + scrollTop: undefined, + }; + // Property descriptors support both compilers while getters read the current inner ref. + Object.defineProperties(handle, { + setSelection: { + enumerable: true, + configurable: true, + get: (): ComposerWithSuggestionsRef['setSelection'] => { + return composerRef.current?.setSelection; }, - ) as ComposerWithSuggestionsRef, - ); + }, + setNativeProps: { + enumerable: true, + configurable: true, + get: (): ComposerWithSuggestionsRef['setNativeProps'] => { + return composerRef.current?.setNativeProps; + }, + }, + preventFocusOnFirstResponderOnce: { + enumerable: true, + configurable: true, + get: (): ComposerWithSuggestionsRef['preventFocusOnFirstResponderOnce'] => { + return composerRef.current?.preventFocusOnFirstResponderOnce; + }, + }, + scrollTop: { + enumerable: true, + configurable: true, + get: (): ComposerWithSuggestionsRef['scrollTop'] => { + const input = composerRef.current; + return input && 'scrollTop' in input ? input.scrollTop : undefined; + }, + }, + }); + return handle; + }); const onClear = useCallback( - (textOnClear: string) => { + (textOnClear: string | undefined) => { resetScrollPosition(); // Note: use the value when the clear happened, not the current value which might have changed already onClearProp(textOnClear); diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts index 566e01914903..9ae193251a85 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionComposeUtils.ts @@ -17,7 +17,7 @@ const updateNativeSelectionValue = (composerRef: RefObject, requestIdleCallback(() => { // note: this implementation is only available on non-web RN, thus the wrapping // 'if' block contains a redundant (since the ref is only used on iOS) platform check - composerRef.current?.setSelection(start, end); + composerRef.current?.setSelection?.(start, end); }); }; diff --git a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx index 326597ab5cdc..2dbbc22ccadb 100644 --- a/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/Suggestions.tsx @@ -1,11 +1,10 @@ import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; -import type {TextSelection} from '@components/Composer/types'; +import type {CustomSelectionChangeEvent, TextSelection} from '@components/Composer/types'; import {useDragAndDropState} from '@components/DragAndDrop/Provider'; import usePrevious from '@hooks/usePrevious'; import type {ForwardedRef} from 'react'; -import type {TextInputSelectionChangeEvent} from 'react-native'; import React, {useEffect, useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; @@ -102,7 +101,7 @@ function Suggestions({ return emojiHandler ?? mentionHandler; }; - const onSelectionChange = (e: TextInputSelectionChangeEvent) => { + const onSelectionChange = (e: CustomSelectionChangeEvent) => { const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e); suggestionMentionRef.current?.onSelectionChange?.(e); return emojiHandler; diff --git a/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts b/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts index 41fa35e2f13a..97ebba53ddb6 100644 --- a/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts +++ b/src/pages/inbox/report/ReportActionCompose/getScrollPosition/types.ts @@ -1,9 +1,11 @@ +import type {ComposerRef} from '@components/Composer/types'; + import type {ComponentRef} from 'react'; import type {TextInput} from 'react-native'; type TextInputScrollProps = { mobileInputScrollPosition: React.RefObject; - textInputRef: React.RefObject | null>; + textInputRef: React.RefObject | ComposerRef | null>; }; type GetScrollPositionType = {scrollValue: number}; diff --git a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts index 99a8f2f23e44..6beb8bc8e520 100644 --- a/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts +++ b/src/pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios.ts @@ -8,7 +8,7 @@ const updateNativeTextInputValue: UpdateNativeTextInputValue = ({text, shouldFor return; } - composerRef.current?.setNativeProps({text}); + composerRef.current?.setNativeProps?.({text}); }; export default updateNativeTextInputValue; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index f3664da4423f..80702600238d 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -59,7 +59,11 @@ function useComposerSubmit(reportID: string) { /** * Add or edit a comment in the composer */ - const validateAndSubmitDraft = (draftMessage: string) => { + const validateAndSubmitDraft = (draftMessage: string | undefined) => { + // Live-markdown may clear before it initializes value. Keep that failure before submission effects. + if (draftMessage === undefined) { + throw new TypeError("Cannot read properties of undefined (reading 'trim')"); + } const draftMessageTrimmed = draftMessage.trim(); const isSubmittingEdit = isEditingInComposer || didResetComposerHeightWhileEditing; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts index 21f768e95792..c3768f5d8b11 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSuggestions.ts @@ -47,7 +47,8 @@ function useComposerSuggestions({composerRef, selection, measureParentContainer, useEffect(() => { // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. - tag.set(findNodeHandle(composerRef.current) ?? -1); + const input = composerRef.current; + tag.set(findNodeHandle(input && 'measure' in input ? input : null) ?? -1); }, [tag, composerRef]); useFocusedInputHandler( diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index d80fc2d53d64..55ccc8145670 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -27,7 +27,7 @@ type UseEditMessageProps = { shouldScrollToLastMessage?: boolean; scrollToLastMessage?: () => void; debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; - composerRef: React.RefObject; + composerRef: React.RefObject | null>; }; /** diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index e6cd200eeb43..d1df646be63a 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -170,7 +170,7 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy }, [action, reportID]); useEffect(() => { - composerFocusKeepFocusOn(composerRef.current as HTMLElement, isFocused, modal, onyxInputFocused); + composerFocusKeepFocusOn(composerRef.current, isFocused, modal, onyxInputFocused); }, [isFocused, modal, onyxInputFocused]); useEffect( diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index b080a903a391..8a5bdf509caa 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Navigation from '@libs/Navigation/Navigation'; -import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import {createOnyxTabNavigator, TabScreenWithFocusTrapWrapper} from '@libs/Navigation/OnyxTabNavigator'; import {isMapOrGPSRequired} from '@libs/PolicyDistanceRatesUtils'; import {getActivePolicies, isGroupPolicy} from '@libs/PolicyUtils'; import {getPayeeName, isExpenseReport, isPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; @@ -25,9 +25,10 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type {SelectedTabRequest} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {TupleToUnion} from 'type-fest'; + import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -38,8 +39,11 @@ import IOURequestStepDistanceGPS from './step/IOURequestStepDistanceGPS'; import IOURequestStepDistanceMap from './step/IOURequestStepDistanceMap'; import IOURequestStepDistanceOdometer from './step/IOURequestStepDistanceOdometer'; +const tabNames = [CONST.TAB_REQUEST.DISTANCE_MAP, CONST.TAB_REQUEST.DISTANCE_MANUAL, CONST.TAB_REQUEST.DISTANCE_GPS, CONST.TAB_REQUEST.DISTANCE_ODOMETER] as const; +const {OnyxTabNavigator, TopTab} = createOnyxTabNavigator(tabNames); + type DistanceRequestStartPageProps = WithWritableReportOrNotFoundProps & { - defaultSelectedTab: SelectedTabRequest; + defaultSelectedTab: TupleToUnion; }; function DistanceRequestStartPage({ diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index 8799dfbf82e5..063b05e2cf0f 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -23,7 +23,7 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldShowPerDiemTabOption} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import {createOnyxTabNavigator, TabScreenWithFocusTrapWrapper} from '@libs/Navigation/OnyxTabNavigator'; import {isPerDiemEligiblePolicy, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import {getPayeeName} from '@libs/ReportUtils'; import {endSpan} from '@libs/telemetry/activeSpans'; @@ -40,6 +40,8 @@ import type {SelectedTabRequest} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import type {TupleToUnion} from 'type-fest'; + import React, {useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -54,8 +56,11 @@ import IOURequestStepPerDiemWorkspace from './step/IOURequestStepPerDiemWorkspac import IOURequestStepScan from './step/IOURequestStepScan'; import IOURequestStepTimeWorkspace from './step/IOURequestStepTimeWorkspace'; +const tabNames = [CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN, CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.PER_DIEM, CONST.TAB_REQUEST.TIME] as const; +const {OnyxTabNavigator, TopTab} = createOnyxTabNavigator(tabNames); + type IOURequestStartPageProps = WithWritableReportOrNotFoundProps & { - defaultSelectedTab: SelectedTabRequest; + defaultSelectedTab: TupleToUnion; }; // Tab indices for IOURequestStartPage diff --git a/tests/ui/ComposerImperativeRefTest.tsx b/tests/ui/ComposerImperativeRefTest.tsx new file mode 100644 index 000000000000..442ce7b08e9c --- /dev/null +++ b/tests/ui/ComposerImperativeRefTest.tsx @@ -0,0 +1,773 @@ +/** Executes Composer platform refs, animated clearing and focus-command consumers. */ +import {act, render, renderHook} from '@testing-library/react-native'; + +import type * as ImplementationModule from '@components/Composer/implementation/index'; +import type * as ImplementationNativeModule from '@components/Composer/implementation/index.native'; +import NativeComposer from '@components/Composer/implementation/index.native'; +import type {ComposerProps, ComposerRef} from '@components/Composer/types'; +import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; + +import type * as UseBlurOnKeyboardHideModule from '@hooks/useBlurOnKeyboardHide'; +import type * as UseHtmlPasteModule from '@hooks/useHtmlPaste/index.ts'; +import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; + +import {composerFocusKeepFocusOn} from '@libs/actions/InputFocus/index.web'; +import {getBrowser, isMobileChrome} from '@libs/Browser'; +import type * as ComponentUtilsModule from '@libs/ComponentUtils/index'; +import type * as ComponentUtilsNativeModule from '@libs/ComponentUtils/index.native'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import preventFirstResponder from '@libs/preventTextInputFocusOnFirstResponderOnce/index.ios'; +import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; + +import type * as ComposerContextModule from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import ComposerWithSuggestions from '@pages/inbox/report/ReportActionCompose/ComposerWithSuggestions'; +import type {ComposerWithSuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ComposerWithSuggestions'; +import updateNativeTextInputValue from '@pages/inbox/report/ReportActionCompose/updateNativeTextInputValue/index.ios'; +import type * as UseComposerSuggestionsModule from '@pages/inbox/report/ReportActionCompose/useComposerSuggestions'; +import type * as UseEditComposerToggleModule from '@pages/inbox/report/ReportActionCompose/useEditComposerToggle'; +import type * as ReportActionEditMessageContextModule from '@pages/inbox/report/ReportActionEditMessageContext'; +import type * as UseDebouncedSaveDraftModule from '@pages/inbox/report/useDebouncedSaveDraft'; +import type * as UseDebouncedSaveReportActionDraftModule from '@pages/inbox/report/useDebouncedSaveReportActionDraft'; + +import type * as ReportModule from '@userActions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Modal} from '@src/types/onyx'; + +import type * as MarkdownTextInputWebModule from '@expensify/react-native-live-markdown/lib/typescript/src/MarkdownTextInput.web'; +import type * as ReactNavigationModule from '@react-navigation/native'; +import type {TextInput, TextInputKeyPressEvent, TextInputSelectionChangeEvent} from 'react-native'; +import type * as UseAnimatedRefModule from 'react-native-reanimated/lib/typescript/hook/useAnimatedRef'; + +import React, {act as actDOM} from 'react'; +import {createRoot} from 'react-dom/client'; +import Onyx from 'react-native-onyx'; +import {dispatchCommand, useAnimatedRef} from 'react-native-reanimated'; + +import createMock from '../utils/createMock'; + +let mockShouldMountComposer = true; +let mockShouldUseNativeComposer = false; +let mockMarkdownInput: TextInput | (HTMLDivElement & Pick) | null = null; +const WebComposer = jest.requireActual('@components/Composer/implementation/index.tsx').default; +const {forceClearInput: clearWebInput} = jest.requireActual('@libs/ComponentUtils/index.ts'); +const mockResetScroll = jest.fn< + ReturnType['resetScrollPosition']>, + Parameters['resetScrollPosition']> +>(); +const mockSuggestionsRef = {current: null}; +const {forceClearInput: clearNativeInput} = jest.requireActual('@libs/ComponentUtils/index.native.ts'); + +jest.mock('@components/Composer', () => { + const ReactModule = jest.requireActual('react'); + const Web = jest.requireActual('@components/Composer/implementation/index.tsx').default; + const Native = jest.requireActual('@components/Composer/implementation/index.native').default; + return (props: ComposerProps) => (mockShouldMountComposer ? ReactModule.createElement(mockShouldUseNativeComposer ? Native : Web, {...props, key: props.placeholder}) : null); +}); +jest.mock('@components/RNMarkdownTextInput', () => { + const ReactModule = jest.requireActual('react'); + return jest.fn((props: Parameters[0]) => ReactModule.createElement('MarkdownInput', {ref: props.ref})); +}); +jest.mock('@expensify/react-native-live-markdown/lib/commonjs/web/MarkdownTextInput.css', () => ({})); +jest.mock('@components/OnyxListItemProvider', () => ({useSession: () => undefined})); +jest.mock('@components/WideRHPContextProvider', () => ({useWideRHPState: () => ({superWideRHPRouteKeys: [], wideRHPRouteKeys: []})})); +jest.mock('@hooks/useTheme', () => () => ({})); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@hooks/useStyleUtils', () => () => ({ + getComposeTextAreaPadding: () => ({}), + getComposerMaxHeightStyle: () => ({}), + getContainerComposeStyles: () => ({paddingVertical: 0}), +})); +jest.mock('@hooks/useMarkdownStyle', () => () => ({})); +jest.mock('@hooks/useIsScrollBarVisible', () => () => false); +jest.mock('@hooks/useBlurOnKeyboardHide', () => jest.fn, Parameters>()); +jest.mock('@hooks/useIsInLandscapeMode', () => jest.fn(() => false)); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/useHtmlPaste', () => jest.requireActual('@hooks/useHtmlPaste/index.ts')); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1})); +jest.mock('@hooks/useIsInSidePanel', () => () => false); +jest.mock('@hooks/useKeyboardState', () => () => ({isKeyboardShown: false})); +jest.mock('@hooks/useLocalize', () => () => ({preferredLocale: 'en'})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/useReportIDToNameMap', () => () => ({})); +jest.mock('@hooks/useResponsiveLayout', () => () => ({shouldUseNarrowLayout: false})); +jest.mock('@hooks/useSidePanelState', () => () => ({isSidePanelHiddenOrLargeScreen: true, shouldHideSidePanel: false})); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useIsFocused: () => true, + useRoute: () => ({name: 'Report', key: 'report'}), + useNavigation: () => ({addListener: () => () => {}}), +})); +jest.mock('@libs/Browser', () => ({isMobileSafari: () => false, isSafari: () => false, isMobileChrome: jest.fn(() => false), getBrowser: jest.fn(() => 'chrome')})); +jest.mock('@libs/ComponentUtils', () => jest.requireActual('@libs/ComponentUtils/index.ts')); +jest.mock('@libs/Navigation/TransitionTracker', () => ({runAfterTransitions: jest.fn(() => ({cancel: jest.fn()}))})); +jest.mock('@libs/canFocusInputOnScreenFocus', () => () => false); +jest.mock('@libs/willBlurTextInputOnTapOutside', () => () => false); +jest.mock('@pages/Share/getFileSize', () => () => Promise.resolve(7)); +jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => ({ + useReportActionActiveEditActions: () => ({ + setEditingMessage: jest.fn< + ReturnType['setEditingMessage']>, + Parameters['setEditingMessage']> + >(), + setCurrentEditMessageSelection: jest.fn< + ReturnType['setCurrentEditMessageSelection']>, + Parameters['setCurrentEditMessageSelection']> + >(), + }), +})); +jest.mock('@pages/inbox/report/useDebouncedSaveDraft', () => () => ({ + saveDraft: jest.fn['saveDraft']>, Parameters['saveDraft']>>(), + isSavePending: {current: false}, +})); +jest.mock('@pages/inbox/report/useDebouncedSaveReportActionDraft', () => () => ({ + saveDraft: jest.fn< + ReturnType['saveDraft']>, + Parameters['saveDraft']> + >(), + isSavePending: {current: false}, + cancelSaveDraft: jest.fn(), +})); +jest.mock('@pages/inbox/report/useDraftMessageVideoAttributeCache', () => () => {}); +jest.mock('@pages/inbox/report/useShouldEditInComposer', () => () => false); +jest.mock('@pages/inbox/report/ReportActionCompose/ComposerContext', () => ({ + useComposerText: () => '', + useComposerActions: () => ({ + setText: jest.fn< + ReturnType['setText']>, + Parameters['setText']> + >(), + }), + useComposerEditState: () => ({editingState: 'off', effectiveDraft: null}), +})); +jest.mock('@pages/inbox/report/ReportActionCompose/useComposerSuggestions', () => () => ({ + measureParentContainerAndReportCursor: jest.fn< + ReturnType['measureParentContainerAndReportCursor']>, + Parameters['measureParentContainerAndReportCursor']> + >(), + onSaveScrollAndHideSuggestionMenu: jest.fn< + ReturnType['onSaveScrollAndHideSuggestionMenu']>, + Parameters['onSaveScrollAndHideSuggestionMenu']> + >(), + resetScrollPosition: mockResetScroll, + raiseIsScrollLayoutTriggered: jest.fn< + ReturnType['raiseIsScrollLayoutTriggered']>, + Parameters['raiseIsScrollLayoutTriggered']> + >(), +})); +jest.mock('@pages/inbox/report/ReportActionCompose/useEditComposerToggle', () => + jest.fn, Parameters>(), +); +jest.mock('@pages/inbox/report/ReportActionCompose/useLastEditableAction', () => () => undefined); +jest.mock('@pages/inbox/report/ReportActionCompose/Suggestions', () => () => null); +jest.mock('@pages/inbox/report/ReportActionCompose/SilentCommentUpdater', () => () => null); +jest.mock('@userActions/Report', () => ({ + broadcastUserIsTyping: jest.fn, Parameters>(), + saveReportActionDraft: jest.fn, Parameters>(), + saveReportDraftComment: jest.fn, Parameters>(), +})); +jest.mock('@userActions/Modal', () => ({areAllModalsHidden: () => true})); + +function markdownProps() { + const props = jest.mocked(RNMarkdownTextInput).mock.calls.at(-1)?.[0]; + if (!props) { + throw new Error('Expected actual Composer to render markdown input'); + } + return props; +} + +function webMarkdownInput(value?: string) { + // Live-markdown augments its actual div with clear/isFocused and may omit value before parsing. + const div = Object.assign(document.createElement('div'), { + clear: jest.fn(() => { + div.textContent = ''; + }), + isFocused: () => document.activeElement === div, + }); + div.setAttribute('contenteditable', 'true'); + div.tabIndex = 0; + document.body.appendChild(div); + if (value !== undefined) { + Object.assign(div, {value}); + } + mockMarkdownInput = div; + return div; +} + +function forwardingProps(ref: React.Ref): Parameters[0] { + return { + reportID: '1', + onFocus: jest.fn[0]['onFocus']>, Parameters[0]['onFocus']>>(), + onBlur: jest.fn[0]['onBlur']>, Parameters[0]['onBlur']>>(), + onValueChange: jest.fn[0]['onValueChange']>, Parameters[0]['onValueChange']>>(), + isComposerFullSize: false, + setIsFullComposerAvailable: jest.fn< + ReturnType[0]['setIsFullComposerAvailable']>, + Parameters[0]['setIsFullComposerAvailable']> + >(), + isMenuVisible: false, + inputPlaceholder: '', + onPasteFile: jest.fn[0]['onPasteFile']>, Parameters[0]['onPasteFile']>>(), + onEnterKeyPress: jest.fn< + ReturnType[0]['onEnterKeyPress']>, + Parameters[0]['onEnterKeyPress']> + >(), + measureParentContainer: jest.fn< + ReturnType[0]['measureParentContainer']>, + Parameters[0]['measureParentContainer']> + >(), + suggestionsRef: mockSuggestionsRef, + isNextModalWillOpenRef: {current: false}, + isGroupPolicyReport: false, + ref, + }; +} + +describe('Composer imperative refs', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockShouldMountComposer = true; + mockShouldUseNativeComposer = false; + jest.mocked(useIsInLandscapeMode).mockReturnValue(false); + jest.mocked(useAnimatedRef).mockImplementation(jest.requireActual('react-native-reanimated/src/hook/useAnimatedRef.ts').useAnimatedRef); + jest.mocked(isMobileChrome).mockReturnValue(false); + jest.mocked(getBrowser).mockReturnValue(CONST.BROWSER.CHROME); + jest.spyOn(Onyx, 'set').mockResolvedValue(); + }); + afterEach(() => { + jest.restoreAllMocks(); + document.body.replaceChildren(); + ReportActionComposeFocusManager.composerRef.current = null; + ReportActionComposeFocusManager.sidePanelComposerRef.current = null; + }); + + it('observes the installed live-markdown div with value omitted before parsing', () => { + // Given the installed web producer receives no value, matching an initially uncontrolled composer. + const LiveMarkdown = jest.requireActual('@expensify/react-native-live-markdown/lib/commonjs/MarkdownTextInput.web.js').default; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const ref = React.createRef>(); + const parser = jest.fn(() => []); + + // When the actual library attaches its div and runs its initial effects. + actDOM(() => + root.render( + , + ), + ); + const input = host.querySelector('div'); + const commands = ref.current; + if (!input || !commands) { + throw new Error('Expected live-markdown host and commands'); + } + + // Then DOM identity and common commands exist while value remains absent, so clear must preserve undefined. + expect(commands).toBe(input); + expect('value' in input).toBe(false); + expect(parser).not.toHaveBeenCalled(); + input.textContent = 'unparsed'; + actDOM(() => commands.clear()); + expect(input.textContent).toBe(''); + expect('value' in input).toBe(false); + actDOM(() => root.unmount()); + }); + + it.each(['hello', undefined])('keeps web commands, absent native capabilities and clear order for value %s', (value) => { + // Given an actual markdown div with either parsed text or the producer's omitted value. + const input = webMarkdownInput(value); + const onSelectionChange = jest.fn>, Parameters>>(); + const onClear = jest.fn>, Parameters>>(); + const ref = React.createRef(); + render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + const handle = ref.current; + if (!handle) { + throw new Error('Expected web Composer ref'); + } + const blur = jest.spyOn(input, 'blur'); + const focus = jest.spyOn(input, 'focus'); + input.scrollTop = 23; + + // When common commands and the custom clear sequence execute. + act(() => { + handle.focus(); + handle.blur(); + handle.clear(); + }); + + // Then underlying clear precedes selection reset and the captured optional value remains unchanged. + expect(focus).toHaveBeenCalledTimes(1); + expect(blur).toHaveBeenCalledTimes(1); + expect(handle.isFocused()).toBe(false); + expect(handle.setSelection).toBeUndefined(); + expect(handle.setNativeProps).toBeUndefined(); + expect(handle.preventFocusOnFirstResponderOnce).toBeUndefined(); + expect('scrollTop' in handle && handle.scrollTop).toBe(23); + expect(input.clear).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith({nativeEvent: {selection: {start: 0, end: 0}}}); + expect(onClear).toHaveBeenCalledWith(value); + const earlierCallOrder1 = input.clear.mock.invocationCallOrder.at(0); + const laterCallOrder1 = onSelectionChange.mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); + const earlierCallOrder2 = onSelectionChange.mock.invocationCallOrder.at(0); + const laterCallOrder2 = onClear.mock.invocationCallOrder.at(0); + if (earlierCallOrder2 === undefined || laterCallOrder2 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder2).toBeLessThan(laterCallOrder2); + expect(markdownProps().selection).toEqual({start: 0, end: 0}); + }); + + it('preserves raw native identity, top-level commands and non-null callback timing', () => { + // Given the native markdown input owns the real input commands at its top level. + const inputClear = jest.fn, Parameters>(); + const inputSetNativeProps = jest.fn, Parameters>(); + const inputPreventFocusOnFirstResponderOnce = jest.fn, Parameters>(); + const input = createMock({ + clear: inputClear, + blur: jest.fn, Parameters>(), + focus: jest.fn, Parameters>(), + isFocused: () => false, + setSelection: jest.fn, Parameters>(), + setNativeProps: inputSetNativeProps, + preventFocusOnFirstResponderOnce: inputPreventFocusOnFirstResponderOnce, + }); + mockMarkdownInput = input; + const callback = jest.fn(); + const view = render(, {createNodeMock: () => mockMarkdownInput}); + const ref = {current: input}; + + // When native selection/update/first-responder operations and clear use that ref. + input.clear(); + preventFirstResponder(ref); + updateNativeTextInputValue({composerRef: ref, shouldForceNativeValueUpdate: true, text: 'draft'}); + updateNativeTextInputValue({composerRef: ref, shouldForceNativeValueUpdate: false, text: 'ignored'}); + view.unmount(); + + // Then the callback exposes the same object and detachment does not add a null notification. + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(input); + expect(inputClear).toHaveBeenCalledTimes(1); + expect(inputPreventFocusOnFirstResponderOnce).toHaveBeenCalledTimes(1); + expect(inputSetNativeProps).toHaveBeenCalledTimes(1); + expect(inputSetNativeProps).toHaveBeenCalledWith({text: 'draft'}); + }); + + it('keeps the landscape proxy focus suppression and object-ref behavior', () => { + // Given native landscape mode suppresses programmatic focus through its existing proxy. + const inputFocus = jest.fn, Parameters>(); + const inputClear = jest.fn, Parameters>(); + const input = createMock({ + focus: inputFocus, + clear: inputClear, + isFocused: () => false, + }); + mockMarkdownInput = input; + jest.mocked(useIsInLandscapeMode).mockReturnValue(true); + const callback = jest.fn(); + render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + const proxy = callback.mock.calls.at(0)?.[0]; + if (!proxy) { + throw new Error('Expected landscape proxy'); + } + + // When consumers request focus and clear, and a separate object ref is supplied. + proxy.focus(); + proxy.clear(); + const objectRef = React.createRef(); + render(, {createNodeMock: () => mockMarkdownInput}); + + // Then only focus is suppressed and the original object-ref behavior remains intact. + expect(proxy).not.toBe(input); + expect(inputFocus).not.toHaveBeenCalled(); + expect(inputClear).toHaveBeenCalledTimes(1); + expect(markdownProps().autoFocus).toBe(false); + expect(objectRef.current).toBeNull(); + }); + + it('forwards native capabilities from the current inner ref and keeps absent common-command failures', () => { + // Given the forwarding interface exposes native commands only when its actual inner Composer does. + mockShouldUseNativeComposer = true; + const firstClear = jest.fn, Parameters>(); + const firstBlur = jest.fn, Parameters>(); + const firstFocus = jest.fn, Parameters>(); + const firstSetSelection = jest.fn, Parameters>(); + const firstSetNativeProps = jest.fn, Parameters>(); + const firstPreventFocusOnFirstResponderOnce = jest.fn, Parameters>(); + const first = createMock({ + clear: firstClear, + blur: firstBlur, + focus: firstFocus, + isFocused: () => false, + setSelection: firstSetSelection, + setNativeProps: firstSetNativeProps, + preventFocusOnFirstResponderOnce: firstPreventFocusOnFirstResponderOnce, + }); + const secondClear = jest.fn, Parameters>(); + const secondBlur = jest.fn, Parameters>(); + const secondFocus = jest.fn, Parameters>(); + const secondSetSelection = jest.fn, Parameters>(); + const secondSetNativeProps = jest.fn, Parameters>(); + const secondPreventFocusOnFirstResponderOnce = jest.fn, Parameters>(); + const second = createMock({ + clear: secondClear, + blur: secondBlur, + focus: secondFocus, + isFocused: () => false, + setSelection: secondSetSelection, + setNativeProps: secondSetNativeProps, + preventFocusOnFirstResponderOnce: secondPreventFocusOnFirstResponderOnce, + }); + mockMarkdownInput = first; + const ref = React.createRef(); + const props = forwardingProps(ref); + const view = render(, {createNodeMock: () => mockMarkdownInput}); + const handle = ref.current; + if (!handle) { + throw new Error('Expected forwarding interface'); + } + expect(Object.keys(handle)).toEqual([ + 'focus', + 'replaceSelectionWithText', + 'getCurrentText', + 'clearWorklet', + 'resetHeight', + 'clear', + 'blur', + 'isFocused', + 'setSelection', + 'setNativeProps', + 'preventFocusOnFirstResponderOnce', + 'scrollTop', + ]); + for (const key of ['setSelection', 'setNativeProps', 'preventFocusOnFirstResponderOnce', 'scrollTop']) { + const descriptor = Object.getOwnPropertyDescriptor(handle, key); + if (!descriptor) { + throw new Error('Expected own forwarding accessor'); + } + expect(descriptor.enumerable).toBe(true); + expect(descriptor.configurable).toBe(true); + expect(descriptor).toHaveProperty('get', expect.any(Function)); + expect(descriptor).toHaveProperty('set', undefined); + expect('value' in descriptor).toBe(false); + expect('writable' in descriptor).toBe(false); + } + expect(handle.scrollTop).toBeUndefined(); + handle.focus(false, {start: 2, end: 4}); + expect(firstFocus).toHaveBeenCalledTimes(1); + expect(firstSetSelection).toHaveBeenCalledWith(2, 4); + expect(ReportActionComposeFocusManager.composerRef.current).toBe(first); + expect(handle.setSelection).toBe(firstSetSelection); + expect(handle.setNativeProps).toBe(firstSetNativeProps); + expect(handle.preventFocusOnFirstResponderOnce).toBe(firstPreventFocusOnFirstResponderOnce); + + // When the input remounts with a replacement native object, the existing handle getters read current. + mockMarkdownInput = second; + view.rerender( + , + ); + const currentHandle = handle; + if (!currentHandle) { + throw new Error('Expected replacement interface'); + } + currentHandle.clear(); + currentHandle.blur(); + + // Then native command identity and clear/blur remain tied to the actual replacement. + expect(ReportActionComposeFocusManager.composerRef.current).toBe(second); + expect(currentHandle.setSelection).toBe(secondSetSelection); + expect(currentHandle.setNativeProps).toBe(secondSetNativeProps); + expect(currentHandle.preventFocusOnFirstResponderOnce).toBe(secondPreventFocusOnFirstResponderOnce); + expect(secondClear).toHaveBeenCalledTimes(1); + expect(secondBlur).toHaveBeenCalledTimes(1); + expect(firstClear).not.toHaveBeenCalled(); + + mockShouldMountComposer = false; + view.unmount(); + const absentRef = React.createRef(); + render(); + const absent = absentRef.current; + if (!absent) { + throw new Error('Expected absent-input forwarding interface'); + } + expect(() => absent.clear()).toThrow(TypeError); + expect(() => absent.blur()).toThrow(TypeError); + expect(() => absent.isFocused()).toThrow(TypeError); + expect(absent.setSelection).toBeUndefined(); + expect(absent.setNativeProps).toBeUndefined(); + expect(absent.preventFocusOnFirstResponderOnce).toBeUndefined(); + expect(() => absent.focus()).not.toThrow(); + expect(Object.keys(absent)).toEqual(Object.keys(handle)); + expect(absent.scrollTop).toBeUndefined(); + }); + + it('preserves native clear values and MIME-derived attachment filenames', async () => { + // Given clipboard files without extensions rely on their MIME type, while plain text is ignored. + mockMarkdownInput = createMock({isFocused: () => false}); + const onClear = jest.fn>, Parameters>>(); + const onPasteFile = jest.fn>, Parameters>>(); + render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + const props = markdownProps(); + const paste = props.onPaste; + const clear = props.onClear; + if (!paste || !clear) { + throw new Error('Expected native clear and paste callbacks'); + } + + // When native notifications pass through Composer without a ref wrapper. + await act(async () => { + clear(createMock[0]>({nativeEvent: {text: 'draft'}})); + paste(createMock[0]>({nativeEvent: {items: [{type: 'text/plain', data: 'hello'}]}})); + paste(createMock[0]>({nativeEvent: {items: [{type: 'image/png', data: 'file:///photo'}]}})); + await Promise.resolve(); + }); + + // Then text absence is not coerced and the attachment keeps its MIME-derived extension and observed size. + expect(onClear).toHaveBeenCalledWith('draft'); + expect(onPasteFile).toHaveBeenCalledTimes(1); + expect(onPasteFile).toHaveBeenCalledWith({uri: 'file:///photo', name: 'photo.png', type: 'image/png', size: 7}); + }); + + it.each([ + {browser: CONST.BROWSER.CHROME, isComposing: true, keyCode: 13, mobile: false, calls: 0}, + {browser: CONST.BROWSER.SAFARI, isComposing: false, keyCode: 229, mobile: false, calls: 0}, + {browser: CONST.BROWSER.CHROME, isComposing: true, keyCode: 13, mobile: true, calls: 1}, + {browser: CONST.BROWSER.CHROME, isComposing: false, keyCode: 13, mobile: false, calls: 1}, + ])('preserves nativeEvent IME handling for $browser/$keyCode/mobile=$mobile', ({browser, isComposing, keyCode, mobile, calls}) => { + // Given the input owns a real native KeyboardEvent in its React Native notification. + webMarkdownInput('text'); + jest.mocked(getBrowser).mockReturnValue(browser); + jest.mocked(isMobileChrome).mockReturnValue(mobile); + const onKeyPress = jest.fn>, Parameters>>(); + render(, {createNodeMock: () => mockMarkdownInput}); + const event = createMock({nativeEvent: new KeyboardEvent('keydown', {key: 'Enter', isComposing, keyCode})}); + + // When Enter is delivered through the actual web Composer handler and unchanged IME helper. + act(() => markdownProps().onKeyPress?.(event)); + + // Then IME confirmation is suppressed except for the helper's mobile Chrome behavior. + expect(onKeyPress).toHaveBeenCalledTimes(calls); + if (calls) { + expect(onKeyPress).toHaveBeenCalledWith(event); + } + }); + + it('retains paste ownership for another input and accepts attachments from the composer itself', () => { + // Given the active composer and another real input share the document paste listener. + const input = webMarkdownInput('text'); + input.focus(); + const onPasteFile = jest.fn>, Parameters>>(); + render( + true} + onPasteFile={onPasteFile} + />, + {createNodeMock: () => mockMarkdownInput}, + ); + const other = document.createElement('textarea'); + document.body.appendChild(other); + const file = new File(['photo'], 'photo.png', {type: 'image/png'}); + const otherEvent = new Event('paste', {bubbles: true, cancelable: true}); + Object.defineProperty(otherEvent, 'clipboardData', {value: {files: [file], types: [], getData: () => ''}}); + const ownEvent = new Event('paste', {bubbles: true, cancelable: true}); + Object.defineProperty(ownEvent, 'clipboardData', {value: {files: [file], types: [], getData: () => ''}}); + + // When the paste target belongs first to the other input and then to the raw markdown div. + act(() => other.dispatchEvent(otherEvent)); + expect(onPasteFile).not.toHaveBeenCalled(); + expect(otherEvent.defaultPrevented).toBe(false); + act(() => input.dispatchEvent(ownEvent)); + + // Then ownership preserves other input paste while handling the composer's attachment once. + expect(ownEvent.defaultPrevented).toBe(true); + expect(onPasteFile).toHaveBeenCalledWith([file]); + }); + + it('retains caret coordinates on real selection notifications', () => { + // Given layout belongs to the raw markdown div and the current browser range. + const input = webMarkdownInput('text'); + input.textContent = 'text'; + input.scrollTop = 8; + const range = document.createRange(); + range.selectNodeContents(input); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const rectsDescriptor = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects'); + Object.defineProperty(Range.prototype, 'getClientRects', { + configurable: true, + value: () => { + const rects = createMock({length: 1}); + Object.defineProperty(rects, 0, {value: createMock({left: 20, top: 40, height: 10}), enumerable: true, configurable: true, writable: true}); + return rects; + }, + }); + jest.spyOn(input, 'getBoundingClientRect').mockReturnValue(createMock({left: 5, top: 10})); + const changed = jest.fn>, Parameters>>(); + render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + const event = createMock({nativeEvent: {selection: {start: 1, end: 2}}}); + + // When the actual Composer adds relative caret geometry to a real notification. + act(() => markdownProps().onSelectionChange?.(event)); + + // Then selection indices and geometry both reach consumers. + expect(changed).toHaveBeenCalledWith(expect.objectContaining({nativeEvent: {selection: {start: 1, end: 2, positionX: 15 - CONST.SPACE_CHARACTER_WIDTH, positionY: 33}}})); + if (rectsDescriptor) { + Object.defineProperty(Range.prototype, 'getClientRects', rectsDescriptor); + } else { + Reflect.deleteProperty(Range.prototype, 'getClientRects'); + } + }); + + it('clears the current animated web handle after replacement and retains native dispatch', () => { + // Given Reanimated stores the actual public Composer object in its current field. + const first = webMarkdownInput('first'); + const ref = React.createRef(); + const props = forwardingProps(ref); + const view = render(, {createNodeMock: () => mockMarkdownInput}); + const retainedHandle = ref.current; + if (!retainedHandle) { + throw new Error('Expected retained forwarding interface'); + } + expect(retainedHandle.scrollTop).toBe(0); + first.scrollTop = 17; + expect(retainedHandle.scrollTop).toBe(17); + expect(retainedHandle.setSelection).toBeUndefined(); + expect(retainedHandle.setNativeProps).toBeUndefined(); + expect(retainedHandle.preventFocusOnFirstResponderOnce).toBeUndefined(); + const firstHandle = ReportActionComposeFocusManager.composerRef.current; + expect(firstHandle).toBe(ReportActionComposeFocusManager.composerRef.current); + expect(firstHandle).not.toBeNull(); + const second = webMarkdownInput('second'); + + // When the real child is reattached before the existing clearWorklet executes. + view.rerender( + , + ); + const handle = ref.current; + if (!handle) { + throw new Error('Expected forwarding interface'); + } + expect(retainedHandle.scrollTop).toBe(0); + second.scrollTop = 29; + expect(retainedHandle.scrollTop).toBe(29); + const focus = jest.spyOn(second, 'focus'); + act(() => retainedHandle.focus()); + expect(focus).toHaveBeenCalledTimes(1); + act(() => handle.clearWorklet()); + const {result} = renderHook(() => jest.requireActual('react-native-reanimated/src/hook/useAnimatedRef.ts').useAnimatedRef()); + act(() => result.current(ReportActionComposeFocusManager.composerRef.current)); + clearNativeInput(result.current); + + // Then current-ref clearing uses the replacement and native clearing still dispatches the same ref. + expect(first.clear).not.toHaveBeenCalled(); + expect(second.clear).toHaveBeenCalledTimes(1); + expect(ReportActionComposeFocusManager.composerRef.current).not.toBe(firstHandle); + expect(dispatchCommand).toHaveBeenCalledWith(result.current, 'clear'); + expect(handle.setSelection).toBeUndefined(); + expect(handle.setNativeProps).toBeUndefined(); + expect(handle.preventFocusOnFirstResponderOnce).toBeUndefined(); + expect(mockResetScroll).toHaveBeenCalledTimes(1); + result.current.current = null; + expect(() => clearWebInput(result.current)).not.toThrow(); + }); +}); + +const hiddenModal = {} satisfies Modal; + +describe('composerFocusKeepFocusOn actual focus commands', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Onyx, 'set').mockResolvedValue(); + jest.spyOn(ReportActionComposeFocusManager, 'isFocused').mockReturnValue(false); + // The focused branch intentionally saves null without an immediate invocation when Onyx is already focused. + composerFocusKeepFocusOn(null, true, hiddenModal, true); + }); + afterEach(() => jest.restoreAllMocks()); + + it('updates Onyx before immediate focus and preserves the null TypeError after that update', () => { + // Given the helper consumes only a focus command, not a DOM element. + const ref = {focus: jest.fn, Parameters>()} satisfies NonNullable[0]>; + + // When initial focus executes and a missing ref reaches the same immediate branch. + composerFocusKeepFocusOn(ref, true, hiddenModal, false); + expect(() => composerFocusKeepFocusOn(null, true, hiddenModal, false)).toThrow(TypeError); + + // Then both calls update Onyx first, while only the real command object focuses. + expect(Onyx.set).toHaveBeenNthCalledWith(1, ONYXKEYS.INPUT_FOCUSED, true); + expect(Onyx.set).toHaveBeenNthCalledWith(2, ONYXKEYS.INPUT_FOCUSED, true); + expect(ref.focus).toHaveBeenCalledTimes(1); + const earlierCallOrder3 = jest.mocked(Onyx.set).mock.invocationCallOrder.at(0); + const laterCallOrder3 = ref.focus.mock.invocationCallOrder.at(0); + if (earlierCallOrder3 === undefined || laterCallOrder3 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder3).toBeLessThan(laterCallOrder3); + }); + + it('defers focus against the latest saved command object and respects modal/manager gates', () => { + // Given a saved composer loses focus while all modals are hidden. + const first = {focus: jest.fn, Parameters>()}; + const second = {focus: jest.fn, Parameters>()}; + composerFocusKeepFocusOn(first, true, hiddenModal, true); + composerFocusKeepFocusOn(null, false, {isVisible: true}, false); + composerFocusKeepFocusOn(null, false, {willAlertModalBecomeVisible: true}, false); + expect(TransitionTracker.runAfterTransitions).not.toHaveBeenCalled(); + + // When restoration is queued and another composer replaces the saved ref before transitions finish. + composerFocusKeepFocusOn(null, false, hiddenModal, false); + composerFocusKeepFocusOn(second, true, hiddenModal, true); + const deferred = jest.mocked(TransitionTracker.runAfterTransitions).mock.calls.at(0)?.[0]; + if (!deferred?.callback) { + throw new Error('Expected deferred focus'); + } + deferred.callback(); + + // Then the latest object focuses. An already focused manager clears the saved object and prevents later restoration. + expect(first.focus).not.toHaveBeenCalled(); + expect(second.focus).toHaveBeenCalledTimes(1); + jest.mocked(ReportActionComposeFocusManager.isFocused).mockReturnValue(true); + composerFocusKeepFocusOn(null, false, hiddenModal, false); + jest.mocked(ReportActionComposeFocusManager.isFocused).mockReturnValue(false); + composerFocusKeepFocusOn(null, false, hiddenModal, false); + expect(TransitionTracker.runAfterTransitions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/ui/DistanceRequestStartPageTest.tsx b/tests/ui/DistanceRequestStartPageTest.tsx index 5067fc7285ac..4932e78be3f6 100644 --- a/tests/ui/DistanceRequestStartPageTest.tsx +++ b/tests/ui/DistanceRequestStartPageTest.tsx @@ -4,6 +4,7 @@ import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersona import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type * as OnyxTabNavigatorModule from '@libs/Navigation/OnyxTabNavigator'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; @@ -73,12 +74,16 @@ jest.mock('@components/ScreenWrapper', () => { jest.mock('@libs/Navigation/OnyxTabNavigator', () => { const ReactActual = jest.requireActual('react'); - const actual = jest.requireActual<{default: React.ComponentType>}>('@libs/Navigation/OnyxTabNavigator'); - function MockOnyxTabNavigator(props: Record) { - mockOnyxTabNavigatorProps.current = props; - return ReactActual.createElement(actual.default, props); - } - return {...actual, __esModule: true, default: MockOnyxTabNavigator}; + const actual = jest.requireActual('@libs/Navigation/OnyxTabNavigator'); + const createNavigator: typeof actual.createOnyxTabNavigator = (names) => { + const pair = actual.createOnyxTabNavigator(names); + function MockOnyxTabNavigator(props: Parameters[0]) { + mockOnyxTabNavigatorProps.current = props; + return ReactActual.createElement(pair.OnyxTabNavigator, props); + } + return {...pair, OnyxTabNavigator: MockOnyxTabNavigator}; + }; + return {...actual, createOnyxTabNavigator: createNavigator}; }); const REPORT_ID = '1'; @@ -109,7 +114,11 @@ async function setUpOnyx({selectedTab, lastDistanceExpenseType}: {selectedTab?: }); } -async function renderPage(defaultSelectedTab: SelectedTabRequest, iouType: IOUType = CONST.IOU.TYPE.TRACK, action: IOUAction = CONST.IOU.ACTION.CREATE) { +async function renderPage( + defaultSelectedTab: Parameters[0]['defaultSelectedTab'], + iouType: IOUType = CONST.IOU.TYPE.TRACK, + action: IOUAction = CONST.IOU.ACTION.CREATE, +) { render( diff --git a/tests/ui/IOURequestStartPageManualTabTest.tsx b/tests/ui/IOURequestStartPageManualTabTest.tsx index 41372ebb5502..ae2fc96d238e 100644 --- a/tests/ui/IOURequestStartPageManualTabTest.tsx +++ b/tests/ui/IOURequestStartPageManualTabTest.tsx @@ -3,6 +3,8 @@ import {act, render, screen} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type * as OnyxTabNavigatorModule from '@libs/Navigation/OnyxTabNavigator'; + import IOURequestStartPage from '@pages/iou/request/IOURequestStartPage'; import type {IOURequestType, IOUType} from '@src/CONST'; @@ -15,6 +17,8 @@ import {NavigationContainer} from '@react-navigation/native'; import React from 'react'; import Onyx from 'react-native-onyx'; +import type * as CreateMockModule from '../utils/createMock'; + import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const REPORT_ID = '1'; @@ -31,6 +35,8 @@ jest.mock('@rnmapbox/maps', () => ({ setAccessToken: jest.fn(), })); +jest.mock('react-native-tab-view', () => ({TabView: () => null, TabBar: () => null, SceneMap: jest.fn()})); + jest.mock('react-native-vision-camera', () => ({ useCameraDevice: jest.fn(), })); @@ -45,13 +51,23 @@ jest.mock('@react-navigation/native', () => ({ // Render every tab screen inline so the manual tab content is mounted without driving the real tab navigator. jest.mock('@libs/Navigation/OnyxTabNavigator', () => { const ReactModule = jest.requireActual('react'); + const actual = jest.requireActual('@libs/Navigation/OnyxTabNavigator'); + const createMock = jest.requireActual('../utils/createMock').default; + const createNavigator = (names: Parameters>[0]) => { + const pair = actual.createOnyxTabNavigator(names); + const OnyxTabNavigator = ({children}: Parameters[0]) => ReactModule.createElement(ReactModule.Fragment, null, children); + // Production Screen is a null marker. This mock deliberately renders its children for page-content cases. + const Screen = (props: Parameters>[0]) => { + const child = props.children; + const content = typeof child === 'function' ? child(createMock[0]>({})) : child; + return ReactModule.createElement(ReactModule.Fragment, null, content); + }; + return {...pair, OnyxTabNavigator, TopTab: {...pair.TopTab, Screen}}; + }; return { - __esModule: true, - default: ({children}: {children: React.ReactNode}) => ReactModule.createElement(ReactModule.Fragment, null, children), - TopTab: { - Screen: ({children}: {children: () => React.ReactNode}) => ReactModule.createElement(ReactModule.Fragment, null, typeof children === 'function' ? children() : children), - }, - TabScreenWithFocusTrapWrapper: ({children}: {children: React.ReactNode}) => ReactModule.createElement(ReactModule.Fragment, null, children), + ...actual, + createOnyxTabNavigator: createNavigator, + TabScreenWithFocusTrapWrapper: ({children}: Parameters[0]) => ReactModule.createElement(ReactModule.Fragment, null, children), }; }); diff --git a/tests/ui/OnyxTabNavigatorTest.tsx b/tests/ui/OnyxTabNavigatorTest.tsx index 52bcdf749bce..78255791629a 100644 --- a/tests/ui/OnyxTabNavigatorTest.tsx +++ b/tests/ui/OnyxTabNavigatorTest.tsx @@ -2,44 +2,48 @@ import {act, render} from '@testing-library/react-native'; import {ModalActions} from '@components/Modal/Global/ModalContext'; +import useOnyx from '@hooks/useOnyx'; + import ComposerFocusManager from '@libs/ComposerFocusManager'; import getPlatform from '@libs/getPlatform'; -import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import OnyxTabNavigator, {createOnyxTabNavigator, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import type {OnyxTabNavigatorProps} from '@libs/Navigation/OnyxTabNavigator'; +import type {TabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import Tab from '@userActions/Tab'; + import CONST from '@src/CONST'; import KeyboardUtils from '@src/utils/keyboard'; +import type {NavigationProp, ParamListBase} from '@react-navigation/native'; import type {ValueOf} from 'type-fest'; import React from 'react'; import {Keyboard} from 'react-native'; -type MockNavigation = { - getState: () => { - index: number; - routes: Array<{key: string; name: string}>; - }; - dispatch: jest.Mock; -}; - -type MockTabPressEvent = { - target: string; - defaultPrevented: boolean; - preventDefault: jest.Mock; -}; - -type MockScreenListeners = (args: {navigation: MockNavigation}) => { - tabPress: (event: MockTabPressEvent) => void; +import createMock from '../utils/createMock'; + +type TabName = typeof CURRENT_TAB | typeof TARGET_TAB; +type MockNavigation = Pick, 'getState' | 'dispatch'>; +type TabListeners = NonNullable['screenListeners']>; +type TabStateEvent = Parameters>[0]; +type MockTabPressEvent = Parameters>[0] & { + preventDefault: jest.Mock< + ReturnType>[0]['preventDefault']>, + Parameters>[0]['preventDefault']> + >; }; +type MockScreenListeners = (args: {navigation: NavigationProp}) => TabListeners; type ConfirmModalResult = {action: ValueOf}; // Defaults to a promise that never settles, so the modal stays "open" unless a test resolves it. const mockShowConfirmModal = jest.fn((): Promise => new Promise(() => {})); -const mockOnDiscard = jest.fn(); -const mockDispatch = jest.fn(); +const mockOnDiscard = jest.fn, Parameters>(); +const mockDispatch = jest.fn['dispatch']>, Parameters['dispatch']>>(); let mockScreenListeners: MockScreenListeners | undefined; +let mockInitialRouteName: TabName | undefined; jest.mock('@hooks/useConfirmModal', () => ({ __esModule: true, @@ -80,8 +84,9 @@ jest.mock('@libs/getPlatform', () => ({ jest.mock('@react-navigation/material-top-tabs', () => { const ReactModule = jest.requireActual('react'); - const Navigator = ({children, screenListeners}: {children: React.ReactNode; screenListeners: MockScreenListeners}) => { + const Navigator = ({children, screenListeners, initialRouteName}: {children: React.ReactNode; screenListeners: MockScreenListeners; initialRouteName?: TabName}) => { mockScreenListeners = screenListeners; + mockInitialRouteName = initialRouteName; return ReactModule.createElement(ReactModule.Fragment, null, children); }; const Screen = ({children}: {children?: React.ReactNode | (() => React.ReactNode)}) => { @@ -120,23 +125,29 @@ function pressTargetTab(): MockTabPressEvent { throw new Error('Expected the tab navigator to register screen listeners'); } - const navigation: MockNavigation = { - getState: () => ({ - index: 0, - routes: [ - {key: `${CURRENT_TAB}-key`, name: CURRENT_TAB}, - {key: `${TARGET_TAB}-key`, name: TARGET_TAB}, - ], - }), + const navigation = createMock>({ + getState: () => + createMock>({ + index: 0, + routes: [ + {key: `${CURRENT_TAB}-key`, name: CURRENT_TAB}, + {key: `${TARGET_TAB}-key`, name: TARGET_TAB}, + ], + }), dispatch: mockDispatch, - }; + }); const event: MockTabPressEvent = { + type: 'tabPress', target: `${TARGET_TAB}-key`, defaultPrevented: false, - preventDefault: jest.fn(), + preventDefault: jest.fn, Parameters>(), }; - act(() => mockScreenListeners?.({navigation}).tabPress(event)); + const tabPress = mockScreenListeners({navigation}).tabPress; + if (!tabPress) { + throw new Error('Expected tabPress listener'); + } + act(() => tabPress(event)); return event; } @@ -159,7 +170,12 @@ describe('OnyxTabNavigator tab discard input blur', () => { expect(event.preventDefault).toHaveBeenCalledTimes(1); expect(blurActiveInputSpy).toHaveBeenCalledTimes(1); expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); - expect(blurActiveInputSpy.mock.invocationCallOrder.at(0)).toBeLessThan(mockShowConfirmModal.mock.invocationCallOrder.at(0) ?? 0); + const earlierCallOrder1 = blurActiveInputSpy.mock.invocationCallOrder.at(0); + const laterCallOrder1 = mockShowConfirmModal.mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); }); it('does not blur the active input when there are no unsaved changes', () => { @@ -382,6 +398,27 @@ describe('OnyxTabNavigator keyboard dismissal after discarding changes', () => { expect(mockDispatch).not.toHaveBeenCalled(); }); + it('still jumps after a rejected discard callback and releases the pending switch', async () => { + // Given discarding can fail after the user confirms, but must not strand the user on the old tab. + jest.spyOn(Keyboard, 'isVisible').mockReturnValue(false); + mockShowConfirmModal.mockImplementation(() => Promise.resolve({action: ModalActions.CONFIRM})); + mockOnDiscard.mockRejectedValueOnce(new Error('discard failed')); + renderTabNavigator(true, true); + + // When discard rejects and a later press reaches the same navigator. + await act(async () => { + pressTargetTab(); + }); + await act(async () => { + pressTargetTab(); + }); + + // Then each confirmed attempt can jump and no stale pending-modal gate blocks the next attempt. + expect(mockShowConfirmModal).toHaveBeenCalledTimes(2); + expect(mockOnDiscard).toHaveBeenCalledTimes(2); + expect(mockDispatch).toHaveBeenCalledTimes(2); + }); + it('jumps synchronously after discard when not deferring', async () => { jest.spyOn(Keyboard, 'isVisible').mockReturnValue(false); mockShowConfirmModal.mockImplementation(() => Promise.resolve({action: ModalActions.CONFIRM})); @@ -396,3 +433,220 @@ describe('OnyxTabNavigator keyboard dismissal after discarding changes', () => { expect(mockDispatch).toHaveBeenCalledTimes(1); }); }); + +const {OnyxTabNavigator: FiniteNavigator, TopTab: FiniteTabs} = createOnyxTabNavigator([CURRENT_TAB, TARGET_TAB]); + +function renderFiniteNavigator(props: Partial> = {}, hasTargetScreen = true) { + return render( + null} + {...props} + > + {() => null} + {hasTargetScreen && {() => null}} + {false} + {null} + {React.createElement(React.Fragment)} + , + ); +} + +function navigatorListeners() { + if (!mockScreenListeners) { + throw new Error('Expected actual navigator listeners'); + } + const navigation = createMock>({ + getState: () => + createMock>({ + index: 0, + routes: [ + {key: 'current-key', name: CURRENT_TAB}, + {key: 'target-key', name: TARGET_TAB}, + ], + }), + dispatch: mockDispatch, + }); + return mockScreenListeners({navigation}); +} + +function notifyNavigatorState(listeners: TabListeners, index: number) { + const state = listeners.state; + if (!state) { + throw new Error('Expected actual state listener'); + } + // React Navigation owns the finite route names; an out-of-range index yields undefined via .at(). + const event = createMock({type: 'state', data: {state: {index, routeNames: [CURRENT_TAB, TARGET_TAB]}}}); + act(() => state(event)); + return event; +} + +describe('OnyxTabNavigator finite state notifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useOnyx).mockReturnValue(createMock>([CURRENT_TAB, {status: 'loaded'}])); + jest.spyOn(Keyboard, 'isVisible').mockReturnValue(false); + }); + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('calls the caller first, selects initially once and persists before notifying a changed name', () => { + // Given caller state effects and the initial selection share the actual navigator listener. + const callerState = jest.fn>, Parameters>>(); + const selected = jest.fn(); + const initialSelect = jest.fn['onTabSelect']>>, Parameters['onTabSelect']>>>(); + const view = renderFiniteNavigator({screenListeners: {state: callerState}, onTabSelected: selected, onTabSelect: initialSelect}); + const listeners = navigatorListeners(); + + // When initial, changed and repeated unchanged state events arrive. + notifyNavigatorState(listeners, 0); + const event = notifyNavigatorState(listeners, 1); + jest.mocked(useOnyx).mockReturnValue(createMock>([TARGET_TAB, {status: 'loaded'}])); + view.rerender( + null} + onTabSelected={selected} + onTabSelect={initialSelect} + > + {() => null} + {() => null} + , + ); + const secondListeners = navigatorListeners(); + notifyNavigatorState(secondListeners, 1); + view.unmount(); + + // Then the caller keeps event identity and persistence precedes a truthful changed-name notification. + expect(callerState).toHaveBeenCalledWith(event); + expect(initialSelect).toHaveBeenCalledTimes(1); + const initialSelection = initialSelect.mock.calls.at(0); + if (!initialSelection) { + throw new Error('Expected initial tab selection'); + } + expect(initialSelection[0]).toEqual({index: 0}); + expect(Tab.setSelectedTab).toHaveBeenCalledTimes(1); + expect(Tab.setSelectedTab).toHaveBeenCalledWith('test-tab-navigator', TARGET_TAB); + expect(selected).toHaveBeenCalledTimes(1); + expect(selected).toHaveBeenCalledWith(TARGET_TAB); + const earlierCallOrder2 = callerState.mock.invocationCallOrder.at(1); + const laterCallOrder2 = jest.mocked(Tab.setSelectedTab).mock.invocationCallOrder.at(0); + if (earlierCallOrder2 === undefined || laterCallOrder2 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder2).toBeLessThan(laterCallOrder2); + const earlierCallOrder3 = jest.mocked(Tab.setSelectedTab).mock.invocationCallOrder.at(0); + const laterCallOrder3 = selected.mock.invocationCallOrder.at(0); + if (earlierCallOrder3 === undefined || laterCallOrder3 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder3).toBeLessThan(laterCallOrder3); + }); + + it('notifies changed undefined without persisting it and does not repeat initial selection', () => { + // Given a valid persisted tab and a temporarily out-of-range navigator index. + const selected = jest.fn(); + const initialSelect = jest.fn['onTabSelect']>>, Parameters['onTabSelect']>>>(); + renderFiniteNavigator({onTabSelected: selected, onTabSelect: initialSelect}); + const listeners = navigatorListeners(); + + // When state no longer has a route at its index, then restores the same stored tab. + notifyNavigatorState(listeners, 4); + notifyNavigatorState(listeners, 0); + + // Then absence is delivered rather than skipped, and initialization runs only once. + expect(selected).toHaveBeenCalledTimes(1); + expect(selected).toHaveBeenCalledWith(undefined); + expect(Tab.setSelectedTab).not.toHaveBeenCalled(); + expect(initialSelect).toHaveBeenCalledTimes(1); + expect(initialSelect).toHaveBeenCalledWith({index: 4}); + }); + + it.each([ + {stored: TARGET_TAB, hasTargetScreen: true, expected: TARGET_TAB}, + {stored: TARGET_TAB, hasTargetScreen: false, expected: CURRENT_TAB}, + {stored: CONST.TAB_REQUEST.SCAN, hasTargetScreen: true, expected: CURRENT_TAB}, + ])('admits stored $stored only when finite and present in current children', ({stored, hasTargetScreen, expected}) => { + // Given Onyx can contain a tab belonging to a different page or a currently hidden screen. + jest.mocked(useOnyx).mockReturnValue(createMock>([stored, {status: 'loaded'}])); + + // When the actual factory chooses its initial route. + renderFiniteNavigator({}, hasTargetScreen); + + // Then both finite-name and current-child admission precede the default fallback. + expect(mockInitialRouteName).toBe(expected); + }); + + it('reapplies an interrupted press before returning for unchanged selection', () => { + // Given React Navigation can leave the old selected tab after an allowed press. + jest.useFakeTimers(); + const selected = jest.fn(); + renderFiniteNavigator({shouldReapplyInterruptedTabPress: true, onTabSelected: selected}); + const listeners = navigatorListeners(); + const press = listeners.tabPress; + if (!press) { + throw new Error('Expected tabPress listener'); + } + const event: MockTabPressEvent = {type: 'tabPress', target: 'target-key', defaultPrevented: false, preventDefault: jest.fn()}; + + // When the old state is reported immediately after the press. + act(() => press(event)); + notifyNavigatorState(listeners, 0); + act(() => jest.advanceTimersByTime(20)); + + // Then the reapplication survives the unchanged comparison without persisting or notifying that old state. + expect(mockDispatch).toHaveBeenCalledWith(expect.objectContaining({type: 'JUMP_TO', payload: {name: TARGET_TAB}})); + expect(selected).not.toHaveBeenCalled(); + expect(Tab.setSelectedTab).not.toHaveBeenCalled(); + }); + + it('cancels queued interrupted jumps on unmount', () => { + // Given an interrupted press has scheduled a frame against this navigator's old state. + jest.useFakeTimers(); + const view = renderFiniteNavigator({shouldReapplyInterruptedTabPress: true}); + const listeners = navigatorListeners(); + const press = listeners.tabPress; + if (!press) { + throw new Error('Expected tabPress listener'); + } + act(() => press({type: 'tabPress', target: 'target-key', defaultPrevented: false, preventDefault: jest.fn()})); + notifyNavigatorState(listeners, 0); + + // When the navigator unmounts before that frame executes. + view.unmount(); + act(() => jest.advanceTimersByTime(20)); + + // Then cleanup removes the queued dispatch against the detached navigator. + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('respects a caller-prevented tab press before consulting discard guards', () => { + // Given the caller has first refusal over a tab press. + renderFiniteNavigator({screenListeners: {tabPress: (event) => event.preventDefault()}}); + const listeners = navigatorListeners(); + const press = listeners.tabPress; + if (!press) { + throw new Error('Expected tabPress listener'); + } + const event: MockTabPressEvent = { + type: 'tabPress', + target: 'target-key', + defaultPrevented: false, + preventDefault: jest.fn(() => { + Object.defineProperty(event, 'defaultPrevented', {value: true}); + }), + }; + + // When the caller blocks the press. + act(() => press(event)); + + // Then no modal, keyboard wait or jump is started by the navigator. + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/hooks/useHtmlPasteTest/index.tsx b/tests/ui/hooks/useHtmlPasteTest/index.tsx index b5a4f2e755e6..4f08c8d3d611 100644 --- a/tests/ui/hooks/useHtmlPasteTest/index.tsx +++ b/tests/ui/hooks/useHtmlPasteTest/index.tsx @@ -6,6 +6,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; import type {RefObject} from 'react'; +import type {TextInput} from 'react-native'; import Onyx from 'react-native-onyx'; @@ -21,7 +22,7 @@ jest.mock('@src/hooks/useHtmlPaste', (): typeof useHtmlPaste => { }); describe('useHtmlPaste - handlePastePlainText', () => { - let textInputRef: RefObject; + let textInputRef: RefObject<(HTMLDivElement & Pick) | null>; let textInputElement: HTMLDivElement; const createMockClipboardEvent = (text: string): ClipboardEvent => { @@ -55,21 +56,20 @@ describe('useHtmlPaste - handlePastePlainText', () => { textInputElement.setAttribute('contenteditable', 'true'); textInputElement.textContent = ''; document.body.appendChild(textInputElement); - textInputRef = {current: textInputElement}; + textInputRef = {current: Object.assign(textInputElement, {isFocused: () => true})}; if (!Range.prototype.getBoundingClientRect) { - Range.prototype.getBoundingClientRect = () => - ({ - top: 0, - left: 0, - width: 0, - height: 0, - right: 0, - bottom: 0, - x: 0, - y: 0, - toJSON: () => {}, - }) as DOMRect; + Range.prototype.getBoundingClientRect = () => ({ + top: 0, + left: 0, + width: 0, + height: 0, + right: 0, + bottom: 0, + x: 0, + y: 0, + toJSON: () => {}, + }); } }); @@ -85,7 +85,6 @@ describe('useHtmlPaste - handlePastePlainText', () => { mockWindowSelection(selectedText); const event = createMockClipboardEvent(url); - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. const {result} = renderHook(() => useHtmlPaste(textInputRef)); await waitForBatchedUpdatesWithAct(); @@ -105,7 +104,6 @@ describe('useHtmlPaste - handlePastePlainText', () => { mockWindowSelection(''); const event = createMockClipboardEvent(url); - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. const {result} = renderHook(() => useHtmlPaste(textInputRef)); await waitForBatchedUpdatesWithAct(); @@ -125,7 +123,6 @@ describe('useHtmlPaste - handlePastePlainText', () => { mockWindowSelection('what up'); const event = createMockClipboardEvent(plainText); - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. const {result} = renderHook(() => useHtmlPaste(textInputRef)); await waitForBatchedUpdatesWithAct(); @@ -145,7 +142,6 @@ describe('useHtmlPaste - handlePastePlainText', () => { mockWindowSelection(''); const event = createMockClipboardEvent(textWithTrailingWhitespace); - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. const {result} = renderHook(() => useHtmlPaste(textInputRef)); await waitForBatchedUpdatesWithAct(); @@ -167,7 +163,7 @@ describe('useHtmlPaste - handlePastedHTML', () => { const ROOM_NAME = '#general'; const PASTED_HTML = `Hey team`; - let textInputRef: RefObject; + let textInputRef: RefObject<(HTMLDivElement & Pick) | null>; let textInputElement: HTMLDivElement; /** The HTML flavour of the clipboard, which is what Clipboard.setHtml writes when copying a message. */ @@ -185,7 +181,6 @@ describe('useHtmlPaste - handlePastedHTML', () => { }; const pasteHtmlIntoActiveInput = async (html: string) => { - // @ts-expect-error -- this web test intentionally passes a contenteditable DOM ref to the shared hybrid hook. renderHook(() => useHtmlPaste(textInputRef, undefined, true)); await waitForBatchedUpdatesWithAct(); await act(async () => { @@ -207,8 +202,7 @@ describe('useHtmlPaste - handlePastedHTML', () => { textInputElement.textContent = ''; document.body.appendChild(textInputElement); // The hook ignores a paste when the input is not focused, and a bare DOM node has no isFocused(). - Object.defineProperty(textInputElement, 'isFocused', {value: () => true, configurable: true}); - textInputRef = {current: textInputElement}; + textInputRef = {current: Object.assign(textInputElement, {isFocused: () => true})}; const range = document.createRange(); range.selectNodeContents(textInputElement); @@ -223,7 +217,7 @@ describe('useHtmlPaste - handlePastedHTML', () => { it('resolves a pasted report mention to the room name', async () => { // Given the mentioned report is in Onyx, as it is when copying from a chat you can see - await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${MENTIONED_REPORT_ID}`, {reportID: MENTIONED_REPORT_ID, reportName: ROOM_NAME} as Report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${MENTIONED_REPORT_ID}`, {reportID: MENTIONED_REPORT_ID, reportName: ROOM_NAME} satisfies Report); // When the HTML flavour of the clipboard is pasted into the input await pasteHtmlIntoActiveInput(PASTED_HTML); @@ -243,3 +237,106 @@ describe('useHtmlPaste - handlePastedHTML', () => { expect(textInputElement.textContent).toBe('Hey #Hidden team'); }); }); + +describe('useHtmlPaste DOM insertion boundaries', () => { + afterEach(() => { + jest.restoreAllMocks(); + document.body.replaceChildren(); + }); + + it.each(['input', 'textarea'] as const)('uses %s setRangeText with the existing length arithmetic and focusin dispatch', (tagName) => { + // Given the actual RN Web host category, augmented with its isFocused command. + const input = Object.assign(document.createElement(tagName), {isFocused: () => true}); + input.value = 'abcd'; + input.setSelectionRange(1, 3); + const focusIn = jest.fn(); + input.addEventListener('focusin', focusIn); + const setRangeText = jest.spyOn(input, 'setRangeText'); + const frames = jest.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 1); + window.getSelection()?.removeAllRanges(); + const {result} = renderHook(() => useHtmlPaste({current: input}, undefined, false, 6)); + const paste = result.current?.handlePastePlainText; + if (!paste) { + throw new Error('Expected plaintext paste interface'); + } + const event = createMock({clipboardData: {getData: () => 'WORD'}}); + + // When a long paste replaces the selected span. + act(() => paste(event)); + + // Then truncation uses the pre-existing whole-value length and retains focus/scroll scheduling. + expect(setRangeText).toHaveBeenCalledWith('WO'); + expect(input.value).toBe('aWOd'); + expect(focusIn).toHaveBeenCalledTimes(1); + expect(frames).toHaveBeenCalledTimes(1); + }); + + it('inserts a contenteditable Range, retains the caret, dispatches input and scrolls on the scheduled frame', () => { + // Given the markdown div producer with a real selection and overflow geometry. + const input = Object.assign(document.createElement('div'), {isFocused: () => true}); + input.setAttribute('contenteditable', 'true'); + input.textContent = 'ab'; + document.body.appendChild(input); + const range = document.createRange(); + range.selectNodeContents(input); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + Object.defineProperties(input, {clientWidth: {value: 20}, clientHeight: {value: 20}}); + jest.spyOn(input, 'getBoundingClientRect').mockReturnValue(createMock({left: 0, top: 0})); + jest.spyOn(Range.prototype, 'getBoundingClientRect').mockReturnValue(createMock({left: 100, top: 200})); + const frames = jest.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 1); + const inputEvent = jest.fn(); + const focusIn = jest.fn(); + input.addEventListener('input', inputEvent); + input.addEventListener('focusin', focusIn); + const {result} = renderHook(() => useHtmlPaste({current: input}, undefined, false, 5)); + const paste = result.current?.handlePastePlainText; + if (!paste) { + throw new Error('Expected plaintext paste interface'); + } + + // When only three characters fit in the div's current length budget. + act(() => paste(createMock({clipboardData: {getData: () => 'WORD'}}))); + const frame = frames.mock.calls.at(0); + if (!frame) { + throw new Error('Expected scheduled caret scroll'); + } + act(() => frame[0](0)); + + // Then Range insertion and collapsed caret precede the browser scrolling/focus notifications. + expect(input.textContent).toBe('abWOR'); + expect(selection?.anchorOffset).toBe(3); + expect(selection?.isCollapsed).toBe(true); + expect(inputEvent).toHaveBeenCalledTimes(1); + expect(focusIn).toHaveBeenCalledTimes(1); + expect(input.scrollLeft).toBe(90); + expect(input.scrollTop).toBe(190); + }); + + it('does not attempt DOM insertion for absent, native or non-editable div refs', () => { + // Given a native partial provides focus but does not have DOM identity. + const nativeInput = createMock({isFocused: () => true}); + const input = Object.assign(document.createElement('div'), {isFocused: () => true}); + const frames = jest.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(() => 1); + const ref: Parameters[0] = {current: null}; + const {result} = renderHook(() => useHtmlPaste(ref)); + const paste = result.current?.handlePastePlainText; + if (!paste) { + throw new Error('Expected plaintext paste interface'); + } + const event = createMock({clipboardData: {getData: () => 'hello'}}); + + // When each category reaches the existing insertion boundary. + act(() => paste(event)); + ref.current = nativeInput; + act(() => paste(event)); + ref.current = input; + act(() => paste(event)); + + // Then no browser mutation or scheduled scroll is attempted. + expect(input.textContent).toBe(''); + expect(frames).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ComposerSelectionNotificationTest.tsx b/tests/unit/ComposerSelectionNotificationTest.tsx new file mode 100644 index 000000000000..0edfdd2d327d --- /dev/null +++ b/tests/unit/ComposerSelectionNotificationTest.tsx @@ -0,0 +1,102 @@ +/** Covers the real Suggestions forwarding interface for clear and caret notifications. */ +import {act, render} from '@testing-library/react-native'; + +import type {CustomSelectionChangeEvent} from '@components/Composer/types'; + +import type {SuggestionsRef} from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import Suggestions from '@pages/inbox/report/ReportActionCompose/Suggestions'; + +import type {TextInputSelectionChangeEvent} from 'react-native'; + +import React from 'react'; + +import createMock from '../utils/createMock'; + +const mockEmojiSelection = jest.fn>, Parameters>>(); +const mockMentionSelection = jest.fn>, Parameters>>(); + +jest.mock('@components/DragAndDrop/Provider', () => ({useDragAndDropState: () => ({isDraggingOver: false})})); +jest.mock('@pages/inbox/report/ReportActionCompose/SuggestionEmoji', () => { + const ReactModule = jest.requireActual('react'); + return ({ref}: {ref: React.Ref}) => { + ReactModule.useImperativeHandle(ref, () => ({ + onSelectionChange: mockEmojiSelection, + resetSuggestions: jest.fn, Parameters>(), + triggerHotkeyActions: jest.fn, Parameters>(), + updateShouldShowSuggestionMenuToFalse: jest.fn< + ReturnType, + Parameters + >(), + setShouldBlockSuggestionCalc: jest.fn, Parameters>(), + getSuggestions: () => [], + getIsSuggestionsMenuVisible: () => false, + })); + return null; + }; +}); +jest.mock('@pages/inbox/report/ReportActionCompose/SuggestionMention', () => { + const ReactModule = jest.requireActual('react'); + return ({ref}: {ref: React.Ref}) => { + ReactModule.useImperativeHandle(ref, () => ({ + onSelectionChange: mockMentionSelection, + resetSuggestions: jest.fn, Parameters>(), + triggerHotkeyActions: jest.fn, Parameters>(), + updateShouldShowSuggestionMenuToFalse: jest.fn< + ReturnType, + Parameters + >(), + setShouldBlockSuggestionCalc: jest.fn, Parameters>(), + getSuggestions: () => [], + getIsSuggestionsMenuVisible: () => false, + })); + return null; + }; +}); + +describe('Suggestions selection notifications', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each([ + {nativeEvent: {selection: {start: 0, end: 0}}} satisfies CustomSelectionChangeEvent, + createMock({nativeEvent: {selection: {start: 2, end: 4}}}), + {nativeEvent: {selection: {start: 3, end: 3, positionX: 10, positionY: 20}}} satisfies CustomSelectionChangeEvent, + ])('forwards selection $nativeEvent.selection in emoji then mention order', (notification) => { + // Given clear supplies a minimal notification while real input/caret notifications retain their fields. + const ref = React.createRef(); + render( + {}} + updateComment={() => {}} + measureParentContainerAndReportCursor={() => {}} + isGroupPolicyReport={false} + />, + ); + const selectionChanged = ref.current?.onSelectionChange; + if (!selectionChanged) { + throw new Error('Expected Suggestions selection interface'); + } + + // When the existing imperative interface receives the producer's notification. + act(() => selectionChanged(notification)); + + // Then both consumers receive the same object, preserving caret data and forwarding order. + expect(mockEmojiSelection).toHaveBeenCalledWith(notification); + expect(mockMentionSelection).toHaveBeenCalledWith(notification); + const emojiCall = mockEmojiSelection.mock.calls.at(0); + const mentionCall = mockMentionSelection.mock.calls.at(0); + if (!emojiCall || !mentionCall) { + throw new Error('Expected both selection notification calls'); + } + expect(emojiCall[0]).toBe(notification); + expect(mentionCall[0]).toBe(notification); + const earlierCallOrder1 = mockEmojiSelection.mock.invocationCallOrder.at(0); + const laterCallOrder1 = mockMentionSelection.mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); + }); +}); diff --git a/tests/unit/InputUtilsTest.ts b/tests/unit/InputUtilsTest.ts new file mode 100644 index 000000000000..46fd8c6e2e55 --- /dev/null +++ b/tests/unit/InputUtilsTest.ts @@ -0,0 +1,86 @@ +/** Exercises writable DOM scrolling and the native input boundary. */ +import type * as InputUtilsModule from '@libs/InputUtils/index'; +import {scrollToBottom as scrollNativeToBottom, scrollToRight as scrollNativeToRight} from '@libs/InputUtils/index.native'; +import type {ScrollInput} from '@libs/InputUtils/types'; + +import type {TextInput} from 'react-native'; + +import createMock from '../utils/createMock'; + +const {moveSelectionToEnd, scrollToBottom, scrollToRight} = jest.requireActual('@libs/InputUtils/index.ts'); + +describe('InputUtils', () => { + it.each(['input', 'textarea', 'div'] as const)('scrolls an actual %s to its bottom and right edges', (tagName) => { + // Given RN Web inputs/textarea elements and live-markdown divs expose writable scroll offsets. + const input = document.createElement(tagName); + Object.defineProperties(input, {scrollHeight: {value: 120}, scrollWidth: {value: 230}}); + + // When both existing scrolling operations execute against the host node. + scrollToBottom(input); + scrollToRight(input); + + // Then each operation uses its own dimension, including the markdown and multiline producers. + expect(input.scrollTop).toBe(120); + expect(input.scrollLeft).toBe(230); + }); + + it('assigns zero dimensions instead of retaining a prior scroll offset', () => { + // Given an emptied input still has offsets from its previous content. + const input = document.createElement('input'); + input.scrollTop = 12; + input.scrollLeft = 34; + + // When the current dimensions are zero. + scrollToBottom(input); + scrollToRight(input); + + // Then zero is a real assignment, not an absent dimension. + expect(input.scrollTop).toBe(0); + expect(input.scrollLeft).toBe(0); + }); + + it('does not write getter-only native scroll fields or scroll in the native sibling', () => { + // Given ReactNativeElement inherits getter-only scroll fields from ReadOnlyElement. + const nativeInput = createMock({ + get scrollTop() { + return 11; + }, + get scrollLeft() { + return 22; + }, + scrollHeight: 120, + scrollWidth: 230, + }); + const input: Parameters[0] = nativeInput; + + // When property presence could otherwise admit a native ref into DOM scrolling. + expect(() => { + scrollToBottom(input); + scrollToRight(input); + scrollNativeToBottom(input); + scrollNativeToRight(input); + }).not.toThrow(); + + // Then no native write occurs despite the fields being present. + expect(nativeInput.scrollTop).toBe(11); + expect(nativeInput.scrollLeft).toBe(22); + }); + + it('moves an input selection to its end and leaves native selection alone', () => { + // Given the existing selection operation owns only setSelectionRange inputs. + const input = document.createElement('input'); + input.value = 'hello'; + input.setSelectionRange(0, 2); + const setSelection = jest.fn, Parameters>(); + const nativeInput = createMock({setSelection}); + + // When selection-to-end runs on each supported category. + moveSelectionToEnd(input); + moveSelectionToEnd(nativeInput); + + // Then the DOM caret moves to the exact text length without a native dispatch. + expect(input.selectionStart).toBe(5); + expect(input.selectionEnd).toBe(5); + expect(setSelection).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/focusComposerWithDelayTest.ts b/tests/unit/focusComposerWithDelayTest.ts index e086cd81b168..9be650067e15 100644 --- a/tests/unit/focusComposerWithDelayTest.ts +++ b/tests/unit/focusComposerWithDelayTest.ts @@ -1,7 +1,18 @@ +import type {WebComposerRef} from '@components/Composer/types'; + import focusComposerWithDelay from '@libs/focusComposerWithDelay'; +import requestKeyboardForFocusedComposer from '@libs/focusComposerWithDelay/requestKeyboardForFocusedComposer'; import CONST from '@src/CONST'; +import type {TextInput} from 'react-native'; + +import createMock from '../utils/createMock'; + +jest.mock('@libs/focusComposerWithDelay/requestKeyboardForFocusedComposer', () => + jest.fn, Parameters>(), +); + function createInput() { const input = document.createElement('textarea'); return {input, focus: jest.spyOn(input, 'focus')}; @@ -42,3 +53,47 @@ describe('focusComposerWithDelay', () => { expect(focus).toHaveBeenCalled(); }); }); + +describe('focusComposerWithDelay command categories', () => { + afterEach(() => jest.restoreAllMocks()); + + it('focuses a web command handle without inventing a DOM selection method', async () => { + // Given the public web handle exposes common commands and a numeric scroll getter. + const input = { + clear: jest.fn, Parameters>(), + blur: jest.fn, Parameters>(), + focus: jest.fn, Parameters>(), + isFocused: () => false, + scrollTop: 0, + } satisfies WebComposerRef; + + // When focus with a forced selection reaches the unchanged platform selection helper. + await focusComposerWithDelay(input)(false, {start: 2, end: 4}); + + // Then the common focus command executes without requiring native or DOM selection capabilities. + expect(input.focus).toHaveBeenCalledTimes(1); + expect(input.clear).not.toHaveBeenCalled(); + expect(input.blur).not.toHaveBeenCalled(); + }); + + it('keeps native top-level selection and requests the keyboard for an already focused input', async () => { + // Given native selection belongs to the input itself, without a wrapper. + const focus = jest.fn, Parameters>(); + const input = createMock({ + focus, + isFocused: () => false, + setSelection: jest.fn, Parameters>(), + }); + const requestKeyboard = jest.mocked(requestKeyboardForFocusedComposer); + + // When an unfocused input receives selection and a focused input requests its keyboard. + await focusComposerWithDelay(input)(false, {start: 1, end: 3}); + input.isFocused = () => true; + await focusComposerWithDelay(input)(false, {start: 2, end: 2}, true); + + // Then selection uses the top-level native command and the focused branch delegates its keyboard request. + expect(focus).toHaveBeenCalledTimes(1); + expect(input.setSelection).toHaveBeenCalledWith(1, 3); + expect(requestKeyboard).toHaveBeenCalledWith(input, {start: 2, end: 2}); + }); +}); diff --git a/tests/unit/hooks/useEditMessageComposerCommands.test.ts b/tests/unit/hooks/useEditMessageComposerCommands.test.ts new file mode 100644 index 000000000000..e2a780aae61a --- /dev/null +++ b/tests/unit/hooks/useEditMessageComposerCommands.test.ts @@ -0,0 +1,120 @@ +/** Covers the edit hook's delete-modal blur and deferred cancellation focus. */ +import {act, renderHook} from '@testing-library/react-native'; + +import type * as UseReportScrollManagerModule from '@hooks/useReportScrollManager'; + +import type * as ReportModule from '@libs/actions/Report'; + +import {showDeleteModal} from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; +import useEditMessage from '@pages/inbox/report/ReportActionCompose/useEditMessage'; + +import {getFakeReportAction} from '../../utils/LHNTestUtils'; + +type EditMessageProps = Parameters[0]; + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({email: 'user@test.com'})); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/usePersonalDetails', () => ({useAllPersonalDetails: () => [undefined]})); +jest.mock('@hooks/useReportIDToNameMap', () => () => ({})); +jest.mock('@hooks/useReportIsArchived', () => () => false); +jest.mock('@hooks/useReportScrollManager', () => () => ({ + scrollToIndex: jest.fn< + ReturnType['scrollToIndex']>, + Parameters['scrollToIndex']> + >(), +})); +jest.mock('@libs/actions/Report', () => ({ + clearAllReportActionDrafts: jest.fn, Parameters>(), + editReportComment: jest.fn, Parameters>(), +})); +jest.mock('@pages/inbox/report/ReportActionEditMessageContext', () => ({useReportActionActiveEditActions: () => ({stopEditing: jest.fn(), submitEdit: jest.fn()})})); +jest.mock('@pages/inbox/report/useDraftMessageVideoAttributeCache', () => ({draftMessageVideoAttributeCache: new Map()})); +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({showDeleteModal: jest.fn, Parameters>()})); + +function renderEditCommands(composerRef: EditMessageProps['composerRef']) { + // The hook's empty-draft branch only needs validation flush and a real report-action identity. + const debouncedCommentMaxLengthValidation = Object.assign( + jest.fn(() => true), + {flush: () => true, cancel: jest.fn()}, + ); + return renderHook(() => useEditMessage({reportID: '1', originalReportID: undefined, reportAction: getFakeReportAction(), debouncedCommentMaxLengthValidation, composerRef})); +} + +describe('useEditMessage composer commands', () => { + beforeEach(() => jest.clearAllMocks()); + afterEach(() => jest.restoreAllMocks()); + + it('blurs before the modal and focuses the replacement ref only when the idle callback executes', () => { + // Given a command-only composer handle and a later replacement while the modal is open. + const first = {blur: jest.fn(), focus: jest.fn()} satisfies NonNullable; + const replacement = {blur: jest.fn(), focus: jest.fn()} satisfies NonNullable; + const composerRef: EditMessageProps['composerRef'] = {current: first}; + const idle = jest.spyOn(globalThis, 'requestIdleCallback').mockImplementation(() => 1); + const {result} = renderEditCommands(composerRef); + + // When empty-message deletion opens a modal and cancellation schedules focus. + act(() => result.current.publishDraft(' ')); + const modalCall = jest.mocked(showDeleteModal).mock.calls.at(0); + expect(modalCall).toBeDefined(); + if (!modalCall) { + throw new Error('Expected delete modal'); + } + const cancel = modalCall[4]; + if (!cancel) { + throw new Error('Expected modal cancellation callback'); + } + cancel(); + composerRef.current = replacement; + + // Then blur precedes the modal and cancellation does not focus synchronously or capture a stale ref. + expect(first.blur).toHaveBeenCalledTimes(1); + const earlierCallOrder1 = first.blur.mock.invocationCallOrder.at(0); + const laterCallOrder1 = jest.mocked(showDeleteModal).mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); + expect(first.focus).not.toHaveBeenCalled(); + expect(replacement.focus).not.toHaveBeenCalled(); + expect(idle).toHaveBeenCalledTimes(1); + const idleCall = idle.mock.calls.at(0); + expect(idleCall).toBeDefined(); + if (!idleCall) { + throw new Error('Expected deferred focus'); + } + act(() => idleCall[0]({didTimeout: false, timeRemaining: () => 10})); + expect(replacement.focus).toHaveBeenCalledTimes(1); + expect(first.focus).not.toHaveBeenCalled(); + }); + + it.each([true, false])('retains null-ref no-ops with initial absence %s', (isInitiallyAbsent) => { + // Given either initial absence or detachment while cancellation is deferred. + const commands = {blur: jest.fn(), focus: jest.fn()}; + const composerRef: EditMessageProps['composerRef'] = {current: isInitiallyAbsent ? null : commands}; + const idle = jest.spyOn(globalThis, 'requestIdleCallback').mockImplementation(() => 1); + const {result} = renderEditCommands(composerRef); + + // When cancellation runs after the composer has detached. + act(() => result.current.publishDraft('')); + const modalCall = jest.mocked(showDeleteModal).mock.calls.at(0); + if (!modalCall) { + throw new Error('Expected delete modal'); + } + const cancel = modalCall[4]; + if (!cancel) { + throw new Error('Expected modal cancellation callback'); + } + cancel(); + composerRef.current = null; + const idleCall = idle.mock.calls.at(0); + if (!idleCall) { + throw new Error('Expected deferred focus'); + } + act(() => idleCall[0]({didTimeout: false, timeRemaining: () => 10})); + + // Then modal cancellation tolerates absence and never invokes a detached handle. + expect(showDeleteModal).toHaveBeenCalledTimes(1); + expect(commands.blur).toHaveBeenCalledTimes(isInitiallyAbsent ? 0 : 1); + expect(commands.focus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useComposerSubmitClearValueTest.tsx b/tests/unit/useComposerSubmitClearValueTest.tsx new file mode 100644 index 000000000000..7da142f316a2 --- /dev/null +++ b/tests/unit/useComposerSubmitClearValueTest.tsx @@ -0,0 +1,121 @@ +/** Exercises optional clear values at the actual submission hook. */ +import {renderHook} from '@testing-library/react-native'; + +import type * as UseSidePanelActionsModule from '@hooks/useSidePanelActions'; + +import type * as ReportModule from '@libs/actions/Report'; +import {addAttachmentWithComment, addComment} from '@libs/actions/Report'; +import {createTaskFromMarkdown} from '@libs/actions/Task'; +import type * as ActiveSpansModule from '@libs/telemetry/activeSpans'; + +import type * as ComposerContextModule from '@pages/inbox/report/ReportActionCompose/ComposerContext'; +import useComposerSubmit from '@pages/inbox/report/ReportActionCompose/useComposerSubmit'; + +import CONST from '@src/CONST'; + +import createMock from '../utils/createMock'; + +const mockPublishDraft = jest.fn(); +const mockClearComposer = jest.fn(); +const mockMeta = createMock>({composerRef: {current: null}, attachmentFileRef: {current: null}, textRef: {current: ''}}); +const mockEditState = createMock>({ + isEditingInComposer: false, + didResetComposerHeightWhileEditing: false, + effectiveDraft: '', + editingState: CONST.REPORT_ACTION_EDIT_MESSAGE_STATE.OFF, +}); + +jest.mock('@hooks/useAncestors', () => () => []); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1})); +jest.mock('@hooks/useDelegateAccountID', () => () => undefined); +jest.mock('@hooks/useIsInSidePanel', () => () => false); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => false})); +jest.mock('@hooks/useReportIsArchived', () => () => false); +jest.mock('@hooks/useSidePanelActions', () => () => ({ + openSidePanel: jest.fn< + ReturnType['openSidePanel']>, + Parameters['openSidePanel']> + >(), +})); +jest.mock('@react-navigation/native', () => ({useRoute: () => ({name: 'Report'})})); +jest.mock('@pages/inbox/ActionListContext', () => ({useActionListContext: () => ({scrollOffsetRef: {current: 100000}})})); +jest.mock('@pages/inbox/report/ReportActionCompose/ComposerContext', () => ({ + useComposerMeta: () => mockMeta, + useComposerActions: () => ({clearComposer: mockClearComposer}), + useComposerSendState: () => ({isSendDisabled: false}), + useComposerEditState: () => mockEditState, + useComposerEditActions: () => ({ + publishDraft: mockPublishDraft, + setDidResetComposerHeightWhileEditing: jest.fn< + ReturnType['setDidResetComposerHeightWhileEditing']>, + Parameters['setDidResetComposerHeightWhileEditing']> + >(), + }), +})); +jest.mock('@pages/inbox/report/ReportActionCompose/useComposerReportData', () => () => ({report: undefined, effectiveTransactionThreadReportID: undefined})); +jest.mock('@pages/inbox/report/ReportActionCompose/useSidePanelContext', () => () => undefined); +jest.mock('@libs/actions/Report', () => ({ + addAttachmentWithComment: jest.fn, Parameters>(), + addComment: jest.fn, Parameters>(), + clearAgentZeroProcessingIndicator: jest.fn, Parameters>(), + setIsComposerFullSize: jest.fn, Parameters>(), +})); +jest.mock('@libs/actions/Task', () => ({createTaskFromMarkdown: jest.fn, Parameters>(() => false)})); +jest.mock('@libs/ReportActionsUtils', () => ({getAllReportActions: () => []})); +jest.mock('@libs/ReportUtils', () => ({isConciergeChatReport: () => false, canUserPerformWriteAction: () => true, generateReportID: () => '2'})); +jest.mock('@libs/NumberUtils', () => ({rand64: () => '3'})); +jest.mock('@libs/telemetry/activeSpans', () => ({ + startSpan: jest.fn, Parameters>(), +})); +jest.mock('@libs/telemetry/getSendMessageListWeight', () => () => ({reportActionCount: 0, moneyRequestPreviewCount: 0})); +jest.mock('@libs/telemetry/getSendMessageSource', () => () => 'report'); +jest.mock('@libs/telemetry/sendMessageSpans', () => ({getSendMessageVisibleSpanID: () => 'span'})); + +describe('useComposerSubmit clear values', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMeta.attachmentFileRef.current = null; + mockEditState.isEditingInComposer = false; + }); + + it.each(['comment', 'edit', 'attachment'] as const)('throws before %s submission effects for an omitted live-markdown value', (submission) => { + // Given live-markdown can omit value on its augmented div before initial parsing. + mockEditState.isEditingInComposer = submission === 'edit'; + if (submission === 'attachment') { + mockMeta.attachmentFileRef.current = {uri: 'file:///photo.png', name: 'photo.png', type: 'image/png'}; + } + const originalAttachment = mockMeta.attachmentFileRef.current; + const {result} = renderHook(() => useComposerSubmit('1')); + + // When clear supplies undefined rather than fabricating an empty string. + expect(() => result.current.validateAndSubmitDraft(undefined)).toThrow(TypeError); + + // Then no publication, task processing, attachment reset or composer clear occurs. + expect(mockPublishDraft).not.toHaveBeenCalled(); + expect(addComment).not.toHaveBeenCalled(); + expect(addAttachmentWithComment).not.toHaveBeenCalled(); + expect(createTaskFromMarkdown).not.toHaveBeenCalled(); + expect(mockClearComposer).not.toHaveBeenCalled(); + expect(mockMeta.attachmentFileRef.current).toBe(originalAttachment); + }); + + it('retains trimmed strings in the edit, comment and attachment branches', () => { + // Given each existing valid branch receives an actual string. + const {result, rerender} = renderHook(() => useComposerSubmit('1')); + + // When ordinary strings pass through the same submission interface. + result.current.validateAndSubmitDraft(' hello '); + mockEditState.isEditingInComposer = true; + rerender({}); + result.current.validateAndSubmitDraft(' edited '); + mockMeta.attachmentFileRef.current = {uri: 'file:///photo.png', name: 'photo.png', type: 'image/png'}; + result.current.validateAndSubmitDraft(' attached '); + + // Then existing processing retains trimming and branch ownership. + expect(addComment).toHaveBeenCalledWith(expect.objectContaining({text: 'hello'})); + expect(mockPublishDraft).toHaveBeenCalledWith('edited'); + expect(addAttachmentWithComment).toHaveBeenCalledWith(expect.objectContaining({text: 'attached'})); + expect(mockMeta.attachmentFileRef.current).toBeNull(); + }); +}); diff --git a/tests/unit/useResetIOUTypeTabSelectionTest.tsx b/tests/unit/useResetIOUTypeTabSelectionTest.tsx new file mode 100644 index 000000000000..00e6c0aeb789 --- /dev/null +++ b/tests/unit/useResetIOUTypeTabSelectionTest.tsx @@ -0,0 +1,123 @@ +/** Covers optional tab names through reset and the actual odometer hydrator. */ +import {act, renderHook} from '@testing-library/react-native'; + +import type * as UseDefaultParticipantsModule from '@hooks/useDefaultParticipants'; +import type * as UseOnyxModule from '@hooks/useOnyx'; +import useResetIOUType from '@hooks/useResetIOUType'; + +import {initMoneyRequest} from '@userActions/IOU/MoneyRequest'; +import {hydrateOdometerDraftIntoTransaction} from '@userActions/OdometerTransactionUtils'; +import {setTransactionReport} from '@userActions/Transaction'; + +import CONST from '@src/CONST'; +import mockOnyxKeys from '@src/ONYXKEYS'; +import type {OdometerDraft, Transaction} from '@src/types/onyx'; + +import type * as NativeModule from '@react-navigation/native'; + +import {Keyboard} from 'react-native'; + +import createMock from '../utils/createMock'; + +type ResetIOUProps = Parameters[0]; +const mockParticipants: ReturnType['participants'] = [{accountID: 2, selected: true}]; +const mockOdometerDraft = createMock({}); + +jest.mock('@hooks/useOnyx', () => (key: Parameters[0]) => { + if (key === mockOnyxKeys.ODOMETER_DRAFT) { + return [mockOdometerDraft]; + } + return [undefined]; +}); +jest.mock('@hooks/useDefaultParticipants', () => () => ({participants: mockParticipants})); +jest.mock('@hooks/usePersonalPolicy', () => () => ({id: 'personal'})); +jest.mock('@react-navigation/native', () => ({...jest.requireActual('@react-navigation/native'), useFocusEffect: jest.fn()})); +jest.mock('@libs/IOUUtils', () => ({resolveEarlyReportID: jest.fn(() => 'early-report')})); +jest.mock('@libs/TransactionUtils', () => ({getIsFromGlobalCreate: () => true})); +jest.mock('@userActions/IOU/MoneyRequest', () => ({initMoneyRequest: jest.fn, Parameters>()})); +jest.mock('@userActions/Transaction', () => ({setTransactionReport: jest.fn, Parameters>()})); +jest.mock('@userActions/OdometerTransactionUtils', () => ({ + hydrateOdometerDraftIntoTransaction: jest.fn, Parameters>(), + isOdometerDraftPendingHydration: () => true, +})); + +function renderTabReset(overrides: Partial = {}) { + const transaction = createMock({transactionID: 'transaction', reportID: 'old-report', iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL}); + return renderHook(() => useResetIOUType({reportID: '1', report: undefined, transaction, transactionRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, ...overrides})); +} + +describe('useResetIOUType tab selection', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => {}); + }); + afterEach(() => jest.restoreAllMocks()); + + it.each([undefined, CONST.IOU.REQUEST_TYPE.SCAN, CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER])('preserves initialization, report and hydration effects for %s', (tab) => { + // Given a manual draft and global-create participants that resolve a destination report. + const {result} = renderTabReset(); + + // When a changed tab, including a missing route name, reaches the actual callback. + act(() => result.current(tab)); + + // Then undefined uses the existing reset path, while only odometer names hydrate its draft fields. + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); + expect(initMoneyRequest).toHaveBeenCalledWith( + expect.objectContaining({newIouRequestType: tab, currentIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, defaultParticipants: mockParticipants, reportID: '1'}), + ); + expect(setTransactionReport).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: 'early-report'}, true); + const earlierCallOrder1 = jest.mocked(Keyboard.dismiss).mock.invocationCallOrder.at(0); + const laterCallOrder1 = jest.mocked(initMoneyRequest).mock.invocationCallOrder.at(0); + if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder1).toBeLessThan(laterCallOrder1); + const earlierCallOrder2 = jest.mocked(initMoneyRequest).mock.invocationCallOrder.at(0); + const laterCallOrder2 = jest.mocked(setTransactionReport).mock.invocationCallOrder.at(0); + if (earlierCallOrder2 === undefined || laterCallOrder2 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder2).toBeLessThan(laterCallOrder2); + if (tab === CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { + expect(hydrateOdometerDraftIntoTransaction).toHaveBeenCalledWith('transaction', mockOdometerDraft, undefined); + const earlierCallOrder3 = jest.mocked(setTransactionReport).mock.invocationCallOrder.at(0); + const laterCallOrder3 = jest.mocked(hydrateOdometerDraftIntoTransaction).mock.invocationCallOrder.at(0); + if (earlierCallOrder3 === undefined || laterCallOrder3 === undefined) { + throw new Error('Expected both invocation order values'); + } + expect(earlierCallOrder3).toBeLessThan(laterCallOrder3); + } else { + expect(hydrateOdometerDraftIntoTransaction).not.toHaveBeenCalled(); + } + }); + + it('does not reset unchanged names or an already undefined request type', () => { + // Given equality should exclude both ordinary and undefined request types. + const valid = renderTabReset(); + const absent = renderTabReset({transaction: createMock({transactionID: 'transaction'})}); + + // When the callback receives the current request type. + act(() => valid.result.current(CONST.IOU.REQUEST_TYPE.MANUAL)); + act(() => absent.result.current(undefined)); + + // Then equality precedes keyboard, initialization, destination and hydration effects. + expect(Keyboard.dismiss).not.toHaveBeenCalled(); + expect(initMoneyRequest).not.toHaveBeenCalled(); + expect(setTransactionReport).not.toHaveBeenCalled(); + expect(hydrateOdometerDraftIntoTransaction).not.toHaveBeenCalled(); + }); + + it('retains the per-diem keyboard exception without skipping initialization', () => { + // Given the page opts into keeping the keyboard behavior for per-diem selection. + const {result} = renderTabReset({skipKeyboardDismissForPerDiem: true}); + + // When the selected tab becomes per diem. + act(() => result.current(CONST.IOU.REQUEST_TYPE.PER_DIEM)); + + // Then only keyboard dismissal is excluded, with the reset still observable. + expect(Keyboard.dismiss).not.toHaveBeenCalled(); + expect(initMoneyRequest).toHaveBeenCalledWith(expect.objectContaining({newIouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM})); + expect(setTransactionReport).toHaveBeenCalledTimes(1); + expect(hydrateOdometerDraftIntoTransaction).not.toHaveBeenCalled(); + }); +}); From d03b18424524048790051a9855667a01f23f0f09 Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:43:30 +0000 Subject: [PATCH 2/3] Fix reviewer findings for PR #103910 Seatbelt-Operation: reviewer-fix-103910-06791fa4-9c10-4ba3-8718-b35d312fac7c --- .../Composer/implementation/index.tsx | 2 +- src/components/Composer/types.ts | 4 +- src/hooks/useOdometerDraftHydrator.ts | 4 +- src/hooks/useResetIOUType.ts | 8 +- src/libs/Navigation/OnyxTabNavigator.tsx | 8 +- src/libs/actions/InputFocus/index.web.ts | 6 +- .../ComposerWithSuggestions.tsx | 78 ++---------- .../ReportActionCompose/useComposerSubmit.ts | 6 +- tests/ui/ComposerImperativeRefTest.tsx | 118 ++++-------------- tests/ui/OnyxTabNavigatorTest.tsx | 13 +- .../unit/useComposerSubmitClearValueTest.tsx | 25 ++-- .../unit/useResetIOUTypeTabSelectionTest.tsx | 14 +-- 12 files changed, 75 insertions(+), 211 deletions(-) diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx index 25db92af5235..12c7324da68b 100755 --- a/src/components/Composer/implementation/index.tsx +++ b/src/components/Composer/implementation/index.tsx @@ -292,7 +292,7 @@ function Composer({ return; } - const currentText = textInputRef.current.value; + const currentText = textInputRef.current.value ?? ''; textInputRef.current.clear(); // We need to reset the selection to 0,0 manually after clearing the text input on web diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 7727d6d8b2c5..2182c4e33cf4 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -42,9 +42,9 @@ type ComposerProps = Omit & /** * Callback when the input was cleared using the .clear ref method. - * The text parameter is absent if live-markdown has not initialized its value. + * The text parameter is empty if live-markdown has not initialized its value. */ - onClear?: (text: string | undefined) => void; + onClear?: (text: string) => void; /** Callback method handle when the input is changed */ onChangeText?: (numberOfLines: string) => void; diff --git a/src/hooks/useOdometerDraftHydrator.ts b/src/hooks/useOdometerDraftHydrator.ts index a4d2b46258eb..6df590e0dd70 100644 --- a/src/hooks/useOdometerDraftHydrator.ts +++ b/src/hooks/useOdometerDraftHydrator.ts @@ -27,7 +27,7 @@ function useOdometerDraftHydrator({ transactionRequestType, isLoadingTransaction = false, isLoadingSelectedTab = false, -}: UseOdometerDraftHydratorParams): (newIOUType: IOURequestType | undefined) => void { +}: UseOdometerDraftHydratorParams): (newIOUType: IOURequestType) => void { const [odometerDraft] = useOnyx(ONYXKEYS.ODOMETER_DRAFT); useEffect(() => { @@ -54,7 +54,7 @@ function useOdometerDraftHydrator({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [transactionRequestType, odometerDraft, isLoadingTransaction, isLoadingSelectedTab]); - return (newIOUType: IOURequestType | undefined) => { + return (newIOUType: IOURequestType) => { if (newIOUType !== CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER) { return; } diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index 62dd29de808a..961a50ba8900 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -70,7 +70,7 @@ function useResetIOUType({ policy, isTrackDistanceExpense = false, skipKeyboardDismissForPerDiem = false, -}: UseResetIOUTypeParams): (newIOUType: IOURequestType | undefined) => void { +}: UseResetIOUTypeParams): (newIOUType: IOURequestType) => void { const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [hasOnlyPersonalPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasOnlyPersonalPoliciesSelector}); const [lastSelectedDistanceRates] = useOnyx(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES); @@ -96,7 +96,7 @@ function useResetIOUType({ }); const defaultParticipants = resolvedDefaultParticipants.length > 0 ? resolvedDefaultParticipants : undefined; - const resetIOUTypeIfChanged = (newIOUType: IOURequestType | undefined) => { + const resetIOUTypeIfChanged = (newIOUType: IOURequestType) => { if (transaction?.iouRequestType === newIOUType) { return; } @@ -145,9 +145,9 @@ function useResetIOUType({ hydrateOdometerOnLanding(newIOUType); }; - const tabSelectedTypeRef = useRef(null); + const tabSelectedTypeRef = useRef(null); - const onTabSelected = (newIouType: IOURequestType | undefined) => { + const onTabSelected = (newIouType: IOURequestType) => { tabSelectedTypeRef.current = newIouType; resetIOUTypeIfChanged(newIouType); }; diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 4477ad3f7575..b8eae5c610ad 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -42,7 +42,7 @@ type OnyxTabNavigatorProps = ChildrenProps & { id: string; defaultSelectedTab?: Extract; - onTabSelected?: (newTabName: Extract | undefined) => void; + onTabSelected?: (newTabName: TTabName) => void; tabBar: (props: TabSelectorProps) => React.ReactNode; screenListeners?: ScreenListeners>, MaterialTopTabNavigationEventMap>; @@ -390,12 +390,10 @@ function createOnyxTabNavigator(names: ReadonlyAr } const newSelectedTab = routeNames.at(index); reapplyTabSwitchIfInterrupted(navigation, newSelectedTab); - if (selectedTab === newSelectedTab) { + if (!newSelectedTab || selectedTab === newSelectedTab) { return; } - if (newSelectedTab) { - Tab.setSelectedTab(id, newSelectedTab); - } + Tab.setSelectedTab(id, newSelectedTab); onTabSelected(newSelectedTab); }, tabPress: (e) => { diff --git a/src/libs/actions/InputFocus/index.web.ts b/src/libs/actions/InputFocus/index.web.ts index 4f35ad5dc741..aae5246c8772 100644 --- a/src/libs/actions/InputFocus/index.web.ts +++ b/src/libs/actions/InputFocus/index.web.ts @@ -17,11 +17,7 @@ let refSave: Pick | null | undefined; function composerFocusKeepFocusOn(ref: Pick | null, isFocused: boolean, modal: Modal, onyxFocused: boolean) { if (isFocused && !onyxFocused) { inputFocusChange(true); - // Preserve the failed focus invocation after Onyx records focus. - if (ref === null) { - throw new TypeError("Cannot read properties of null (reading 'focus')"); - } - ref.focus(); + ref?.focus(); } if (isFocused) { refSave = ref; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx index 4a73e48c6713..916e8fb36283 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions.tsx @@ -82,18 +82,14 @@ type SyncSelection = { type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; -type ComposerWithSuggestionsRef = Pick & { - readonly setSelection: ComposerRef['setSelection']; - readonly setNativeProps: ComposerRef['setNativeProps']; - readonly preventFocusOnFirstResponderOnce: ComposerRef['preventFocusOnFirstResponderOnce']; - readonly scrollTop: number | undefined; +type ComposerWithSuggestionsRef = Pick & { focus: (shouldDelay?: boolean, forcedSelectionRange?: Selection, forceKeyboardIfAlreadyFocused?: boolean) => void; replaceSelectionWithText: OnEmojiSelected; getCurrentText: () => string; /** * Calling clear will immediately clear the input on the UI thread (its a worklet). - * Once the composer ahs cleared onCleared will be called with the value that was cleared. + * Once the composer has cleared, onClear will be called with the value that was cleared. */ clearWorklet: () => void; @@ -113,7 +109,7 @@ type ComposerWithSuggestionsProps = Partial & onValueChange: (value: string) => void; /** Callback when the composer got cleared on the UI thread */ - onClear?: (text: string | undefined) => void; + onClear?: (text: string) => void; isComposerFullSize: boolean; setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; @@ -957,73 +953,21 @@ function ComposerWithSuggestions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - useImperativeHandle(ref, (): ComposerWithSuggestionsRef => { - const handle: ComposerWithSuggestionsRef = { + useImperativeHandle( + ref, + (): ComposerWithSuggestionsRef => ({ focus, replaceSelectionWithText, getCurrentText, clearWorklet, resetHeight, - clear: () => { - if (!composerRef.current) { - throw new TypeError('Composer ref is not available'); - } - composerRef.current.clear(); - }, - blur: () => { - if (!composerRef.current) { - throw new TypeError('Composer ref is not available'); - } - composerRef.current.blur(); - }, - isFocused: () => { - if (!composerRef.current) { - throw new TypeError('Composer ref is not available'); - } - return composerRef.current.isFocused(); - }, - setSelection: undefined, - setNativeProps: undefined, - preventFocusOnFirstResponderOnce: undefined, - scrollTop: undefined, - }; - // Property descriptors support both compilers while getters read the current inner ref. - Object.defineProperties(handle, { - setSelection: { - enumerable: true, - configurable: true, - get: (): ComposerWithSuggestionsRef['setSelection'] => { - return composerRef.current?.setSelection; - }, - }, - setNativeProps: { - enumerable: true, - configurable: true, - get: (): ComposerWithSuggestionsRef['setNativeProps'] => { - return composerRef.current?.setNativeProps; - }, - }, - preventFocusOnFirstResponderOnce: { - enumerable: true, - configurable: true, - get: (): ComposerWithSuggestionsRef['preventFocusOnFirstResponderOnce'] => { - return composerRef.current?.preventFocusOnFirstResponderOnce; - }, - }, - scrollTop: { - enumerable: true, - configurable: true, - get: (): ComposerWithSuggestionsRef['scrollTop'] => { - const input = composerRef.current; - return input && 'scrollTop' in input ? input.scrollTop : undefined; - }, - }, - }); - return handle; - }); + blur: () => composerRef.current?.blur(), + isFocused: () => composerRef.current?.isFocused() ?? false, + }), + ); const onClear = useCallback( - (textOnClear: string | undefined) => { + (textOnClear: string) => { resetScrollPosition(); // Note: use the value when the clear happened, not the current value which might have changed already onClearProp(textOnClear); diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts index 80702600238d..f3664da4423f 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerSubmit.ts @@ -59,11 +59,7 @@ function useComposerSubmit(reportID: string) { /** * Add or edit a comment in the composer */ - const validateAndSubmitDraft = (draftMessage: string | undefined) => { - // Live-markdown may clear before it initializes value. Keep that failure before submission effects. - if (draftMessage === undefined) { - throw new TypeError("Cannot read properties of undefined (reading 'trim')"); - } + const validateAndSubmitDraft = (draftMessage: string) => { const draftMessageTrimmed = draftMessage.trim(); const isSubmittingEdit = isEditingInComposer || didResetComposerHeightWhileEditing; diff --git a/tests/ui/ComposerImperativeRefTest.tsx b/tests/ui/ComposerImperativeRefTest.tsx index 442ce7b08e9c..91ebfa402ff5 100644 --- a/tests/ui/ComposerImperativeRefTest.tsx +++ b/tests/ui/ComposerImperativeRefTest.tsx @@ -266,7 +266,7 @@ describe('Composer imperative refs', () => { throw new Error('Expected live-markdown host and commands'); } - // Then DOM identity and common commands exist while value remains absent, so clear must preserve undefined. + // Then DOM identity and common commands exist while the producer leaves value absent. expect(commands).toBe(input); expect('value' in input).toBe(false); expect(parser).not.toHaveBeenCalled(); @@ -306,7 +306,7 @@ describe('Composer imperative refs', () => { handle.clear(); }); - // Then underlying clear precedes selection reset and the captured optional value remains unchanged. + // Then clear precedes selection reset and a missing input value is normalized to an empty string. expect(focus).toHaveBeenCalledTimes(1); expect(blur).toHaveBeenCalledTimes(1); expect(handle.isFocused()).toBe(false); @@ -316,7 +316,7 @@ describe('Composer imperative refs', () => { expect('scrollTop' in handle && handle.scrollTop).toBe(23); expect(input.clear).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenCalledWith({nativeEvent: {selection: {start: 0, end: 0}}}); - expect(onClear).toHaveBeenCalledWith(value); + expect(onClear).toHaveBeenCalledWith(value ?? ''); const earlierCallOrder1 = input.clear.mock.invocationCallOrder.at(0); const laterCallOrder1 = onSelectionChange.mock.invocationCallOrder.at(0); if (earlierCallOrder1 === undefined || laterCallOrder1 === undefined) { @@ -405,39 +405,16 @@ describe('Composer imperative refs', () => { expect(objectRef.current).toBeNull(); }); - it('forwards native capabilities from the current inner ref and keeps absent common-command failures', () => { - // Given the forwarding interface exposes native commands only when its actual inner Composer does. + it('keeps the outer commands tied to the current input and safely handles an absent input', () => { + // Given the outer handle exposes only the commands its consumers use. mockShouldUseNativeComposer = true; - const firstClear = jest.fn, Parameters>(); const firstBlur = jest.fn, Parameters>(); const firstFocus = jest.fn, Parameters>(); const firstSetSelection = jest.fn, Parameters>(); - const firstSetNativeProps = jest.fn, Parameters>(); - const firstPreventFocusOnFirstResponderOnce = jest.fn, Parameters>(); - const first = createMock({ - clear: firstClear, - blur: firstBlur, - focus: firstFocus, - isFocused: () => false, - setSelection: firstSetSelection, - setNativeProps: firstSetNativeProps, - preventFocusOnFirstResponderOnce: firstPreventFocusOnFirstResponderOnce, - }); - const secondClear = jest.fn, Parameters>(); + const firstIsFocused = jest.fn, Parameters>(() => false); + const first = createMock({blur: firstBlur, focus: firstFocus, isFocused: firstIsFocused, setSelection: firstSetSelection}); const secondBlur = jest.fn, Parameters>(); - const secondFocus = jest.fn, Parameters>(); - const secondSetSelection = jest.fn, Parameters>(); - const secondSetNativeProps = jest.fn, Parameters>(); - const secondPreventFocusOnFirstResponderOnce = jest.fn, Parameters>(); - const second = createMock({ - clear: secondClear, - blur: secondBlur, - focus: secondFocus, - isFocused: () => false, - setSelection: secondSetSelection, - setNativeProps: secondSetNativeProps, - preventFocusOnFirstResponderOnce: secondPreventFocusOnFirstResponderOnce, - }); + const second = createMock({blur: secondBlur, isFocused: () => false}); mockMarkdownInput = first; const ref = React.createRef(); const props = forwardingProps(ref); @@ -446,42 +423,17 @@ describe('Composer imperative refs', () => { if (!handle) { throw new Error('Expected forwarding interface'); } - expect(Object.keys(handle)).toEqual([ - 'focus', - 'replaceSelectionWithText', - 'getCurrentText', - 'clearWorklet', - 'resetHeight', - 'clear', - 'blur', - 'isFocused', - 'setSelection', - 'setNativeProps', - 'preventFocusOnFirstResponderOnce', - 'scrollTop', - ]); - for (const key of ['setSelection', 'setNativeProps', 'preventFocusOnFirstResponderOnce', 'scrollTop']) { - const descriptor = Object.getOwnPropertyDescriptor(handle, key); - if (!descriptor) { - throw new Error('Expected own forwarding accessor'); - } - expect(descriptor.enumerable).toBe(true); - expect(descriptor.configurable).toBe(true); - expect(descriptor).toHaveProperty('get', expect.any(Function)); - expect(descriptor).toHaveProperty('set', undefined); - expect('value' in descriptor).toBe(false); - expect('writable' in descriptor).toBe(false); - } - expect(handle.scrollTop).toBeUndefined(); + expect(Object.keys(handle)).toEqual(['focus', 'replaceSelectionWithText', 'getCurrentText', 'clearWorklet', 'resetHeight', 'blur', 'isFocused']); handle.focus(false, {start: 2, end: 4}); + handle.blur(); expect(firstFocus).toHaveBeenCalledTimes(1); expect(firstSetSelection).toHaveBeenCalledWith(2, 4); + expect(firstBlur).toHaveBeenCalledTimes(1); + firstIsFocused.mockReturnValue(true); + expect(handle.isFocused()).toBe(true); expect(ReportActionComposeFocusManager.composerRef.current).toBe(first); - expect(handle.setSelection).toBe(firstSetSelection); - expect(handle.setNativeProps).toBe(firstSetNativeProps); - expect(handle.preventFocusOnFirstResponderOnce).toBe(firstPreventFocusOnFirstResponderOnce); - // When the input remounts with a replacement native object, the existing handle getters read current. + // When the input remounts, the retained handle uses the replacement input. mockMarkdownInput = second; view.rerender( { inputPlaceholder="replacement" />, ); - const currentHandle = handle; - if (!currentHandle) { - throw new Error('Expected replacement interface'); - } - currentHandle.clear(); - currentHandle.blur(); + handle.blur(); - // Then native command identity and clear/blur remain tied to the actual replacement. + // Then commands observe the current input and missing inputs are safe. expect(ReportActionComposeFocusManager.composerRef.current).toBe(second); - expect(currentHandle.setSelection).toBe(secondSetSelection); - expect(currentHandle.setNativeProps).toBe(secondSetNativeProps); - expect(currentHandle.preventFocusOnFirstResponderOnce).toBe(secondPreventFocusOnFirstResponderOnce); - expect(secondClear).toHaveBeenCalledTimes(1); expect(secondBlur).toHaveBeenCalledTimes(1); - expect(firstClear).not.toHaveBeenCalled(); - + expect(firstBlur).toHaveBeenCalledTimes(1); + expect(handle.isFocused()).toBe(false); mockShouldMountComposer = false; view.unmount(); const absentRef = React.createRef(); @@ -513,15 +456,10 @@ describe('Composer imperative refs', () => { if (!absent) { throw new Error('Expected absent-input forwarding interface'); } - expect(() => absent.clear()).toThrow(TypeError); - expect(() => absent.blur()).toThrow(TypeError); - expect(() => absent.isFocused()).toThrow(TypeError); - expect(absent.setSelection).toBeUndefined(); - expect(absent.setNativeProps).toBeUndefined(); - expect(absent.preventFocusOnFirstResponderOnce).toBeUndefined(); + expect(() => absent.blur()).not.toThrow(); + expect(absent.isFocused()).toBe(false); expect(() => absent.focus()).not.toThrow(); expect(Object.keys(absent)).toEqual(Object.keys(handle)); - expect(absent.scrollTop).toBeUndefined(); }); it('preserves native clear values and MIME-derived attachment filenames', async () => { @@ -664,12 +602,6 @@ describe('Composer imperative refs', () => { if (!retainedHandle) { throw new Error('Expected retained forwarding interface'); } - expect(retainedHandle.scrollTop).toBe(0); - first.scrollTop = 17; - expect(retainedHandle.scrollTop).toBe(17); - expect(retainedHandle.setSelection).toBeUndefined(); - expect(retainedHandle.setNativeProps).toBeUndefined(); - expect(retainedHandle.preventFocusOnFirstResponderOnce).toBeUndefined(); const firstHandle = ReportActionComposeFocusManager.composerRef.current; expect(firstHandle).toBe(ReportActionComposeFocusManager.composerRef.current); expect(firstHandle).not.toBeNull(); @@ -686,9 +618,6 @@ describe('Composer imperative refs', () => { if (!handle) { throw new Error('Expected forwarding interface'); } - expect(retainedHandle.scrollTop).toBe(0); - second.scrollTop = 29; - expect(retainedHandle.scrollTop).toBe(29); const focus = jest.spyOn(second, 'focus'); act(() => retainedHandle.focus()); expect(focus).toHaveBeenCalledTimes(1); @@ -702,9 +631,6 @@ describe('Composer imperative refs', () => { expect(second.clear).toHaveBeenCalledTimes(1); expect(ReportActionComposeFocusManager.composerRef.current).not.toBe(firstHandle); expect(dispatchCommand).toHaveBeenCalledWith(result.current, 'clear'); - expect(handle.setSelection).toBeUndefined(); - expect(handle.setNativeProps).toBeUndefined(); - expect(handle.preventFocusOnFirstResponderOnce).toBeUndefined(); expect(mockResetScroll).toHaveBeenCalledTimes(1); result.current.current = null; expect(() => clearWebInput(result.current)).not.toThrow(); @@ -723,13 +649,13 @@ describe('composerFocusKeepFocusOn actual focus commands', () => { }); afterEach(() => jest.restoreAllMocks()); - it('updates Onyx before immediate focus and preserves the null TypeError after that update', () => { + it('updates Onyx before immediate focus and safely skips a missing ref', () => { // Given the helper consumes only a focus command, not a DOM element. const ref = {focus: jest.fn, Parameters>()} satisfies NonNullable[0]>; // When initial focus executes and a missing ref reaches the same immediate branch. composerFocusKeepFocusOn(ref, true, hiddenModal, false); - expect(() => composerFocusKeepFocusOn(null, true, hiddenModal, false)).toThrow(TypeError); + expect(() => composerFocusKeepFocusOn(null, true, hiddenModal, false)).not.toThrow(); // Then both calls update Onyx first, while only the real command object focuses. expect(Onyx.set).toHaveBeenNthCalledWith(1, ONYXKEYS.INPUT_FOCUSED, true); diff --git a/tests/ui/OnyxTabNavigatorTest.tsx b/tests/ui/OnyxTabNavigatorTest.tsx index 78255791629a..d00435e14651 100644 --- a/tests/ui/OnyxTabNavigatorTest.tsx +++ b/tests/ui/OnyxTabNavigatorTest.tsx @@ -496,7 +496,7 @@ describe('OnyxTabNavigator finite state notifications', () => { it('calls the caller first, selects initially once and persists before notifying a changed name', () => { // Given caller state effects and the initial selection share the actual navigator listener. const callerState = jest.fn>, Parameters>>(); - const selected = jest.fn(); + const selected = jest.fn(); const initialSelect = jest.fn['onTabSelect']>>, Parameters['onTabSelect']>>>(); const view = renderFiniteNavigator({screenListeners: {state: callerState}, onTabSelected: selected, onTabSelect: initialSelect}); const listeners = navigatorListeners(); @@ -547,9 +547,9 @@ describe('OnyxTabNavigator finite state notifications', () => { expect(earlierCallOrder3).toBeLessThan(laterCallOrder3); }); - it('notifies changed undefined without persisting it and does not repeat initial selection', () => { + it('ignores a missing route name without persisting or notifying and selects initially once', () => { // Given a valid persisted tab and a temporarily out-of-range navigator index. - const selected = jest.fn(); + const selected = jest.fn(); const initialSelect = jest.fn['onTabSelect']>>, Parameters['onTabSelect']>>>(); renderFiniteNavigator({onTabSelected: selected, onTabSelect: initialSelect}); const listeners = navigatorListeners(); @@ -558,9 +558,8 @@ describe('OnyxTabNavigator finite state notifications', () => { notifyNavigatorState(listeners, 4); notifyNavigatorState(listeners, 0); - // Then absence is delivered rather than skipped, and initialization runs only once. - expect(selected).toHaveBeenCalledTimes(1); - expect(selected).toHaveBeenCalledWith(undefined); + // Then missing names do not escape to consumers, and initialization runs only once. + expect(selected).not.toHaveBeenCalled(); expect(Tab.setSelectedTab).not.toHaveBeenCalled(); expect(initialSelect).toHaveBeenCalledTimes(1); expect(initialSelect).toHaveBeenCalledWith({index: 4}); @@ -584,7 +583,7 @@ describe('OnyxTabNavigator finite state notifications', () => { it('reapplies an interrupted press before returning for unchanged selection', () => { // Given React Navigation can leave the old selected tab after an allowed press. jest.useFakeTimers(); - const selected = jest.fn(); + const selected = jest.fn(); renderFiniteNavigator({shouldReapplyInterruptedTabPress: true, onTabSelected: selected}); const listeners = navigatorListeners(); const press = listeners.tabPress; diff --git a/tests/unit/useComposerSubmitClearValueTest.tsx b/tests/unit/useComposerSubmitClearValueTest.tsx index 7da142f316a2..2fd970500fba 100644 --- a/tests/unit/useComposerSubmitClearValueTest.tsx +++ b/tests/unit/useComposerSubmitClearValueTest.tsx @@ -79,25 +79,32 @@ describe('useComposerSubmit clear values', () => { mockEditState.isEditingInComposer = false; }); - it.each(['comment', 'edit', 'attachment'] as const)('throws before %s submission effects for an omitted live-markdown value', (submission) => { - // Given live-markdown can omit value on its augmented div before initial parsing. + it.each(['comment', 'edit', 'attachment'] as const)('handles normalized empty input in the %s branch', (submission) => { + // Given web Composer normalizes an omitted live-markdown value to an empty string. mockEditState.isEditingInComposer = submission === 'edit'; if (submission === 'attachment') { mockMeta.attachmentFileRef.current = {uri: 'file:///photo.png', name: 'photo.png', type: 'image/png'}; } - const originalAttachment = mockMeta.attachmentFileRef.current; const {result} = renderHook(() => useComposerSubmit('1')); - // When clear supplies undefined rather than fabricating an empty string. - expect(() => result.current.validateAndSubmitDraft(undefined)).toThrow(TypeError); + // When the normalized clear value reaches the actual submission hook. + expect(() => result.current.validateAndSubmitDraft('')).not.toThrow(); - // Then no publication, task processing, attachment reset or composer clear occurs. - expect(mockPublishDraft).not.toHaveBeenCalled(); + // Then empty comments are skipped, while edits and attachments retain their existing empty-text behavior. expect(addComment).not.toHaveBeenCalled(); - expect(addAttachmentWithComment).not.toHaveBeenCalled(); expect(createTaskFromMarkdown).not.toHaveBeenCalled(); expect(mockClearComposer).not.toHaveBeenCalled(); - expect(mockMeta.attachmentFileRef.current).toBe(originalAttachment); + if (submission === 'edit') { + expect(mockPublishDraft).toHaveBeenCalledWith(''); + expect(addAttachmentWithComment).not.toHaveBeenCalled(); + } else if (submission === 'attachment') { + expect(addAttachmentWithComment).toHaveBeenCalledWith(expect.objectContaining({text: ''})); + expect(mockPublishDraft).not.toHaveBeenCalled(); + } else { + expect(mockPublishDraft).not.toHaveBeenCalled(); + expect(addAttachmentWithComment).not.toHaveBeenCalled(); + } + expect(mockMeta.attachmentFileRef.current).toBeNull(); }); it('retains trimmed strings in the edit, comment and attachment branches', () => { diff --git a/tests/unit/useResetIOUTypeTabSelectionTest.tsx b/tests/unit/useResetIOUTypeTabSelectionTest.tsx index 00e6c0aeb789..4a14a66245ec 100644 --- a/tests/unit/useResetIOUTypeTabSelectionTest.tsx +++ b/tests/unit/useResetIOUTypeTabSelectionTest.tsx @@ -1,4 +1,4 @@ -/** Covers optional tab names through reset and the actual odometer hydrator. */ +/** Covers valid tab names through reset and the actual odometer hydrator. */ import {act, renderHook} from '@testing-library/react-native'; import type * as UseDefaultParticipantsModule from '@hooks/useDefaultParticipants'; @@ -53,14 +53,14 @@ describe('useResetIOUType tab selection', () => { }); afterEach(() => jest.restoreAllMocks()); - it.each([undefined, CONST.IOU.REQUEST_TYPE.SCAN, CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER])('preserves initialization, report and hydration effects for %s', (tab) => { + it.each([CONST.IOU.REQUEST_TYPE.SCAN, CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER])('preserves initialization, report and hydration effects for %s', (tab) => { // Given a manual draft and global-create participants that resolve a destination report. const {result} = renderTabReset(); - // When a changed tab, including a missing route name, reaches the actual callback. + // When a valid changed tab reaches the actual callback. act(() => result.current(tab)); - // Then undefined uses the existing reset path, while only odometer names hydrate its draft fields. + // Then the changed tab resets the draft, while only odometer names hydrate its fields. expect(Keyboard.dismiss).toHaveBeenCalledTimes(1); expect(initMoneyRequest).toHaveBeenCalledWith( expect.objectContaining({newIouRequestType: tab, currentIouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, defaultParticipants: mockParticipants, reportID: '1'}), @@ -91,14 +91,12 @@ describe('useResetIOUType tab selection', () => { } }); - it('does not reset unchanged names or an already undefined request type', () => { - // Given equality should exclude both ordinary and undefined request types. + it('does not reset an unchanged request type', () => { + // Given the draft already uses the selected request type. const valid = renderTabReset(); - const absent = renderTabReset({transaction: createMock({transactionID: 'transaction'})}); // When the callback receives the current request type. act(() => valid.result.current(CONST.IOU.REQUEST_TYPE.MANUAL)); - act(() => absent.result.current(undefined)); // Then equality precedes keyboard, initialization, destination and hydration effects. expect(Keyboard.dismiss).not.toHaveBeenCalled(); From fee1ddc13bbaf6ef16ede61a479c6f5ccb3020bd Mon Sep 17 00:00:00 2001 From: KJ21-ENG <140263938+KJ21-ENG@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:21:49 +0000 Subject: [PATCH 3/3] ci: rerun checks after upstream Jest worker termination Seatbelt-Operation: empty-retry-sign-103910-65127d1d-5aba-4816-8dfb-2acc4eb6ea3c