Skip to content

Commit a05cfb6

Browse files
committed
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 Expensify#87174
1 parent 7f50cca commit a05cfb6

2 files changed

Lines changed: 57 additions & 8 deletions

File tree

src/libs/Navigation/AppNavigator/Navigators/Overlay/BaseOverlay.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {useCardAnimation} from '@react-navigation/stack';
2-
import React from 'react';
2+
import React, {useCallback} from 'react';
33
// eslint-disable-next-line no-restricted-imports
44
import {Animated, View} from 'react-native';
55
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
@@ -21,14 +21,25 @@ type BaseOverlayProps = {
2121

2222
/* Overlay position from the right edge of the container */
2323
positionRightValue?: number | Animated.Value | Animated.AnimatedAddition<number>;
24+
25+
/* When true, the overlay stays visible but swallows clicks without invoking onPress.
26+
Used to block dismiss while an RHP stack transition is in flight (see issue #87174). */
27+
disabled?: boolean;
2428
};
2529

2630
// 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.
27-
function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0}: BaseOverlayProps) {
31+
function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.sideBarWidth, positionRightValue = 0, disabled = false}: BaseOverlayProps) {
2832
const styles = useThemeStyles();
2933
const {current} = useCardAnimation();
3034
const {translate} = useLocalize();
3135

36+
const guardedPress = useCallback(() => {
37+
if (disabled) {
38+
return;
39+
}
40+
onPress?.();
41+
}, [disabled, onPress]);
42+
3243
return (
3344
<Animated.View
3445
id="BaseOverlay"
@@ -42,20 +53,22 @@ function BaseOverlay({onPress, progress, positionLeftValue = -2 * variables.side
4253
everything behaves normally like one big pressable */}
4354
<PressableWithoutFeedback
4455
style={[styles.draggableTopBar, styles.boxShadowNone, styles.cursorAuto]}
45-
onPress={onPress}
56+
onPress={guardedPress}
4657
accessibilityLabel={translate('common.close')}
4758
role={CONST.ROLE.BUTTON}
4859
id={CONST.OVERLAY.TOP_BUTTON_NATIVE_ID}
4960
tabIndex={-1}
61+
disabled={disabled}
5062
/>
5163
<PressableWithoutFeedback
5264
style={[styles.flex1, styles.boxShadowNone, styles.cursorAuto]}
53-
onPress={onPress}
65+
onPress={guardedPress}
5466
accessibilityLabel={translate('common.close')}
5567
role={CONST.ROLE.BUTTON}
5668
noDragArea
5769
id={CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID}
5870
tabIndex={-1}
71+
disabled={disabled}
5972
/>
6073
</View>
6174
</Animated.View>

src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {NavigatorScreenParams} from '@react-navigation/native';
22
import {useFocusEffect} from '@react-navigation/native';
3-
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
3+
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
44
// eslint-disable-next-line no-restricted-imports
55
import {Animated, DeviceEventEmitter, InteractionManager} from 'react-native';
66
import {DialogLabelProvider} from '@components/DialogLabelContext';
@@ -61,13 +61,18 @@ function MissingPersonalDetailsWithPINContext(props: Record<string, unknown>) {
6161
);
6262
}
6363

64-
function SecondaryOverlay() {
64+
type SecondaryOverlayProps = {
65+
disabled: boolean;
66+
};
67+
68+
function SecondaryOverlay({disabled}: SecondaryOverlayProps) {
6569
const {shouldRenderSecondaryOverlayForWideRHP, shouldRenderSecondaryOverlayForRHPOnWideRHP, shouldRenderSecondaryOverlayForRHPOnSuperWideRHP} = useWideRHPState();
6670
const {sidePanelOffset} = useSidePanelState();
6771

6872
if (shouldRenderSecondaryOverlayForWideRHP) {
6973
return (
7074
<Overlay
75+
disabled={disabled}
7176
progress={secondOverlayWideRHPProgress}
7277
positionRightValue={Animated.add(sidePanelOffset.current, animatedWideRHPWidth)}
7378
onPress={() => Navigation.closeRHPFlow()}
@@ -78,6 +83,7 @@ function SecondaryOverlay() {
7883
if (shouldRenderSecondaryOverlayForRHPOnWideRHP) {
7984
return (
8085
<Overlay
86+
disabled={disabled}
8187
progress={secondOverlayRHPOnWideRHPProgress}
8288
positionRightValue={Animated.add(sidePanelOffset.current, variables.sideBarWidth)}
8389
onPress={Navigation.dismissToPreviousRHP}
@@ -88,6 +94,7 @@ function SecondaryOverlay() {
8894
if (shouldRenderSecondaryOverlayForRHPOnSuperWideRHP) {
8995
return (
9096
<Overlay
97+
disabled={disabled}
9198
progress={secondOverlayRHPOnSuperWideRHPProgress}
9299
positionRightValue={Animated.add(sidePanelOffset.current, variables.sideBarWidth)}
93100
onPress={Navigation.dismissToSuperWideRHP}
@@ -107,6 +114,14 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
107114
const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
108115
const containerRef = useRef(null);
109116
const isExecutingRef = useRef<boolean>(false);
117+
// Tracks whether the inner RHP stack is currently running a non-closing push/pop animation.
118+
// Clicking the overlay mid-push leaves stale animation state (see issue #87174), so we
119+
// ignore the dismiss press until the in-flight transition finishes. The ref gates the
120+
// synchronous dispatchers; the state mirror drives the `disabled` prop on the overlays so
121+
// the Pressable itself no-ops (defense in depth against cases where the dispatcher path
122+
// bypasses the ref guard, e.g. inline Navigation.dismissToPreviousRHP).
123+
const isOverlayDismissEnabledRef = useRef<boolean>(true);
124+
const [isOverlayDismissEnabled, setIsOverlayDismissEnabled] = useState<boolean>(true);
110125
const screenOptions = useRHPScreenOptions();
111126
const {superWideRHPRouteKeys, shouldRenderTertiaryOverlay} = useWideRHPState();
112127
const {clearWideRHPKeys, syncRHPKeys} = useWideRHPActions();
@@ -165,12 +180,31 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
165180
abandonReviewDuplicateTransactions();
166181
});
167182
},
183+
transitionStart: (event: {data?: {closing?: boolean}}) => {
184+
// Only disable dismiss when a screen is starting to open. A starting close is
185+
// either the user's own dismiss (benign) or a cancellation of an in-flight open
186+
// (handled by transitionEnd below).
187+
if (event.data?.closing) {
188+
return;
189+
}
190+
isOverlayDismissEnabledRef.current = false;
191+
setIsOverlayDismissEnabled(false);
192+
},
193+
transitionEnd: () => {
194+
// Re-enable on ANY transitionEnd (opening or closing). If an opening transition
195+
// is interrupted by a pop, React Navigation cancels the open and only emits
196+
// transitionEnd with closing: true for the popped route — the opening's own
197+
// closing: false end never fires. Ignoring closing: true here would leave the
198+
// gate stuck false forever and swallow subsequent overlay dismiss clicks.
199+
isOverlayDismissEnabledRef.current = true;
200+
setIsOverlayDismissEnabled(true);
201+
},
168202
}),
169203
[navigation, route.params?.screen],
170204
);
171205

172206
const handleOverlayPress = useCallback(() => {
173-
if (isExecutingRef.current) {
207+
if (isExecutingRef.current || !isOverlayDismissEnabledRef.current) {
174208
return;
175209
}
176210
isExecutingRef.current = true;
@@ -215,6 +249,7 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
215249
<NoDropZone>
216250
{!shouldUseNarrowLayout && (
217251
<Overlay
252+
disabled={!isOverlayDismissEnabled}
218253
positionLeftValue={overlayPositionLeft}
219254
onPress={handleOverlayPress}
220255
/>
@@ -466,9 +501,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) {
466501
{/* The third and second overlays are displayed here to cover RHP screens wider than the currently focused screen. */}
467502
{/* Clicking on these overlays redirects you to the RHP screen below them. */}
468503
{/* The width of these overlays is equal to the width of the screen minus the width of the currently focused RHP screen (positionRightValue) */}
469-
{!shouldUseNarrowLayout && <SecondaryOverlay />}
504+
{!shouldUseNarrowLayout && <SecondaryOverlay disabled={!isOverlayDismissEnabled} />}
470505
{!shouldUseNarrowLayout && shouldRenderTertiaryOverlay && (
471506
<Overlay
507+
disabled={!isOverlayDismissEnabled}
472508
progress={thirdOverlayProgress}
473509
positionRightValue={Animated.add(sidePanelOffset.current, variables.sideBarWidth)}
474510
onPress={Navigation.dismissToPreviousRHP}

0 commit comments

Comments
 (0)