MetaMask / MetaMask/metamask-mobile

Fix single-entry cache thrashing in `selectSingleTokenByAddressAndChainId` (O(n) scan per call)

Open
#31,490 1 comment 0 reactions 0 assignees View on GitHub
area-performance Sev2 Sev2-normal size-S ta-triaged team-assets
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.