Skip to content

Commit 3e6439e

Browse files
chore(runway): cherry-pick feat: add network picker deeplink cp-7.65.0 (#25768)
- feat: add network picker deeplink cp-7.65.0 (#25446) <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Add deeplink support to open the network picker modal from the home screen. This allows users to directly open the network selection modal via a universal link to the home screen, improving navigation flow for network-specific actions. Deeplink: https://link.metamask.io/home?openNetworkSelector=true ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: feat: add network picker deeplink ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-2571 ## **Manual testing steps** 1. Generate a link that a user can click (or spin up inside bash command) 2. "Click" network picker deeplink 3. EXPECTED - should open home screen and network deeplink ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://www.loom.com/share/53a5b4ea0e4245be85f8c96d4871a351 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- <a href="https://cursor.com/background-agent?bcId=bc-126b3cd2-5a62-4ebc-9054-8ac666811dd2"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/agents?id=bc-126b3cd2-5a62-4ebc-9054-8ac666811dd2"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches navigation/deeplink behavior on the Wallet home screen and relies on delayed `setParams` timing, which could cause flaky or repeated navigation if React Navigation behavior changes. > > **Overview** > Adds support for a new Home deeplink query param, `openNetworkSelector=true`, which navigates to Wallet home and then triggers the network selector sheet after a small delay. > > Refactors Wallet deeplink-on-focus logic into a reusable `useHomeDeepLinkEffects` hook that now handles both Perps tab selection and the new network selector action, and clears consumed navigation params by nulling them out to avoid repeated triggers. > > Updates legacy `navigateToHomeUrl` to parse the new param and `setParams` post-navigation (delayed), adds/updates unit tests for both the handler and hook, and documents the new param in `docs/readme/deeplinking.md`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 73cbed9. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [b539420](b539420) Co-authored-by: Prithpal Sooriya <prithpal.sooriya@consensys.net>
1 parent a5d86be commit 3e6439e

5 files changed

Lines changed: 248 additions & 56 deletions

File tree

app/components/Views/Wallet/index.test.tsx

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,11 @@ jest.mock('../../../component-library/components-temp/Tabs', () => {
9191
};
9292
});
9393

94-
import Wallet from './';
94+
import Wallet, { useHomeDeepLinkEffects } from './';
9595
import renderWithProvider, {
9696
renderScreen,
9797
} from '../../../util/test/renderWithProvider';
98-
import { screen as RNScreen } from '@testing-library/react-native';
98+
import { screen as RNScreen, renderHook } from '@testing-library/react-native';
9999
import Routes from '../../../constants/navigation/Routes';
100100
import { backgroundState } from '../../../util/test/initial-root-state';
101101
import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils';
@@ -104,11 +104,17 @@ import Engine from '../../../core/Engine';
104104
import { useSelector } from 'react-redux';
105105
import { mockedPerpsFeatureFlagsEnabledState } from '../../UI/Perps/mocks/remoteFeatureFlagMocks';
106106
import { initialState as cardInitialState } from '../../../core/redux/slices/card';
107-
import { NavigationProp, ParamListBase } from '@react-navigation/native';
107+
import {
108+
NavigationProp,
109+
ParamListBase,
110+
useFocusEffect,
111+
useRoute,
112+
} from '@react-navigation/native';
108113
import {
109114
IconColor,
110115
IconName,
111116
} from '../../../component-library/components/Icons/Icon';
117+
import { PERFORMANCE_CONFIG } from '../../UI/Perps/constants/perpsConfig';
112118

113119
const MOCK_ADDRESS = '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272';
114120

@@ -1679,3 +1685,114 @@ describe('Wallet', () => {
16791685
});
16801686
});
16811687
});
1688+
1689+
describe('useHomeDeepLinkEffects', () => {
1690+
beforeEach(() => {
1691+
jest.clearAllMocks();
1692+
jest.useFakeTimers();
1693+
});
1694+
1695+
afterEach(() => {
1696+
jest.runOnlyPendingTimers();
1697+
jest.useRealTimers();
1698+
});
1699+
1700+
const arrangeMocks = () => {
1701+
const mockSetParams = jest.fn();
1702+
const mockOnPerpsTabsSelected = jest.fn();
1703+
const mockOnNetworkSelectorSelected = jest.fn();
1704+
const mockUseRoute = jest
1705+
.mocked(useRoute)
1706+
.mockReturnValue({ key: 'route', name: 'route', params: {} });
1707+
return {
1708+
mockNavigate,
1709+
mockSetParams,
1710+
navigation: {
1711+
setParams: mockSetParams,
1712+
} as unknown as NavigationProp<ParamListBase>,
1713+
mockUseRoute,
1714+
mockOnPerpsTabsSelected,
1715+
mockOnNetworkSelectorSelected,
1716+
};
1717+
};
1718+
1719+
interface DeepLinkTestCase {
1720+
testName: string;
1721+
params: Record<string, unknown>;
1722+
isPerpsEnabled: boolean;
1723+
assertCase: (mocks: ReturnType<typeof arrangeMocks>) => void;
1724+
}
1725+
1726+
const testCases: DeepLinkTestCase[] = [
1727+
{
1728+
testName: 'navigates to perps tab when shouldSelectPerpsTab is true',
1729+
params: { shouldSelectPerpsTab: true },
1730+
isPerpsEnabled: true,
1731+
assertCase: (mocks) => {
1732+
expect(mocks.mockOnPerpsTabsSelected).toHaveBeenCalled();
1733+
expect(mocks.mockSetParams).toHaveBeenCalledWith({
1734+
shouldSelectPerpsTab: null,
1735+
});
1736+
},
1737+
},
1738+
{
1739+
testName: 'navigates to perps tab when initialTab is perps',
1740+
params: { initialTab: 'perps' },
1741+
isPerpsEnabled: true,
1742+
assertCase: (mocks) => {
1743+
expect(mocks.mockOnPerpsTabsSelected).toHaveBeenCalled();
1744+
expect(mocks.mockSetParams).toHaveBeenCalledWith({ initialTab: null });
1745+
},
1746+
},
1747+
{
1748+
testName:
1749+
'navigates to network selector when openNetworkSelector is true',
1750+
params: { openNetworkSelector: true },
1751+
isPerpsEnabled: false,
1752+
assertCase: (mocks) => {
1753+
expect(mocks.mockOnNetworkSelectorSelected).toHaveBeenCalled();
1754+
expect(mocks.mockSetParams).toHaveBeenCalledWith({
1755+
openNetworkSelector: null,
1756+
});
1757+
},
1758+
},
1759+
{
1760+
testName:
1761+
'performs no deeplink action when no deeplink params are provided',
1762+
params: {}, // no deeplink params
1763+
isPerpsEnabled: true,
1764+
assertCase: (mocks) => {
1765+
expect(mocks.mockOnPerpsTabsSelected).not.toHaveBeenCalled();
1766+
expect(mocks.mockOnNetworkSelectorSelected).not.toHaveBeenCalled();
1767+
expect(mocks.mockSetParams).not.toHaveBeenCalled();
1768+
},
1769+
},
1770+
];
1771+
1772+
it.each(testCases)('$testName', ({ params, isPerpsEnabled, assertCase }) => {
1773+
const mocks = arrangeMocks();
1774+
1775+
// Setup the mocked useRoute to return the provided params.
1776+
mocks.mockUseRoute.mockReturnValue({
1777+
key: 'route',
1778+
name: 'route',
1779+
params,
1780+
});
1781+
const mockUseFocusEffect = jest.mocked(useFocusEffect);
1782+
1783+
renderHook(() =>
1784+
useHomeDeepLinkEffects({
1785+
isPerpsEnabled,
1786+
onPerpsTabSelected: mocks.mockOnPerpsTabsSelected,
1787+
onNetworkSelectorSelected: mocks.mockOnNetworkSelectorSelected,
1788+
navigation: mocks.navigation,
1789+
}),
1790+
);
1791+
1792+
const focusCallback = mockUseFocusEffect.mock.calls[0][0];
1793+
focusCallback();
1794+
1795+
jest.advanceTimersByTime(PERFORMANCE_CONFIG.NavigationParamsDelayMs);
1796+
assertCase(mocks);
1797+
});
1798+
});

