Skip to content

Commit 08fdc9c

Browse files
authored
fix: select correct swap source asset when navigating from browser (#23534)
<!-- 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** This PR fixes an issue where when users selected the swap button for an asset presented in browser URL bar, that asset was not selected as source when navigating to swap page. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **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: fixes an issue where when users selected the swap button for an asset presented in browser URL bar, that asset was not selected as source when navigating to swap page. ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-3487 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** https://github.com/user-attachments/assets/2b9a5836-77da-41b7-899d-86fe73469021 <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/1c1ad351-da97-49c7-a64c-47f63fd0f345 <!-- [screenshots/recordings] --> ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Pass the selected token from URL Autocomplete to Swaps navigation and update the navigation hook to accept a token override with proper chain handling and fallbacks. > > - **Swaps/Bridge Navigation**: > - Update `useSwapBridgeNavigation` to accept an optional `tokenOverride` in `goToNativeBridge` and `goToSwaps`. > - Use the override (or provided `sourceToken`) to determine the effective chain ID, format chain IDs, and select the candidate source token. > - Add fallback to mainnet native token when the source chain isn’t bridge-enabled. > - Expand tests to cover token override, mainnet fallback, home-page filter network, Solana CAIP handling, and analytics events. > - **URL Autocomplete**: > - On swap button press, construct a `BridgeToken` from `TokenSearchResult` and invoke `goToSwaps` with it. > - Adjust tests to assert `goToSwaps` is called with the correct token, plus additional tests for loading indicator, result de-duplication, recents limiting, and reset on hide. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 37e7967. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 0c5df82 commit 08fdc9c

File tree

4 files changed

+317
-18
lines changed

4 files changed

+317
-18
lines changed

app/components/UI/Bridge/hooks/useSwapBridgeNavigation/index.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,15 @@ export const useSwapBridgeNavigation = ({
5757

5858
// Unified swaps/bridge UI
5959
const goToNativeBridge = useCallback(
60-
(bridgeViewMode: BridgeViewMode) => {
60+
(bridgeViewMode: BridgeViewMode, tokenOverride?: BridgeToken) => {
61+
// Use tokenOverride if provided, otherwise fall back to tokenBase
62+
const effectiveTokenBase = tokenOverride ?? tokenBase;
63+
6164
// Determine effective chain ID - use home page filter network when no sourceToken provided
6265
const getEffectiveChainId = (): CaipChainId | Hex => {
63-
if (tokenBase) {
66+
if (effectiveTokenBase) {
6467
// If specific token provided, use its chainId
65-
return tokenBase.chainId;
68+
return effectiveTokenBase.chainId;
6669
}
6770

6871
// No token provided - check home page filter network
@@ -82,7 +85,7 @@ export const useSwapBridgeNavigation = ({
8285

8386
let bridgeSourceNativeAsset;
8487
try {
85-
if (!tokenBase) {
88+
if (!effectiveTokenBase) {
8689
bridgeSourceNativeAsset = getNativeAssetForChainId(effectiveChainId);
8790
}
8891
} catch (error) {
@@ -104,7 +107,7 @@ export const useSwapBridgeNavigation = ({
104107
: undefined;
105108

106109
const candidateSourceToken =
107-
tokenBase ?? bridgeNativeSourceTokenFormatted;
110+
effectiveTokenBase ?? bridgeNativeSourceTokenFormatted;
108111
const isBridgeEnabledSource = getIsBridgeEnabledSource(effectiveChainId);
109112
let sourceToken = isBridgeEnabledSource
110113
? candidateSourceToken
@@ -167,9 +170,12 @@ export const useSwapBridgeNavigation = ({
167170
);
168171
const { networkModal } = useAddNetwork();
169172

170-
const goToSwaps = useCallback(() => {
171-
goToNativeBridge(BridgeViewMode.Unified);
172-
}, [goToNativeBridge]);
173+
const goToSwaps = useCallback(
174+
(tokenOverride?: BridgeToken) => {
175+
goToNativeBridge(BridgeViewMode.Unified, tokenOverride);
176+
},
177+
[goToNativeBridge],
178+
);
173179

174180
return {
175181
goToSwaps,

app/components/UI/Bridge/hooks/useSwapBridgeNavigation/useSwapBridgeNavigation.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ jest.mock('../../../../hooks/useMetrics', () => {
3737
};
3838
});
3939

40+
const mockGetIsBridgeEnabledSource = jest.fn(() => true);
4041
jest.mock('../../../../../core/redux/slices/bridge', () => ({
4142
...jest.requireActual('../../../../../core/redux/slices/bridge'),
42-
selectIsBridgeEnabledSourceFactory: jest.fn(() => () => true),
43+
selectIsBridgeEnabledSourceFactory: jest.fn(
44+
() => mockGetIsBridgeEnabledSource,
45+
),
4346
}));
4447

4548
const mockGoToPortfolioBridge = jest.fn();
@@ -140,6 +143,9 @@ describe('useSwapBridgeNavigation', () => {
140143

141144
// Reset selectChainId mock to default
142145
(selectChainId as unknown as jest.Mock).mockReturnValue(mockChainId);
146+
147+
// Reset bridge enabled mock to default (enabled)
148+
mockGetIsBridgeEnabledSource.mockReturnValue(true);
143149
});
144150

145151
it('uses native token when no token is provided', () => {
@@ -202,6 +208,93 @@ describe('useSwapBridgeNavigation', () => {
202208
});
203209
});
204210

211+
it('uses tokenOverride when passed to goToSwaps', () => {
212+
const configuredToken: BridgeToken = {
213+
address: '0x0000000000000000000000000000000000000001',
214+
symbol: 'TOKEN',
215+
name: 'Test Token',
216+
decimals: 18,
217+
chainId: mockChainId,
218+
};
219+
220+
const overrideToken: BridgeToken = {
221+
address: '0x0000000000000000000000000000000000000002',
222+
symbol: 'OVERRIDE',
223+
name: 'Override Token',
224+
decimals: 18,
225+
chainId: '0x89' as Hex,
226+
};
227+
228+
const { result } = renderHookWithProvider(
229+
() =>
230+
useSwapBridgeNavigation({
231+
location: mockLocation,
232+
sourcePage: mockSourcePage,
233+
sourceToken: configuredToken,
234+
}),
235+
{ state: initialState },
236+
);
237+
238+
result.current.goToSwaps(overrideToken);
239+
240+
expect(mockNavigate).toHaveBeenCalledWith('Bridge', {
241+
screen: 'BridgeView',
242+
params: {
243+
sourceToken: overrideToken,
244+
sourcePage: mockSourcePage,
245+
bridgeViewMode: BridgeViewMode.Unified,
246+
},
247+
});
248+
});
249+
250+
it('falls back to ETH on mainnet when bridge is not enabled for source chain', () => {
251+
mockGetIsBridgeEnabledSource.mockReturnValue(false);
252+
253+
// Mock that getNativeAssetForChainId returns ETH for mainnet fallback
254+
(getNativeAssetForChainId as jest.Mock).mockReturnValue({
255+
address: '0x0000000000000000000000000000000000000000',
256+
name: 'Ether',
257+
symbol: 'ETH',
258+
decimals: 18,
259+
});
260+
261+
const unsupportedToken: BridgeToken = {
262+
address: '0x0000000000000000000000000000000000000001',
263+
symbol: 'UNSUPPORTED',
264+
name: 'Unsupported Token',
265+
decimals: 18,
266+
chainId: '0x999' as Hex,
267+
};
268+
269+
const { result } = renderHookWithProvider(
270+
() =>
271+
useSwapBridgeNavigation({
272+
location: mockLocation,
273+
sourcePage: mockSourcePage,
274+
sourceToken: unsupportedToken,
275+
}),
276+
{ state: initialState },
277+
);
278+
279+
result.current.goToSwaps();
280+
281+
expect(mockNavigate).toHaveBeenCalledWith('Bridge', {
282+
screen: 'BridgeView',
283+
params: {
284+
sourceToken: {
285+
address: '0x0000000000000000000000000000000000000000',
286+
name: 'Ether',
287+
symbol: 'ETH',
288+
image: '',
289+
decimals: 18,
290+
chainId: '0x1',
291+
},
292+
sourcePage: mockSourcePage,
293+
bridgeViewMode: BridgeViewMode.Unified,
294+
},
295+
});
296+
});
297+
205298
it('navigates to Bridge when goToSwaps is called and bridge UI is enabled', () => {
206299
const { result } = renderHookWithProvider(
207300
() =>

app/components/UI/UrlAutocomplete/index.test.tsx

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,35 @@ jest.mock('../../../selectors/tokenSearchDiscoveryDataController', () => {
147147
};
148148
});
149149

150+
const mockGoToSwaps = jest.fn();
151+
jest.mock('../Bridge/hooks/useSwapBridgeNavigation', () => ({
152+
...jest.requireActual('../Bridge/hooks/useSwapBridgeNavigation'),
153+
useSwapBridgeNavigation: jest.fn(() => ({
154+
goToSwaps: mockGoToSwaps,
155+
networkModal: null,
156+
})),
157+
}));
158+
159+
// Mock useFavicon to prevent async state updates warning
160+
jest.mock('../../hooks/useFavicon/useFavicon', () => ({
161+
__esModule: true,
162+
default: jest.fn(() => ({
163+
isLoading: false,
164+
isLoaded: true,
165+
error: null,
166+
favicon: null,
167+
})),
168+
}));
169+
150170
describe('UrlAutocomplete', () => {
151171
beforeAll(() => {
152172
jest.useFakeTimers();
153173
});
154174

175+
beforeEach(() => {
176+
jest.clearAllMocks();
177+
});
178+
155179
afterAll(() => {
156180
jest.useFakeTimers({ legacyFakeTimers: true });
157181
});
@@ -331,7 +355,7 @@ describe('UrlAutocomplete', () => {
331355
).toBeDefined();
332356
});
333357

334-
it('should swap a token when the swap button is pressed', async () => {
358+
it('calls goToSwaps when the swap button is pressed', async () => {
335359
mockUseTSDReturnValue({
336360
results: [
337361
{
@@ -364,7 +388,7 @@ describe('UrlAutocomplete', () => {
364388
{ includeHiddenElements: true },
365389
);
366390
fireEvent.press(swapButton);
367-
expect(mockNavigate).toHaveBeenCalled();
391+
expect(mockGoToSwaps).toHaveBeenCalled();
368392
});
369393

370394
it('should call onSelect when a bookmark is selected', async () => {
@@ -416,4 +440,167 @@ describe('UrlAutocomplete', () => {
416440
fireEvent.press(result);
417441
expect(onSelect).toHaveBeenCalled();
418442
});
443+
444+
it('calls goToSwaps with correct BridgeToken when swap button is pressed', async () => {
445+
mockUseTSDReturnValue({
446+
results: [
447+
{
448+
tokenAddress: '0x123',
449+
chainId: '0x1',
450+
name: 'Dogecoin',
451+
symbol: 'DOGE',
452+
usdPrice: 1,
453+
usdPricePercentChange: {
454+
oneDay: 1,
455+
},
456+
logoUrl: 'https://example.com/doge.png',
457+
},
458+
],
459+
isLoading: false,
460+
reset: jest.fn(),
461+
searchTokens: jest.fn(),
462+
});
463+
const ref = React.createRef<UrlAutocompleteRef>();
464+
render(<UrlAutocomplete ref={ref} onSelect={noop} onDismiss={noop} />, {
465+
state: defaultState,
466+
});
467+
468+
act(() => {
469+
ref.current?.search('dog');
470+
jest.runAllTimers();
471+
});
472+
473+
const swapButton = await screen.findByTestId(
474+
'autocomplete-result-swap-button',
475+
{ includeHiddenElements: true },
476+
);
477+
fireEvent.press(swapButton);
478+
479+
expect(mockGoToSwaps).toHaveBeenCalledWith({
480+
address: '0x123',
481+
name: 'Dogecoin',
482+
symbol: 'DOGE',
483+
chainId: '0x1',
484+
image: 'https://example.com/doge.png',
485+
decimals: 18,
486+
});
487+
});
488+
489+
it('resets token search when hide method is called via ref', async () => {
490+
const resetMock = jest.fn();
491+
mockUseTSDReturnValue({
492+
results: [
493+
{
494+
tokenAddress: '0x123',
495+
chainId: '0x1',
496+
name: 'Dogecoin',
497+
symbol: 'DOGE',
498+
usdPrice: 1,
499+
usdPricePercentChange: {
500+
oneDay: 1,
501+
},
502+
},
503+
],
504+
isLoading: false,
505+
reset: resetMock,
506+
searchTokens: jest.fn(),
507+
});
508+
const ref = React.createRef<UrlAutocompleteRef>();
509+
render(<UrlAutocomplete ref={ref} onSelect={noop} onDismiss={noop} />, {
510+
state: defaultState,
511+
});
512+
513+
act(() => {
514+
ref.current?.search('dog');
515+
jest.runAllTimers();
516+
});
517+
518+
expect(
519+
await screen.findByText('Dogecoin', { includeHiddenElements: true }),
520+
).toBeDefined();
521+
522+
act(() => {
523+
ref.current?.hide();
524+
});
525+
526+
expect(resetMock).toHaveBeenCalled();
527+
});
528+
529+
it('displays token section header with loading indicator when loading', async () => {
530+
mockUseTSDReturnValue({
531+
results: [],
532+
isLoading: true,
533+
reset: jest.fn(),
534+
searchTokens: jest.fn(),
535+
});
536+
const ref = React.createRef<UrlAutocompleteRef>();
537+
render(<UrlAutocomplete ref={ref} onSelect={noop} onDismiss={noop} />, {
538+
state: defaultState,
539+
});
540+
541+
act(() => {
542+
ref.current?.search('token');
543+
jest.runAllTimers();
544+
});
545+
546+
expect(
547+
await screen.findByText('Tokens', { includeHiddenElements: true }),
548+
).toBeDefined();
549+
expect(
550+
await screen.findByTestId('loading-indicator', {
551+
includeHiddenElements: true,
552+
}),
553+
).toBeDefined();
554+
});
555+
556+
it('removes duplicate results with same url and category', async () => {
557+
const ref = React.createRef<UrlAutocompleteRef>();
558+
render(<UrlAutocomplete ref={ref} onSelect={noop} onDismiss={noop} />, {
559+
state: {
560+
...defaultState,
561+
browser: {
562+
history: [
563+
{ url: 'https://www.google.com', name: 'Google' },
564+
{ url: 'https://www.google.com', name: 'Google Duplicate' },
565+
],
566+
},
567+
},
568+
});
569+
570+
act(() => {
571+
ref.current?.search('google');
572+
jest.runAllTimers();
573+
});
574+
575+
const googleResults = await screen.findAllByText(/Google/, {
576+
includeHiddenElements: true,
577+
});
578+
expect(googleResults.length).toBe(1);
579+
});
580+
581+
it('limits recent results to MAX_RECENTS', async () => {
582+
const historyItems = Array.from({ length: 10 }, (_, i) => ({
583+
url: `https://www.site${i}.com`,
584+
name: `Site${i}`,
585+
}));
586+
const ref = React.createRef<UrlAutocompleteRef>();
587+
render(<UrlAutocomplete ref={ref} onSelect={noop} onDismiss={noop} />, {
588+
state: {
589+
...defaultState,
590+
browser: { history: historyItems },
591+
bookmarks: [],
592+
},
593+
});
594+
595+
act(() => {
596+
ref.current?.search('Site');
597+
jest.runAllTimers();
598+
});
599+
600+
// MAX_RECENTS is 5, so with 10 items, only 5 should show
601+
const recentsHeader = await screen.findByText('Recents', {
602+
includeHiddenElements: true,
603+
});
604+
expect(recentsHeader).toBeDefined();
605+
});
419606
});

0 commit comments

Comments
 (0)