Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ describe('MoneyHomeView', () => {
apyDecimal: 0.05,
apyPercent: 5,
apyPercentFormatted: '5%',
isApyLoading: false,
vaultApyQuery: {
data: { apy: 0.05, timestamp: '2026-01-01T00:00:00Z' },
isLoading: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const MoneyHomeView = () => {
const {
totalFiatFormatted,
totalFiatRaw,
vaultApyQuery,
isApyLoading,
isBalanceLoading,
lastKnownTotalFiatFormatted,
refetchBalance,
Expand Down Expand Up @@ -721,7 +721,7 @@ const MoneyHomeView = () => {
<MoneyEarnings
monthlyEarnings={monthlyEarnings}
yearlyEarnings={yearlyEarnings}
isLoading={vaultApyQuery.isLoading || isBalanceLoading}
isLoading={isApyLoading || isBalanceLoading}
onInfoPress={handleEarningsInfoPress}
privacyMode={privacyMode}
/>
Expand All @@ -742,7 +742,7 @@ const MoneyHomeView = () => {
COMPONENT_NAMES.MONEY_HOW_IT_WORKS_SECTION_HEADER,
})
}
isLoading={vaultApyQuery.isLoading}
isLoading={isApyLoading}
/>
<MoneyMusdTokenRow
onPress={() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function mockBalance({
useMoneyAccountBalanceMock.mockReturnValue({
apyDecimal,
apyPercent,
vaultApyQuery: { isLoading },
isApyLoading: isLoading,
} as unknown as ReturnType<typeof useMoneyAccountBalance>);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function BalanceProjection({
projectedYears,
}: BalanceProjectionProps) {
const navigation = useNavigation();
const { vaultApyQuery, apyDecimal, apyPercent } = useMoneyAccountBalance();
const { isApyLoading, apyDecimal, apyPercent } = useMoneyAccountBalance();
const formatFiat = useFiatFormatter();
const { trackTooltipClicked } = useMoneyAnalytics({
screen_name: SCREEN_NAMES.MONEY_DEPOSIT,
Expand Down Expand Up @@ -83,7 +83,7 @@ export function BalanceProjection({
});
}, [navigation, trackTooltipClicked]);

if (vaultApyQuery.isLoading) {
if (isApyLoading) {
return (
<View testID="balance-projection-skeleton">
<Skeleton height={20} width={160} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,7 @@ const createBalanceMock = (
apyDecimal: 0.04,
apyPercent: 4,
apyPercentFormatted: '4%',
vaultApyQuery: {
data: { apy: 0.04, timestamp: '2026-01-01T00:00:00Z' },
isLoading: false,
},
isApyLoading: false,
moneyBalanceQuery: {
data: {
musdBalance: '1000000000',
Expand Down Expand Up @@ -577,10 +574,8 @@ describe('MoneyBalanceCard', () => {
it('renders APY skeleton when APY is loading', () => {
mockUseMoneyAccountBalance.mockReturnValue(
createBalanceMock({
vaultApyQuery: {
data: undefined,
isLoading: true,
} as ReturnType<typeof useMoneyAccountBalance>['vaultApyQuery'],
apyPercent: undefined,
isApyLoading: true,
}),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const MoneyBalanceCard = () => {
isBalanceFetchError,
isBalanceFetching,
refetchBalance,
vaultApyQuery,
isApyLoading,
} = useMoneyAccountBalance();
const { hasMoneyAccount } = useMoneyAccountInfo();
const { navigateToMoneyHome } = useMoneyNavigation();
Expand Down Expand Up @@ -310,7 +310,7 @@ const MoneyBalanceCard = () => {
twClassName="gap-2"
>
{renderBalanceSlot()}
{vaultApyQuery.isLoading ? (
{isApyLoading ? (
<Skeleton
height={20}
width={60}
Expand Down
135 changes: 135 additions & 0 deletions app/components/UI/Money/hooks/useMoneyAccountBalance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,141 @@ describe('useMoneyAccountBalance', () => {
});
});

// The data-service bridge can remove the vault APY query out from under an
// active observer (service-side cache GC publishes a "removed" event that
// `createUIQueryClient` honours), leaving the rebuilt query with
// `data: undefined` / `isLoading: true`. The hook carries the last live APY
// across such windows so the UI never loses an APY it has already shown.
describe('last-known APY carry', () => {
it('keeps the last live APY when the query transiently loses its data and reloads', () => {
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: { apy: 0.05 },
isLoading: false,
});

const { result, rerender } = renderHook(() => useMoneyAccountBalance());
expect(result.current.apyDecimal).toBe(0.05);

setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: undefined,
isLoading: true,
isError: false,
});
rerender(undefined);

expect(result.current.apyDecimal).toBe(0.05);
expect(result.current.apyPercent).toBe(5);
expect(result.current.apyPercentFormatted).toBe('5%');
});

it('prefers the carried live APY over the fallback when the query later errors', () => {
setupDefaultSelectors({
remoteApyConfig: {
vaultApyFallback: 0.04,
vaultApyOverride: undefined,
},
});
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: { apy: 0.05 },
isLoading: false,
});

const { result, rerender } = renderHook(() => useMoneyAccountBalance());
expect(result.current.apyDecimal).toBe(0.05);

setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: undefined,
isLoading: false,
isError: true,
});
rerender(undefined);

expect(result.current.apyDecimal).toBe(0.05);
});

it('still yields to vaultApyOverride when a carried value exists', () => {
setupDefaultSelectors({
remoteApyConfig: {
vaultApyFallback: undefined,
vaultApyOverride: 0.08,
},
});
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: { apy: 0.05 },
isLoading: false,
});

const { result, rerender } = renderHook(() => useMoneyAccountBalance());

setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: undefined,
isLoading: true,
});
rerender(undefined);

expect(result.current.apyDecimal).toBe(0.08);
});

it('updates the carried value when a fresh live APY arrives', () => {
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: { apy: 0.05 },
isLoading: false,
});

const { result, rerender } = renderHook(() => useMoneyAccountBalance());

setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: { apy: 0.06 },
isLoading: false,
});
rerender(undefined);

setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: undefined,
isLoading: true,
});
rerender(undefined);

expect(result.current.apyDecimal).toBe(0.06);
});
});

describe('isApyLoading', () => {
it('is true while the query loads with no APY available from any source', () => {
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: undefined,
isLoading: true,
});

const { result } = renderHook(() => useMoneyAccountBalance());

expect(result.current.isApyLoading).toBe(true);
});

it('is false once the query has settled with data', () => {
const { result } = renderHook(() => useMoneyAccountBalance());

expect(result.current.isApyLoading).toBe(false);
});

it('is false while the query reloads with a carried last-known APY', () => {
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: { apy: 0.05 },
isLoading: false,
});

const { result, rerender } = renderHook(() => useMoneyAccountBalance());

setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
data: undefined,
isLoading: true,
});
rerender(undefined);

expect(result.current.isApyLoading).toBe(false);
});
});

