Skip to content

Commit 5d16844

Browse files
authored
fix(ramps): Single-owner React Query fetching for providers, tokens, and payment methods (#28224)
## **Description** Payment methods, providers, and tokens were being fetched redundantly from two independent paths (controller `fireAndForget` + React Query), causing 2–3 duplicate API calls per user action with a ~10s spinner. This PR makes the mobile client the single fetch owner for all ramp data. React Query handles providers and payment methods with proper caching and invalidation. Tokens are fetched directly from RampsBootstrap. Screens that don't need payment methods no longer trigger fetches. Changes: 1. **`RampsBootstrap.tsx`** — Now fetches all three data sources at app root: `useRampsProviders` (React Query), `useRampsPaymentMethods` (React Query), and `getTokens` (direct controller call on region change). This follows the agreed flow: app loads → fetch providers and tokens → provider selected → fetch payment methods. 2. **`useRampsProviders.ts`** — Reads provider list from React Query cache (not controller state). Invalidates all ramp queries on region change to force fresh fetches. Passes full `Provider` object to `setSelectedProvider` for auto-selection (avoids crash when controller state is empty). 3. **`useRampsPaymentMethods.ts`** — Query key reduced to `[regionCode, providerId]` only. Token/fiat are passed to the API call but not the key, so token changes don't trigger refetches. Passes full `PaymentMethod` object to controller (avoids crash when controller state is empty). 4. **`paymentMethods.ts` (query config)** — `staleTime: 5min`. Query key only includes `regionCode` and `providerId`. 5. **`providers.ts` (query config)** — `staleTime: 15min`. Removed `forceRefresh: true` from queryFn (controller's own cache handles it). 6. **`SettingsModal.tsx`** — Uses `useRampsProviders` instead of `useRampsController` (no more payment methods fetch on settings screen). 7. **`TokenNotAvailableModal.tsx`** — Uses `useRampsProviders` + `useRampsTokens` instead of `useRampsController`. 8. **`RegionSelector.tsx`** — Uses `useRampsUserRegion` + `useRampsCountries` instead of `useRampsController`. 9. **`PaymentSelectionModal.tsx`** — Filters out payment methods with no available quote once quotes load, preventing dead-end options (e.g. Apple Pay shown but no provider returns a quote for it). 10. **Tests updated** — Removed `tokenSupportedByProvider` test, updated query key and staleTime assertions. ## **Changelog** CHANGELOG entry: Fixed duplicate payment method, provider, and token API calls during buy flow; React Query is now the single fetch owner for ramp data ## **Related issues** Fixes: [TRAM-3398](https://consensyssoftware.atlassian.net/browse/TRAM-3398) Depends on core PR: [MetaMask/core#8354](MetaMask/core#8354) (removes `fireAndForget` calls from controller) ## **Manual testing steps** ```gherkin Feature: Single-owner fetching for ramp data Scenario: App load fetches providers, tokens, and payment methods Given user opens the app (password screen) When the app loads Then getProviders, getTokens, and getPaymentMethods each fire once And providers, tokens, and payment methods are populated in state Scenario: Token selection does not trigger payment methods fetch Given user is on the token selection screen When user selects a token (e.g. Ethereum) Then getPaymentMethods is NOT called And the BuildQuote screen loads with cached payment methods Scenario: Provider change triggers a single payment methods fetch Given user is on the BuildQuote screen with a provider selected When user changes the provider (e.g. Transak -> Coinbase) Then getPaymentMethods fires exactly once for the new provider And the payment method pill auto-switches to Debit or Credit Scenario: Region switch refreshes all data Given user is on the settings screen When user changes region (e.g. France -> Finland -> France) Then getProviders and getPaymentMethods fire for each new region And switching back to a previous region also triggers fresh fetches Scenario: Settings screen does not trigger payment methods fetch Given user navigates to the Buy & Sell settings screen Then getPaymentMethods is NOT called And no unnecessary API calls appear in the debug dashboard Scenario: Payment selection modal hides dead-end options Given user taps the payment method pill When quotes load for all payment methods Then payment methods with no available quote are filtered out ``` ## **Screenshots/Recordings** ### **Before** <!-- 2-3 getPaymentMethods calls per token selection visible in debug dashboard --> ### **After** <!-- Single getPaymentMethods call only on provider change; no calls on settings/navigation --> ## **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)). ## **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] > **Medium Risk** > Changes ramp buy-flow data fetching/caching and controller interaction patterns (query keys, invalidation, and provider/payment-method selection), which could affect availability/performance across regions and providers. UI impact is limited but touches core buy-flow bootstrap and selection logic. > > **Overview** > **Makes the mobile client the single fetch owner for ramps buy data.** `RampsBootstrap` now preloads providers (with side effects), payment methods, and triggers token fetching on region availability so downstream screens don’t cause redundant requests. > > **Reworks React Query behavior for providers and payment methods.** `useRampsProviders` reads the provider list from the React Query cache, adds optional `enableSideEffects` to avoid duplicate invalidation/auto-selection, and invalidates all `ramps` queries on region changes. `useRampsPaymentMethods` simplifies the query to be provider-scoped (query key is `regionCode` + `providerId`, 5-min `staleTime`) and updates controller setters to accept full objects/null. > > **UI behavior tweaks and hook decoupling.** `PaymentSelectionModal` now hides payment methods that have no non-custom-action quote once quotes load, showing the “no payment methods available” state instead. Several screens (`SettingsModal`, `TokenNotAvailableModal`, `RegionSelector`) switch from `useRampsController` to narrower hooks (`useRampsProviders`, `useRampsTokens`, `useRampsUserRegion`, `useRampsCountries`) to prevent unrelated fetches. Dependency bump updates `@metamask/ramps-controller` to `^13.0.0`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 75f85ca. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 0711cca commit 5d16844

20 files changed

Lines changed: 343 additions & 254 deletions
Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import React from 'react';
22
import { render } from '@testing-library/react-native';
3+
import { Provider } from 'react-redux';
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5+
import { configureStore } from '@reduxjs/toolkit';
36
import RampsBootstrap from './RampsBootstrap';
47

58
const mockUseRampsSmartRouting = jest.fn();
69
const mockUseRampsProviders = jest.fn();
10+
const mockUseRampsPaymentMethods = jest.fn();
711

812
jest.mock('./hooks/useRampsSmartRouting', () => ({
913
__esModule: true,
@@ -15,26 +19,72 @@ jest.mock('./hooks/useRampsProviders', () => ({
1519
default: (...args: unknown[]) => mockUseRampsProviders(...args),
1620
}));
1721

22+
jest.mock('./hooks/useRampsPaymentMethods', () => ({
23+
__esModule: true,
24+
default: (...args: unknown[]) => mockUseRampsPaymentMethods(...args),
25+
}));
26+
27+
jest.mock('../../../core/Engine', () => ({
28+
context: {
29+
RampsController: {
30+
getTokens: jest.fn().mockResolvedValue({ topTokens: [], allTokens: [] }),
31+
},
32+
},
33+
}));
34+
35+
const createMockStore = () =>
36+
configureStore({
37+
reducer: {
38+
engine: () => ({
39+
backgroundState: {
40+
RampsController: {
41+
userRegion: {
42+
regionCode: 'us',
43+
country: { currency: 'USD' },
44+
},
45+
},
46+
},
47+
}),
48+
},
49+
});
50+
51+
const queryClient = new QueryClient({
52+
defaultOptions: { queries: { retry: false } },
53+
});
54+
55+
const renderBootstrap = () => {
56+
const store = createMockStore();
57+
return render(
58+
<Provider store={store}>
59+
<QueryClientProvider client={queryClient}>
60+
<RampsBootstrap />
61+
</QueryClientProvider>
62+
</Provider>,
63+
);
64+
};
65+
1866
describe('RampsBootstrap', () => {
1967
afterEach(() => {
2068
jest.clearAllMocks();
2169
});
2270

2371
it('calls useRampsSmartRouting on mount', () => {
24-
render(<RampsBootstrap />);
25-
72+
renderBootstrap();
2673
expect(mockUseRampsSmartRouting).toHaveBeenCalledTimes(1);
2774
});
2875

2976
it('calls useRampsProviders on mount', () => {
30-
render(<RampsBootstrap />);
31-
77+
renderBootstrap();
3278
expect(mockUseRampsProviders).toHaveBeenCalledTimes(1);
3379
});
3480

35-
it('renders null', () => {
36-
const { toJSON } = render(<RampsBootstrap />);
81+
it('calls useRampsPaymentMethods on mount', () => {
82+
renderBootstrap();
83+
expect(mockUseRampsPaymentMethods).toHaveBeenCalledTimes(1);
84+
});
3785

86+
it('renders null', () => {
87+
const { toJSON } = renderBootstrap();
3888
expect(toJSON()).toBeNull();
3989
});
4090
});

app/components/UI/Ramp/RampsBootstrap.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { useEffect } from 'react';
2+
import { useSelector } from 'react-redux';
13
import useRampsSmartRouting from './hooks/useRampsSmartRouting';
24
import useRampsProviders from './hooks/useRampsProviders';
5+
import useRampsPaymentMethods from './hooks/useRampsPaymentMethods';
6+
import { selectUserRegion } from '../../../selectors/rampsController';
7+
import Engine from '../../../core/Engine';
38

49
/**
510
* Ramps app bootstrap: runs smart routing, controller hydration, and provider
@@ -11,10 +16,25 @@ import useRampsProviders from './hooks/useRampsProviders';
1116
*
1217
* V2: RampsController is initialized by Engine (rampsControllerInit). Provider
1318
* auto-selection runs when providers load. Mount at app root.
19+
*
20+
* Tokens, providers, and payment methods are all fetched here so they are
21+
* ready when the user enters the buy flow.
1422
*/
1523
function RampsBootstrap(): null {
1624
useRampsSmartRouting();
17-
useRampsProviders();
25+
useRampsProviders({ enableSideEffects: true });
26+
useRampsPaymentMethods();
27+
28+
// Fetch tokens when region is available. Tokens don't use React Query
29+
// (they're needed for controller-side validation in setSelectedToken),
30+
// so we trigger the fetch directly.
31+
const userRegion = useSelector(selectUserRegion);
32+
useEffect(() => {
33+
if (userRegion?.regionCode) {
34+
Engine.context.RampsController.getTokens(userRegion.regionCode, 'buy');
35+
}
36+
}, [userRegion?.regionCode]);
37+
1838
return null;
1939
}
2040

app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.test.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ describe('PaymentSelectionModal', () => {
362362
});
363363
});
364364

365-
it('shows payment method without quote when only custom-action quote matches', () => {
365+
it('filters out payment method when only custom-action quote matches', () => {
366366
const customActionQuote = {
367367
provider: '/providers/transak',
368368
quote: {
@@ -381,7 +381,11 @@ describe('PaymentSelectionModal', () => {
381381
loading: false,
382382
}));
383383

384-
const { getAllByText } = renderWithProvider(PaymentSelectionModal);
385-
expect(getAllByText('Debit or Credit').length).toBeGreaterThan(0);
384+
const { queryAllByText, getByText } = renderWithProvider(
385+
PaymentSelectionModal,
386+
);
387+
// Payment methods with only custom-action quotes are filtered out
388+
expect(queryAllByText('Debit or Credit').length).toBe(0);
389+
expect(getByText('fiat_on_ramp.no_payment_methods_available')).toBeTruthy();
386390
});
387391
});

app/components/UI/Ramp/Views/Modals/PaymentSelectionModal/PaymentSelectionModal.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,18 @@ function PaymentSelectionModal() {
219219
</ScrollView>
220220
);
221221
}
222-
if (paymentMethods.length === 0) {
222+
// Filter out payment methods that have no available quote once quotes
223+
// have loaded. This avoids showing dead-end options to the user.
224+
const visiblePaymentMethods =
225+
!quotesLoading && quotes
226+
? paymentMethods.filter((pm) =>
227+
quotes.success?.some(
228+
(q) => q.quote?.paymentMethod === pm.id && !isCustomAction(q),
229+
),
230+
)
231+
: paymentMethods;
232+
233+
if (visiblePaymentMethods.length === 0) {
223234
return (
224235
<ScrollView
225236
style={styles.list}
@@ -235,7 +246,7 @@ function PaymentSelectionModal() {
235246
return (
236247
<FlatList
237248
style={styles.list}
238-
data={paymentMethods}
249+
data={visiblePaymentMethods}
239250
renderItem={renderPaymentMethod}
240251
keyExtractor={(item) => item.id}
241252
keyboardDismissMode="none"

app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,8 @@ const mockInAppBrowser = InAppBrowser as jest.Mocked<typeof InAppBrowser>;
121121
let mockSelectedProvider: Provider | null = createMockProvider();
122122
const mockSetSelectedProvider = jest.fn();
123123

124-
jest.mock('../../../hooks/useRampsController', () => ({
125-
useRampsController: () => ({
124+
jest.mock('../../../hooks/useRampsProviders', () => ({
125+
useRampsProviders: () => ({
126126
selectedProvider: mockSelectedProvider,
127127
setSelectedProvider: mockSetSelectedProvider,
128128
}),

app/components/UI/Ramp/Views/Modals/SettingsModal/SettingsModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
import Logger from '../../../../../../util/Logger';
2626
import HeaderCompactStandard from '../../../../../../component-library/components-temp/HeaderCompactStandard';
2727
import MenuItem from '../../../components/MenuItem';
28-
import { useRampsController } from '../../../hooks/useRampsController';
28+
import { useRampsProviders } from '../../../hooks/useRampsProviders';
2929
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
3030
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
3131
import {
@@ -53,7 +53,7 @@ function SettingsModal() {
5353
const sheetRef = useRef<BottomSheetRef>(null);
5454
const navigation = useNavigation();
5555
const { toastRef } = useContext(ToastContext);
56-
const { selectedProvider, setSelectedProvider } = useRampsController();
56+
const { selectedProvider, setSelectedProvider } = useRampsProviders();
5757

5858
const [isAuthenticatedWithProvider, setIsAuthenticatedWithProvider] =
5959
useState<boolean>(false);

app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.test.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,14 @@ let mockSelectedToken: unknown = {
5858
symbol: 'USDC',
5959
};
6060

61-
jest.mock('../../../hooks/useRampsController', () => ({
62-
useRampsController: () => ({
61+
jest.mock('../../../hooks/useRampsProviders', () => ({
62+
useRampsProviders: () => ({
6363
selectedProvider: mockSelectedProvider,
64+
}),
65+
}));
66+
67+
jest.mock('../../../hooks/useRampsTokens', () => ({
68+
useRampsTokens: () => ({
6469
selectedToken: mockSelectedToken,
6570
}),
6671
}));

app/components/UI/Ramp/Views/Modals/TokenNotAvailableModal/TokenNotAvailableModal.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
import Routes from '../../../../../../constants/navigation/Routes';
2222
import styleSheet from './TokenNotAvailableModal.styles';
2323
import { useStyles } from '../../../../../hooks/useStyles';
24-
import { useRampsController } from '../../../hooks/useRampsController';
24+
import { useRampsProviders } from '../../../hooks/useRampsProviders';
25+
import { useRampsTokens } from '../../../hooks/useRampsTokens';
2526
import { createProviderSelectionModalNavigationDetails } from '../ProviderSelectionModal';
2627
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
2728
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
@@ -48,7 +49,8 @@ function TokenNotAvailableModal() {
4849
const sheetRef = useRef<BottomSheetRef>(null);
4950
const { styles } = useStyles(styleSheet, {});
5051

51-
const { selectedProvider, selectedToken } = useRampsController();
52+
const { selectedProvider } = useRampsProviders();
53+
const { selectedToken } = useRampsTokens();
5254

5355
const tokenName = selectedToken?.name ?? '';
5456
const providerName = selectedProvider?.name ?? '';

0 commit comments

Comments
 (0)