app/components/Views/Wallet/index.tsx

Lines changed: 87 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,80 @@ interface WalletTokensTabViewProps {
231231
}) => void;
232232
defiEnabled: boolean;
233233
collectiblesEnabled: boolean;
234-
navigationParams?: {
235-
shouldSelectPerpsTab?: boolean;
236-
initialTab?: string;
237-
};
238234
}
239235

236+
interface WalletRouteParams {
237+
openNetworkSelector?: boolean | null;
238+
shouldSelectPerpsTab?: boolean | null;
239+
initialTab?: string | null;
240+
}
241+
242+
export const useHomeDeepLinkEffects = (opts: {
243+
isPerpsEnabled: boolean;
244+
onPerpsTabSelected: () => void;
245+
onNetworkSelectorSelected: () => void;
246+
navigation: NavigationProp<ParamListBase>;
247+
}) => {
248+
const {
249+
isPerpsEnabled,
250+
onPerpsTabSelected,
251+
onNetworkSelectorSelected,
252+
navigation,
253+
} = opts;
254+
255+
const route = useRoute<RouteProp<{ params: WalletRouteParams }, 'params'>>();
256+
257+
// Handle tab selection from navigation params (e.g., from deeplinks)
258+
// This uses useFocusEffect to ensure the tab selection happens when the screen receives focus
259+
useFocusEffect(
260+
useCallback(() => {
261+
const params = route.params;
262+
263+
const clearParams = () => {
264+
if (navigation?.setParams) {
265+
// React-Navigation shallow merges params, so we need to set each param to null to clear them
266+
const nullParams: Record<string, null> = {};
267+
Object.keys(params).forEach((key) => {
268+
nullParams[key] = null;
269+
});
270+
navigation.setParams(nullParams);
271+
}
272+
};
273+
274+
const handleDelayedDeeplinkAction = (action: () => void) => {
275+
const timer = setTimeout(() => {
276+
// Call action
277+
action();
278+
279+
// Clear all deeplink params
280+
clearParams();
281+
return;
282+
}, PERFORMANCE_CONFIG.NavigationParamsDelayMs);
283+
284+
return () => clearTimeout(timer);
285+
};
286+
287+
// Perps Tab Selection Deeplink
288+
const shouldSelectPerpsTab = params?.shouldSelectPerpsTab;
289+
const initialTab = params?.initialTab;
290+
if ((shouldSelectPerpsTab || initialTab === 'perps') && isPerpsEnabled) {
291+
return handleDelayedDeeplinkAction(() => onPerpsTabSelected());
292+
}
293+
294+
// Network Picker Deeplink
295+
if (params?.openNetworkSelector) {
296+
return handleDelayedDeeplinkAction(() => onNetworkSelectorSelected());
297+
}
298+
}, [
299+
route.params,
300+
isPerpsEnabled,
301+
navigation,
302+
onPerpsTabSelected,
303+
onNetworkSelectorSelected,
304+
]),
305+
);
306+
};
307+
240308
const WalletTokensTabView = forwardRef<
241309
WalletTokensTabViewHandle,
242310
WalletTokensTabViewProps
@@ -261,14 +329,8 @@ const WalletTokensTabView = forwardRef<
261329
[isPredictFlagEnabled],
262330
);
263331