it('collapses sub-cent total fiat to $0.00 when both balances are 1 minimal unit', () => {
setupDefaultQueries({
data: {
Expand Down
31 changes: 29 additions & 2 deletions app/components/UI/Money/hooks/useMoneyAccountBalance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useDispatch, useSelector } from 'react-redux';
import { useEffect, useMemo, useCallback } from 'react';
import { useEffect, useMemo, useCallback, useRef } from 'react';
import {
type MoneyAccountBalanceResponse,
type NormalizedVaultApyResponse,
Expand Down Expand Up @@ -52,11 +52,20 @@
apyDecimal: number | undefined;
apyPercent: number | undefined;
apyPercentFormatted: string | undefined;
/**
* True while the APY query is loading and no APY of any kind is available
* (no live value, no carried last-known value, no override, no fallback).
* Prefer this over `vaultApyQuery.isLoading` for skeletons: the raw flag
* flips back to true whenever the bridge bug (see the last-known carry
* below) rebuilds the query, which would flicker UI that already has an
* APY to show.
*/
isApyLoading: boolean;
}

const useMoneyAccountBalance = (
refetchInterval: number = DEFAULT_REFETCH_INTERVAL,
): UseMoneyAccountBalanceResult => {

Check failure on line 68 in app/components/UI/Money/hooks/useMoneyAccountBalance.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=MetaMask_metamask-mobile&issues=AZ9bY6WeP_v9YghLUeTs&open=AZ9bY6WeP_v9YghLUeTs&pullRequest=33193
const dispatch = useDispatch();
const { primaryMoneyAccount } = useMoneyAccountInfo();
const moneyAccountAddress = primaryMoneyAccount?.address;
Expand Down Expand Up @@ -197,7 +206,22 @@
? lastKnownBalance.value
: undefined;

const serviceApy = vaultApyQuery.data?.apy;
const liveServiceApy = vaultApyQuery.data?.apy;

// Currently `BaseDataService` in core clears cached data every 5 minutes
// even if it's actively observed. When the component using this hook re-renders
// this lead to `data:undefined` being returned.
//
// This should be properly fixed in core - but for the time being we've
// created this ref which holds onto the last known value - so in cases
// where we lose the underlying data we still render the last good value.
const lastKnownServiceApyRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (liveServiceApy !== undefined) {
lastKnownServiceApyRef.current = liveServiceApy;
}
}, [liveServiceApy]);
const serviceApy = liveServiceApy ?? lastKnownServiceApyRef.current;

// During first load with no cache, do not show fallback to avoid flicker.
// Show fallback on explicit APY query errors (service outage path) or when
Expand All @@ -224,6 +248,8 @@
const apyPercentFormatted =
apyPercent !== undefined ? `${apyPercent}%` : undefined;

const isApyLoading = vaultApyQuery.isLoading && apyDecimal === undefined;

return {
moneyBalanceQuery,
vaultApyQuery,
Expand All @@ -242,6 +268,7 @@
apyDecimal,
apyPercent,
apyPercentFormatted,
isApyLoading,
};
};

Expand Down
Loading