Skip to content

Commit f959f79

Browse files
authored
feat(card): implement skeleton loading states (MetaMask#18541)
<!-- 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 implements skeleton loading states for the MetaMask Card Home screen to improve user experience during data fetching and loading operations. **What is the reason for the change?** The Card Home screen previously used a generic loading spinner component during data fetching operations (priority token data, balance information, and network/account switching). While functional, the spinner didn't provide users with a clear indication of what content was being loaded or maintain the visual structure of the interface. **What is the improvement/solution?** Replaced the loading spinner with skeleton loading components that provide contextual visual placeholders during loading states: - **Balance Skeleton**: Replaces spinner with a skeleton placeholder that matches the expected balance text dimensions - **Card Asset Item Skeleton**: Shows the shape and size of the asset item component while priority token data loads - **Add Funds Button Skeleton**: Maintains button placement and size during loading states The skeleton components use the existing `Skeleton` component from the component library with appropriate sizing and styling to match the actual content dimensions. This approach provides users with a better sense of what content is loading and maintains visual hierarchy, compared to the previous generic spinner. Each skeleton has specific test IDs for proper testing coverage. ## **Changelog** CHANGELOG entry: Replaced loading spinner with skeleton loading states in Card Home screen for improved user experience and content anticipation ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Card Home Skeleton Loading Scenario: user views Card Home while data is loading Given the user is on the Card Home screen And priority token data is being fetched When the screen is in loading state Then skeleton placeholders should be visible for balance, asset item, and add funds button And the skeleton components should replace the previous loading spinner And users should see content-shaped placeholders instead of generic loading indicator And the skeleton components should match the dimensions of actual content Scenario: user views Card Home with slow network connection Given the user has a slow network connection And the user navigates to Card Home When token balance is in TOKEN_BALANCE_LOADING state Then a skeleton placeholder should appear in place of the balance text And the privacy toggle should remain functional Scenario: user switches networks on Card Home Given the user is on Card Home with a different network than Linea And network switching is in progress When isLoadingNetworkChange is true Then skeleton loading states should be displayed And the UI layout should remain stable during the transition ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- Users would see a generic loading spinner without context about what content was being loaded --> https://github.com/user-attachments/assets/6b1da7e8-75a1-4123-9366-e6344c58e701 ### **After** <!-- Users now see content-shaped skeleton placeholders that provide clear visual anticipation of the loading content and maintain interface structure --> https://github.com/user-attachments/assets/430aebbc-fc62-4fa7-9eb3-4645da616841 ## **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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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.
1 parent 5bb75ab commit f959f79

10 files changed

Lines changed: 204 additions & 737 deletions

File tree

app/components/UI/Card/Views/CardHome/CardHome.styles.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const createStyles = (theme: Theme) =>
1010
backgroundColor: theme.colors.background.default,
1111
gap: 8,
1212
},
13+
skeletonRounded: {
14+
borderRadius: 12,
15+
},
1316
errorDescription: {
1417
textAlign: 'center',
1518
paddingHorizontal: 46,
@@ -44,7 +47,6 @@ const createStyles = (theme: Theme) =>
4447
alignItems: 'center',
4548
marginHorizontal: 16,
4649
paddingVertical: 16,
47-
position: 'relative',
4850
},
4951
balanceTextContainer: {
5052
alignItems: 'center',

app/components/UI/Card/Views/CardHome/CardHome.test.tsx

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { AllowanceState } from '../../types';
1010
import { useGetPriorityCardToken } from '../../hooks/useGetPriorityCardToken';
1111
import { useOpenSwaps } from '../../hooks/useOpenSwaps';
1212
import { useMetrics } from '../../../../hooks/useMetrics';
13+
import { useIsCardholder } from '../../hooks/useIsCardholder';
1314
import {
1415
TOKEN_BALANCE_LOADING,
1516
TOKEN_BALANCE_LOADING_UPPERCASE,
@@ -22,6 +23,7 @@ import {
2223
} from '../../../../../selectors/featureFlagController/deposit';
2324
import { selectChainId } from '../../../../../selectors/networkController';
2425
import { selectCardholderAccounts } from '../../../../../core/redux/slices/card';
26+
import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController';
2527
const mockNavigate = jest.fn();
2628
const mockGoBack = jest.fn();
2729
const mockSetNavigationOptions = jest.fn();
@@ -98,6 +100,10 @@ jest.mock('../../hooks/useNavigateToCardPage', () => ({
98100
useNavigateToCardPage: () => mockUseNavigateToCardPage(),
99101
}));
100102

103+
jest.mock('../../hooks/useIsCardholder', () => ({
104+
useIsCardholder: jest.fn(),
105+
}));
106+
101107
jest.mock('../../../Bridge/hooks/useSwapBridgeNavigation', () => ({
102108
useSwapBridgeNavigation: () => mockUseSwapBridgeNavigation(),
103109
SwapBridgeNavigationLocation: {
@@ -328,6 +334,8 @@ describe('CardHome Component', () => {
328334
openSwaps: mockOpenSwaps,
329335
});
330336

337+
(useIsCardholder as jest.Mock).mockReturnValue(true);
338+
331339
(useMetrics as jest.Mock).mockReturnValue({
332340
trackEvent: mockTrackEvent,
333341
createEventBuilder: mockCreateEventBuilder,
@@ -351,6 +359,21 @@ describe('CardHome Component', () => {
351359
if (selector === selectCardholderAccounts) {
352360
return [mockCurrentAddress];
353361
}
362+
if (selector === selectSelectedInternalAccount) {
363+
return {
364+
address: mockCurrentAddress,
365+
id: 'account-id',
366+
type: 'eip155:eoa',
367+
options: {},
368+
metadata: {
369+
name: 'Test Account',
370+
importTime: Date.now(),
371+
keyring: { type: 'HD Key Tree' },
372+
},
373+
scopes: [],
374+
methods: [],
375+
};
376+
}
354377
if (
355378
selector
356379
.toString()
@@ -403,6 +426,21 @@ describe('CardHome Component', () => {
403426
if (selector === selectCardholderAccounts) {
404427
return [mockCurrentAddress];
405428
}
429+
if (selector === selectSelectedInternalAccount) {
430+
return {
431+
address: mockCurrentAddress,
432+
id: 'account-id',
433+
type: 'eip155:eoa',
434+
options: {},
435+
metadata: {
436+
name: 'Test Account',
437+
importTime: Date.now(),
438+
keyring: { type: 'HD Key Tree' },
439+
},
440+
scopes: [],
441+
methods: [],
442+
};
443+
}
406444
if (
407445
selector
408446
.toString()
@@ -445,9 +483,16 @@ describe('CardHome Component', () => {
445483
});
446484

447485
render();
448-
expect(screen.getByTestId(CardHomeSelectors.LOADER)).toBeTruthy();
449-
});
450486

487+
// When loading, skeleton components should be visible with their specific testIDs
488+
expect(screen.getByTestId(CardHomeSelectors.BALANCE_SKELETON)).toBeTruthy();
489+
expect(
490+
screen.getByTestId(CardHomeSelectors.CARD_ASSET_ITEM_SKELETON),
491+
).toBeTruthy();
492+
expect(
493+
screen.getByTestId(CardHomeSelectors.ADD_FUNDS_BUTTON_SKELETON),
494+
).toBeTruthy();
495+
});
451496
it('opens AddFundsBottomSheet when add funds button is pressed with USDC token', async () => {
452497
render();
453498

@@ -718,6 +763,21 @@ describe('CardHome Component', () => {
718763
if (selector === selectCardholderAccounts) {
719764
return [mockCurrentAddress];
720765
}
766+
if (selector === selectSelectedInternalAccount) {
767+
return {
768+
address: mockCurrentAddress,
769+
id: 'account-id',
770+
type: 'eip155:eoa',
771+
options: {},
772+
metadata: {
773+
name: 'Test Account',
774+
importTime: Date.now(),
775+
keyring: { type: 'HD Key Tree' },
776+
},
777+
scopes: [],
778+
methods: [],
779+
};
780+
}
721781
if (selector.toString().includes('selectChainId')) {
722782
return '0x1'; // Ethereum mainnet - fallback
723783
}
@@ -779,6 +839,21 @@ describe('CardHome Component', () => {
779839
if (selector === selectCardholderAccounts) {
780840
return [mockCurrentAddress];
781841
}
842+
if (selector === selectSelectedInternalAccount) {
843+
return {
844+
address: mockCurrentAddress,
845+
id: 'account-id',
846+
type: 'eip155:eoa',
847+
options: {},
848+
metadata: {
849+
name: 'Test Account',
850+
importTime: Date.now(),
851+
keyring: { type: 'HD Key Tree' },
852+
},
853+
scopes: [],
854+
methods: [],
855+
};
856+
}
782857
if (selector.toString().includes('selectChainId')) {
783858
return '0x1'; // Ethereum mainnet - fallback
784859
}
@@ -866,12 +941,11 @@ describe('CardHome Component', () => {
866941
secondaryBalance: '1000 USDC',
867942
});
868943

869-
const { getByText } = render();
944+
render();
870945

871-
// When balance is TOKEN_BALANCE_LOADING, it should render a SkeletonText component
872-
// instead of actual balance text. Since we can't access testID easily, we check
873-
// that the loading constants are not rendered as text
874-
expect(() => getByText(TOKEN_BALANCE_LOADING)).toThrow();
946+
// When balance is TOKEN_BALANCE_LOADING, it should render a Skeleton component
947+
// instead of actual balance text
948+
expect(screen.getByTestId(CardHomeSelectors.BALANCE_SKELETON)).toBeTruthy();
875949
});
876950

877951
it('displays skeleton loader when balance is TOKEN_BALANCE_LOADING_UPPERCASE', () => {
@@ -885,11 +959,11 @@ describe('CardHome Component', () => {
885959
secondaryBalance: '1000 USDC',
886960
});
887961

888-
const { getByText } = render();
962+
render();
889963

890-
// When balance is TOKEN_BALANCE_LOADING_UPPERCASE, it should render a SkeletonText component
964+
// When balance is TOKEN_BALANCE_LOADING_UPPERCASE, it should render a Skeleton component
891965
// instead of actual balance text
892-
expect(() => getByText(TOKEN_BALANCE_LOADING_UPPERCASE)).toThrow();
966+
expect(screen.getByTestId(CardHomeSelectors.BALANCE_SKELETON)).toBeTruthy();
893967
});
894968

895969
it('falls back to mainBalance when balanceFiat is TOKEN_RATE_UNDEFINED', () => {

0 commit comments

Comments
 (0)