Skip to content

Commit 390d487

Browse files
committed
fix(confirmations): avoid stale confirmation on back from Activity after a transaction
After a full-screen confirmation submitted, the redirect pushed the Activity screen on top of the now-consumed confirmation (approval deleted via deleteAfterResult), so pressing back returned to it and it rendered an infinite loader. A new navigateToActivityAfterConfirmation helper replaces the confirmation's stack with Activity in a single StackActions.replace, avoiding the stale screen and the pop+push double-animation. Wired into the shared transaction confirm hook, batch approvals, the Earn lending deposit/withdrawal views, and the legacy staking footer. TMCU-1001
1 parent 524410d commit 390d487

7 files changed

Lines changed: 298 additions & 8 deletions

File tree

app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ScrollView, View } from 'react-native';
1111
import { useSelector } from 'react-redux';
1212
import { strings } from '../../../../../../locales/i18n';
1313
import Routes from '../../../../../constants/navigation/Routes';
14+
import { navigateToActivityAfterConfirmation } from '../../../../../util/navigation/navigateToActivityAfterConfirmation';
1415
import Engine from '../../../../../core/Engine';
1516
import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts';
1617
import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController';
@@ -415,7 +416,7 @@ const EarnLendingDepositConfirmationView = () => {
415416
});
416417
// There is variance in when navigation can be called across chains
417418
setTimeout(() => {
418-
navigation.navigate(Routes.TRANSACTIONS_VIEW);
419+
navigateToActivityAfterConfirmation(navigation);
419420
}, 0);
420421
},
421422
({ transactionMeta }) => transactionMeta.id === transactionId,

app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import Text, {
2121
TextVariant,
2222
} from '../../../../../component-library/components/Texts/Text';
2323
import Routes from '../../../../../constants/navigation/Routes';
24+
import { navigateToActivityAfterConfirmation } from '../../../../../util/navigation/navigateToActivityAfterConfirmation';
2425
import {
2526
IMetaMetricsEvent,
2627
MetaMetricsEvents,
@@ -316,7 +317,7 @@ const EarnLendingWithdrawalConfirmationView = () => {
316317
});
317318
// There is variance in when navigation can be called across chains
318319
setTimeout(() => {
319-
navigation.navigate(Routes.TRANSACTIONS_VIEW);
320+
navigateToActivityAfterConfirmation(navigation);
320321
}, 0);
321322
},
322323
({ transactionMeta }) => transactionMeta.id === transactionId,

app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
FooterButtonGroupActions,
2323
FooterButtonGroupProps,
2424
} from './FooterButtonGroup.types';
25-
import Routes from '../../../../../../../constants/navigation/Routes';
25+
import { navigateToActivityAfterConfirmation } from '../../../../../../../util/navigation/navigateToActivityAfterConfirmation';
2626
import usePoolStakedUnstake from '../../../../hooks/usePoolStakedUnstake';
2727
import { useAnalytics } from '../../../../../../hooks/useAnalytics/useAnalytics';
2828
import {
@@ -59,7 +59,6 @@ const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => {
5959
const { styles } = useStyles(styleSheet, {});
6060

6161
const navigation = useNavigation();
62-
const { navigate } = navigation;
6362

6463
const { trackEvent, createEventBuilder } = useAnalytics();
6564

@@ -117,7 +116,7 @@ const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => {
117116
() => {
118117
submitTxMetaMetric(STAKING_TX_METRIC_EVENTS[action].SUBMITTED);
119118
setDidSubmitTransaction(false);
120-
navigate(Routes.TRANSACTIONS_VIEW);
119+
navigateToActivityAfterConfirmation(navigation);
121120
},
122121
({ transactionMeta }) => transactionMeta.id === transactionId,
123122
);
@@ -148,7 +147,7 @@ const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => {
148147
(transactionMeta) => transactionMeta.id === transactionId,
149148
);
150149
},
151-
[action, navigate, submitTxMetaMetric],
150+
[action, navigation, submitTxMetaMetric],
152151
);
153152

