Skip to content

Commit 5ff421a

Browse files
committed
fix: loading vs Not available + navigation back in several scenarios
1 parent b6614ab commit 5ff421a

File tree

8 files changed

+323
-33
lines changed

8 files changed

+323
-33
lines changed

app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,38 @@ describe('BuildQuote', () => {
16811681
);
16821682
});
16831683

1684+
it('navigates to token unavailable modal when params.assetId is missing but selectedToken is set', async () => {
1685+
mockUseParams.mockImplementation(() => ({}));
1686+
mockSelectedProvider = {
1687+
id: '/providers/transak',
1688+
name: 'Transak',
1689+
environmentType: 'PRODUCTION',
1690+
description: 'Test Provider',
1691+
hqAddress: '123 Test St',
1692+
links: [],
1693+
logos: { light: '', dark: '', height: 24, width: 79 },
1694+
supportedCryptoCurrencies: {
1695+
[MOCK_ASSET_ID]: true,
1696+
},
1697+
};
1698+
mockPaymentMethods = [];
1699+
mockPaymentMethodsStatus = 'success';
1700+
1701+
renderWithTheme(<BuildQuote />);
1702+
1703+
await act(async () => {
1704+
await flushPromises();
1705+
});
1706+
1707+
expect(mockNavigate).toHaveBeenCalledWith(
1708+
'RampModals',
1709+
expect.objectContaining({
1710+
screen: 'RampTokenNotAvailableModal',
1711+
params: { assetId: MOCK_ASSET_ID },
1712+
}),
1713+
);
1714+
});
1715+
16841716
it('does not navigate to token unavailable modal when no provider is selected', () => {
16851717
mockSelectedProvider = null;
16861718

@@ -1694,6 +1726,36 @@ describe('BuildQuote', () => {
16941726
);
16951727
});
16961728

1729+
it('navigates to token unavailable modal via supportedCryptoCurrencies when params.assetId is missing', async () => {
1730+
mockUseParams.mockImplementation(() => ({}));
1731+
mockSelectedProvider = {
1732+
id: '/providers/transak',
1733+
name: 'Transak',
1734+
environmentType: 'PRODUCTION',
1735+
description: 'Test Provider',
1736+
hqAddress: '123 Test St',
1737+
links: [],
1738+
logos: { light: '', dark: '', height: 24, width: 79 },
1739+
supportedCryptoCurrencies: {
1740+
'eip155:1/slip44:60': true,
1741+
},
1742+
};
1743+
1744+
renderWithTheme(<BuildQuote />);
1745+
1746+
await act(async () => {
1747+
await flushPromises();
1748+
});
1749+
1750+
expect(mockNavigate).toHaveBeenCalledWith(
1751+
'RampModals',
1752+
expect.objectContaining({
1753+
screen: 'RampTokenNotAvailableModal',
1754+
params: { assetId: MOCK_ASSET_ID },
1755+
}),
1756+
);
1757+
});
1758+
16971759
it('does not re-navigate to token unavailable modal on re-renders', async () => {
16981760
mockSelectedProvider = {
16991761
id: '/providers/transak',
@@ -1737,5 +1799,129 @@ describe('BuildQuote', () => {
17371799
}),
17381800
);
17391801
});
1802+
1803+
it('re-navigates to token unavailable modal when provider changes to another unsupporting provider', async () => {
1804+
// First provider doesn't support the token
1805+
mockSelectedProvider = {
1806+
id: '/providers/transak',
1807+
name: 'Transak',
1808+
environmentType: 'PRODUCTION',
1809+
description: 'Test Provider',
1810+
hqAddress: '123 Test St',
1811+
links: [],
1812+
logos: { light: '', dark: '', height: 24, width: 79 },
1813+
supportedCryptoCurrencies: {
1814+
'eip155:1/slip44:60': true,
1815+
},
1816+
};
1817+
1818+
const { rerender } = renderWithTheme(<BuildQuote />);
1819+
1820+
await act(async () => {
1821+
await flushPromises();
1822+
});
1823+
1824+
expect(mockNavigate).toHaveBeenCalledWith(
1825+
'RampModals',
1826+
expect.objectContaining({
1827+
screen: 'RampTokenNotAvailableModal',
1828+
params: { assetId: MOCK_ASSET_ID },
1829+
}),
1830+
);
1831+
1832+
mockNavigate.mockClear();
1833+
1834+
// Switch to a different provider that also doesn't support the token
1835+
mockSelectedProvider = {
1836+
id: '/providers/mercuryo',
1837+
name: 'Mercuryo',
1838+
environmentType: 'PRODUCTION',
1839+
description: 'Test Provider 2',
1840+
hqAddress: '456 Test St',
1841+
links: [],
1842+
logos: { light: '', dark: '', height: 24, width: 79 },
1843+
supportedCryptoCurrencies: {
1844+
'eip155:1/slip44:60': true,
1845+
},
1846+
};
1847+
1848+
rerender(
1849+
<ThemeContext.Provider value={mockTheme}>
1850+
<BuildQuote />
1851+
</ThemeContext.Provider>,
1852+
);
1853+
1854+
await act(async () => {
1855+
await flushPromises();
1856+
});
1857+
1858+
// Modal should re-appear for the new provider
1859+
expect(mockNavigate).toHaveBeenCalledWith(
1860+
'RampModals',
1861+
expect.objectContaining({
1862+
screen: 'RampTokenNotAvailableModal',
1863+
params: { assetId: MOCK_ASSET_ID },
1864+
}),
1865+
);
1866+
});
1867+
1868+
it('re-navigates to token unavailable modal when payment methods return empty after provider change', async () => {
1869+
// First provider: token unavailable via empty payment methods
1870+
mockSelectedProvider = {
1871+
id: '/providers/transak',
1872+
name: 'Transak',
1873+
environmentType: 'PRODUCTION',
1874+
description: 'Test Provider',
1875+
hqAddress: '123 Test St',
1876+
links: [],
1877+
logos: { light: '', dark: '', height: 24, width: 79 },
1878+
};
1879+
mockPaymentMethodsStatus = 'success';
1880+
mockPaymentMethods = [];
1881+
1882+
const { rerender } = renderWithTheme(<BuildQuote />);
1883+
1884+
await act(async () => {
1885+
await flushPromises();
1886+
});
1887+
1888+
expect(mockNavigate).toHaveBeenCalledWith(
1889+
'RampModals',
1890+
expect.objectContaining({
1891+
screen: 'RampTokenNotAvailableModal',
1892+
}),
1893+
);
1894+
1895+
mockNavigate.mockClear();
1896+
1897+
// Switch provider — payment methods still empty for new provider
1898+
mockSelectedProvider = {
1899+
id: '/providers/mercuryo',
1900+
name: 'Mercuryo',
1901+
environmentType: 'PRODUCTION',
1902+
description: 'Test Provider 2',
1903+
hqAddress: '456 Test St',
1904+
links: [],
1905+
logos: { light: '', dark: '', height: 24, width: 79 },
1906+
};
1907+
1908+
rerender(
1909+
<ThemeContext.Provider value={mockTheme}>
1910+
<BuildQuote />
1911+
</ThemeContext.Provider>,
1912+
);
1913+
1914+
await act(async () => {
1915+
await flushPromises();
1916+
});
1917+
1918+
// Modal should re-appear for the new provider
1919+
expect(mockNavigate).toHaveBeenCalledWith(
1920+
'RampModals',
1921+
expect.objectContaining({
1922+
screen: 'RampTokenNotAvailableModal',
1923+
}),
1924+
);
1925+
});
17401926
});
17411927
});

