Skip to content

Commit 0cd03cb

Browse files
committed
feat(frontend): split balance cache/refresh signals
Cached GET and refresh POST were both writing the same per-chain status, so consumers gated on "any blockchain query running" hid cached balances until the refresh completed. Introduce useBalanceRefreshState (per-chain in-flight set) and useBalanceStatus (hasCachedData / isInitialLoading / isRefreshing), migrate the blockchain page, dashboard summary, accounts table, and account loading gates.
1 parent 272d78c commit 0cd03cb

16 files changed

Lines changed: 510 additions & 45 deletions

frontend/app/src/modules/accounts/table/AccountBalancesTable.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const {
5858
totalValue,
5959
} = useAccountTableData<T>(() => accounts, expandedIds, chainFilter);
6060
61-
const { accountOperation, isAnyLoading, isRowLoading, isSectionLoading } = useAccountLoadingStates<T>(() => category);
61+
const { accountOperation, isInitialLoading, isRowLoading, isSectionLoading } = useAccountLoadingStates<T>(() => category);
6262
6363
const { confirmDelete, edit } = useAccountOperations<T>({
6464
onEdit: account => emit('edit', account),
@@ -85,7 +85,7 @@ defineExpose({
8585
v-model:collapsed="collapsed"
8686
:cols="cols"
8787
:rows="rows"
88-
:loading="group && isAnyLoading"
88+
:loading="group && isInitialLoading"
8989
row-attr="id"
9090
:empty="{ description: t('data_table.no_data') }"
9191
:loading-text="t('account_balances.data_table.loading')"

frontend/app/src/modules/accounts/table/use-account-loading-states.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
22
import type { AccountDataRow } from './types';
33
import type { BlockchainAccountBalance } from '@/modules/accounts/blockchain-accounts';
4+
import { useAccountCategoryHelper } from '@/modules/accounts/use-account-category-helper';
45
import { useBlockchainAccountLoading } from '@/modules/accounts/use-blockchain-account-loading';
6+
import { useBalanceRefreshState } from '@/modules/balances/use-balance-refresh-state';
7+
import { isCacheInitialLoading } from '@/modules/balances/use-balance-status';
58
import { Section } from '@/modules/core/common/status';
69
import { useStatusStore } from '@/modules/core/common/use-status-store';
710
import { TaskType } from '@/modules/core/tasks/task-type';
811
import { useTaskStore } from '@/modules/core/tasks/use-task-store';
912

1013
interface UseAccountLoadingStates<T extends BlockchainAccountBalance> {
1114
accountOperation: ComputedRef<boolean>;
12-
isAnyLoading: ComputedRef<boolean>;
15+
isInitialLoading: ComputedRef<boolean>;
1316
isRowLoading: (row: AccountDataRow<T>) => boolean;
1417
isSectionLoading: ComputedRef<boolean>;
1518
}
@@ -18,27 +21,42 @@ export function useAccountLoadingStates<T extends BlockchainAccountBalance>(
1821
category: MaybeRefOrGetter<string>,
1922
): UseAccountLoadingStates<T> {
2023
const { useIsTaskRunning } = useTaskStore();
21-
const { getIsLoading } = useStatusStore();
24+
const statusStore = useStatusStore();
25+
const { status } = storeToRefs(statusStore);
26+
const { refreshingChains } = storeToRefs(useBalanceRefreshState());
2227
const { isSectionLoading } = useBlockchainAccountLoading(category);
28+
const { chainIds } = useAccountCategoryHelper(category);
2329

2430
const accountOperation = logicOr(
2531
useIsTaskRunning(TaskType.ADD_ACCOUNT),
2632
useIsTaskRunning(TaskType.REMOVE_ACCOUNT),
2733
isSectionLoading,
2834
);
2935

30-
const isAnyLoading = logicOr(accountOperation, isSectionLoading);
36+
const isInitialLoading = computed<boolean>(() => {
37+
const chains = get(status)[Section.BLOCKCHAIN];
38+
if (!chains)
39+
return false;
40+
const categoryChains = get(chainIds);
41+
const candidates = categoryChains.length > 0
42+
? categoryChains.filter(chain => chain in chains)
43+
: Object.keys(chains);
44+
if (candidates.length === 0)
45+
return false;
46+
return candidates.some(chain => isCacheInitialLoading(chains[chain]));
47+
});
3148

3249
function isRowLoading(row: AccountDataRow<T>): boolean {
50+
const refreshing = get(refreshingChains);
3351
if (row.type === 'account')
34-
return getIsLoading(Section.BLOCKCHAIN, row.chain);
52+
return refreshing.has(row.chain);
3553
else
36-
return row.chains.some(chain => getIsLoading(Section.BLOCKCHAIN, chain));
54+
return row.chains.some(chain => refreshing.has(chain));
3755
}
3856

3957
return {
4058
accountOperation,
41-
isAnyLoading,
59+
isInitialLoading,
4260
isRowLoading,
4361
isSectionLoading,
4462
};

frontend/app/src/modules/accounts/use-account-loading.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,30 @@
11
import type { ComputedRef, Ref } from 'vue';
2+
import { useBalanceRefreshState } from '@/modules/balances/use-balance-refresh-state';
23
import { TaskType } from '@/modules/core/tasks/task-type';
34
import { useTaskStore } from '@/modules/core/tasks/use-task-store';
45

56
interface UseAccountLoadingReturn {
67
pending: Ref<boolean>;
78
loading: ComputedRef<boolean>;
8-
isQueryingBlockchain: ComputedRef<boolean>;
9-
isBlockchainLoading: ComputedRef<boolean>;
109
isAccountOperationRunning: (blockchain?: string) => ComputedRef<boolean>;
1110
}
1211

1312
export const useAccountLoading = createSharedComposable((): UseAccountLoadingReturn => {
1413
const pending = ref<boolean>(false);
1514

1615
const { useIsTaskRunning } = useTaskStore();
17-
18-
const isQueryingBlockchain = useIsTaskRunning(TaskType.QUERY_BLOCKCHAIN_BALANCES);
19-
const isLoopringLoading = useIsTaskRunning(TaskType.L2_LOOPRING);
20-
21-
const isBlockchainLoading = computed<boolean>(() => get(isQueryingBlockchain) || get(isLoopringLoading));
16+
const { isRefreshing } = storeToRefs(useBalanceRefreshState());
2217

2318
const isAccountOperationRunning = (blockchain?: string): ComputedRef<boolean> =>
2419
logicOr(
2520
useIsTaskRunning(TaskType.ADD_ACCOUNT, blockchain ? { blockchain } : {}),
2621
useIsTaskRunning(TaskType.REMOVE_ACCOUNT, blockchain ? { blockchain } : {}),
2722
);
2823

29-
const loading: ComputedRef<boolean> = logicOr(isAccountOperationRunning(), pending, isQueryingBlockchain);
24+
const loading: ComputedRef<boolean> = logicOr(isAccountOperationRunning(), pending, isRefreshing);
3025

3126
return {
3227
isAccountOperationRunning,
33-
isBlockchainLoading,
34-
isQueryingBlockchain,
3528
loading,
3629
pending,
3730
};

frontend/app/src/modules/accounts/use-blockchain-account-loading.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
22
import { useAccountCategoryHelper } from '@/modules/accounts/use-account-category-helper';
33
import { useTokenDetectionStore } from '@/modules/balances/blockchain/use-token-detection-store';
4+
import { useBalanceRefreshState } from '@/modules/balances/use-balance-refresh-state';
45
import { Section } from '@/modules/core/common/status';
56
import { useStatusStore } from '@/modules/core/common/use-status-store';
67
import { TaskType } from '@/modules/core/tasks/task-type';
@@ -19,6 +20,7 @@ export function useBlockchainAccountLoading(category: MaybeRefOrGetter<string> =
1920
const { isTaskRunning, useIsTaskRunning } = useTaskStore();
2021
const { massDetecting } = storeToRefs(useTokenDetectionStore());
2122
const { getIsLoading } = useStatusStore();
23+
const { refreshingChains } = storeToRefs(useBalanceRefreshState());
2224

2325
const { chainIds, isEvm } = useAccountCategoryHelper(category);
2426

@@ -35,10 +37,11 @@ export function useBlockchainAccountLoading(category: MaybeRefOrGetter<string> =
3537
});
3638

3739
const isSectionLoading = computed<boolean>(() => {
40+
const refreshing = get(refreshingChains);
3841
if (!toValue(category))
39-
return getIsLoading(Section.BLOCKCHAIN);
42+
return getIsLoading(Section.BLOCKCHAIN) || refreshing.size > 0;
4043

41-
return get(chainIds).some(chain => getIsLoading(Section.BLOCKCHAIN, chain));
44+
return get(chainIds).some(chain => getIsLoading(Section.BLOCKCHAIN, chain) || refreshing.has(chain));
4245
});
4346

4447
const isDetectingTokens = computed<boolean>(() => get(isEvm) && isDefined(massDetecting));
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { Blockchain } from '@rotki/common';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { createAccount } from '@/modules/accounts/create-account';
4+
import { useBlockchainAccountsStore } from '@/modules/accounts/use-blockchain-accounts-store';
5+
import { useBalanceRefreshState } from '@/modules/balances/use-balance-refresh-state';
6+
import { useBalanceStatus } from '@/modules/balances/use-balance-status';
7+
8+
vi.mock('@/modules/core/notifications/use-notifications-store', () => ({
9+
useNotificationsStore: vi.fn().mockReturnValue({}),
10+
}));
11+
12+
vi.mock('@/modules/balances/use-balances-store', () => ({
13+
useBalancesStore: vi.fn().mockReturnValue({
14+
updateBalances: vi.fn(),
15+
}),
16+
}));
17+
18+
vi.mock('@/modules/core/common/use-supported-chains', async () => {
19+
const { computed } = await import('vue');
20+
return {
21+
useSupportedChains: vi.fn().mockReturnValue({
22+
getChainName: vi.fn((chain: string) => computed(() => chain)),
23+
}),
24+
};
25+
});
26+
27+
const mockQueryBlockchainBalances = vi.fn();
28+
const mockRefreshBlockchainBalances = vi.fn();
29+
30+
vi.mock('@/modules/balances/api/use-blockchain-balances-api', () => ({
31+
useBlockchainBalancesApi: vi.fn().mockReturnValue({
32+
queryBlockchainBalances: (...args: unknown[]) => mockQueryBlockchainBalances(...args),
33+
refreshBlockchainBalances: (...args: unknown[]) => mockRefreshBlockchainBalances(...args),
34+
queryXpubBalances: vi.fn(),
35+
}),
36+
}));
37+
38+
interface Deferred<T> {
39+
promise: Promise<T>;
40+
resolve: (value: T) => void;
41+
}
42+
43+
function deferred<T>(): Deferred<T> {
44+
let resolve!: (value: T) => void;
45+
const promise = new Promise<T>((res) => {
46+
resolve = res;
47+
});
48+
return { promise, resolve };
49+
}
50+
51+
const pendingTasks = new Map<number, Deferred<{ success: true; result: unknown }>>();
52+
let nextTaskId = 1;
53+
54+
vi.mock('@/modules/core/tasks/use-task-handler', async importOriginal => ({
55+
...(await importOriginal<Record<string, unknown>>()),
56+
useTaskHandler: vi.fn().mockReturnValue({
57+
runTask: vi.fn().mockImplementation(async (taskFn: () => Promise<{ taskId: number }>) => {
58+
const { taskId } = await taskFn();
59+
const d = deferred<{ success: true; result: unknown }>();
60+
pendingTasks.set(taskId, d);
61+
return d.promise;
62+
}),
63+
}),
64+
}));
65+
66+
const { useBalanceProcessingService } = await import('@/modules/balances/services/use-balance-processing-service');
67+
68+
function emptyBalances(blockchain: string): unknown {
69+
return {
70+
perAccount: { [blockchain]: {} },
71+
totals: { assets: {}, liabilities: {} },
72+
};
73+
}
74+
75+
describe('useBalanceProcessingService', () => {
76+
beforeEach(() => {
77+
setActivePinia(createPinia());
78+
pendingTasks.clear();
79+
nextTaskId = 1;
80+
mockQueryBlockchainBalances.mockReset();
81+
mockRefreshBlockchainBalances.mockReset();
82+
83+
const { updateAccounts } = useBlockchainAccountsStore();
84+
updateAccounts(Blockchain.ETH, [
85+
createAccount({ address: '0x1', label: null, tags: null }, { chain: Blockchain.ETH, nativeAsset: 'ETH' }),
86+
]);
87+
});
88+
89+
it('should flip hasCachedData when the cached GET resolves, before refresh POST completes', async () => {
90+
mockQueryBlockchainBalances.mockImplementation(async () => ({ taskId: nextTaskId++ }));
91+
mockRefreshBlockchainBalances.mockImplementation(async () => ({ taskId: nextTaskId++ }));
92+
93+
const service = useBalanceProcessingService();
94+
const { hasCachedData, isRefreshing } = useBalanceStatus(Blockchain.ETH);
95+
96+
const cachedPromise = service.handleCachedFetch(
97+
{ addresses: undefined, blockchain: Blockchain.ETH, isXpub: false },
98+
undefined,
99+
);
100+
const refreshPromise = service.handleRefresh(
101+
{ addresses: undefined, blockchain: Blockchain.ETH, isXpub: false },
102+
);
103+
104+
// wait for both api calls to have registered their tasks
105+
await vi.waitFor(() => {
106+
expect(pendingTasks.size).toBe(2);
107+
});
108+
109+
expect(get(hasCachedData)).toBe(false);
110+
expect(get(isRefreshing)).toBe(true);
111+
112+
// resolve the cached GET task first (taskId 1)
113+
pendingTasks.get(1)!.resolve({ success: true, result: emptyBalances(Blockchain.ETH) });
114+
await cachedPromise;
115+
116+
expect(get(hasCachedData)).toBe(true);
117+
expect(get(isRefreshing)).toBe(true); // refresh POST still in flight
118+
119+
// resolve the refresh POST task
120+
pendingTasks.get(2)!.resolve({ success: true, result: emptyBalances(Blockchain.ETH) });
121+
await refreshPromise;
122+
123+
expect(get(hasCachedData)).toBe(true);
124+
expect(get(isRefreshing)).toBe(false);
125+
});
126+
127+
it('should clear isRefreshing even when the refresh POST fails', async () => {
128+
mockRefreshBlockchainBalances.mockImplementation(async () => ({ taskId: nextTaskId++ }));
129+
130+
const service = useBalanceProcessingService();
131+
const refreshState = useBalanceRefreshState();
132+
const isEthRefreshing = refreshState.useIsRefreshing(Blockchain.ETH);
133+
134+
const refreshPromise = service.handleRefresh(
135+
{ addresses: undefined, blockchain: Blockchain.ETH, isXpub: false },
136+
);
137+
138+
await vi.waitFor(() => {
139+
expect(pendingTasks.size).toBe(1);
140+
});
141+
expect(get(isEthRefreshing)).toBe(true);
142+
143+
pendingTasks.get(1)!.resolve({
144+
success: false,
145+
message: 'cancelled',
146+
cancelled: true,
147+
backendCancelled: false,
148+
skipped: false,
149+
} as never);
150+
await refreshPromise;
151+
152+
expect(get(isEthRefreshing)).toBe(false);
153+
});
154+
});

frontend/app/src/modules/balances/services/use-balance-processing-service.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type BtcBalances,
88
type FetchBlockchainBalancePayload,
99
} from '@/modules/balances/types/blockchain-balances';
10+
import { useBalanceRefreshState } from '@/modules/balances/use-balance-refresh-state';
1011
import { useBalancesStore } from '@/modules/balances/use-balances-store';
1112
import { useBlockchainRefreshTimestampsStore } from '@/modules/balances/use-blockchain-refresh-timestamps-store';
1213
import { logger } from '@/modules/core/common/logging/logging';
@@ -35,7 +36,8 @@ export function useBalanceProcessingService(): UseBalanceProcessingServiceReturn
3536
const { updateTimestamps } = useBlockchainRefreshTimestampsStore();
3637
const { getChainName } = useSupportedChains();
3738
const { t } = useI18n({ useScope: 'global' });
38-
const { isFirstLoad, resetStatus, setStatus } = useStatusUpdater(Section.BLOCKCHAIN);
39+
const { getStatus, resetStatus, setStatus } = useStatusUpdater(Section.BLOCKCHAIN);
40+
const refreshState = useBalanceRefreshState();
3941

4042
const processBalanceResult = (blockchain: string, result: unknown): void => {
4143
const parsedBalances: BlockchainBalances = BlockchainBalances.parse(result);
@@ -101,17 +103,23 @@ export function useBalanceProcessingService(): UseBalanceProcessingServiceReturn
101103

102104
const handleCachedFetch = async (payload: FetchBlockchainBalancePayload, threshold: string | undefined): Promise<void> => {
103105
const { blockchain } = payload;
104-
setStatus(isFirstLoad() ? Status.LOADING : Status.REFRESHING, { subsection: blockchain });
106+
if (getStatus({ subsection: blockchain }) !== Status.LOADED)
107+
setStatus(Status.LOADING, { subsection: blockchain });
105108
await executeBalanceQuery(blockchain, async () => queryBlockchainBalances(payload, threshold));
106109
};
107110

108111
const handleRefresh = async (payload: FetchBlockchainBalancePayload): Promise<void> => {
109112
const { blockchain, isXpub } = payload;
110-
setStatus(Status.REFRESHING, { subsection: blockchain });
111-
await executeBalanceQuery(
112-
blockchain,
113-
async () => !isXpub ? refreshBlockchainBalances(payload) : queryXpubBalances(payload),
114-
);
113+
refreshState.start(blockchain);
114+
try {
115+
await executeBalanceQuery(
116+
blockchain,
117+
async () => !isXpub ? refreshBlockchainBalances(payload) : queryXpubBalances(payload),
118+
);
119+
}
120+
finally {
121+
refreshState.stop(blockchain);
122+
}
115123
};
116124

117125
return {

0 commit comments

Comments
 (0)