154153
const handleConfirmation = async () => {

app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { useGaslessSupportedSmartTransactions } from '../gas/useGaslessSupported
2626
import { cloneDeep } from 'lodash';
2727
import { useTransactionPayQuotes } from '../pay/useTransactionPayData';
2828
import { useMusdConfirmNavigation } from '../../../../UI/Earn/hooks/useMusdConfirmNavigation';
29+
import { navigateToActivityAfterConfirmation } from '../../../../../util/navigation/navigateToActivityAfterConfirmation';
2930
import { useFiatConfirm } from '../pay/useFiatConfirm';
3031
import { useHandleHwSend } from '../../../../UI/HardwareWallet/Swaps/useHandleHwSend';
3132

@@ -200,7 +201,7 @@ export function useTransactionConfirm() {
200201
isFullScreenConfirmation &&
201202
!hasTransactionType(transactionMetadata, GO_BACK_TYPES)
202203
) {
203-
navigation.navigate(Routes.TRANSACTIONS_VIEW);
204+
navigateToActivityAfterConfirmation(navigation);
204205
} else {
205206
navigation.goBack();
206207
}

app/components/Views/confirmations/hooks/useConfirmActions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { TransactionType } from '@metamask/transaction-controller';
55

66
import PPOMUtil from '../../../../lib/ppom/ppom-util';
77
import Routes from '../../../../constants/navigation/Routes';
8+
import { navigateToActivityAfterConfirmation } from '../../../../util/navigation/navigateToActivityAfterConfirmation';
89
import { MetaMetricsEvents } from '../../../../core/Analytics';
910

1011
import { isSignatureRequest } from '../utils/confirm';
@@ -76,7 +77,7 @@ export const useConfirmActions = () => {
7677
});
7778