264-
const {
265-
navigation,
266-
onChangeTab,
267-
defiEnabled,
268-
collectiblesEnabled,
269-
navigationParams,
270-
} = props;
271-
const route = useRoute<RouteProp<ParamListBase, string>>();
332+
const { navigation, onChangeTab, defiEnabled, collectiblesEnabled } = props;
333+
272334
const tabsListRef = useRef<TabsListRef>(null);
273335
const { enabledNetworks: allEnabledNetworks } = useCurrentNetworkInfo();
274336

@@ -420,40 +482,19 @@ const WalletTokensTabView = forwardRef<
420482
}
421483
}, [currentTabIndex, perpsTabIndex, isPerpsTabVisible, isPerpsEnabled]);
422484

423-
// Handle tab selection from navigation params (e.g., from deeplinks)
424-
// This uses useFocusEffect to ensure the tab selection happens when the screen receives focus
425-
useFocusEffect(
426-
useCallback(() => {
427-
// Check both navigationParams prop and route params for tab selection
428-
// Type assertion needed as route params are not strongly typed in navigation
429-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
430-
const params = navigationParams || (route.params as any);
431-
const shouldSelectPerpsTab = params?.shouldSelectPerpsTab;
432-
const initialTab = params?.initialTab;
433-
434-
if ((shouldSelectPerpsTab || initialTab === 'perps') && isPerpsEnabled) {
435-
// Calculate the index of the Perps tab
436-
// Tokens is always at index 0, Perps is at index 1 when enabled
437-
const targetPerpsTabIndex = 1;
438-
439-
// Small delay ensures the TabsList is fully rendered before selection
440-
const timer = setTimeout(() => {
441-
tabsListRef.current?.goToTabIndex(targetPerpsTabIndex);
442-
443-
// Clear the params to prevent re-selection on subsequent focuses
444-
// This is important for navigation state management
445-
if (navigation?.setParams) {
446-
navigation.setParams({
447-
shouldSelectPerpsTab: false,
448-
initialTab: undefined,
449-
});
450-
}
451-
}, PERFORMANCE_CONFIG.NavigationParamsDelayMs);
452-
453-
return () => clearTimeout(timer);
454-
}
455-
}, [route.params, isPerpsEnabled, navigationParams, navigation]),
456-
);
485+
// Handle deep link effects
486+
useHomeDeepLinkEffects({
487+
navigation,
488+
isPerpsEnabled,
489+
onPerpsTabSelected: () => {
490+
tabsListRef.current?.goToTabIndex(1);
491+
},
492+
onNetworkSelectorSelected: () => {
493+
navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, {
494+
screen: Routes.SHEET.NETWORK_SELECTOR,
495+
});
496+
},
497+
});
457498

