Performance audit finding · Severity: High · Effort: Medium · Fix risk: Simple · Test safety net: Partial
Owner: @MetaMask/metamask-assets (suggested)
File: app/selectors/tokensController.ts:174
What is this about?
selectSingleTokenByAddressAndChainId is a parameterized createSelector with reselect's default single-entry cache. Every call with a different (tokenAddress, chainId) pair busts the cache of the previous call, and the result function runs an O(n) scan (Object.values(allTokens[chainId] ?? {}).flat() then .find()) on every miss. In a list rendering N rows that each resolve a token, the "memoized" selector recomputes N times per render cycle, forever.
Why it matters
Cost scales with token count × visible rows. For a power-user data profile (~90+ assets) this is a full token-map flatten + linear scan per row per render, on hot asset paths.
Scenario
N/A — see Technical Details.
Design
N/A — internal performance change; no UI/design impact.
Technical Details
Evidence
app/selectors/tokensController.ts:174
export const selectSingleTokenByAddressAndChainId = createSelector(
getTokensControllerAllTokens,
(_state: RootState, tokenAddress: Hex) => tokenAddress,
(_state: RootState, _tokenAddress: Hex, chainId: Hex) => chainId,
(allTokens, tokenAddress, chainId) => {
const chainTokens = Object.values(allTokens[chainId] ?? {}).flat();
return chainTokens.find(
(token) => token.address.toLowerCase() === tokenAddress.toLowerCase(),
);
},
);
Fix
Preferred: a lookup-map selector — memoize one chainId → addressLowercase → Token index that recomputes only when allTokens changes, and have consumers key into it (O(1) per lookup, no per-arg cache to bust). Alternative: a makeSelectSingleTokenByAddressAndChainId() factory instantiated per call site with useMemo, so each call site owns its own cache slot.
Threat Modeling Framework
N/A — performance-only change; behavior is preserved, no new data flow / trust boundary / attack surface.
Acceptance Criteria
- Index selector returns the same reference across two dispatches with unchanged token data (assert with
toBe).
selector.recomputations() (or console.count in the result fn) drops from N-per-render to ~1-per-data-change while scrolling a token list.
References
What is this about?
selectSingleTokenByAddressAndChainIdis a parameterizedcreateSelectorwith reselect's default single-entry cache. Every call with a different(tokenAddress, chainId)pair busts the cache of the previous call, and the result function runs an O(n) scan (Object.values(allTokens[chainId] ?? {}).flat()then.find()) on every miss. In a list rendering N rows that each resolve a token, the "memoized" selector recomputes N times per render cycle, forever.Why it matters
Cost scales with token count × visible rows. For a power-user data profile (~90+ assets) this is a full token-map flatten + linear scan per row per render, on hot asset paths.
Scenario
N/A — see Technical Details.
Design
N/A — internal performance change; no UI/design impact.
Technical Details
Evidence
app/selectors/tokensController.ts:174Fix
Preferred: a lookup-map selector — memoize one
chainId → addressLowercase → Tokenindex that recomputes only whenallTokenschanges, and have consumers key into it (O(1) per lookup, no per-arg cache to bust). Alternative: amakeSelectSingleTokenByAddressAndChainId()factory instantiated per call site withuseMemo, so each call site owns its own cache slot.Threat Modeling Framework
N/A — performance-only change; behavior is preserved, no new data flow / trust boundary / attack surface.
Acceptance Criteria
toBe).selector.recomputations()(orconsole.countin the result fn) drops from N-per-render to ~1-per-data-change while scrolling a token list.References
app/selectors/tokensController.ts:174mms-performancesweep recipes added in perf: Augmentmms-performancewith frontend learnings from the Extension performance audit skills#49 (mm-state-normalization— parameterized-selector cache thrashing); pattern catalogued in the extension audit as MetaMask-planning#6484