MetaMask / MetaMask/metamask-mobile
Fix single-entry cache thrashing in `selectSingleTokenByAddressAndChainId` (O(n) scan per call)
- Dominant language
- TypeScript
- Stars
- 3k
- Forks
- 1.7k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 669
Description
> **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`
```ts
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
- File: `app/selectors/tokensController.ts:174`
- Source: `mms-performance` sweep recipes added in MetaMask/skills#49 (`mm-state-normalization` — parameterized-selector cache thrashing); pattern catalogued in the extension audit as MetaMask-planning#6484
- Status: **UNVALIDATED** (static evidence; needs profiler confirmation)
Contributor guide
Research direction
Start in app/selectors/tokensController.ts at selectSingleTokenByAddressAndChainId, then inspect its call sites and selector tests. Validate the current recomputation behavior and compare it with the proposed lookup-map or selector-factory approaches; done means preserved lookup behavior, stable references across unchanged data, and recomputation near once per data change.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- performance
- Issue type
- Refactor
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100