From 5e33f0df98abc3376c6414a2d4e71179e3776ce9 Mon Sep 17 00:00:00 2001
From: Matthew Grainger <46547583+Matt561@users.noreply.github.com>
Date: Thu, 23 Apr 2026 13:34:32 +0000
Subject: [PATCH] chore(runway): cherry-pick fix: musd money hub 7.74.0 release
blockers cp-7.74.0 (#29225)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR fixes various Money Hub release blockers for the 7.74.0 release.
## **Changelog**
CHANGELOG entry: fixing money hub 7.74.0 release blockers
## **Related issues**
Fixes:
- [MUSD-688: Money hub's primary "Convert to mUSD" button must redirect
to the single convert
screen](https://consensyssoftware.atlassian.net/browse/MUSD-688)
- [MUSD-689: Money Hub's "Max" and "Custom" buttons aren't disabled when
there's a conversion in
flight.](https://consensyssoftware.atlassian.net/browse/MUSD-689)
- [MUSD-690: Successful conversions are redirected to the home screen
when quick convert flag
disabled](https://consensyssoftware.atlassian.net/browse/MUSD-690)
- [MUSD-686: Skeleton loaders misaligned with content and UX
expectations](https://consensyssoftware.atlassian.net/browse/MUSD-686)
- [MUSD-692: Money Hub shows two bonus claim buttons when user has bonus
but doesn't hold
mUSD](https://consensyssoftware.atlassian.net/browse/MUSD-692)
## **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**
### **Before**
### **After**
Post-conversion redirect fixes
## **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.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **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.
---
> [!NOTE]
> **Medium Risk**
> Changes navigation behavior after mUSD conversions and updates Money
Hub conversion CTAs/state gating, which can affect user flow and back
stack behavior. Also refactors/loading-skeleton logic and empty-state
actions, with moderate risk of regressions in conditional rendering and
analytics-trigger timing.
>
> **Overview**
> Fixes Money Hub mUSD post-conversion routing by updating
`useMusdConfirmNavigation` to operate on the *parent* navigator stack
(pop vs replace to `Routes.WALLET.CASH_TOKENS_FULL_VIEW`) when the Money
Hub flag is enabled, avoiding stale confirmation screens remaining in
history.
>
> Updates Money Hub convert UI to respect conversion-in-progress state:
`MoneyConvertStablecoins` now disables row actions when there are
unapproved/in-flight conversions and shows per-token pending status via
`selectMusdConversionStatuses`.
>
> Improves Cash full view UX and metrics timing: token-list rendering is
gated behind an InteractionManager “ready” signal only when the user has
mUSD (showing a new content-area skeleton that mirrors the eventual
layout), the primary "Convert to mUSD" CTA now launches the *custom
amount* flow (`initiateCustomConversion` with `preferredPaymentToken`),
and the empty state can hide the secondary Merkl "Claim bonus" button in
Money Hub mode to prevent duplicate claim CTAs. Tests were updated/added
accordingly.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1bb82abd7d5a207b07e66f042e522f78d921511c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../hooks/useMusdConfirmNavigation.test.ts | 186 +++++---------
.../UI/Earn/hooks/useMusdConfirmNavigation.ts | 62 +++--
.../MoneyConvertStablecoins.test.tsx | 19 ++
.../MoneyConvertStablecoins.tsx | 38 +++
.../CashTokensFullView.test.tsx | 83 ++++--
.../CashTokensFullView/CashTokensFullView.tsx | 80 +++---
.../CashTokensFullViewSkeleton.test.tsx | 82 +++++-
.../CashTokensFullViewSkeleton.tsx | 241 ++++++++++++++----
.../Cash/CashGetMusdEmptyState.test.tsx | 36 +++
.../Sections/Cash/CashGetMusdEmptyState.tsx | 4 +-
10 files changed, 581 insertions(+), 250 deletions(-)
diff --git a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts
index 150c7decf123..befc59dced19 100644
--- a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts
+++ b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.test.ts
@@ -1,14 +1,14 @@
import { act, renderHook } from '@testing-library/react-hooks';
+import { StackActions } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import Routes from '../../../../constants/navigation/Routes';
import { useMusdConfirmNavigation } from './useMusdConfirmNavigation';
-import { useMusdConversionTokens } from './useMusdConversionTokens';
-import { useTransactionPayIsMaxAmount } from '../../../Views/confirmations/hooks/pay/useTransactionPayData';
-import { AssetType } from '../../../Views/confirmations/types/token';
+import { selectMoneyHubEnabledFlag } from '../../Money/selectors/featureFlags';
const mockNavigate = jest.fn();
-const mockGoBack = jest.fn();
-const mockCanGoBack = jest.fn();
+const mockDispatch = jest.fn();
+const mockGetState = jest.fn();
+const mockGetParent = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
@@ -19,156 +19,92 @@ jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
navigate: mockNavigate,
- goBack: mockGoBack,
- canGoBack: mockCanGoBack,
+ getParent: mockGetParent,
}),
}));
-jest.mock(
- '../../../Views/confirmations/hooks/pay/useTransactionPayData',
- () => ({
- useTransactionPayIsMaxAmount: jest.fn(),
- }),
-);
-
-jest.mock('./useMusdConversionTokens');
-
-const mockUseTransactionPayIsMaxAmount =
- useTransactionPayIsMaxAmount as jest.MockedFunction<
- typeof useTransactionPayIsMaxAmount
- >;
-const mockUseMusdConversionTokens =
- useMusdConversionTokens as jest.MockedFunction<
- typeof useMusdConversionTokens
- >;
-
-const createTokenWithBalance = (
- overrides: Partial = {},
-): AssetType =>
- ({
- address: '0xToken1',
- chainId: '0x1',
- symbol: 'USDC',
- rawBalance: '0x1000',
- ...overrides,
- }) as AssetType;
+const mockUseSelector = useSelector as jest.Mock;
-describe('useMusdConfirmNavigation', () => {
- const useSelectorMock = useSelector as jest.Mock;
+const createParentState = (routeNames: string[]) => ({
+ routes: routeNames.map((name) => ({ name, key: `${name}-key` })),
+});
- beforeEach(() => {
- jest.resetAllMocks();
- mockUseTransactionPayIsMaxAmount.mockReturnValue(false);
- mockUseMusdConversionTokens.mockReturnValue({
- tokens: [
- createTokenWithBalance(),
- createTokenWithBalance({ address: '0xToken2', symbol: 'USDT' }),
- ],
- filterAllowedTokens: jest.fn(),
- isConversionToken: jest.fn(),
- isMusdSupportedOnChain: jest.fn(),
- hasConvertibleTokensByChainId: jest.fn(),
- });
+const setupMoneyHubEnabled = (enabled: boolean) => {
+ mockUseSelector.mockImplementation((selector: unknown) => {
+ if (selector === selectMoneyHubEnabledFlag) return enabled;
+ return undefined;
});
+};
- it('goes back when quick convert is enabled and navigation can go back', () => {
- useSelectorMock.mockReturnValue(true);
- mockCanGoBack.mockReturnValue(true);
-
- const { result } = renderHook(() => useMusdConfirmNavigation());
-
- act(() => {
- result.current.navigateOnConfirm();
+describe('useMusdConfirmNavigation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setupMoneyHubEnabled(false);
+ mockGetParent.mockReturnValue({
+ dispatch: mockDispatch,
+ getState: mockGetState,
});
-
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- expect(mockNavigate).not.toHaveBeenCalled();
});
- it('navigates to wallet view when quick convert is enabled and cannot go back', () => {
- useSelectorMock.mockReturnValue(true);
- mockCanGoBack.mockReturnValue(false);
-
- const { result } = renderHook(() => useMusdConfirmNavigation());
-
- act(() => {
- result.current.navigateOnConfirm();
+ describe('when Money Hub is enabled', () => {
+ beforeEach(() => {
+ setupMoneyHubEnabled(true);
});
- expect(mockGoBack).not.toHaveBeenCalled();
- expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW);
- });
+ it('pops the parent stack when CashTokensFullView is already below', () => {
+ mockGetState.mockReturnValue(
+ createParentState(['Home', Routes.WALLET.CASH_TOKENS_FULL_VIEW]),
+ );
- it('navigates to wallet view when quick convert is disabled', () => {
- useSelectorMock.mockReturnValue(false);
+ const { result } = renderHook(() => useMusdConfirmNavigation());
- const { result } = renderHook(() => useMusdConfirmNavigation());
+ act(() => {
+ result.current.navigateOnConfirm();
+ });
- act(() => {
- result.current.navigateOnConfirm();
+ expect(mockDispatch).toHaveBeenCalledWith(StackActions.pop());
+ expect(mockNavigate).not.toHaveBeenCalled();
});
- expect(mockCanGoBack).not.toHaveBeenCalled();
- expect(mockGoBack).not.toHaveBeenCalled();
- expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW);
- });
+ it('replaces the current route with CashTokensFullView when it is not in the stack', () => {
+ mockGetState.mockReturnValue(createParentState(['Home']));
- it('navigates to wallet view when max converting the last token', () => {
- useSelectorMock.mockReturnValue(true);
- mockCanGoBack.mockReturnValue(true);
- mockUseTransactionPayIsMaxAmount.mockReturnValue(true);
- mockUseMusdConversionTokens.mockReturnValue({
- tokens: [createTokenWithBalance()],
- filterAllowedTokens: jest.fn(),
- isConversionToken: jest.fn(),
- isMusdSupportedOnChain: jest.fn(),
- hasConvertibleTokensByChainId: jest.fn(),
- });
+ const { result } = renderHook(() => useMusdConfirmNavigation());
- const { result } = renderHook(() => useMusdConfirmNavigation());
+ act(() => {
+ result.current.navigateOnConfirm();
+ });
- act(() => {
- result.current.navigateOnConfirm();
+ expect(mockDispatch).toHaveBeenCalledWith(
+ StackActions.replace(Routes.WALLET.CASH_TOKENS_FULL_VIEW),
+ );
+ expect(mockNavigate).not.toHaveBeenCalled();
});
- expect(mockGoBack).not.toHaveBeenCalled();
- expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW);
- });
+ it('falls back to wallet view when parent navigation is unavailable', () => {
+ mockGetParent.mockReturnValue(null);
- it('goes back when max converting with multiple tokens remaining', () => {
- useSelectorMock.mockReturnValue(true);
- mockCanGoBack.mockReturnValue(true);
- mockUseTransactionPayIsMaxAmount.mockReturnValue(true);
+ const { result } = renderHook(() => useMusdConfirmNavigation());
- const { result } = renderHook(() => useMusdConfirmNavigation());
+ act(() => {
+ result.current.navigateOnConfirm();
+ });
- act(() => {
- result.current.navigateOnConfirm();
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW);
});
-
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- expect(mockNavigate).not.toHaveBeenCalled();
});
- it('goes back when custom converting the last token with partial amount', () => {
- useSelectorMock.mockReturnValue(true);
- mockCanGoBack.mockReturnValue(true);
- mockUseTransactionPayIsMaxAmount.mockReturnValue(false);
- mockUseMusdConversionTokens.mockReturnValue({
- tokens: [createTokenWithBalance()],
- filterAllowedTokens: jest.fn(),
- isConversionToken: jest.fn(),
- isMusdSupportedOnChain: jest.fn(),
- hasConvertibleTokensByChainId: jest.fn(),
- });
+ describe('when Money Hub is disabled', () => {
+ it('navigates to wallet view', () => {
+ const { result } = renderHook(() => useMusdConfirmNavigation());
- const { result } = renderHook(() => useMusdConfirmNavigation());
+ act(() => {
+ result.current.navigateOnConfirm();
+ });
- act(() => {
- result.current.navigateOnConfirm();
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.WALLET_VIEW);
+ expect(mockDispatch).not.toHaveBeenCalled();
});
-
- expect(mockGoBack).toHaveBeenCalledTimes(1);
- expect(mockNavigate).not.toHaveBeenCalled();
});
});
diff --git a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts
index e2b0f8004485..de69ba4bf38e 100644
--- a/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts
+++ b/app/components/UI/Earn/hooks/useMusdConfirmNavigation.ts
@@ -1,43 +1,49 @@
import { useCallback } from 'react';
-import { useNavigation } from '@react-navigation/native';
-import { useSelector } from 'react-redux';
+import { StackActions, useNavigation } from '@react-navigation/native';
import Routes from '../../../../constants/navigation/Routes';
-import { selectMusdQuickConvertEnabledFlag } from '../selectors/featureFlags';
-import { useMusdConversionTokens } from './useMusdConversionTokens';
-import { useTransactionPayIsMaxAmount } from '../../../Views/confirmations/hooks/pay/useTransactionPayData';
+import { selectMoneyHubEnabledFlag } from '../../Money/selectors/featureFlags';
+import { useSelector } from 'react-redux';
export const useMusdConfirmNavigation = () => {
const navigation = useNavigation();
- const isMusdQuickConvertEnabled = useSelector(
- selectMusdQuickConvertEnabledFlag,
- );
-
- const isMaxAmount = useTransactionPayIsMaxAmount();
- const { tokens: conversionTokens } = useMusdConversionTokens();
+ const isMoneyHubEnabled = useSelector(selectMoneyHubEnabledFlag);
+
+ // We must operate on the parent (MainNavigator) stack because the
+ // confirmation screen lives inside a nested EarnScreenStack. A plain
+ // navigation.navigate() from inside EarnScreenStack would push
+ // CashTokensFullView on top without removing EarnScreens, leaving the
+ // stale confirmation screen in the back stack. To prevent that:
+ // - pop: if CashTokensFullView is already below (entered from Money Hub)
+ // - replace: if it isn't (entered from TokenListItem or asset detail)
+ const handleMoneyHubNavigation = useCallback((): boolean => {
+ const parentNavigation = navigation.getParent();
+ if (!parentNavigation) {
+ return false;
+ }
- const isLastConvertibleToken = conversionTokens.length <= 1;
+ const parentState = parentNavigation.getState();
+ const isCashTokensFullViewInStack = parentState.routes.some(
+ (route) => route.name === Routes.WALLET.CASH_TOKENS_FULL_VIEW,
+ );
+
+ if (isCashTokensFullViewInStack) {
+ parentNavigation.dispatch(StackActions.pop());
+ } else {
+ parentNavigation.dispatch(
+ StackActions.replace(Routes.WALLET.CASH_TOKENS_FULL_VIEW),
+ );
+ }
+ return true;
+ }, [navigation]);
const navigateOnConfirm = useCallback(() => {
- if (isMusdQuickConvertEnabled) {
- if (isMaxAmount && isLastConvertibleToken) {
- navigation.navigate(Routes.WALLET_VIEW);
- return;
- }
-
- if (navigation.canGoBack()) {
- navigation.goBack();
- return;
- }
+ if (isMoneyHubEnabled && handleMoneyHubNavigation()) {
+ return;
}
navigation.navigate(Routes.WALLET_VIEW);
- }, [
- isMusdQuickConvertEnabled,
- navigation,
- isMaxAmount,
- isLastConvertibleToken,
- ]);
+ }, [handleMoneyHubNavigation, isMoneyHubEnabled, navigation]);
return {
navigateOnConfirm,
diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx
index fe5e1893b45e..8433297d1e1c 100644
--- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx
+++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.test.tsx
@@ -1,10 +1,23 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
+import { useSelector } from 'react-redux';
import MoneyConvertStablecoins from './MoneyConvertStablecoins';
import { MoneyConvertStablecoinsTestIds } from './MoneyConvertStablecoins.testIds';
import { strings } from '../../../../../../locales/i18n';
import { AssetType } from '../../../../Views/confirmations/types/token';
import { ConvertTokenRowTestIds } from '../../../Earn/components/Musd/ConvertTokenRow';
+import {
+ selectHasUnapprovedMusdConversion,
+ selectHasInFlightMusdConversion,
+ selectMusdConversionStatuses,
+} from '../../../Earn/selectors/musdConversionStatus';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(),
+}));
+
+const mockUseSelector = useSelector as jest.Mock;
jest.mock('../../../../../component-library/base-components/TagBase', () => ({
__esModule: true,
@@ -100,6 +113,12 @@ const defaultProps = {
describe('MoneyConvertStablecoins', () => {
beforeEach(() => {
jest.clearAllMocks();
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === selectHasUnapprovedMusdConversion) return false;
+ if (selector === selectHasInFlightMusdConversion) return false;
+ if (selector === selectMusdConversionStatuses) return {};
+ return undefined;
+ });
});
describe('with eligible tokens', () => {
diff --git a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx
index a5ed3723061d..411a42fbeac2 100644
--- a/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx
+++ b/app/components/UI/Money/components/MoneyConvertStablecoins/MoneyConvertStablecoins.tsx
@@ -30,6 +30,13 @@ import ConvertTokenRow from '../../../Earn/components/Musd/ConvertTokenRow';
import { AssetType } from '../../../../Views/confirmations/types/token';
import { MoneyConvertStablecoinsTestIds } from './MoneyConvertStablecoins.testIds';
import { CaipChainId } from '@metamask/utils';
+import { useSelector } from 'react-redux';
+import {
+ createTokenChainKey,
+ selectHasInFlightMusdConversion,
+ selectHasUnapprovedMusdConversion,
+ selectMusdConversionStatuses,
+} from '../../../Earn/selectors/musdConversionStatus';
interface MoneyConvertStablecoinsProps {
tokens: AssetType[];
@@ -134,6 +141,33 @@ const MoneyConvertStablecoins = ({
}: MoneyConvertStablecoinsProps) => {
const hasTokens = tokens.length > 0;
+ const hasUnapprovedMusdConversion = useSelector(
+ selectHasUnapprovedMusdConversion,
+ );
+ const hasInFlightMusdConversion = useSelector(
+ selectHasInFlightMusdConversion,
+ );
+
+ const conversionStatusesByTokenChainKey = useSelector(
+ selectMusdConversionStatuses,
+ );
+
+ const isConversionPending = (token: AssetType) => {
+ const tokenAddress = token.address;
+ const tokenChainId = token.chainId;
+
+ const tokenChainKey =
+ tokenAddress && tokenChainId
+ ? createTokenChainKey(tokenAddress, tokenChainId)
+ : undefined;
+
+ const txStatusInfo = tokenChainKey
+ ? conversionStatusesByTokenChainKey[tokenChainKey]
+ : undefined;
+
+ return Boolean(txStatusInfo?.isPending);
+ };
+
return (
@@ -164,6 +198,10 @@ const MoneyConvertStablecoins = ({
token={token}
onMaxPress={onMaxPress}
onEditPress={onEditPress}
+ areActionsDisabled={
+ hasUnapprovedMusdConversion || hasInFlightMusdConversion
+ }
+ isConversionPending={isConversionPending(token)}
/>
))}
diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx b/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx
index f4868633e6bc..bd132da3d5fd 100644
--- a/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx
+++ b/app/components/Views/CashTokensFullView/CashTokensFullView.test.tsx
@@ -29,7 +29,10 @@ jest.mock('@react-navigation/native', () => ({
}),
}));
-const mockUseMusdBalance = jest.fn(() => ({ hasMusdBalanceOnAnyChain: false }));
+const mockUseMusdBalance = jest.fn(() => ({
+ hasMusdBalanceOnAnyChain: false,
+ tokenBalanceByChain: {},
+}));
jest.mock('../../UI/Earn/hooks/useMusdBalance', () => ({
useMusdBalance: () => mockUseMusdBalance(),
}));
@@ -224,7 +227,10 @@ describe('CashTokensFullView', () => {
beforeEach(() => {
jest.clearAllMocks();
flushInteractionManager();
- mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false });
+ mockUseMusdBalance.mockReturnValue({
+ hasMusdBalanceOnAnyChain: false,
+ tokenBalanceByChain: {},
+ });
mockUseMusdConversionTokens.mockReturnValue({ tokens: [] });
mockSelectMoneyHubEnabledFlag.mockReturnValue(false);
@@ -253,14 +259,20 @@ describe('CashTokensFullView', () => {
});
it('renders Get mUSD empty state when user has no mUSD', () => {
- mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false });
+ mockUseMusdBalance.mockReturnValue({
+ hasMusdBalanceOnAnyChain: false,
+ tokenBalanceByChain: {},
+ });
renderWithProvider();
expect(screen.getByTestId('cash-get-musd-empty-state')).toBeOnTheScreen();
expect(screen.getByText('Get mUSD')).toBeOnTheScreen();
});
it('renders Tokens with isFullView and showOnlyMusd when user has mUSD', () => {
- mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: true });
+ mockUseMusdBalance.mockReturnValue({
+ hasMusdBalanceOnAnyChain: true,
+ tokenBalanceByChain: { '0x1': '1.0' },
+ });
renderWithProvider();
expect(screen.getByTestId('tokens-cash-view')).toBeOnTheScreen();
expect(
@@ -346,7 +358,10 @@ describe('CashTokensFullView', () => {
});
it('empty-state Buy button passes mUSD assetId to goToBuy', () => {
- mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false });
+ mockUseMusdBalance.mockReturnValue({
+ hasMusdBalanceOnAnyChain: false,
+ tokenBalanceByChain: {},
+ });
mockSelectMoneyHubEnabledFlag.mockReturnValue(true);
renderWithProvider();
fireEvent.press(screen.getByText('Buy'));
@@ -355,9 +370,7 @@ describe('CashTokensFullView', () => {
});
});
- it('renders CashTokensFullViewSkeleton on first render before data is marked loaded', () => {
- // Prevent InteractionManager's callback from running so the view stays
- // in its loading state for the duration of the render.
+ it('renders header immediately and content skeleton before InteractionManager fires when user has mUSD', () => {
jest.restoreAllMocks();
jest
.spyOn(InteractionManager, 'runAfterInteractions')
@@ -366,15 +379,45 @@ describe('CashTokensFullView', () => {
done: jest.fn(),
cancel: jest.fn(),
}));
+ mockUseMusdBalance.mockReturnValue({
+ hasMusdBalanceOnAnyChain: true,
+ tokenBalanceByChain: { '0x1': '1.0' },
+ });
renderWithProvider();
+ expect(screen.getByText('Money')).toBeOnTheScreen();
expect(
screen.getByTestId(CashTokensFullViewSkeletonTestIds.CONTAINER),
).toBeOnTheScreen();
});
+ it('renders content immediately without skeleton when user has no mUSD', () => {
+ jest.restoreAllMocks();
+ jest
+ .spyOn(InteractionManager, 'runAfterInteractions')
+ .mockImplementation(() => ({
+ then: jest.fn(),
+ done: jest.fn(),
+ cancel: jest.fn(),
+ }));
+ mockUseMusdBalance.mockReturnValue({
+ hasMusdBalanceOnAnyChain: false,
+ tokenBalanceByChain: {},
+ });
+
+ renderWithProvider();
+ expect(screen.getByText('Money')).toBeOnTheScreen();
+ expect(screen.getByTestId('cash-get-musd-empty-state')).toBeOnTheScreen();
+ expect(
+ screen.queryByTestId(CashTokensFullViewSkeletonTestIds.CONTAINER),
+ ).not.toBeOnTheScreen();
+ });
+
it('wires RefreshControl onRefresh to useCashTokensRefresh.onRefresh on the Tokens branch', async () => {
- mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: true });
+ mockUseMusdBalance.mockReturnValue({
+ hasMusdBalanceOnAnyChain: true,
+ tokenBalanceByChain: { '0x1': '1.0' },
+ });
const onRefresh = jest.fn().mockResolvedValue(undefined);
mockUseCashTokensRefresh.mockReturnValue({ refreshing: false, onRefresh });
@@ -387,7 +430,10 @@ describe('CashTokensFullView', () => {
});
it('wires RefreshControl onRefresh to useCashTokensRefresh.onRefresh on the empty-state branch', async () => {
- mockUseMusdBalance.mockReturnValue({ hasMusdBalanceOnAnyChain: false });
+ mockUseMusdBalance.mockReturnValue({
+ hasMusdBalanceOnAnyChain: false,
+ tokenBalanceByChain: {},
+ });
const onRefresh = jest.fn().mockResolvedValue(undefined);
mockUseCashTokensRefresh.mockReturnValue({ refreshing: false, onRefresh });
@@ -475,11 +521,11 @@ describe('CashTokensFullView', () => {
});
});
- it('calls initiateMaxConversion on first conversionToken via handleConvertPress', async () => {
+ it('calls initiateCustomConversion with preferredPaymentToken via handleConvertPress', async () => {
mockSelectMoneyHubEnabledFlag.mockReturnValue(true);
const token = { address: '0xabc', chainId: '0x1' } as AssetType;
mockUseMusdConversionTokens.mockReturnValue({ tokens: [token] });
- mockInitiateMaxConversion.mockResolvedValue(undefined);
+ mockInitiateCustomConversion.mockResolvedValue(undefined);
renderWithProvider();
@@ -487,7 +533,12 @@ describe('CashTokensFullView', () => {
fireEvent.press(screen.getByText('Convert to mUSD'));
});
- expect(mockInitiateMaxConversion).toHaveBeenCalledWith(token);
+ expect(mockInitiateCustomConversion).toHaveBeenCalledWith({
+ preferredPaymentToken: {
+ address: '0xabc',
+ chainId: '0x1',
+ },
+ });
});
it('logs error when handleConvertPress fails', async () => {
@@ -495,7 +546,7 @@ describe('CashTokensFullView', () => {
const token = { address: '0xabc', chainId: '0x1' } as AssetType;
mockUseMusdConversionTokens.mockReturnValue({ tokens: [token] });
const error = new Error('convert CTA failed');
- mockInitiateMaxConversion.mockRejectedValue(error);
+ mockInitiateCustomConversion.mockRejectedValue(error);
const loggerSpy = jest.spyOn(
jest.requireMock('../../../util/Logger').default,
'error',
@@ -525,8 +576,8 @@ describe('CashTokensFullView', () => {
// so we test the early return by having tokens initially then not.
// Actually, the early return is only hit if conversionTokens[0] is falsy.
// Since the CTA only renders when hasConversionTokens, we just verify
- // the convert press calls initiateMaxConversion above.
- expect(mockInitiateMaxConversion).not.toHaveBeenCalled();
+ // the convert press calls initiateCustomConversion above.
+ expect(mockInitiateCustomConversion).not.toHaveBeenCalled();
});
it('calls goToSwaps when Swap button is pressed', () => {
diff --git a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx
index a11efc029f77..4c24ae3f2858 100644
--- a/app/components/Views/CashTokensFullView/CashTokensFullView.tsx
+++ b/app/components/Views/CashTokensFullView/CashTokensFullView.tsx
@@ -70,30 +70,30 @@ const CashTokensFullView = () => {
const navigation = useNavigation();
const tw = useTailwind();
const { trackEvent, createEventBuilder } = useAnalytics();
- const { hasMusdBalanceOnAnyChain } = useMusdBalance();
+ const { hasMusdBalanceOnAnyChain, tokenBalanceByChain } = useMusdBalance();
+
+ const numChainsWithMusdBalance = Object.keys(tokenBalanceByChain).length;
+
const { tokens: conversionTokens } = useMusdConversionTokens();
const isMoneyHubEnabled = useSelector(selectMoneyHubEnabledFlag);
const hasConversionTokens = conversionTokens.length > 0;
- // Loading signal: neither useMusdBalance nor useMusdConversionTokens expose
- // an isLoading flag (they derive from synchronous Redux selectors). We mirror
- // the Tokens component's hasInitialLoad pattern and flip loading off after
- // the first InteractionManager tick so the Hub's dedicated skeleton shows on
- // the first paint instead of falling through to TokenListSkeleton.
- const [isLoading, setIsLoading] = useState(true);
+ const [isTokenListReady, setIsTokenListReady] = useState(false);
useEffect(() => {
const handle = InteractionManager.runAfterInteractions(() => {
- setIsLoading(false);
+ setIsTokenListReady(true);
});
return () => handle.cancel();
}, []);
const screenViewedRef = useRef(false);
+ const isScreenReady = !hasMusdBalanceOnAnyChain || isTokenListReady;
+
useEffect(() => {
- if (isLoading || screenViewedRef.current || !isMoneyHubEnabled) return;
+ if (!isScreenReady || screenViewedRef.current || !isMoneyHubEnabled) return;
screenViewedRef.current = true;
const hasConvertibleTokens = conversionTokens.length > 0;
@@ -121,11 +121,11 @@ const CashTokensFullView = () => {
.build(),
);
}, [
- isLoading,
conversionTokens,
createEventBuilder,
trackEvent,
isMoneyHubEnabled,
+ isScreenReady,
]);
const merklRefetchRef = useRef<(() => void) | null>(null);
@@ -225,9 +225,8 @@ const CashTokensFullView = () => {
.addProperties({
location: MONEY_EVENT_LOCATIONS.MONEY_HUB,
button_type: 'text_button',
- button_action: 'max',
- redirects_to:
- MUSD_EVENT_LOCATIONS.QUICK_CONVERT_MAX_BOTTOM_SHEET_CONFIRMATION_SCREEN,
+ button_action: 'custom',
+ redirects_to: MUSD_EVENT_LOCATIONS.CUSTOM_AMOUNT_SCREEN,
asset_symbol: topToken.symbol,
network_chain_id: topToken.chainId,
network_name: topToken.chainId
@@ -237,13 +236,23 @@ const CashTokensFullView = () => {
.build(),
);
- await initiateMaxConversion(topToken);
+ await initiateCustomConversion({
+ preferredPaymentToken: {
+ address: topToken.address as Hex,
+ chainId: topToken.chainId as Hex,
+ },
+ });
} catch (error) {
Logger.error(error as Error, {
message: '[CashTokensFullView] Failed to initiate convert CTA',
});
}
- }, [conversionTokens, createEventBuilder, initiateMaxConversion, trackEvent]);
+ }, [
+ conversionTokens,
+ createEventBuilder,
+ initiateCustomConversion,
+ trackEvent,
+ ]);
const handleSwapsPress = useCallback(() => {
trackEvent(
@@ -309,10 +318,6 @@ const CashTokensFullView = () => {
],
);
- if (isLoading) {
- return ;
- }
-
return (
{
{strings('homepage.sections.cash')}
{hasMusdBalanceOnAnyChain ? (
-
- }
- />
+ isTokenListReady ? (
+
+ }
+ />
+ ) : (
+
+ )
) : (
{
}
>
-
+
{isMoneyHubEnabled ? bonusAndConvertSections : undefined}
diff --git a/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.test.tsx b/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.test.tsx
index 727c93916e3f..9b29d81f5c7b 100644
--- a/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.test.tsx
+++ b/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.test.tsx
@@ -1,14 +1,86 @@
import React from 'react';
-import { render } from '@testing-library/react-native';
+import { render, screen } from '@testing-library/react-native';
import CashTokensFullViewSkeleton, {
CashTokensFullViewSkeletonTestIds,
} from './CashTokensFullViewSkeleton';
+const {
+ CONTAINER,
+ TOKEN_ROW,
+ EMPTY_STATE_ROW,
+ BONUS_SECTION,
+ CONVERT_SECTION,
+} = CashTokensFullViewSkeletonTestIds;
+
describe('CashTokensFullViewSkeleton', () => {
it('renders the skeleton container', () => {
- const { getByTestId } = render();
- expect(
- getByTestId(CashTokensFullViewSkeletonTestIds.CONTAINER),
- ).toBeOnTheScreen();
+ render(
+ ,
+ );
+ expect(screen.getByTestId(CONTAINER)).toBeOnTheScreen();
+ });
+
+ it('renders one token row skeleton per chain with mUSD balance', () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByTestId(TOKEN_ROW)).toHaveLength(2);
+ expect(screen.queryByTestId(EMPTY_STATE_ROW)).not.toBeOnTheScreen();
+ });
+
+ it('renders a single token row skeleton when user has mUSD on one chain', () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByTestId(TOKEN_ROW)).toHaveLength(1);
+ expect(screen.queryByTestId(EMPTY_STATE_ROW)).not.toBeOnTheScreen();
+ });
+
+ it('renders empty state row skeleton when user has no mUSD balance', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId(EMPTY_STATE_ROW)).toBeOnTheScreen();
+ expect(screen.queryByTestId(TOKEN_ROW)).not.toBeOnTheScreen();
+ });
+
+ it('renders bonus and convert sections when MoneyHub is enabled', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId(BONUS_SECTION)).toBeOnTheScreen();
+ expect(screen.getByTestId(CONVERT_SECTION)).toBeOnTheScreen();
+ });
+
+ it('omits bonus and convert sections when MoneyHub is disabled', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByTestId(BONUS_SECTION)).not.toBeOnTheScreen();
+ expect(screen.queryByTestId(CONVERT_SECTION)).not.toBeOnTheScreen();
});
});
diff --git a/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.tsx b/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.tsx
index bc310e2fd01a..926ab6ca7969 100644
--- a/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.tsx
+++ b/app/components/Views/CashTokensFullView/CashTokensFullViewSkeleton.tsx
@@ -1,68 +1,223 @@
import React from 'react';
import { ScrollView } from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import {
Box,
+ BoxAlignItems,
BoxFlexDirection,
- HeaderBase,
+ BoxJustifyContent,
Skeleton,
} from '@metamask/design-system-react-native';
export const CashTokensFullViewSkeletonTestIds = {
CONTAINER: 'cash-tokens-full-view-skeleton',
+ TOKEN_ROW: 'skeleton-token-row',
+ EMPTY_STATE_ROW: 'skeleton-empty-state-row',
+ BONUS_SECTION: 'skeleton-bonus-section',
+ CONVERT_SECTION: 'skeleton-convert-section',
};
+interface CashTokensFullViewSkeletonProps {
+ numChainsWithMusdBalance: number;
+ isMoneyHubEnabled: boolean;
+ conversionTokenCount: number;
+}
+
/**
- * First-paint loading skeleton for the Money Hub (CashTokensFullView).
- *
- * Mirrors the Hub's section layout (header / hero / bonus card /
- * convert-stablecoins card / bottom CTA) so the initial render no longer
- * flashes the generic TokenList skeleton before the real Hub renders.
+ * Mirrors a single TokenListItem row: 40px avatar circle, two text lines
+ * on the left (name + price), two text lines on the right (fiat + balance).
*/
-const CashTokensFullViewSkeleton = () => {
- const tw = useTailwind();
+const TokenRowSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
- return (
- (
+
+
-
+
+
+
+
+
+
+
+
+);
+
+/**
+ * Mirrors AssetOverviewClaimBonus: divider, header row with tag pill,
+ * two label + value rows, a full-width CTA button, and a closing divider.
+ */
+const BonusSectionSkeleton = () => (
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
-
-
-
-
+/**
+ * Mirrors a single ConvertTokenRow: 32px token icon, name + balance text,
+ * and action button placeholders on the right.
+ */
+const ConvertTokenRowSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
-
-
-
+/**
+ * Mirrors MoneyConvertStablecoins: heading, description, 2x2 feature tag
+ * pills (Dollar-backed, No lockups, No MetaMask fee, Daily bonus),
+ * optional convert-token rows, and a learn-more button.
+ */
+const ConvertSectionSkeleton = ({ tokenCount }: { tokenCount: number }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {tokenCount > 0 && (
+
+ {Array.from({ length: Math.min(tokenCount, 3) }, (_, index) => (
+
+ ))}
+
+ )}
+
+
+
+
+);
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+/**
+ * Content-area loading skeleton for the Money Hub (CashTokensFullView).
+ *
+ * Accepts synchronous Redux-derived props so it can mirror the exact layout
+ * branch the real content will take: token rows vs empty state, and whether
+ * the MoneyHub bonus/convert sections appear.
+ */
+const CashTokensFullViewSkeleton = ({
+ numChainsWithMusdBalance,
+ isMoneyHubEnabled,
+ conversionTokenCount,
+}: CashTokensFullViewSkeletonProps) => {
+ const tw = useTailwind();
-
-
-
-
+ return (
+
+ {numChainsWithMusdBalance > 0 ? (
+ <>
+ {Array.from({ length: numChainsWithMusdBalance }, (_, index) => (
+
+ ))}
+ >
+ ) : (
+
+ )}
+ {isMoneyHubEnabled && (
+ <>
+
+
+ >
+ )}
+
);
};
diff --git a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx
index cc2d603f4137..6ee701c507a3 100644
--- a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx
+++ b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.test.tsx
@@ -249,6 +249,42 @@ describe('CashGetMusdEmptyState', () => {
).toBeNull();
});
+ it('hides Claim bonus button when hideClaimButton is true even with claimable reward', () => {
+ mockUseMerklBonusClaim.mockReturnValue({
+ claimableReward: '12.34',
+ lifetimeBonusClaimed: null,
+ hasPendingClaim: false,
+ isClaiming: false,
+ error: null,
+ claimRewards: mockClaimRewards,
+ refetch: jest.fn(),
+ });
+
+ renderWithProvider();
+
+ expect(
+ screen.queryByTestId(CashGetMusdEmptyStateSelectors.CLAIM_BONUS_BUTTON),
+ ).toBeNull();
+ });
+
+ it('shows Claim bonus button when hideClaimButton is false and claimable reward exists', () => {
+ mockUseMerklBonusClaim.mockReturnValue({
+ claimableReward: '12.34',
+ lifetimeBonusClaimed: null,
+ hasPendingClaim: false,
+ isClaiming: false,
+ error: null,
+ claimRewards: mockClaimRewards,
+ refetch: jest.fn(),
+ });
+
+ renderWithProvider();
+
+ expect(
+ screen.getByTestId(CashGetMusdEmptyStateSelectors.CLAIM_BONUS_BUTTON),
+ ).toBeOnTheScreen();
+ });
+
it('calls claimRewards and tracks analytics when Claim bonus is pressed', () => {
mockUseMerklBonusClaim.mockReturnValue({
claimableReward: '1.00',
diff --git a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx
index c19a7f29e076..ffd587379a37 100644
--- a/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx
+++ b/app/components/Views/Homepage/Sections/Cash/CashGetMusdEmptyState.tsx
@@ -59,6 +59,7 @@ import { useCashNavigation } from './useCashNavigation';
interface CashGetMusdEmptyStateProps {
isFullView?: boolean;
+ hideClaimButton?: boolean;
}
/**
@@ -69,6 +70,7 @@ interface CashGetMusdEmptyStateProps {
*/
const CashGetMusdEmptyState = ({
isFullView = false,
+ hideClaimButton = false,
}: CashGetMusdEmptyStateProps) => {
const tw = useTailwind();
const { toastRef } = useContext(ToastContext);
@@ -339,7 +341,7 @@ const CashGetMusdEmptyState = ({
)}
- {hasClaimableBonus ? (
+ {hasClaimableBonus && !hideClaimButton ? (