Skip to content

Commit 8932391

Browse files
committed
fix: musd-1128 prevent apy from disappearing
1 parent e5e6ae0 commit 8932391

8 files changed

Lines changed: 176 additions & 18 deletions

File tree

app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ describe('MoneyHomeView', () => {
478478
apyDecimal: 0.05,
479479
apyPercent: 5,
480480
apyPercentFormatted: '5%',
481+
isApyLoading: false,
481482
vaultApyQuery: {
482483
data: { apy: 0.05, timestamp: '2026-01-01T00:00:00Z' },
483484
isLoading: false,

app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ const MoneyHomeView = () => {
126126
const {
127127
totalFiatFormatted,
128128
totalFiatRaw,
129-
vaultApyQuery,
129+
isApyLoading,
130130
isBalanceLoading,
131131
lastKnownTotalFiatFormatted,
132132
refetchBalance,
@@ -721,7 +721,7 @@ const MoneyHomeView = () => {
721721
<MoneyEarnings
722722
monthlyEarnings={monthlyEarnings}
723723
yearlyEarnings={yearlyEarnings}
724-
isLoading={vaultApyQuery.isLoading || isBalanceLoading}
724+
isLoading={isApyLoading || isBalanceLoading}
725725
onInfoPress={handleEarningsInfoPress}
726726
privacyMode={privacyMode}
727727
/>
@@ -742,7 +742,7 @@ const MoneyHomeView = () => {
742742
COMPONENT_NAMES.MONEY_HOW_IT_WORKS_SECTION_HEADER,
743743
})
744744
}
745-
isLoading={vaultApyQuery.isLoading}
745+
isLoading={isApyLoading}
746746
/>
747747
<MoneyMusdTokenRow
748748
onPress={() =>

app/components/UI/Money/components/BalanceProjection/BalanceProjection.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function mockBalance({
5353
useMoneyAccountBalanceMock.mockReturnValue({
5454
apyDecimal,
5555
apyPercent,
56-
vaultApyQuery: { isLoading },
56+
isApyLoading: isLoading,
5757
} as unknown as ReturnType<typeof useMoneyAccountBalance>);
5858
}
5959

app/components/UI/Money/components/BalanceProjection/BalanceProjection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function BalanceProjection({
3838
projectedYears,
3939
}: BalanceProjectionProps) {
4040
const navigation = useNavigation();
41-
const { vaultApyQuery, apyDecimal, apyPercent } = useMoneyAccountBalance();
41+
const { isApyLoading, apyDecimal, apyPercent } = useMoneyAccountBalance();
4242
const formatFiat = useFiatFormatter();
4343
const { trackTooltipClicked } = useMoneyAnalytics({
4444
screen_name: SCREEN_NAMES.MONEY_DEPOSIT,
@@ -83,7 +83,7 @@ export function BalanceProjection({
8383
});
8484
}, [navigation, trackTooltipClicked]);
8585

86-
if (vaultApyQuery.isLoading) {
86+
if (isApyLoading) {
8787
return (
8888
<View testID="balance-projection-skeleton">
8989
<Skeleton height={20} width={160} />

app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.test.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,7 @@ const createBalanceMock = (
122122
apyDecimal: 0.04,
123123
apyPercent: 4,
124124
apyPercentFormatted: '4%',
125-
vaultApyQuery: {
126-
data: { apy: 0.04, timestamp: '2026-01-01T00:00:00Z' },
127-
isLoading: false,
128-
},
125+
isApyLoading: false,
129126
moneyBalanceQuery: {
130127
data: {
131128
musdBalance: '1000000000',
@@ -577,10 +574,8 @@ describe('MoneyBalanceCard', () => {
577574
it('renders APY skeleton when APY is loading', () => {
578575
mockUseMoneyAccountBalance.mockReturnValue(
579576
createBalanceMock({
580-
vaultApyQuery: {
581-
data: undefined,
582-
isLoading: true,
583-
} as ReturnType<typeof useMoneyAccountBalance>['vaultApyQuery'],
577+
apyPercent: undefined,
578+
isApyLoading: true,
584579
}),
585580
);
586581

app/components/UI/Money/components/MoneyBalanceCard/MoneyBalanceCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const MoneyBalanceCard = () => {
6161
isBalanceFetchError,
6262
isBalanceFetching,
6363
refetchBalance,
64-
vaultApyQuery,
64+
isApyLoading,
6565
} = useMoneyAccountBalance();
6666
const { hasMoneyAccount } = useMoneyAccountInfo();
6767
const { navigateToMoneyHome } = useMoneyNavigation();
@@ -310,7 +310,7 @@ const MoneyBalanceCard = () => {
310310
twClassName="gap-2"
311311
>
312312
{renderBalanceSlot()}
313-
{vaultApyQuery.isLoading ? (
313+
{isApyLoading ? (
314314
<Skeleton
315315
height={20}
316316
width={60}

app/components/UI/Money/hooks/useMoneyAccountBalance.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,141 @@ describe('useMoneyAccountBalance', () => {
522522
});
523523
});
524524

525+
// The data-service bridge can remove the vault APY query out from under an
526+
// active observer (service-side cache GC publishes a "removed" event that
527+
// `createUIQueryClient` honours), leaving the rebuilt query with
528+
// `data: undefined` / `isLoading: true`. The hook carries the last live APY
529+
// across such windows so the UI never loses an APY it has already shown.
530+
describe('last-known APY carry', () => {
531+
it('keeps the last live APY when the query transiently loses its data and reloads', () => {
532+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
533+
data: { apy: 0.05 },
534+
isLoading: false,
535+
});
536+
537+
const { result, rerender } = renderHook(() => useMoneyAccountBalance());
538+
expect(result.current.apyDecimal).toBe(0.05);
539+
540+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
541+
data: undefined,
542+
isLoading: true,
543+
isError: false,
544+
});
545+
rerender(undefined);
546+
547+
expect(result.current.apyDecimal).toBe(0.05);
548+
expect(result.current.apyPercent).toBe(5);
549+
expect(result.current.apyPercentFormatted).toBe('5%');
550+
});
551+
552+
it('prefers the carried live APY over the fallback when the query later errors', () => {
553+
setupDefaultSelectors({
554+
remoteApyConfig: {
555+
vaultApyFallback: 0.04,
556+
vaultApyOverride: undefined,
557+
},
558+
});
559+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
560+
data: { apy: 0.05 },
561+
isLoading: false,
562+
});
563+
564+
const { result, rerender } = renderHook(() => useMoneyAccountBalance());
565+
expect(result.current.apyDecimal).toBe(0.05);
566+
567+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
568+
data: undefined,
569+
isLoading: false,
570+
isError: true,
571+
});
572+
rerender(undefined);
573+
574+
expect(result.current.apyDecimal).toBe(0.05);
575+
});
576+
577+
it('still yields to vaultApyOverride when a carried value exists', () => {
578+
setupDefaultSelectors({
579+
remoteApyConfig: {
580+
vaultApyFallback: undefined,
581+
vaultApyOverride: 0.08,
582+
},
583+
});
584+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
585+
data: { apy: 0.05 },
586+
isLoading: false,
587+
});
588+
589+
const { result, rerender } = renderHook(() => useMoneyAccountBalance());
590+
591+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
592+
data: undefined,
593+
isLoading: true,
594+
});
595+
rerender(undefined);
596+
597+
expect(result.current.apyDecimal).toBe(0.08);
598+
});
599+
600+
it('updates the carried value when a fresh live APY arrives', () => {
601+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
602+
data: { apy: 0.05 },
603+
isLoading: false,
604+
});
605+
606+
const { result, rerender } = renderHook(() => useMoneyAccountBalance());
607+
608+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
609+
data: { apy: 0.06 },
610+
isLoading: false,
611+
});
612+
rerender(undefined);
613+
614+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
615+
data: undefined,
616+
isLoading: true,
617+
});
618+
rerender(undefined);
619+
620+
expect(result.current.apyDecimal).toBe(0.06);
621+
});
622+
});
623+
624+
describe('isApyLoading', () => {
625+
it('is true while the query loads with no APY available from any source', () => {
626+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
627+
data: undefined,
628+
isLoading: true,
629+
});
630+
631+
const { result } = renderHook(() => useMoneyAccountBalance());
632+
633+
expect(result.current.isApyLoading).toBe(true);
634+
});
635+
636+
it('is false once the query has settled with data', () => {
637+
const { result } = renderHook(() => useMoneyAccountBalance());
638+
639+
expect(result.current.isApyLoading).toBe(false);
640+
});
641+
642+
it('is false while the query reloads with a carried last-known APY', () => {
643+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
644+
data: { apy: 0.05 },
645+
isLoading: false,
646+
});
647+
648+
const { result, rerender } = renderHook(() => useMoneyAccountBalance());
649+
650+
setupDefaultQueries(DEFAULT_MONEY_BALANCE_QUERY, {
651+
data: undefined,
652+
isLoading: true,
653+
});
654+
rerender(undefined);
655+
656+
expect(result.current.isApyLoading).toBe(false);
657+
});
658+
});
659+
525660
it('collapses sub-cent total fiat to $0.00 when both balances are 1 minimal unit', () => {
526661
setupDefaultQueries({
527662
data: {

app/components/UI/Money/hooks/useMoneyAccountBalance.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useDispatch, useSelector } from 'react-redux';
2-
import { useEffect, useMemo, useCallback } from 'react';
2+
import { useEffect, useMemo, useCallback, useRef } from 'react';
33
import {
44
type MoneyAccountBalanceResponse,
55
type NormalizedVaultApyResponse,
@@ -52,6 +52,15 @@ interface UseMoneyAccountBalanceResult {
5252
apyDecimal: number | undefined;
5353
apyPercent: number | undefined;
5454
apyPercentFormatted: string | undefined;
55+
/**
56+
* True while the APY query is loading and no APY of any kind is available
57+
* (no live value, no carried last-known value, no override, no fallback).
58+
* Prefer this over `vaultApyQuery.isLoading` for skeletons: the raw flag
59+
* flips back to true whenever the bridge bug (see the last-known carry
60+
* below) rebuilds the query, which would flicker UI that already has an
61+
* APY to show.
62+
*/
63+
isApyLoading: boolean;
5564
}
5665

5766
const useMoneyAccountBalance = (
@@ -197,7 +206,22 @@ const useMoneyAccountBalance = (
197206
? lastKnownBalance.value
198207
: undefined;
199208

200-
const serviceApy = vaultApyQuery.data?.apy;
209+
const liveServiceApy = vaultApyQuery.data?.apy;
210+
211+
// Currently `BaseDataService` in core clears cached data every 5 minutes
212+
// even if it's actively observed. When the component using this hook re-renders
213+
// this lead to `data:undefined` being returned.
214+
//
215+
// This should be properly fixed in core - but for the time being we've
216+
// created this ref which holds onto the last known value - so in cases
217+
// where we lose the underlying data we still render the last good value.
218+
const lastKnownServiceApyRef = useRef<number | undefined>(undefined);
219+
useEffect(() => {
220+
if (liveServiceApy !== undefined) {
221+
lastKnownServiceApyRef.current = liveServiceApy;
222+
}
223+
}, [liveServiceApy]);
224+
const serviceApy = liveServiceApy ?? lastKnownServiceApyRef.current;
201225

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

251+
const isApyLoading = vaultApyQuery.isLoading && apyDecimal === undefined;
252+
227253
return {
228254
moneyBalanceQuery,
229255
vaultApyQuery,
@@ -242,6 +268,7 @@ const useMoneyAccountBalance = (
242268
apyDecimal,
243269
apyPercent,
244270
apyPercentFormatted,
271+
isApyLoading,
245272
};
246273
};
247274

0 commit comments

Comments
 (0)