7879
if (approvalType === ApprovalType.TransactionBatch) {
79-
navigation.navigate(Routes.TRANSACTIONS_VIEW);
80+
navigateToActivityAfterConfirmation(navigation);
8081
} else {
8182
navigation.goBack();
8283
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import React from 'react';
2+
import { Text } from 'react-native';
3+
import { render, fireEvent, act } from '@testing-library/react-native';
4+
import {
5+
NavigationContainer,
6+
createNavigationContainerRef,
7+
useNavigation,
8+
type NavigationState,
9+
type PartialState,
10+
} from '@react-navigation/native';
11+
import { createNativeStackNavigator } from '@react-navigation/native-stack';
12+
import Routes from '../../constants/navigation/Routes';
13+
import { navigateToActivityAfterConfirmation } from './navigateToActivityAfterConfirmation';
14+
15+
// This is an integration test against REAL React Navigation state (not mocked),
16+
// because the fix depends on how the confirmation pop and the Activity push
17+
// interact across nested navigators (and the ordering differs by stack shape).
18+
// It reconstructs the exact stack shape each flow has at transaction-submission
19+
// time (see the flow traces in the PR description) and asserts that after the
20+
// redirect:
21+
// 1. the user lands on the Activity screen, and
22+
// 2. pressing "back" returns to the transaction-building screen, not the
23+
// consumed confirmation.
24+
25+
const RootStack = createNativeStackNavigator();
26+
const NestedStack = createNativeStackNavigator();
27+
28+
const REDESIGNED = Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS;
29+
30+
const Probe = ({ label }: { label: string }) => <Text>{label}</Text>;
31+
32+
// A confirmation screen whose only job is to trigger the redirect helper with
33+
// its own (nested) navigation object — mirroring how the real confirmation
34+
// footers/hooks call it.
35+
const ConfirmationScreen = () => {
36+
const navigation = useNavigation();
37+
return (
38+
<Text
39+
testID="redirect-trigger"
40+
onPress={() => navigateToActivityAfterConfirmation(navigation)}
41+
>
42+
confirm
43+
</Text>
44+
);
45+
};
46+
47+
const StakeScreensNavigator = () => (
48+
<NestedStack.Navigator screenOptions={{ headerShown: false }}>
49+
<NestedStack.Screen name={Routes.STAKING.STAKE}>
50+
{() => <Probe label="stake-input" />}
51+
</NestedStack.Screen>
52+
<NestedStack.Screen name={REDESIGNED} component={ConfirmationScreen} />
53+
</NestedStack.Navigator>
54+
);
55+
56+
const EarnScreensNavigator = () => (
57+
<NestedStack.Navigator screenOptions={{ headerShown: false }}>
58+
<NestedStack.Screen
59+
name={Routes.EARN.LENDING_DEPOSIT_CONFIRMATION}
60+
component={ConfirmationScreen}
61+
/>
62+
</NestedStack.Navigator>
63+
);
64+
65+
const SendNavigator = () => (
66+
<NestedStack.Navigator screenOptions={{ headerShown: false }}>
67+
<NestedStack.Screen name={Routes.SEND.AMOUNT}>
68+
{() => <Probe label="send-amount" />}
69+
</NestedStack.Screen>
70+
<NestedStack.Screen name={Routes.SEND.RECIPIENT}>
71+
{() => <Probe label="send-recipient" />}
72+
</NestedStack.Screen>
73+
<NestedStack.Screen name={REDESIGNED} component={ConfirmationScreen} />
74+
</NestedStack.Navigator>
75+
);
76+
77+
const renderTree = (
78+
initialState: PartialState<NavigationState>,
79+
ref: ReturnType<typeof createNavigationContainerRef>,
80+
) =>
81+
render(
82+
<NavigationContainer ref={ref} initialState={initialState}>
83+
{/* TRANSACTIONS_VIEW is registered at the root level, as it is when the
84+
Money-account feature is enabled — this is what lets a bare
85+
navigate(TRANSACTIONS_VIEW) push Activity on top of a confirmation. */}
86+
<RootStack.Navigator screenOptions={{ headerShown: false }}>
87+
<RootStack.Screen name={Routes.HOME_TABS}>
88+
{() => <Probe label="home" />}
89+
</RootStack.Screen>
90+
<RootStack.Screen
91+
name="StakeScreens"
92+
component={StakeScreensNavigator}
93+
/>
94+
<RootStack.Screen
95+
name={Routes.EARN.ROOT}
96+
component={EarnScreensNavigator}
97+
/>
98+
<RootStack.Screen
99+
name={Routes.SEND.DEFAULT}
100+
component={SendNavigator}
101+
/>
102+
<RootStack.Screen name={Routes.TRANSACTIONS_VIEW}>
103+
{() => <Probe label="activity" />}
104+
</RootStack.Screen>
105+
</RootStack.Navigator>
106+
</NavigationContainer>,
107+
);
108+
109+
// Name of the currently focused leaf route in the whole tree.
110+
const focusedRouteName = (
111+
ref: ReturnType<typeof createNavigationContainerRef>,
112+
): string | undefined => ref.getCurrentRoute()?.name;
113+
114+
// Names of the top-level (root) routes, in order.
115+
const rootRouteNames = (
116+
ref: ReturnType<typeof createNavigationContainerRef>,
117+
): string[] => (ref.getRootState()?.routes ?? []).map((route) => route.name);
118+
119+
describe('navigateToActivityAfterConfirmation', () => {
120+
it('flow 1 (staking): replaces the flow stack with Activity; back returns to Wallet home', () => {
121+
const ref = createNavigationContainerRef();
122+
const { getByTestId } = renderTree(
123+
{
124+
routes: [
125+
{ name: Routes.HOME_TABS },
126+
{
127+
name: 'StakeScreens',
128+
state: {
129+
index: 1,
130+
routes: [{ name: Routes.STAKING.STAKE }, { name: REDESIGNED }],
131+
},
132+
},
133+
],
134+
index: 1,
135+
} as PartialState<NavigationState>,
136+
ref,
137+
);
138+
139+
fireEvent.press(getByTestId('redirect-trigger'));
140+
141+
// The whole StakeScreens flow stack is replaced by Activity (the build
142+
// screen is nested inside it, so it goes with it).
143+
expect(focusedRouteName(ref)).toBe(Routes.TRANSACTIONS_VIEW);
144+
expect(rootRouteNames(ref)).toEqual([
145+
Routes.HOME_TABS,
146+
Routes.TRANSACTIONS_VIEW,
147+
]);
148+
149+
// Back returns to Wallet home, not the consumed confirmation.
150+
act(() => {
151+
ref.goBack();
152+
});
153+
expect(focusedRouteName(ref)).toBe(Routes.HOME_TABS);
154+
});
155+
156+
it('flow 2 (lending): replaces only the confirmation stack; back returns to the input screen in the surviving sibling stack', () => {
157+
const ref = createNavigationContainerRef();
158+
const { getByTestId } = renderTree(
159+
{
160+
routes: [
161+
{ name: Routes.HOME_TABS },
162+
{
163+
name: 'StakeScreens',
164+
state: { index: 0, routes: [{ name: Routes.STAKING.STAKE }] },
165+
},
166+
{
167+
name: Routes.EARN.ROOT,
168+
state: {
169+
index: 0,
170+
routes: [{ name: Routes.EARN.LENDING_DEPOSIT_CONFIRMATION }],
171+
},
172+
},
173+
],
174+
index: 2,
175+
} as PartialState<NavigationState>,
176+
ref,
177+
);
178+
179+
fireEvent.press(getByTestId('redirect-trigger'));
180+
181+
// Only the EarnScreens stack (which held just the confirmation) is replaced;
182+
// the sibling StakeScreens stack with the input screen survives beneath.
183+
expect(focusedRouteName(ref)).toBe(Routes.TRANSACTIONS_VIEW);
184+
expect(rootRouteNames(ref)).toEqual([
185+
Routes.HOME_TABS,
186+
'StakeScreens',
187+
Routes.TRANSACTIONS_VIEW,
188+
]);
189+
190+
act(() => {
191+
ref.goBack();
192+
});
193+
expect(focusedRouteName(ref)).toBe(Routes.STAKING.STAKE);
194+
});
195+
196+
it('flow 3 (send): replaces the flow stack with Activity; back returns to Wallet home', () => {
197+
const ref = createNavigationContainerRef();
198+
const { getByTestId } = renderTree(
199+
{
200+
routes: [
201+
{ name: Routes.HOME_TABS },
202+
{
203+
name: Routes.SEND.DEFAULT,
204+
state: {
205+
index: 2,
206+
routes: [
207+
{ name: Routes.SEND.AMOUNT },
208+
{ name: Routes.SEND.RECIPIENT },
209+
{ name: REDESIGNED },
210+
],
211+
},
212+
},
213+
],
214+
index: 1,
215+
} as PartialState<NavigationState>,
216+
ref,
217+
);
218+
219+
fireEvent.press(getByTestId('redirect-trigger'));
220+
221+
// The whole Send flow stack (Amount/Recipient/confirmation) is replaced.
222+
expect(focusedRouteName(ref)).toBe(Routes.TRANSACTIONS_VIEW);
223+
expect(rootRouteNames(ref)).toEqual([
224+
Routes.HOME_TABS,
225+
Routes.TRANSACTIONS_VIEW,
226+
]);
227+
228+
act(() => {
229+
ref.goBack();
230+
});
231+
expect(focusedRouteName(ref)).toBe(Routes.HOME_TABS);
232+
});
233+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
StackActions,
3+
type NavigationProp,
4+
type ParamListBase,
5+
} from '@react-navigation/native';
6+
import Routes from '../../constants/navigation/Routes';
7+
8+
// Only the navigation methods this helper needs. Using Pick avoids coupling to
9+
// the caller's exact NavigationProp variant (hooks override `getState`, class
10+
// props differ, etc.).
11+
type ActivityRedirectNavigation = Pick<
12+
NavigationProp<ParamListBase>,
13+
'navigate' | 'getParent'
14+
>;
15+
16+
/**
17+
* Redirects to the Activity screen after a transaction is submitted, dropping
18+
* the now-consumed confirmation from the back stack so "back" from Activity does
19+
* not land on the stale confirmation (whose approval was consumed via
20+
* `deleteAfterResult`, so revisiting it renders an infinite loader).
21+
*
22+
* The confirmation always lives in a nested stack that is a direct child of the
23+
* root navigator (the flow stack: Send, StakeScreens, EarnScreens…). Replacing
24+
* that whole stack with Activity, in one `StackActions.replace`, is a single
25+
* clean transition — no pop-then-push double animation, no half-finished pop
26+
* flashing on back. Native-stack can't cleanly remove a nested confirmation while
27+
* keeping its sibling build screen AND push a root-level Activity in one move, so
28+
* we replace the stack instead. What "back" lands on then depends on the flow's
29+
* shape:
30+
*
31+
* Same stack as the build screen (send, staking): the whole flow stack is
32+
* replaced, so "back" returns to Wallet home.
33+
*
34+
* Sole screen of its own root-sibling stack (lending): only the confirmation's
35+
* stack is replaced; its input screen lives in a sibling stack that survives, so
36+
* "back" returns to that input screen.
37+
*
38+
* Falls back to a plain `navigate` when the parent tree can't be resolved. See the
39+
* real-navigator integration tests in `navigateToActivityAfterConfirmation.test.tsx`.
40+
*
41+
* @param navigation - The confirmation screen's navigation object.
42+
*/
43+
export function navigateToActivityAfterConfirmation(
44+
navigation: ActivityRedirectNavigation,
45+
): void {
46+
const rootNavigation = navigation.getParent?.();
47+
48+
if (!rootNavigation) {
49+
navigation.navigate(Routes.TRANSACTIONS_VIEW);
50+
return;
51+
}
52+
53+
rootNavigation.dispatch(StackActions.replace(Routes.TRANSACTIONS_VIEW));
54+
}

0 commit comments

Comments
 (0)