From a05cfb656d2a350c639cf2d86dcd492277c1fe2e Mon Sep 17 00:00:00 2001 From: KJ21-ENG Date: Tue, 21 Apr 2026 05:27:11 +0530 Subject: [PATCH 1/4] fix: prevent overlay dismiss during in-flight RHP transitions Clicking the RHP overlay while an inner Stack push is animating interrupts the transition and leaves stale animation state behind. The next RHP then opens with a visibly sluggish slide-in. Gate the primary, secondary, and tertiary overlay dismiss paths on the RightModalNavigator inner Stack's transitionStart/End events. A ref mirrors a useState so the BaseOverlay Pressable receives a `disabled` prop that no-ops clicks during opens. transitionEnd always re-enables the gate so a cancelled open (forward nav immediately followed by back) cannot leave the overlay permanently unresponsive. Fixes https://github.com/Expensify/App/issues/87174 --- .../Navigators/Overlay/BaseOverlay.tsx | 21 +++++++-- .../Navigators/RightModalNavigator.tsx | 44 +++++++++++++++++-- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx index ba96b4ac8ca9..462cbda445d4 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx @@ -1,5 +1,5 @@ import {useCardAnimation} from '@react-navigation/stack'; -import React from 'react'; +import React, {useCallback} from 'react'; // eslint-disable-next-line no-restricted-imports import {Animated, View} from 'react-native'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -21,14 +21,25 @@ type BaseOverlayProps = { /* Overlay position from the right edge of the container */ positionRightValue?: number | Animated.Value | Animated.AnimatedAddition; + + /* When true, the overlay stays visible but swallows clicks without invoking onPress. + Used to block dismiss while an RHP stack transition is in flight (see issue #87174). */ + disabled?: boolean; }; // The default value of positionLeftValue is equal to -2 * variables.sideBarWidth, because we need to stretch the overlay to cover the sidebar and the translate animation distance. -function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) { +function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0, disabled = false}: BaseOverlayProps) { const styles = useThemeStyles(); const {current} = useCardAnimation(); const {translate} = useLocalize(); + const guardedPress = useCallback(() => { + if (disabled) { + return; + } + onPress?.(); + }, [disabled, onPress]); + return ( diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 1f82e1378524..a8b03a3fc70d 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,6 +1,6 @@ import type {NavigatorScreenParams} from '@react-navigation/native'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native'; import {DialogLabelProvider} from '@components/DialogLabelContext'; @@ -61,13 +61,18 @@ function MissingPersonalDetailsWithPINContext(props: Record) { ); } -function SecondaryOverlay() { +type SecondaryOverlayProps = { + disabled: boolean; +}; + +function SecondaryOverlay({disabled}: SecondaryOverlayProps) { const {shouldRenderSecondaryOverlayForWideRHP, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForRHPOnSuperWideRHP} = useWideRHPState(); const {sidePanelOffset} = useSidePanelState(); if (shouldRenderSecondaryOverlayForWideRHP) { return ( Navigation.closeRHPFlow()} @@ -78,6 +83,7 @@ function SecondaryOverlay() { if (shouldRenderSecondaryOverlayForRHPOnWideRHP) { return ( (false); + // Tracks whether the inner RHP stack is currently running a non-closing push/pop animation. + // Clicking the overlay mid-push leaves stale animation state (see issue #87174), so we + // ignore the dismiss press until the in-flight transition finishes. The ref gates the + // synchronous dispatchers; the state mirror drives the `disabled` prop on the overlays so + // the Pressable itself no-ops (defense in depth against cases where the dispatcher path + // bypasses the ref guard, e.g. inline Navigation.dismissToPreviousRHP). + const isOverlayDismissEnabledRef = useRef(true); + const [isOverlayDismissEnabled, setIsOverlayDismissEnabled] = useState(true); const screenOptions = useRHPScreenOptions(); const {superWideRHPRouteKeys, shouldRenderTertiaryOverlay} = useWideRHPState(); const {clearWideRHPKeys, syncRHPKeys} = useWideRHPActions(); @@ -165,12 +180,31 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { abandonReviewDuplicateTransactions(); }); }, + transitionStart: (event: {data?: {closing?: boolean}}) => { + // Only disable dismiss when a screen is starting to open. A starting close is + // either the user's own dismiss (benign) or a cancellation of an in-flight open + // (handled by transitionEnd below). + if (event.data?.closing) { + return; + } + isOverlayDismissEnabledRef.current = false; + setIsOverlayDismissEnabled(false); + }, + transitionEnd: () => { + // Re-enable on ANY transitionEnd (opening or closing). If an opening transition + // is interrupted by a pop, React Navigation cancels the open and only emits + // transitionEnd with closing: true for the popped route — the opening's own + // closing: false end never fires. Ignoring closing: true here would leave the + // gate stuck false forever and swallow subsequent overlay dismiss clicks. + isOverlayDismissEnabledRef.current = true; + setIsOverlayDismissEnabled(true); + }, }), [navigation, route.params?.screen], ); const handleOverlayPress = useCallback(() => { - if (isExecutingRef.current) { + if (isExecutingRef.current || !isOverlayDismissEnabledRef.current) { return; } isExecutingRef.current = true; @@ -215,6 +249,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { {!shouldUseNarrowLayout && ( @@ -466,9 +501,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { {/* The third and second overlays are displayed here to cover RHP screens wider than the currently focused screen. */} {/* Clicking on these overlays redirects you to the RHP screen below them. */} {/* The width of these overlays is equal to the width of the screen minus the width of the currently focused RHP screen (positionRightValue) */} - {!shouldUseNarrowLayout && } + {!shouldUseNarrowLayout && } {!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && ( Date: Wed, 22 Apr 2026 16:51:10 +0530 Subject: [PATCH 2/4] Revert "fix: prevent overlay dismiss during in-flight RHP transitions" This reverts commit a05cfb656d2a350c639cf2d86dcd492277c1fe2e. --- .../Navigators/Overlay/BaseOverlay.tsx | 21 ++------- .../Navigators/RightModalNavigator.tsx | 44 ++----------------- 2 files changed, 8 insertions(+), 57 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx index 462cbda445d4..ba96b4ac8ca9 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx @@ -1,5 +1,5 @@ import {useCardAnimation} from '@react-navigation/stack'; -import React, {useCallback} from 'react'; +import React from 'react'; // eslint-disable-next-line no-restricted-imports import {Animated, View} from 'react-native'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; @@ -21,25 +21,14 @@ type BaseOverlayProps = { /* Overlay position from the right edge of the container */ positionRightValue?: number | Animated.Value | Animated.AnimatedAddition; - - /* When true, the overlay stays visible but swallows clicks without invoking onPress. - Used to block dismiss while an RHP stack transition is in flight (see issue #87174). */ - disabled?: boolean; }; // The default value of positionLeftValue is equal to -2 * variables.sideBarWidth, because we need to stretch the overlay to cover the sidebar and the translate animation distance. -function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0, disabled = false}: BaseOverlayProps) { +function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) { const styles = useThemeStyles(); const {current} = useCardAnimation(); const {translate} = useLocalize(); - const guardedPress = useCallback(() => { - if (disabled) { - return; - } - onPress?.(); - }, [disabled, onPress]); - return ( diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index a8b03a3fc70d..1f82e1378524 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,6 +1,6 @@ import type {NavigatorScreenParams} from '@react-navigation/native'; import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native'; import {DialogLabelProvider} from '@components/DialogLabelContext'; @@ -61,18 +61,13 @@ function MissingPersonalDetailsWithPINContext(props: Record) { ); } -type SecondaryOverlayProps = { - disabled: boolean; -}; - -function SecondaryOverlay({disabled}: SecondaryOverlayProps) { +function SecondaryOverlay() { const {shouldRenderSecondaryOverlayForWideRHP, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForRHPOnSuperWideRHP} = useWideRHPState(); const {sidePanelOffset} = useSidePanelState(); if (shouldRenderSecondaryOverlayForWideRHP) { return ( Navigation.closeRHPFlow()} @@ -83,7 +78,6 @@ function SecondaryOverlay({disabled}: SecondaryOverlayProps) { if (shouldRenderSecondaryOverlayForRHPOnWideRHP) { return ( (false); - // Tracks whether the inner RHP stack is currently running a non-closing push/pop animation. - // Clicking the overlay mid-push leaves stale animation state (see issue #87174), so we - // ignore the dismiss press until the in-flight transition finishes. The ref gates the - // synchronous dispatchers; the state mirror drives the `disabled` prop on the overlays so - // the Pressable itself no-ops (defense in depth against cases where the dispatcher path - // bypasses the ref guard, e.g. inline Navigation.dismissToPreviousRHP). - const isOverlayDismissEnabledRef = useRef(true); - const [isOverlayDismissEnabled, setIsOverlayDismissEnabled] = useState(true); const screenOptions = useRHPScreenOptions(); const {superWideRHPRouteKeys, shouldRenderTertiaryOverlay} = useWideRHPState(); const {clearWideRHPKeys, syncRHPKeys} = useWideRHPActions(); @@ -180,31 +165,12 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { abandonReviewDuplicateTransactions(); }); }, - transitionStart: (event: {data?: {closing?: boolean}}) => { - // Only disable dismiss when a screen is starting to open. A starting close is - // either the user's own dismiss (benign) or a cancellation of an in-flight open - // (handled by transitionEnd below). - if (event.data?.closing) { - return; - } - isOverlayDismissEnabledRef.current = false; - setIsOverlayDismissEnabled(false); - }, - transitionEnd: () => { - // Re-enable on ANY transitionEnd (opening or closing). If an opening transition - // is interrupted by a pop, React Navigation cancels the open and only emits - // transitionEnd with closing: true for the popped route — the opening's own - // closing: false end never fires. Ignoring closing: true here would leave the - // gate stuck false forever and swallow subsequent overlay dismiss clicks. - isOverlayDismissEnabledRef.current = true; - setIsOverlayDismissEnabled(true); - }, }), [navigation, route.params?.screen], ); const handleOverlayPress = useCallback(() => { - if (isExecutingRef.current || !isOverlayDismissEnabledRef.current) { + if (isExecutingRef.current) { return; } isExecutingRef.current = true; @@ -249,7 +215,6 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { {!shouldUseNarrowLayout && ( @@ -501,10 +466,9 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { {/* The third and second overlays are displayed here to cover RHP screens wider than the currently focused screen. */} {/* Clicking on these overlays redirects you to the RHP screen below them. */} {/* The width of these overlays is equal to the width of the screen minus the width of the currently focused RHP screen (positionRightValue) */} - {!shouldUseNarrowLayout && } + {!shouldUseNarrowLayout && } {!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && ( Date: Wed, 22 Apr 2026 16:52:26 +0530 Subject: [PATCH 3/4] fix: migrate auto-focus from raw InteractionManager to useAutoFocusInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `InteractionManager.runAfterInteractions(() => ref.focus())` pattern in SplitListItem, DatePicker, and IOURequestEditReportCommon with the canonical `useAutoFocusInput` hook. The raw pattern has no cancellation mechanism, so a deferred focus callback can survive the dismissing screen and fire during the next RHP's slide-in animation, blocking frames and causing the sluggish animation reported in #87174. useAutoFocusInput wraps the same call in a useEffect whose cleanup calls focusTaskHandle.cancel(), and a useFocusEffect that resets the screen-transition-ended flag on unfocus — together these cancel any pending focus task when the screen starts closing. Aligns these three outlier files with the same pattern applied in #87070 and the 100+ existing useAutoFocusInput usages elsewhere. Fixes https://github.com/Expensify/App/issues/87174 --- src/components/DatePicker/index.tsx | 34 ++++++++++++------- .../SelectionList/ListItem/SplitListItem.tsx | 31 ++++++----------- .../step/IOURequestEditReportCommon.tsx | 5 +++ 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index f91c0816c12f..95f533ede2d6 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import TextInput from '@components/TextInput'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -41,10 +42,13 @@ function DatePicker({ const [isModalVisible, setIsModalVisible] = useState(false); const [selectedDate, setSelectedDate] = useState(() => value ?? defaultValue ?? ''); const [popoverPosition, setPopoverPosition] = useState({horizontal: 0, vertical: 0}); - const textInputRef = useRef(null); + const textInputRef = useRef(null); const anchorRef = useRef(null); const [isInverted, setIsInverted] = useState(false); - const isAutoFocused = useRef(false); + + const {inputCallbackRef: autoFocusCallbackRef} = useAutoFocusInput(); + const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); + autoFocusCallbackRefRef.current = autoFocusCallbackRef; useEffect(() => { if (shouldSaveDraft && formID) { @@ -103,16 +107,20 @@ function DatePicker({ }); }, [calculatePopoverPosition, windowWidth]); - useEffect(() => { - if (!autoFocus || isAutoFocused.current) { - return; - } - isAutoFocused.current = true; - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - textInputRef.current?.focus(); - }); - }, [autoFocus]); + // Combined ref: updates textInputRef (needed for blur() in showDatePickerModal) and connects + // autoFocusCallbackRef only when autoFocus=true so useAutoFocusInput's useFocusEffect cleanup + // can cancel any pending focus task when the screen starts closing. + const combinedTextInputRef = useCallback( + (ref: BaseTextInputRef | null) => { + textInputRef.current = ref; + if (autoFocus) { + (autoFocusCallbackRefRef.current as unknown as (ref: BaseTextInputRef | null) => void)(ref); + } + }, + // autoFocusCallbackRefRef is a stable ref — its identity never changes, so it's not a dep + // eslint-disable-next-line react-hooks/exhaustive-deps + [autoFocus], + ); const getValidDateForCalendar = useMemo(() => { if (!selectedDate) { @@ -129,7 +137,7 @@ function DatePicker({ style={styles.mv2} > ({ const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); - const splitItem = item as unknown as SplitListItemType; const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency); @@ -55,7 +53,7 @@ function SplitListItem({ [splitItem], ); - const inputRef = useRef(null); + const {inputCallbackRef: autoFocusCallbackRef} = useAutoFocusInput(); // Animated highlight style for selected item const animatedHighlightStyle = useAnimatedHighlightStyle({ @@ -75,23 +73,14 @@ function SplitListItem({ onInputFocus?.(item); }, [onInputFocus, item]); - // Auto-focus input when item is selected and screen transition ends - useEffect(() => { - if (!didScreenTransitionEnd || !splitItem.isSelected || !splitItem.isEditable || !inputRef.current) { + // Only connect the auto-focus ref to the selected item so useAutoFocusInput's useFocusEffect + // cleanup can cancel any pending focus task when the screen starts closing, preventing + // the focused input from interfering with the close animation. + const inputCallbackRef: (ref: BaseTextInputRef | null) => void = (ref) => { + if (!splitItem.isSelected || !splitItem.isEditable) { return; } - - // Use InteractionManager to ensure input focus happens after all animations/interactions complete. - // This prevents focus from interrupting modal close/open animations which would cause UI glitches - // and "jumping" behavior when quickly navigating between screens. - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - inputRef.current?.focus(); - }); - }, [didScreenTransitionEnd, splitItem.isSelected, splitItem.isEditable]); - - const inputCallbackRef = (ref: BaseTextInputRef | null) => { - inputRef.current = ref; + (autoFocusCallbackRef as unknown as (ref: BaseTextInputRef | null) => void)(ref); }; const isPercentageMode = splitItem.mode === CONST.TAB.SPLIT.PERCENTAGE; diff --git a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx index da07d152f929..d8772eb1ac97 100644 --- a/src/pages/iou/request/step/IOURequestEditReportCommon.tsx +++ b/src/pages/iou/request/step/IOURequestEditReportCommon.tsx @@ -6,6 +6,8 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import type {ListItem} from '@components/SelectionList/types'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -67,6 +69,7 @@ function IOURequestEditReportCommon({ isTimeRequest = false, }: Props) { const icons = useMemoizedLazyExpensifyIcons(['Close', 'Document']); + const {inputCallbackRef} = useAutoFocusInput(); const {translate, localeCompare} = useLocalize(); const personalDetails = usePersonalDetails(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -302,6 +305,8 @@ function IOURequestEditReportCommon({ label: translate('common.search'), headerMessage, onChangeText: setSearchValue, + disableAutoFocus: true, + ref: inputCallbackRef as (ref: BaseTextInputRef | null) => void, }} shouldSingleExecuteRowSelect initiallyFocusedItemKey={selectedReportID} From cf8f5770485f11dec741c149929e31c4ed498978 Mon Sep 17 00:00:00 2001 From: KJ21-ENG Date: Wed, 22 Apr 2026 17:04:23 +0530 Subject: [PATCH 4/4] test: wrap IOURequestEditReportCommon test in NavigationContainer `useAutoFocusInput` (newly used in IOURequestEditReportCommon) calls `useNavigation()` internally, which requires a NavigationContainer ancestor. Wrap the test render in one so the hook resolves. --- tests/ui/IOURequestEditReportCommonTest.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/ui/IOURequestEditReportCommonTest.tsx b/tests/ui/IOURequestEditReportCommonTest.tsx index 7ba3322f00b0..22d2b1e746e2 100644 --- a/tests/ui/IOURequestEditReportCommonTest.tsx +++ b/tests/ui/IOURequestEditReportCommonTest.tsx @@ -1,3 +1,4 @@ +import {NavigationContainer} from '@react-navigation/native'; import {act, render, screen} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import ComposeProviders from '@components/ComposeProviders'; @@ -44,15 +45,17 @@ jest.mock('@components/OptionListContextProvider', () => ({ */ const renderIOURequestEditReportCommon = ({selectedReportID = '', selectedPolicyID}: {selectedReportID: string; selectedPolicyID?: string}) => render( - - - , + + + + + , ); describe('IOURequestEditReportCommon', () => {