From db682a870ffe60051e522f1a0cbd376631ec10e6 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 14 Sep 2026 17:12:53 +0100 Subject: [PATCH 1/2] feat(assets-controller): add transient per-account loading state for user-visible fetches Add a non-persisted 'assetsLoadingStatus' state map (Record) that is populated while getAssets runs for user-visible moments and cleared when the fetch settles. Only the two user-action fetch sites (account group change, unlock/startup refresh) pass the new optional 'trigger' option, so background fetches (polling, price/tx/currency refreshes) never flap the indicator. The field is marked persist: false, so no migration is needed. Also export selectors (getAccountLoadingStatus, isAccountLoading, getAccountsLoadingStatus, isAnyAccountLoading) for the UX layer. getAssets is refactored into a thin lifecycle wrapper around the extracted #getAssetsInternal pipeline (body unchanged), and tempHealAssetsInfoMetadata is made generic over the state slice so its return type stays assignable to the widened controller state. --- packages/assets-controller/CHANGELOG.md | 5 + .../AssetsController-method-action-types.ts | 28 ++ .../src/AssetsController.test.ts | 286 +++++++++++++++++- .../assets-controller/src/AssetsController.ts | 140 +++++++++ packages/assets-controller/src/index.ts | 8 + .../src/migrations/healAssetsInfoMetadata.ts | 12 +- .../src/selectors/loading.test.ts | 125 ++++++++ .../src/selectors/loading.ts | 99 ++++++ packages/assets-controller/src/types.ts | 12 + 9 files changed, 710 insertions(+), 5 deletions(-) create mode 100644 packages/assets-controller/src/selectors/loading.test.ts create mode 100644 packages/assets-controller/src/selectors/loading.ts diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 8e1bbf0f5bd..1a45aa1b744 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a transient, non-persisted per-account assets loading state. `AssetsControllerState` now includes `assetsLoadingStatus: Record` (`'accountSwitch' | 'unlock'`), populated while `getAssets` runs for user-visible moments and cleared when the fetch settles; the field is never persisted. `getAssets` accepts a new optional `trigger` option that opts a fetch into this behavior — only account switches and unlock-time startup refreshes pass it ([#PR](https://github.com/MetaMask/core/pull/PR)) +- Add loading-state selectors `getAccountLoadingStatus`, `isAccountLoading`, `getAccountsLoadingStatus`, and `isAnyAccountLoading` for the UX layer to read `assetsLoadingStatus` ([#PR](https://github.com/MetaMask/core/pull/PR)) + ### Changed - Bump `@metamask/account-tree-controller` from `^10.0.0` to `^10.0.1` ([#10166](https://github.com/MetaMask/core/pull/10166)) diff --git a/packages/assets-controller/src/AssetsController-method-action-types.ts b/packages/assets-controller/src/AssetsController-method-action-types.ts index 0e0479ff245..dbde23af24b 100644 --- a/packages/assets-controller/src/AssetsController-method-action-types.ts +++ b/packages/assets-controller/src/AssetsController-method-action-types.ts @@ -5,6 +5,34 @@ import type { AssetsController } from './AssetsController.js'; +/** + * Fetch assets for the given accounts, publishing a transient per-account + * loading state while user-visible fetches are in flight. + * + * When `options.trigger` is set, each requested account is marked in + * `state.assetsLoadingStatus` with the trigger for the duration of the + * fetch (set before the fetch starts, removed when it settles — success or + * failure). This exists so the UI can show a loading indicator during + * user-visible moments (account switch, unlock) without inferring it from + * balance state. Pass a trigger **only** for those moments; background + * fetches (polling, price refreshes, post-transaction refreshes) must omit + * it so the indicator does not flap. + * + * @param accounts - Accounts to fetch assets for. + * @param options - Fetch options. + * @param options.chainIds - Chains to fetch for; defaults to enabled chains. + * @param options.assetTypes - Asset types to fetch (fungible, native). + * @param options.forceUpdate - Skip cache and fetch fresh data. + * @param options.bypassServerCache - Also bypass server-side HTTP caches. + * @param options.dataTypes - Data types to fetch (balances, info, prices). + * @param options.assetsForPriceUpdate - Asset IDs to fetch prices for. + * @param options.updateMode - `'merge'` to combine with existing state + * instead of replacing it. + * @param options.trigger - User-visible moment this fetch belongs to; when + * set, per-account loading status is published while the fetch is in + * flight. Omit for background fetches. + * @returns The combined assets per account, read from state after the fetch. + */ export type AssetsControllerGetAssetsAction = { type: `AssetsController:getAssets`; handler: AssetsController['getAssets']; diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index dc31450f492..b8f86c11846 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1,5 +1,6 @@ -import { clientControllerSelectors } from '@metamask/client-controller'; /* eslint-disable jest/unbound-method */ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { clientControllerSelectors } from '@metamask/client-controller'; import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; import type { ApiPlatformClient } from '@metamask/core-backend'; import type { InternalAccount } from '@metamask/keyring-internal-api'; @@ -89,6 +90,63 @@ function createMockQueryApiClient(): ApiPlatformClient { } as unknown as ApiPlatformClient; } +/** + * A query API client whose Accounts API calls can be frozen mid-flight so a + * forced `getAssets()` run is observable while in flight. + * + * While "armed", every Accounts API network/balance call returns a promise + * that stays pending until `release()` is called, then resolves with a valid + * (empty) response shape. While "disarmed", calls resolve immediately. + * + * @returns The gated client plus `arm()`/`release()` controls. + */ +function createGatedQueryApiClient(): { + client: ApiPlatformClient; + arm: () => void; + release: () => void; +} { + let armed = false; + let releaseGate: () => void = () => undefined; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + const gatedCall = + (value: () => Result): (() => Promise) => + () => + armed ? gate.then(value) : Promise.resolve(value()); + + const client = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: gatedCall(() => ({ + fullSupport: ['eip155:1'], + partialSupport: [], + })), + fetchV5MultiAccountBalances: gatedCall(() => ({ + balances: [], + unprocessedNetworks: [], + })), + fetchV6MultiAccountBalances: gatedCall(() => ({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + })), + }, + } as unknown as ApiPlatformClient; + + return { + client, + arm: (): void => { + armed = true; + }, + release: (): void => { + armed = false; + releaseGate(); + }, + }; +} + type AllActions = MessengerActions; type AllEvents = MessengerEvents; @@ -334,6 +392,7 @@ describe('AssetsController', () => { customAssets: {}, assetPreferences: {}, selectedCurrency: 'usd', + assetsLoadingStatus: {}, }); }); @@ -363,6 +422,7 @@ describe('AssetsController', () => { customAssets: {}, assetPreferences: {}, selectedCurrency: 'usd', + assetsLoadingStatus: {}, }); }); }); @@ -520,6 +580,7 @@ describe('AssetsController', () => { assetsPrice: {}, customAssets: {}, selectedCurrency: 'usd', + assetsLoadingStatus: {}, }); // Action handlers should be registered @@ -3765,6 +3826,229 @@ describe('AssetsController', () => { }); }); + describe('assets loading status', () => { + it('marks each requested account as loading while a triggered getAssets is in flight, then clears it', async () => { + const { client, arm, release } = createGatedQueryApiClient(); + + await withController( + { queryApiClient: client }, + async ({ controller }) => { + const accountA = createMockInternalAccount(); + const accountB = createMockInternalAccount({ + id: 'mock-account-id-2', + }); + + // Let the Accounts API data source finish its construction-time + // supported-networks lookup so the armed fetch is gated on the + // balances call (deterministic in-flight window). + await flushPromises(); + + arm(); + const fetchPromise = controller.getAssets([accountA, accountB], { + forceUpdate: true, + trigger: 'accountSwitch', + }); + await flushPromises(); + + // In flight: both accounts are marked loading with the trigger. + expect(controller.state.assetsLoadingStatus).toStrictEqual({ + [accountA.id]: 'accountSwitch', + [accountB.id]: 'accountSwitch', + }); + + release(); + await fetchPromise; + + // Settled: the transient entries are removed. + expect(controller.state.assetsLoadingStatus).toStrictEqual({}); + }, + ); + }); + + it('clears the loading status when a triggered getAssets rejects', async () => { + await withController(async ({ controller }) => { + const account = createMockInternalAccount(); + // Force the fetch to fail before the pipeline runs. + const customAssetsSpy = jest + .spyOn(controller, 'getCustomAssets') + .mockImplementation(() => { + throw new Error('fetch failed'); + }); + + await expect( + controller.getAssets([account], { + forceUpdate: true, + trigger: 'accountSwitch', + }), + ).rejects.toThrow('fetch failed'); + + expect(controller.state.assetsLoadingStatus).toStrictEqual({}); + customAssetsSpy.mockRestore(); + }); + }); + + it('does not set the loading status for fetches without a trigger (polling/refresh paths)', async () => { + const { client, arm, release } = createGatedQueryApiClient(); + + await withController( + { queryApiClient: client }, + async ({ controller }) => { + const account = createMockInternalAccount(); + + await flushPromises(); + + arm(); + const fetchPromise = controller.getAssets([account], { + forceUpdate: true, + }); + await flushPromises(); + + // In flight, but no trigger: no loading status is published. + expect(controller.state.assetsLoadingStatus).toStrictEqual({}); + + release(); + await fetchPromise; + + expect(controller.state.assetsLoadingStatus).toStrictEqual({}); + }, + ); + }); + + it('marks accounts as loading with the unlock trigger when tracking restarts after unlock', async () => { + const { client, arm, release } = createGatedQueryApiClient(); + + await withController( + { + queryApiClient: client, + clientControllerState: { isUiOpen: true }, + }, + async ({ controller, messenger }) => { + // Start tracking with the gate disarmed so the first startup + // refresh settles and the Accounts API active chains are known. + await activateTracking(messenger); + expect(controller.state.assetsLoadingStatus).toStrictEqual({}); + + // Lock, then unlock with the gate armed: #start() runs a fresh + // #runStartupRefresh() -> getAssets(trigger: 'unlock'), frozen + // mid-flight by the gated balances call. + messenger.publish('KeyringController:lock'); + arm(); + messenger.publish('KeyringController:unlock'); + await flushPromises(); + + expect(controller.state.assetsLoadingStatus[MOCK_ACCOUNT_ID]).toBe( + 'unlock', + ); + + release(); + await flushPromises(); + + expect(controller.state.assetsLoadingStatus).toStrictEqual({}); + }, + ); + }); + + it('marks accounts as loading with the accountSwitch trigger on selected group change', async () => { + const { client, arm, release } = createGatedQueryApiClient(); + + await withController( + { queryApiClient: client }, + async ({ controller, messenger }) => { + // Start tracking with the gate disarmed so the startup refresh settles. + await activateTracking(messenger); + expect(controller.state.assetsLoadingStatus).toStrictEqual({}); + + arm(); + (messenger.publish as CallableFunction)( + 'AccountTreeController:selectedAccountGroupChange', + 'entropy:mock-keyring-id-1/1', + 'entropy:mock-keyring-id-1/0', + ); + await flushPromises(); + + // The group-change refresh is frozen mid-flight by the gated client. + expect(controller.state.assetsLoadingStatus[MOCK_ACCOUNT_ID]).toBe( + 'accountSwitch', + ); + + release(); + await flushPromises(); + + expect(controller.state.assetsLoadingStatus).toStrictEqual({}); + }, + ); + }); + + it('emits state change events when the loading status is set and cleared', async () => { + const { client, arm, release } = createGatedQueryApiClient(); + + await withController( + { queryApiClient: client }, + async ({ controller, messenger }) => { + const stateChanges: AssetsControllerState[] = []; + messenger.subscribe('AssetsController:stateChanged', (state) => { + stateChanges.push(state); + }); + + const account = createMockInternalAccount(); + + await flushPromises(); + + arm(); + const fetchPromise = controller.getAssets([account], { + forceUpdate: true, + trigger: 'accountSwitch', + }); + await flushPromises(); + + const whileLoading = stateChanges.find( + (state) => + state.assetsLoadingStatus[account.id] === 'accountSwitch', + ); + expect(whileLoading).toBeDefined(); + + release(); + await fetchPromise; + + const afterCleared = stateChanges.at(-1); + expect(afterCleared?.assetsLoadingStatus[account.id]).toBeUndefined(); + }, + ); + }); + + it('does not persist the loading status in the persisted state snapshot', async () => { + const { client, arm, release } = createGatedQueryApiClient(); + + await withController( + { queryApiClient: client }, + async ({ controller }) => { + const account = createMockInternalAccount(); + + await flushPromises(); + + arm(); + const fetchPromise = controller.getAssets([account], { + forceUpdate: true, + trigger: 'accountSwitch', + }); + await flushPromises(); + + // Transient state: excluded from the persisted snapshot even while + // an entry is present. + const persisted = deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ); + expect(persisted).not.toHaveProperty('assetsLoadingStatus'); + + release(); + await fetchPromise; + }, + ); + }); + }); + describe('account tree initialized', () => { it('triggers start when the tree initializes after unlock with empty accounts', async () => { const getAccountsMock = jest.fn().mockReturnValue([]); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index e3dffd5630b..98f1bb09bad 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -139,6 +139,7 @@ import type { AssetBalance, AccountWithSupportedChains, AssetType, + AssetsLoadingTrigger, DataType, DataRequest, DataResponse, @@ -261,6 +262,13 @@ export type AssetsControllerState = { assetPreferences: { [assetId: string]: AssetPreferences }; /** Currently-active ISO 4217 currency code */ selectedCurrency: SupportedCurrency; + /** + * Transient per-account loading state for user-visible asset fetches + * (account switch, unlock). An entry is present while the fetch for that + * account is in flight and its value is the trigger; entries are removed + * when the fetch settles. Never persisted. + */ + assetsLoadingStatus: Record; }; /** @@ -283,6 +291,8 @@ export function getDefaultAssetsControllerState(): AssetsControllerState { customAssets: {}, assetPreferences: {}, selectedCurrency: 'usd', + // Transient loading state — never restored from persisted state. + assetsLoadingStatus: {}, }; } @@ -504,6 +514,14 @@ const stateMetadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + assetsLoadingStatus: { + // Transient in-flight fetch markers (account switch, unlock). Never + // persisted — a restarted client always starts with no fetch in flight. + persist: false, + includeInStateLogs: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, }; // ============================================================================ @@ -1362,6 +1380,7 @@ export class AssetsController extends BaseController< await this.getAssets(accounts, { chainIds: [...this.#enabledChains], forceUpdate: true, + trigger: 'unlock', }); // Seed before subscribe so the price poll / update fetch sees natives // and default tracked assets that were never returned by balance APIs. @@ -1604,6 +1623,34 @@ export class AssetsController extends BaseController< // PUBLIC API: QUERY METHODS // ============================================================================ + /** + * Fetch assets for the given accounts, publishing a transient per-account + * loading state while user-visible fetches are in flight. + * + * When `options.trigger` is set, each requested account is marked in + * `state.assetsLoadingStatus` with the trigger for the duration of the + * fetch (set before the fetch starts, removed when it settles — success or + * failure). This exists so the UI can show a loading indicator during + * user-visible moments (account switch, unlock) without inferring it from + * balance state. Pass a trigger **only** for those moments; background + * fetches (polling, price refreshes, post-transaction refreshes) must omit + * it so the indicator does not flap. + * + * @param accounts - Accounts to fetch assets for. + * @param options - Fetch options. + * @param options.chainIds - Chains to fetch for; defaults to enabled chains. + * @param options.assetTypes - Asset types to fetch (fungible, native). + * @param options.forceUpdate - Skip cache and fetch fresh data. + * @param options.bypassServerCache - Also bypass server-side HTTP caches. + * @param options.dataTypes - Data types to fetch (balances, info, prices). + * @param options.assetsForPriceUpdate - Asset IDs to fetch prices for. + * @param options.updateMode - `'merge'` to combine with existing state + * instead of replacing it. + * @param options.trigger - User-visible moment this fetch belongs to; when + * set, per-account loading status is published while the fetch is in + * flight. Omit for background fetches. + * @returns The combined assets per account, read from state after the fetch. + */ async getAssets( accounts: InternalAccount[], options?: { @@ -1621,6 +1668,58 @@ export class AssetsController extends BaseController< assetsForPriceUpdate?: Caip19AssetId[]; /** When set to `'merge'`, fetch result is merged with existing state instead of replacing. Use for partial fetches (e.g. newly added chains). */ updateMode?: AssetsUpdateMode; + /** + * User-visible moment this fetch belongs to. When set, per-account + * loading status is published in `state.assetsLoadingStatus` while the + * fetch is in flight. Omit for background fetches. + */ + trigger?: AssetsLoadingTrigger; + }, + ): Promise>> { + const { trigger } = options ?? {}; + + if (!trigger || accounts.length === 0) { + return this.#getAssetsInternal(accounts, options); + } + + this.#setAssetsLoadingStatus(accounts, trigger); + try { + return await this.#getAssetsInternal(accounts, options); + } finally { + this.#clearAssetsLoadingStatus(accounts, trigger); + } + } + + /** + * Fetch pipeline and state read for {@link AssetsController.getAssets}. + * Kept separate so the public method stays a thin loading-state lifecycle + * wrapper around this fetch logic. + * + * @param accounts - Accounts to fetch assets for. + * @param options - Fetch options (forwarded from `getAssets`). + * @param options.chainIds - Chains to fetch for; defaults to enabled chains. + * @param options.assetTypes - Asset types to fetch (fungible, native). + * @param options.forceUpdate - Skip cache and fetch fresh data. + * @param options.bypassServerCache - Also bypass server-side HTTP caches. + * @param options.dataTypes - Data types to fetch (balances, info, prices). + * @param options.assetsForPriceUpdate - Asset IDs to fetch prices for. + * @param options.updateMode - `'merge'` to combine with existing state + * instead of replacing it. + * @param options.trigger - User-visible moment this fetch belongs to + * (loading status is handled by the `getAssets` wrapper). + * @returns The combined assets per account, read from state after the fetch. + */ + async #getAssetsInternal( + accounts: InternalAccount[], + options?: { + chainIds?: ChainId[]; + assetTypes?: AssetType[]; + forceUpdate?: boolean; + bypassServerCache?: boolean; + dataTypes?: DataType[]; + assetsForPriceUpdate?: Caip19AssetId[]; + updateMode?: AssetsUpdateMode; + trigger?: AssetsLoadingTrigger; }, ): Promise>> { const chainIds = options?.chainIds ?? [...this.#enabledChains]; @@ -1798,6 +1897,46 @@ export class AssetsController extends BaseController< return result; } + /** + * Mark the given accounts as loading for a user-visible fetch trigger. + * + * @param accounts - Accounts whose fetch is starting. + * @param trigger - The user-visible moment the fetch belongs to. + */ + #setAssetsLoadingStatus( + accounts: InternalAccount[], + trigger: AssetsLoadingTrigger, + ): void { + this.update((state) => { + for (const account of accounts) { + state.assetsLoadingStatus[account.id] = trigger; + } + }); + } + + /** + * Clear the loading marker this fetch set for the given accounts. + * + * Only removes entries still pointing at `trigger`, so a newer fetch for + * the same account (a different trigger) is never clobbered by an older + * fetch settling. + * + * @param accounts - Accounts whose fetch settled. + * @param trigger - The trigger the entries were set with. + */ + #clearAssetsLoadingStatus( + accounts: InternalAccount[], + trigger: AssetsLoadingTrigger, + ): void { + this.update((state) => { + for (const account of accounts) { + if (state.assetsLoadingStatus[account.id] === trigger) { + delete state.assetsLoadingStatus[account.id]; + } + } + }); + } + async getAssetsBalance( accounts: InternalAccount[], options?: { @@ -3637,6 +3776,7 @@ export class AssetsController extends BaseController< await this.getAssets(accounts, { chainIds: [...this.#enabledChains], forceUpdate: true, + trigger: 'accountSwitch', }); } diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index fca7ca96476..64e761eb8ef 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -93,6 +93,7 @@ export type { DataRequest, DataResponse, AssetsUpdateMode, + AssetsLoadingTrigger, // Middleware types Context, NextFunction, @@ -199,6 +200,13 @@ export { getInternalAccountsForGroup, } from './selectors/balance.js'; +export { + getAccountLoadingStatus, + getAccountsLoadingStatus, + isAccountLoading, + isAnyAccountLoading, +} from './selectors/loading.js'; + export type { AccountGroupBalance, AccountsById, diff --git a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts index 75c0c6ae868..0af51f28559 100644 --- a/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts +++ b/packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts @@ -99,9 +99,11 @@ export type AssetsInfoHealingPatch = { const log = createModuleLogger(projectLogger, 'tempHealAssetsInfoMetadata'); -export type TempHealAssetsInfoMetadataOptions = { +export type TempHealAssetsInfoMetadataOptions< + State extends AssetsControllerStateInternal = AssetsControllerStateInternal, +> = { /** Current `AssetsController` state the healing patch is computed against. */ - state: AssetsControllerStateInternal; + state: State; /** * Host-provided getter for the untrusted legacy state root (see * `AssetsControllerOptions.tempMigrateAssetsInfoMetadataAssets3346`). @@ -121,11 +123,13 @@ export type TempHealAssetsInfoMetadataOptions = { * @returns Updated controller state with the healing patch applied, or the * original state when there is nothing to heal or healing fails. */ -export function tempHealAssetsInfoMetadata({ +export function tempHealAssetsInfoMetadata< + State extends AssetsControllerStateInternal = AssetsControllerStateInternal, +>({ state, getMigrationState, captureException, -}: TempHealAssetsInfoMetadataOptions): AssetsControllerStateInternal { +}: TempHealAssetsInfoMetadataOptions): State { const reportError = (error: unknown): void => { log('Failed to heal assetsInfo metadata', error); captureException?.( diff --git a/packages/assets-controller/src/selectors/loading.test.ts b/packages/assets-controller/src/selectors/loading.test.ts new file mode 100644 index 00000000000..55861b2ec60 --- /dev/null +++ b/packages/assets-controller/src/selectors/loading.test.ts @@ -0,0 +1,125 @@ +import type { AssetsControllerState } from '../AssetsController.js'; +import type { AccountId } from '../types.js'; +import { + getAccountLoadingStatus, + getAccountsLoadingStatus, + isAccountLoading, + isAnyAccountLoading, +} from './loading.js'; + +const ACCOUNT_ID_A: AccountId = 'mock-account-id-1'; +const ACCOUNT_ID_B: AccountId = 'mock-account-id-2'; +const ACCOUNT_ID_C: AccountId = 'mock-account-id-3'; + +const createState = ( + assetsLoadingStatus: AssetsControllerState['assetsLoadingStatus'], +): Pick => ({ + assetsLoadingStatus, +}); + +describe('loading selectors', () => { + describe('getAccountLoadingStatus', () => { + it('returns the trigger for an account with an in-flight fetch', () => { + const state = createState({ + [ACCOUNT_ID_A]: 'accountSwitch', + [ACCOUNT_ID_B]: 'unlock', + }); + + expect(getAccountLoadingStatus(state, ACCOUNT_ID_A)).toBe( + 'accountSwitch', + ); + expect(getAccountLoadingStatus(state, ACCOUNT_ID_B)).toBe('unlock'); + }); + + it('returns undefined when the account is not loading', () => { + const state = createState({ [ACCOUNT_ID_A]: 'accountSwitch' }); + + expect(getAccountLoadingStatus(state, ACCOUNT_ID_B)).toBeUndefined(); + }); + + it('returns undefined when no account is loading', () => { + const state = createState({}); + + expect(getAccountLoadingStatus(state, ACCOUNT_ID_A)).toBeUndefined(); + }); + }); + + describe('isAccountLoading', () => { + it('returns true while the account has an in-flight fetch', () => { + const state = createState({ + [ACCOUNT_ID_A]: 'accountSwitch', + [ACCOUNT_ID_B]: 'unlock', + }); + + expect(isAccountLoading(state, ACCOUNT_ID_A)).toBe(true); + expect(isAccountLoading(state, ACCOUNT_ID_B)).toBe(true); + }); + + it('returns false when the account is not loading', () => { + const state = createState({ [ACCOUNT_ID_A]: 'accountSwitch' }); + + expect(isAccountLoading(state, ACCOUNT_ID_B)).toBe(false); + }); + + it('returns false when no account is loading', () => { + const state = createState({}); + + expect(isAccountLoading(state, ACCOUNT_ID_A)).toBe(false); + }); + }); + + describe('getAccountsLoadingStatus', () => { + it('returns loading entries only for the requested account ids', () => { + const state = createState({ + [ACCOUNT_ID_A]: 'accountSwitch', + [ACCOUNT_ID_B]: 'unlock', + }); + + expect( + getAccountsLoadingStatus(state, [ACCOUNT_ID_A, ACCOUNT_ID_C]), + ).toStrictEqual({ [ACCOUNT_ID_A]: 'accountSwitch' }); + }); + + it('returns an empty record when none of the requested accounts are loading', () => { + const state = createState({ [ACCOUNT_ID_B]: 'unlock' }); + + expect( + getAccountsLoadingStatus(state, [ACCOUNT_ID_A, ACCOUNT_ID_C]), + ).toStrictEqual({}); + }); + + it('returns an empty record for an empty account list', () => { + const state = createState({ [ACCOUNT_ID_A]: 'accountSwitch' }); + + expect(getAccountsLoadingStatus(state, [])).toStrictEqual({}); + }); + }); + + describe('isAnyAccountLoading', () => { + it('returns true when any requested account is loading', () => { + const state = createState({ + [ACCOUNT_ID_A]: 'accountSwitch', + [ACCOUNT_ID_B]: 'unlock', + }); + + expect(isAnyAccountLoading(state, [ACCOUNT_ID_B, ACCOUNT_ID_C])).toBe( + true, + ); + }); + + it('returns false when none of the requested accounts are loading', () => { + const state = createState({ [ACCOUNT_ID_A]: 'accountSwitch' }); + + expect(isAnyAccountLoading(state, [ACCOUNT_ID_B, ACCOUNT_ID_C])).toBe( + false, + ); + }); + + it('defaults to all accounts when no account ids are given', () => { + expect(isAnyAccountLoading(createState({}))).toBe(false); + expect( + isAnyAccountLoading(createState({ [ACCOUNT_ID_C]: 'accountSwitch' })), + ).toBe(true); + }); + }); +}); diff --git a/packages/assets-controller/src/selectors/loading.ts b/packages/assets-controller/src/selectors/loading.ts new file mode 100644 index 00000000000..a51bdee9734 --- /dev/null +++ b/packages/assets-controller/src/selectors/loading.ts @@ -0,0 +1,99 @@ +import type { AssetsControllerState } from '../AssetsController.js'; +import type { AccountId, AssetsLoadingTrigger } from '../types.js'; + +/** + * Loading-state selectors over + * {@link AssetsControllerState.assetsLoadingStatus}. + * + * `assetsLoadingStatus` is transient (never persisted): an entry is present + * while a user-visible asset fetch (account switch, unlock) is in flight for + * that account, and its value is the trigger. Absence means "not loading". + * + * All selectors are synchronous and read from the controller state slice, + * so they can be used directly in `useSelector`-style subscriptions to + * `AssetsController:stateChanged`. + */ + +/** + * Get the loading trigger for a single account, if a user-visible fetch is + * currently in flight for it. + * + * @param state - AssetsController state slice. + * @param accountId - The account id (`InternalAccount.id`). + * @returns The trigger for the in-flight fetch, or `undefined` when the + * account is not loading. + */ +export function getAccountLoadingStatus( + state: Pick, + accountId: AccountId, +): AssetsLoadingTrigger | undefined { + return state.assetsLoadingStatus?.[accountId]; +} + +/** + * Check whether a user-visible asset fetch is in flight for an account. + * + * @param state - AssetsController state slice. + * @param accountId - The account id (`InternalAccount.id`). + * @returns True while the account's assets are loading. + */ +export function isAccountLoading( + state: Pick, + accountId: AccountId, +): boolean { + return getAccountLoadingStatus(state, accountId) !== undefined; +} + +/** + * Get the in-flight loading triggers for a set of accounts (e.g. every + * account in the selected account group). + * + * @param state - AssetsController state slice. + * @param accountIds - The account ids to report on. + * @returns A record containing an entry for each requested account that is + * currently loading, keyed by account id. + */ +export function getAccountsLoadingStatus( + state: Pick, + accountIds: AccountId[], +): Record { + const result: Record = {}; + const loadingStatus = state.assetsLoadingStatus ?? {}; + for (const accountId of accountIds) { + const trigger = loadingStatus[accountId]; + if (trigger !== undefined) { + result[accountId] = trigger; + } + } + return result; +} + +/** + * Check whether any of the given accounts has a user-visible fetch in + * flight. When `accountIds` is omitted, checks every account with a loading + * entry in state. + * + * @param state - AssetsController state slice. + * @param accountIds - Optional account ids to restrict the check to. + * @returns True if at least one of the accounts is loading. + */ +export function isAnyAccountLoading( + state: Pick, + accountIds?: AccountId[], +): boolean { + const loadingStatus = state.assetsLoadingStatus ?? {}; + if (accountIds) { + for (const accountId of accountIds) { + if (loadingStatus[accountId] !== undefined) { + return true; + } + } + return false; + } + for (const accountId in loadingStatus) { + if (loadingStatus[accountId] !== undefined) { + return true; + } + } + return false; +} diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 501220b6e5f..a99c5035774 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -408,6 +408,18 @@ export type DataResponse = { */ export type AssetsUpdateMode = 'full' | 'merge' | 'update'; +/** + * User-visible moments for which an assets fetch is surfaced as a loading + * state in {@link AssetsControllerState.assetsLoadingStatus}. + * + * - **accountSwitch**: The selected account group changed (e.g. the user + * switched accounts), so balances for the newly selected accounts are + * being fetched. + * - **unlock**: The wallet was unlocked (or tracking started after unlock), + * so balances are being fetched before anything is renderable. + */ +export type AssetsLoadingTrigger = 'accountSwitch' | 'unlock'; + // ============================================================================ // DATA SOURCE <-> CONTROLLER (DIRECT CALLS, NO MESSENGER PER SOURCE) // ============================================================================ From 47b7803dfde74b597b06ea47b07374650ecee9d4 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 14 Sep 2026 17:15:03 +0100 Subject: [PATCH 2/2] docs(assets-controller): link changelog entries to PR --- packages/assets-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 1a45aa1b744..742aae65282 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add a transient, non-persisted per-account assets loading state. `AssetsControllerState` now includes `assetsLoadingStatus: Record` (`'accountSwitch' | 'unlock'`), populated while `getAssets` runs for user-visible moments and cleared when the fetch settles; the field is never persisted. `getAssets` accepts a new optional `trigger` option that opts a fetch into this behavior — only account switches and unlock-time startup refreshes pass it ([#PR](https://github.com/MetaMask/core/pull/PR)) -- Add loading-state selectors `getAccountLoadingStatus`, `isAccountLoading`, `getAccountsLoadingStatus`, and `isAnyAccountLoading` for the UX layer to read `assetsLoadingStatus` ([#PR](https://github.com/MetaMask/core/pull/PR)) +- Add a transient, non-persisted per-account assets loading state. `AssetsControllerState` now includes `assetsLoadingStatus: Record` (`'accountSwitch' | 'unlock'`), populated while `getAssets` runs for user-visible moments and cleared when the fetch settles; the field is never persisted. `getAssets` accepts a new optional `trigger` option that opts a fetch into this behavior — only account switches and unlock-time startup refreshes pass it ([#10229](https://github.com/MetaMask/core/pull/10229)) +- Add loading-state selectors `getAccountLoadingStatus`, `isAccountLoading`, `getAccountsLoadingStatus`, and `isAnyAccountLoading` for the UX layer to read `assetsLoadingStatus` ([#10229](https://github.com/MetaMask/core/pull/10229)) ### Changed