You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Performance audit finding · Severity: Sev3 (corrected from High — see below) · Effort: Low–Medium · Fix risk: Low (behavior-preserving input narrowing) · Test safety net: Partial
Owner: @MetaMask/metamask-assets (suggested)
File: app/selectors/assets/assets-list.ts:107,157
What is this about?
Correction to the original framing. This ticket previously claimed the cost was a per-check deep compare of the entire asset state that "scales with power-user data." That mechanism does not hold up under inspection:
createDeepEqualSelector uses fast-equalsdeepEqual (app/selectors/util.ts:30), not lodash. deepEqualshort-circuits on reference-equal sub-values (an a[key] === b[key] fast path per key) and bails on the first difference. A fresh wrapper object does not force a full traversal.
The bulk slices in the composite — allTokens, tokenBalances, marketData, balances, conversionRates, assetsMetadata, accountsAssets, … — are each produced by a createDeepEqualSelector in assets-migration.ts, so they are reference-stable when unchanged. deepEqual hits === on those keys and never descends into the thousands-of-entries token/balance subtrees.
So the per-check compare is ~O(number of top-level composite keys) pointer comparisons, not O(asset count). The deep-compare cost is not the problem.
The real issue is input over-subscription → unnecessary result recomputation.getStateForAssetSelector (:107) spreads three whole controllers into the composite:
But the upstream consumer _selectAssetsBySelectedAccountGroup reads a narrow AssetListState (@metamask/assets-controllers, selectors/token-selectors.ts:94) — from those three controllers it uses onlyaccountTree, internalAccounts, and networkConfigurationsByChainId. The spreads drag every other field into the deep-equal'd composite, where any change to an extraneous field flips deepEqual to false and re-runs the full asset derivation — producing a new output reference that cascades to the 15+ dependent selectors and the per-row selectAsset (#31492).
The extraneous fields are exactly the ones that churn on background timers, unrelated to any balance/price change:
Each of these, while an asset surface is mounted, triggers a full asset re-derivation + a per-row selectAsset cascade across every visible token-list row — even though no token, balance, or price changed.
Component footprint (the cascade blast radius — GitHub code search, two hops from the root):
Direct consumers: useTokensWithBalance (Bridge), useTokenAtomicActions (TokenDetails), and the send flow's useAccountTokens/useRouteParams.
Via the useTokensWithBalance hook alone: 29 files — Bridge token selection and batch-sell, Card (asset balances, swaps, spending limit), Perps payment tokens, and the leaderboard QuickBuy flow.
Net: roughly 50 unique non-test files across 8 product surfaces (wallet home, homepage, token details, send, bridge, card, perps, quick-buy). On each spurious recompute the token list multiplies the per-row selectAsset re-derivation by every visible row.
Scenario
N/A — see Technical Details.
Design
N/A — internal performance change; no UI/design impact.
Technical Details
Fix direction — narrow the three spreads to the exact consumed fields; leave the already-stabilized get* slices untouched:
After this, the composite's only recompute triggers are genuine asset-relevant changes; networksMetadata / selectedNetworkClientId / account-tree sync-flag churn no longer invalidates it.
Keep createDeepEqualSelector — do not switch to a plain createSelector. The three narrowed fields (accountTree, internalAccounts, networkConfigurationsByChainId) are raw state reads, not deep-equal-stabilized, so a reducer that replaces one with a new-but-deeply-equal reference would force a recompute under a plain selector; deepEqual absorbs that. (This is the opposite of the original ticket's suggestion to drop the deep-equal wrapper, which would regress recompute frequency on these surfaces.)
Verify before narrowing that _selectAllAssets (via selectAllAssetsGrouped) and the Tron path (selectTronSpecialAssetsBySelectedAccountGroup) consume the same AssetListState and read no field beyond it.
Threat Modeling Framework
N/A — performance-only change; behavior is preserved, no new data flow / trust boundary / attack surface.
Acceptance Criteria
Dispatching a NetworkController.networksMetadata update (or toggling AccountTreeController.isAccountTreeSyncingInProgress) with token/balance/price data unchanged does not increment selectAssetsBySelectedAccountGroup.recomputations().
Recompute count on selectAssetsBySelectedAccountGroup increments ~once per actual asset-state change (token add/remove, balance, price, account-tree structure), not per background poll/sync.
Status: UNVALIDATED — mechanism corrected and verified against source; magnitude (background-trigger frequency × re-derivation cost while asset surfaces are mounted) still needs profiler confirmation.
What is this about?
Correction to the original framing. This ticket previously claimed the cost was a per-check deep compare of the entire asset state that "scales with power-user data." That mechanism does not hold up under inspection:
createDeepEqualSelectorusesfast-equalsdeepEqual(app/selectors/util.ts:30), not lodash.deepEqualshort-circuits on reference-equal sub-values (ana[key] === b[key]fast path per key) and bails on the first difference. A fresh wrapper object does not force a full traversal.allTokens,tokenBalances,marketData,balances,conversionRates,assetsMetadata,accountsAssets, … — are each produced by acreateDeepEqualSelectorinassets-migration.ts, so they are reference-stable when unchanged.deepEqualhits===on those keys and never descends into the thousands-of-entries token/balance subtrees.So the per-check compare is ~O(number of top-level composite keys) pointer comparisons, not O(asset count). The deep-compare cost is not the problem.
The real issue is input over-subscription → unnecessary result recomputation.
getStateForAssetSelector(:107) spreads three whole controllers into the composite:But the upstream consumer
_selectAssetsBySelectedAccountGroupreads a narrowAssetListState(@metamask/assets-controllers,selectors/token-selectors.ts:94) — from those three controllers it uses onlyaccountTree,internalAccounts, andnetworkConfigurationsByChainId. The spreads drag every other field into the deep-equal'd composite, where any change to an extraneous field flipsdeepEqualtofalseand re-runs the full asset derivation — producing a new output reference that cascades to the 15+ dependent selectors and the per-rowselectAsset(#31492).The extraneous fields are exactly the ones that churn on background timers, unrelated to any balance/price change:
NetworkController.networksMetadata— network availability / EIP-1559 status polling (per enabled network).NetworkController.selectedNetworkClientId— changes on every network switch.AccountTreeController.isAccountTreeSyncingInProgress— a boolean that toggles on every account-tree sync cycle (≥2deepEqual=falseflips per sync).AccountTreeController.hasAccountTreeSyncingSyncedAtLeastOnce,accountGroupsMetadata,accountWalletsMetadata— sync / metadata churn.Each of these, while an asset surface is mounted, triggers a full asset re-derivation + a per-row
selectAssetcascade across every visible token-list row — even though no token, balance, or price changed.Component footprint (the cascade blast radius — GitHub code search, two hops from the root):
useTokensWithBalance(Bridge),useTokenAtomicActions(TokenDetails), and the send flow'suseAccountTokens/useRouteParams.selectSortedAssetsBySelectedAccountGroup: the wallet token list itself (app/components/UI/Tokens/index.tsx— which rendersTokenListItemper row, whereselectAssetadds the per-row multiplier of Fix 3 per-row parameterized-selector cache misses inTokenListItem(fresh object-literal args) #31492; 26 component-sideselectAssetreferences) and the Homepage tokens section.useTokensWithBalancehook alone: 29 files — Bridge token selection and batch-sell, Card (asset balances, swaps, spending limit), Perps payment tokens, and the leaderboard QuickBuy flow.Net: roughly 50 unique non-test files across 8 product surfaces (wallet home, homepage, token details, send, bridge, card, perps, quick-buy). On each spurious recompute the token list multiplies the per-row
selectAssetre-derivation by every visible row.Scenario
N/A — see Technical Details.
Design
N/A — internal performance change; no UI/design impact.
Technical Details
Fix direction — narrow the three spreads to the exact consumed fields; leave the already-stabilized
get*slices untouched:After this, the composite's only recompute triggers are genuine asset-relevant changes;
networksMetadata/selectedNetworkClientId/ account-tree sync-flag churn no longer invalidates it.Keep
createDeepEqualSelector— do not switch to a plaincreateSelector. The three narrowed fields (accountTree,internalAccounts,networkConfigurationsByChainId) are raw state reads, not deep-equal-stabilized, so a reducer that replaces one with a new-but-deeply-equal reference would force a recompute under a plain selector;deepEqualabsorbs that. (This is the opposite of the original ticket's suggestion to drop the deep-equal wrapper, which would regress recompute frequency on these surfaces.)Verify before narrowing that
_selectAllAssets(viaselectAllAssetsGrouped) and the Tron path (selectTronSpecialAssetsBySelectedAccountGroup) consume the sameAssetListStateand read no field beyond it.Threat Modeling Framework
N/A — performance-only change; behavior is preserved, no new data flow / trust boundary / attack surface.
Acceptance Criteria
NetworkController.networksMetadataupdate (or togglingAccountTreeController.isAccountTreeSyncingInProgress) with token/balance/price data unchanged does not incrementselectAssetsBySelectedAccountGroup.recomputations().selectAssetsBySelectedAccountGroupincrements ~once per actual asset-state change (token add/remove, balance, price, account-tree structure), not per background poll/sync.TokenListItem(fresh object-literal args) #31492 after this lands.References
app/selectors/assets/assets-list.ts:107(input),:157(wrapper);app/selectors/util.ts:30(createDeepEqualSelector=fast-equalsdeepEqual);@metamask/assets-controllersselectors/token-selectors.ts:94(AssetListState— the narrow consumed-field set)TokenListItem(fresh object-literal args) #31492 (per-rowselectAsset— the recompute-cascade target, not a deep-compare cost)selectLocalTransactions#31499.mms-performancedependency-tree mapping (perf: Augmentmms-performancewith frontend learnings from the Extension performance audit skills#49,mm-selector-cascade)