app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,19 @@ import TruncatedError from '../../components/TruncatedError';
6666
import { PROVIDER_LINKS } from '../../Aggregator/types';
6767
import { useBlinkingCursor } from '../../hooks/useBlinkingCursor';
6868

69+
/**
70+
* Identifies which flow the user used to enter the Buy screen.
71+
* - 'tokenInfo': Home → Tokens → Token Info → Buy
72+
* - 'homeTokenList': Home → (token list with Buy buttons) → Buy
73+
* - undefined: Home → Buy → Token Selection → BuildQuote (standard flow)
74+
*/
75+
export type BuyFlowOrigin = 'tokenInfo' | 'homeTokenList';
76+
6977
export interface BuildQuoteParams {
7078
assetId?: string;
7179
nativeFlowError?: string;
80+
/** Which flow the user used to enter the Buy screen. */
81+
buyFlowOrigin?: BuyFlowOrigin;
7282
}
7383

7484
/**
@@ -140,6 +150,7 @@ function BuildQuote() {
140150
paymentMethods,
141151
getWidgetUrl,
142152
paymentMethodsLoading,
153+
paymentMethodsFetching,
143154
paymentMethodsStatus,
144155
selectedPaymentMethod,
145156
} = useRampsController();
@@ -157,51 +168,101 @@ function BuildQuote() {
157168
const tokenStateIsSettled =
158169
!params?.assetId || selectedToken?.assetId === params.assetId;
159170

171+
// Controller state is the source of truth for the active token;
172+
// route params are only used as bootstrapping input.
173+
const effectiveAssetId = selectedToken?.assetId ?? params?.assetId;
174+
160175
const isTokenUnavailable = useMemo(() => {
161-
if (!selectedProvider) {
176+
if (!selectedProvider || !effectiveAssetId) {
177+
return false;
178+
}
179+
180+
// Only determine unavailability after payment methods have fully settled.
181+
// This prevents the modal from flashing during loading/idle/error states
182+
// (e.g. after a provider change while the new query is still in flight).
183+
// Also wait for background refetches to complete — react-query may return
184+
// stale cached data (status='success') while refetching for a new provider.
185+
if (paymentMethodsStatus !== 'success' || paymentMethodsFetching) {
162186
return false;
163187
}
164188

189+
// If payment methods returned results, token IS available
190+
// (payment methods API is more authoritative than supportedCryptoCurrencies)
191+
if (paymentMethods.length > 0) {
192+
return false;
193+
}
194+
195+
// Provider explicitly doesn't support this token
165196
if (
166-
params?.assetId &&
167197
selectedProvider.supportedCryptoCurrencies &&
168-
!selectedProvider.supportedCryptoCurrencies[params.assetId]
198+
!selectedProvider.supportedCryptoCurrencies[effectiveAssetId]
169199
) {
170200
return true;
171201
}
172202

173-
if (
174-
params?.assetId &&
175-
tokenStateIsSettled &&
176-
paymentMethodsStatus === 'success' &&
177-
paymentMethods.length === 0
178-
) {
203+
// Payment methods loaded but empty
204+
if (tokenStateIsSettled) {
179205
return true;
180206
}
181207

182208
return false;
183209
}, [
184210
selectedProvider,
185-
params?.assetId,
211+
effectiveAssetId,
212+
paymentMethodsFetching,
186213
tokenStateIsSettled,
187214
paymentMethodsStatus,
188215
paymentMethods.length,
189216
]);
190217

191-
const hasShownTokenUnavailableRef = useRef(false);
218+
// Tracks which provider:token combination was last shown the modal,
219+
// so we don't duplicate-navigate within the same visit but DO re-show
220+
// when the combination changes.
221+
const lastShownUnavailableKeyRef = useRef<string>('');
222+
223+
// Bump a counter on screen focus so the modal effect re-evaluates
224+
// when the user navigates away (e.g. token selection) and comes back.
225+
const [focusTrigger, setFocusTrigger] = useState(0);
226+
useFocusEffect(
227+
useCallback(() => {
228+
lastShownUnavailableKeyRef.current = '';
229+
setFocusTrigger((c) => c + 1);
230+
}, []),
231+
);
192232

233+
// Show "Token Not Available" modal when the selected token is unavailable
234+
// for the current provider. Debounced to let the query settle — prevents
235+
// the modal from flashing when isTokenUnavailable is briefly true due to
236+
// stale cached data before the fresh response arrives.
193237
useEffect(() => {
194-
if (isTokenUnavailable && !hasShownTokenUnavailableRef.current) {
195-
hasShownTokenUnavailableRef.current = true;
238+
if (!isOnBuildQuoteScreen || !isTokenUnavailable) {
239+
lastShownUnavailableKeyRef.current = '';
240+
return;
241+
}
242+
243+
const key = `${selectedProvider?.id}:${effectiveAssetId}`;
244+
if (lastShownUnavailableKeyRef.current === key) return;
245+
246+
const timer = setTimeout(() => {
247+
lastShownUnavailableKeyRef.current = key;
196248
navigation.navigate(
197249
...createTokenNotAvailableModalNavigationDetails({
198-
assetId: params?.assetId ?? '',
250+
assetId: effectiveAssetId ?? '',
251+
buyFlowOrigin: params?.buyFlowOrigin,
199252
}),
200253
);
201-
} else if (!isTokenUnavailable) {
202-
hasShownTokenUnavailableRef.current = false;
203-
}
204-
}, [isTokenUnavailable, params?.assetId, navigation]);
254+
}, 600);
255+
256+
return () => clearTimeout(timer);
257+
}, [
258+
isOnBuildQuoteScreen,
259+
params?.buyFlowOrigin,
260+
isTokenUnavailable,
261+
effectiveAssetId,
262+
navigation,
263+
selectedProvider?.id,
264+
focusTrigger,
265+
]);
205266

206267
const {
207268
checkExistingToken: transakCheckExistingToken,
@@ -741,7 +802,7 @@ function BuildQuote() {
741802
strings('fiat_on_ramp.select_payment_method')
742803
}
743804
isLoading={paymentMethodsLoading}
744-
onPress={handlePaymentPillPress}
805+
onPress={isTokenUnavailable ? undefined : handlePaymentPillPress}
745806
/>
746807
</View>
747808
</View>

0 commit comments

Comments
 (0)