458499
// Build tabs array dynamically based on enabled features
459500
const tabsToRender = useMemo(() => {
@@ -568,7 +609,6 @@ const Wallet = ({
568609
storePrivacyPolicyClickedOrClosed,
569610
}: WalletProps) => {
570611
const { navigate } = useNavigation();
571-
const route = useRoute<RouteProp<ParamListBase, string>>();
572612
const walletRef = useRef(null);
573613
const walletTokensTabViewRef = useRef<WalletTokensTabViewHandle>(null);
574614
const scrollViewRef = useRef<ScrollView>(null);
@@ -1399,7 +1439,6 @@ const Wallet = ({
13991439
onChangeTab={onChangeTab}
14001440
defiEnabled={defiEnabled}
14011441
collectiblesEnabled={collectiblesEnabled}
1402-
navigationParams={route.params}
14031442
/>
14041443
</>
14051444
</>

app/core/DeeplinkManager/handlers/legacy/__tests__/handleHomeUrl.test.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,36 @@ import NavigationService from '../../../../NavigationService';
22
import { setContentPreviewToken } from '../../../../../actions/notification/helpers';
33
import { navigateToHomeUrl } from '../handleHomeUrl';
44
import Routes from '../../../../../constants/navigation/Routes';
5+
import { PERFORMANCE_CONFIG } from '../../../../../components/UI/Perps/constants/perpsConfig';
56

67
jest.mock('../../../../NavigationService');
78
jest.mock('../../../../../actions/notification/helpers');
89

910
describe('navigateToHomeUrl', () => {
10-
beforeEach(() => jest.clearAllMocks());
11+
beforeEach(() => {
12+
jest.clearAllMocks();
13+
jest.useFakeTimers();
14+
});
15+
16+
afterEach(() => {
17+
jest.runOnlyPendingTimers();
18+
jest.useRealTimers();
19+
});
1120

1221
const arrangeMocks = () => {
1322
const mockNavigate = jest.fn();
23+
const mockSetParams = jest.fn();
1424
NavigationService.navigation = {
1525
navigate: mockNavigate,
26+
setParams: mockSetParams,
1627
} as unknown as typeof NavigationService.navigation;
1728

1829
const mockSetContentPreviewToken = jest.mocked(setContentPreviewToken);
1930

2031
return {
2132
mockNavigate,
2233
mockSetContentPreviewToken,
34+
mockSetParams,
2335
};
2436
};
2537

@@ -39,11 +51,14 @@ describe('navigateToHomeUrl', () => {
3951
expect(mocks.mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME);
4052
});
4153

42-
it('falls back to navigated to home sceen when no homePath', () => {
54+
it('navigates to home screen with openNetworkSelector param when requested', () => {
4355
const mocks = arrangeMocks();
44-
navigateToHomeUrl({ homePath: undefined });
56+
navigateToHomeUrl({ homePath: 'home?openNetworkSelector=true' });
4557

46-
expect(mocks.mockSetContentPreviewToken).toHaveBeenCalledWith(null);
4758
expect(mocks.mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME);
59+
jest.advanceTimersByTime(PERFORMANCE_CONFIG.NavigationParamsDelayMs);
60+
expect(mocks.mockSetParams).toHaveBeenCalledWith({
61+
openNetworkSelector: true,
62+
});
4863
});
4964
});

0 commit comments

Comments
 (0)