Skip to content

feat(assets-controller): add transient per-account loading state for user-visible fetches - #10230

Open
Prithpal-Sooriya wants to merge 8 commits into
mainfrom
feat/assets-transient-loading-state
Open

Prithpal-Sooriya wants to merge 8 commits into
mainfrom
feat/assets-transient-loading-state

Conversation

@Prithpal-Sooriya

@Prithpal-Sooriya Prithpal-Sooriya commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a transient, non-persisted per-account loading state to AssetsController, published only while assets are being fetched for specific user-visible UX moments (account switch, unlock), plus selectors for the UX layer to read it.

Problem

The UI has no signal for "an assets fetch is in flight because the user just did something" (switched accounts / unlocked the wallet). Balance state can't be used to infer this (stale balances are indistinguishable from fresh ones), and getAssets is called constantly in the background (polling, price/currency/tx refreshes) — so "any fetch in flight" is not the same as "the user is waiting".

Approach

  • New state field: assetsLoadingStatus: Record<AccountId, AssetsLoadingTrigger> where AssetsLoadingTrigger = 'accountSwitch' | 'unlock'. A string-literal union was chosen over Record<_, boolean>: the key's presence already encodes "loading", the value tells the UI which moment is loading (so it can render the right skeleton/label), and the union is extensible for future triggers without a second field or a breaking change.
  • Transient by construction: the field's state metadata is persist: false (with includeInStateLogs, includeInDebugSnapshot, usedInUi set). A restarted client always begins with no fetch in flight, so nothing is persisted and no migration is needed.
  • Scoped opt-in: getAssets accepts a new optional trigger option. Only the two user-action call sites pass it — #handleAccountGroupChanged ('accountSwitch') and #runStartupRefresh ('unlock'). Every other getAssets caller (polling, price updates, tx confirmations, currency changes, network events) is unchanged and never publishes loading state, so the indicator cannot flap due to background work.
  • Lifecycle in getAssets: the public method is now a thin wrapper — set the entries, try { await } finally { clear } — around the extracted, unchanged fetch pipeline (#getAssetsInternal). Entries are cleared conditionally (only if they still hold this fetch's trigger), so an older fetch settling can never clobber a newer fetch's marker for the same account.
  • Selectors: getAccountLoadingStatus(state, accountId), isAccountLoading(state, accountId), getAccountsLoadingStatus(state), isAnyAccountLoading(state) are exported from the package index for the UX team, mirroring the existing selectors/balance.ts pattern.

getAssets complexity & decorator evaluation (design note)

The requested analysis of getAssets complexity and whether a TypeScript decorator would be cleaner:

  • getAssets was already a long, multi-stage pipeline (force-update fast/slow paths, middleware chain, merge/replace modes). The loading concern is not deeply entangled with that logic — it is a strict request-scoped lifecycle (set → run → clear). Modeling it inside the pipeline body would have raised cyclomatic complexity and added error-handling paths through ~180 lines that today have none.
  • A decorator (@withLoadingStatus) was evaluated and rejected:
    • No precedent: there are zero decorators in the entire monorepo; the codebase's established idiom for wrapping controller methods is functional composition (e.g. withTrace in src/utils/trace.ts). Introducing the first decorator raises toolchain risk (the repo's tsconfig does not enable experimentalDecorators; the TC39 stage-3 decorators proposal has evolving semantics) and review cost.
    • Not a generic concern: the wrapper is parameter-aware (per-account keys from the first argument, trigger from an options bag property) — a decorator would need bespoke argument inspection anyway, buying no reuse for other methods.
    • Discoverability: decorators hide control flow from readers and from the generated action types; an explicit wrapper keeps the lifecycle visible at the method signature, which is what AssetsController-method-action-types.ts (auto-generated from AssetsController['getAssets']) naturally picks up.
  • The chosen standard implementation adds ~10 lines to getAssets and two small private helpers, with zero cyclomatic-complexity growth in the fetch pipeline itself (its body moved verbatim to #getAssetsInternal). This is the smallest diff that satisfies the lifecycle requirement while staying within repo idioms.

Related issues

Checklist

  • I've updated the changelog (Unreleased → Added, packages/assets-controller/CHANGELOG.md).
  • Tests added/updated: 7 new controller tests covering set/clear per trigger, no-trigger fetches, event wiring (accountSwitch, unlock), stateChanged emission, non-persistence via deriveStateFromMetadata, and concurrent-fetch marker safety; 12 selector tests.
  • yarn workspace @metamask/assets-controller run test — 1034/1034 passing.
  • Monorepo yarn build (type check) passing.
  • yarn eslint … --fix --prune-suppressions and yarn lint:misc:check clean on touched files; AssetsController-method-action-types.ts regenerated via yarn workspace @metamask/assets-controller run messenger-action-types:generate.

Test plan

  1. yarn workspace @metamask/assets-controller run test — all suites pass.
  2. Gated-client unit tests observe assetsLoadingStatus mid-flight (Accounts API calls frozen via an armed gate) and after release — entries present exactly while the fetch is in flight and cleared on settle.
  3. deriveStateFromMetadata test asserts the field is excluded from persisted state.

Note

Medium Risk
Changes the public getAssets lifecycle and adds UI-facing state with concurrency rules; mistakes could show wrong loading indicators or affect overlapping refresh behavior, though scope is limited to forced fetches and nothing is persisted.

Overview
Adds transient, non-persisted controller state so the UI can tell when specific accounts are waiting on a forced assets fetch: assetsLoadingStatus ('loading' | 'loaded') plus internal assetsLoadingTokens to handle overlapping getAssets calls safely.

A @trackAssetsLoading decorator wraps getAssets and only updates this state when forceUpdate: true (cache reads and background-style calls are ignored). Accounts are marked loading at call start and settled to loaded in a finally block (including on failure); older in-flight fetches cannot flip an account to loaded while a newer fetch still owns the token.

New exports: AssetsLoadingStatus and loading selectors (getAccountLoadingStatus, isAccountLoading, group/selected-group helpers). State metadata keeps loading fields off persisted snapshots (persist: false for both).

Also fixes the temporary tempHealAssetsInfoMetadata constructor path to merge healed fields via a full state spread instead of passing a partial object into update.

Reviewed by Cursor Bugbot for commit d02e8ba. Bugbot is set up for automated code reviews on this repo. Configure here.

…user-visible fetches

Add a non-persisted 'assetsLoadingStatus' state map
(Record<AccountId, 'accountSwitch' | 'unlock'>) 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/assets-controller/src/AssetsController.ts Outdated
Comment thread packages/assets-controller/src/AssetsController-method-action-types.ts Outdated
Comment thread packages/assets-controller/src/AssetsController.ts Outdated
Comment thread packages/assets-controller/src/types.ts Outdated
Comment thread packages/assets-controller/src/types.ts Outdated
Comment thread packages/assets-controller/CHANGELOG.md Outdated

@Prithpal-Sooriya Prithpal-Sooriya left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look through self-review comments

…, fix queued-switch gap

- Replace the trigger union with a simple 'loading' | 'loaded' status,
  set before acquiring the refresh mutex so queued switches are marked
  immediately, and settled to 'loaded' when the fetch finishes
- Move the lifecycle into the account-switch and unlock handlers; revert
  getAssets to its original form (no trigger option), keeping the
  generated action-types file unchanged
- Trim comments and shorten the changelog entry

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/assets-controller/src/AssetsController.ts Outdated
Comment thread packages/assets-controller/src/migrations/healAssetsInfoMetadata.ts Outdated
Comment thread packages/assets-controller/src/selectors/loading.ts
Comment thread packages/assets-controller/src/selectors/loading.ts
Comment thread packages/assets-controller/src/AssetsController.test.ts Outdated
…sh settle

Track a per-account token when the loading marker is set; only the
invocation that owns the token settles it to 'loaded'. An older refresh
finishing while a newer queued refresh owns the marker now leaves it
'loading' instead of clobbering it to 'loaded'.
Comment thread packages/assets-controller/src/AssetsController.ts Outdated
Comment thread packages/assets-controller/src/AssetsController.ts Outdated
Per review feedback, mark the assets loading status inside getAssets
itself using a trackAssetsLoading method decorator instead of in the
startup-refresh and account-group-change handlers, keeping the token
ownership logic so an older overlapping fetch cannot clobber a newer
fetch's loading marker.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5355bd1. Configure here.

Comment thread packages/assets-controller/src/trackAssetsLoading.ts
Comment thread packages/assets-controller/src/trackAssetsLoading.ts Outdated
Per review feedback: base the decorator's controller type on the real
AssetsController (picking its state), store the loading ownership tokens
in the controller's non-persisted state instead of a module WeakMap, and
add isolated tests for the decorator. Calls that do not force an update
are cache reads and no longer mark the loading status.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant