Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ For reviewing a PR/diff or auditing a file, component, or feature. Output: findi

- **Targeted** (single file / component / small diff): read the files and report concrete findings with `file:line`.
- **Broad** (whole feature / repo): run the grep sweeps below and triage hits; don't read everything.
- **Audit wave / program** (scheduled audit of a surface or division): per-surface audits miss mechanism-level patterns that live in *shared* infrastructure (`app/selectors`, shared hooks, the store) — run the cross-cutting sweeps below over the shared dirs **once per wave**, not once per team, and route findings to surface owners. Attach quantified acceptance criteria up front (template in [mm-planning.md](mm-planning.md)). If the surface ships on both platforms, cross-check the sibling platform's audit findings for the same surface before fresh discovery — the React/Redux mechanism patterns recur across extension and mobile.

Always: **measure before asserting impact** where feasible, and respect the guardrails at the bottom (don't over-flag).

Expand Down Expand Up @@ -42,8 +43,10 @@ Read the call sites: is a data hook running for tabs/pages/items that aren't vis
grep -rn "createSelector(" app/selectors --include="*.ts" | grep -v createDeepEqualSelector
grep -rn "=> .*\.\(map\|filter\|sort\|reverse\)\|new Set\|new Map\|Object\.\(values\|keys\|entries\)\|?? {}\|?? \[\]" app/selectors --include="*.ts"
grep -rn "\.sort(\|\.reverse(\|\.push(\|\.splice(" app/selectors --include="*.ts" # mutation
grep -rn "(_state\|(_," app/selectors --include="*.ts" # parameterized selectors — single-entry cache → mm-state-normalization.md
grep -rnE "export (function|const) (get|select)[A-Z][A-Za-z]* = \(state|export function (get|select)" app/selectors --include="*.ts" # plain unmemoized function selectors (no createSelector at all)
```
Check each result function for: identity/passthrough, new collection without deep-equal, mutation, `state=>state` input.
Check each result function for: identity/passthrough, new collection without deep-equal, mutation, `state=>state` input. If one broken selector has **many consumers**, switch to the cascade playbook — map the dependency tree to closure and plan the fix order *before* fixing anything: [mm-selector-cascade.md](mm-selector-cascade.md).

### Redux / useSelector → [mm-redux-antipatterns.md](mm-redux-antipatterns.md)
```bash
Expand All @@ -57,11 +60,18 @@ grep -rn "dispatch(" app --include="*.ts" --include="*.tsx" | grep -v ".test." |
grep -rn "Provider value={{" app --include="*.tsx" | grep -v ".test."
```

### Hooks → [mm-hook-dependency-arrays.md](mm-hook-dependency-arrays.md)
### Hooks → [mm-hook-dependency-arrays.md](mm-hook-dependency-arrays.md) / [mm-useeffect-antipatterns.md](mm-useeffect-antipatterns.md)
```bash
grep -rn "\[JSON.stringify\|, JSON.stringify" app --include="*.ts" --include="*.tsx" | grep -v ".test."
grep -rn -A6 "useEffect(" app --include="*.ts" --include="*.tsx" | grep -E "fetch\(|\.then\(" | grep -v "signal\|cancelled\|abort" | grep -v ".test." # async effects without cancellation
```
(`exhaustive-deps` is NOT linted in this repo — check effect deps by hand.)
(`exhaustive-deps` is NOT linted in this repo — check effect deps by hand.) For effect-body problems — derived state via useEffect+setState, effect chains, post-unmount setState — use the read pass in [mm-useeffect-antipatterns.md](mm-useeffect-antipatterns.md).

### React Compiler coverage → [mm-react-compiler-error-triage.md](mm-react-compiler-error-triage.md)
```bash
grep -rn "use no memo" app --include="*.ts" --include="*.tsx" # opt-outs: each needs a reason + TODO
```
For a re-render-heavy screen, confirm the components are actually **compiled** (`Memo ✨` in DevTools) before suggesting manual memoization — they may be sitting in the error/unsupported bucket.

### Animations → [mm-layout-animations.md](mm-layout-animations.md)
```bash
Expand Down Expand Up @@ -97,6 +107,7 @@ Grep finds *syntactic* patterns. The highest-impact re-render bugs are *data-flo
- **Render-phase side effects / setState** — any `setState(...)`, `dispatch(...)`, or `trackEvent(...)` in a render body (not inside `useEffect`/`useCallback`)? Triggers extra render passes.
- **O(n²) reduce-with-spread** — `reduce((acc, x) => ({ ...acc, ... }), {})` rebuilt every render.
- **Per-item subscription hooks** — trace each into its manager; shared subscription = fine, per-subscriber whole-dataset snapshot = bug. → [mm-streaming-realtime.md](mm-streaming-realtime.md)
- **Deep-equal selector inputs** — for every `createDeepEqualSelector`, read its *input selectors*: an input function that allocates a fresh composite per call (object spreads of controller state, other selectors' results collected into a new object) forces the deep compare to run over the whole composite on every check — and no result-function grep catches it. `grep -rn -B3 "createDeepEqualSelector(" app/selectors` lists the sites; read each first argument. → [mm-selector-cascade.md](mm-selector-cascade.md) (proactive mode)

Confirm any hit with the Profiler ("why did this render?") before asserting — see [mm-tools.md](mm-tools.md).

Expand All @@ -107,6 +118,8 @@ Confirm any hit with the Profiler ("why did this render?") before asserting —
- [ ] No real-time / high-frequency data dispatched to Redux
- [ ] `Context.Provider value` is memoized (not an inline object)
- [ ] No `JSON.stringify` in a hot dependency array
- [ ] Async effects guard against post-unmount / stale setState (cancelled flag or `AbortController`)
- [ ] No new parameterized selector (single-entry cache) on a list/hot path — use a lookup-map selector instead
- [ ] Layout animations use Reanimated v3, not `Animated` + `useNativeDriver:false`
- [ ] Growable lists use FlashList with stable keys (+ `getItemType` if mixed)
- [ ] New event listeners / timers / subscriptions have cleanup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,6 @@ For each hit, ask: *does this dependency change identity every render?* If yes,

## Related

- [mm-useeffect-antipatterns.md](mm-useeffect-antipatterns.md) — the effect-body side: derived state, effect chains, unmount-safe async, cleanup
- [js-react-compiler.md](js-react-compiler.md) / [mm-react-compiler.md](mm-react-compiler.md) — automatic memoization on opted-in paths
- [js-concurrent-react.md](js-concurrent-react.md) — defer expensive derived work
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ The cheapest performance fix is the one you make before writing code. Catch arch
| Risk | Trigger question | Default mitigation |
|---|---|---|
| Real-time / WebSocket data | Updates faster than once per user action? | Never put it in Redux. Local state / shared value / direct UI update. Manage subscribe/unsubscribe by visibility + app foreground/background; avoid double-subscribe. See [mm-redux-antipatterns.md](mm-redux-antipatterns.md). |
| Unbounded data | Can the list/dataset grow without ceiling? | Paginate + virtualize from day one; plan server-side filtering. |
| Unbounded data | Can the list/dataset grow without ceiling? | Paginate + virtualize from day one; plan server-side filtering. Never persist unbounded data via redux-persist — use a dedicated storage layer. |
| Large lists | >~50 items now, infinite later? | FlashList v2 with stable keys + `getItemType`; no heavy work per item. [js-lists-flatlist-flashlist.md](js-lists-flatlist-flashlist.md) |
| New selector / derived state | Adding `createSelector`? | Decide memoization + equality up front; never identity/mutation. [mm-selector-memoization.md](mm-selector-memoization.md) |
| New selector / derived state | Adding `createSelector`? | Decide memoization + equality up front; never identity/mutation. [mm-selector-memoization.md](mm-selector-memoization.md). Frequent keyed lookups? Decide the lookup shape now (keyed index vs O(n) scan) — [mm-state-normalization.md](mm-state-normalization.md) |
| Heavy computation | Big transforms, sorts, regex on large input? | Server offload, or memoize, or defer with `useDeferredValue`. |
| Crypto | Hashing/signing/derivation in hot path? | `react-native-quick-crypto` (already installed); keep off the JS thread. |
| New npm dependency | Adds to `package.json`? | Check size (Expo Atlas / bundlephobia); avoid main-package/barrel imports; reuse existing libs (we already have dayjs, luxon, lodash). [bundle-library-size.md](bundle-library-size.md) |
Expand All @@ -34,7 +34,7 @@ The cheapest performance fix is the one you make before writing code. Catch arch

## System-design checklist

- **State shape:** new Redux slice for real-time data? → flag. New selector? → memoization + equality decided now.
- **State shape:** new Redux slice for real-time data? → flag. New selector? → memoization + equality decided now. Frequent lookups by key? → plan a `byId`/`byAddress` index ([mm-state-normalization.md](mm-state-normalization.md)).
- **Subscription lifecycle:** diagram subscribe/unsubscribe tied to mount/unmount + foreground/background; no double-subscribe; cleanup guaranteed.
- **List strategy:** ScrollView only for <20 fixed items; FlashList for anything that can grow; no `.map()` in JSX for growable lists.
- **Data flow:** minimize how many components subscribe to a frequently-updating selector.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: React Compiler Error Triage & Coverage Accounting (MetaMask)
impact: HIGH
tags: react-compiler, panicThreshold, error-triage, coverage, babel, build
---

# Skill: React Compiler Error Triage & Coverage Accounting

The React Compiler **fails open**: when it can't compile a component, it silently skips it and ships the unoptimized original. The build stays green, DevTools shows no warning — you just don't get the memoization. Once the compiler is enabled broadly (metamask-mobile#31171 enabled v1.0.0 app-wide), the question stops being "is it on?" and becomes **"what is it actually compiling, and which of its errors are worth fixing?"** This file is the triage playbook. Extension PR metamask-extension#38007 is the reference implementation.

## The `panicThreshold` ladder

`panicThreshold` controls when a compiler diagnostic fails the build instead of silently skipping the file:

| Setting | Build fails on | Use for |
|---|---|---|
| `'none'` (default) | never — every failed file is **silently skipped** | production builds, always |
| `'critical_errors'` | only critical errors (compiler-internal invariant violations) | CI / debug builds, first ratchet target |
| `'all_errors'` | every diagnostic, including unsupported syntax | CI / debug builds, end-state ratchet |

**The ratchet strategy** (extension roadmap, MetaMask-planning#6552 → #6553): keep production at `'none'` permanently; aim for a *non-production* build that passes at `'critical_errors'`, then at `'all_errors'`. Each ratchet step turns a class of silent skips into a visible, fixable error list. Never enable a non-`'none'` threshold in a release build — one un-compilable file would block the release for an optimization that is optional by design.

## Triage: unsupported syntax vs. legitimate errors

Compiler diagnostics are **not one bucket**. The logger event's `category` field separates them, and the distinction decides whether you act:

- **`category === 'Todo'` → "unsupported."** Syntax or a pattern the compiler *itself* has not implemented yet. There is **no actionable fix on our side** — rewriting working code to appease an unimplemented compiler path is wasted effort and churn. Count these separately, leave the code alone, and re-check after compiler upgrades.
- **Any other category (e.g. `InvalidReact`, `InvalidJS`) → legitimate, actionable.** A real Rules-of-React violation in our code (mutation during render, conditional hooks, side effects in render). Fixing it both unlocks compilation *and* removes a latent correctness bug.

A healthcheck that doesn't make this split is noise: the `Todo` count swamps the actionable list and the team learns to ignore the output. The extension's verbose run at enablement (metamask-extension#38007) is the canonical illustration — of 7,308 files processed: 253 compiled, **31 actionable errors**, **7,024 unsupported** (`Todo`). Without the split that reads as ~7,000 hopeless errors; with it, the team's backlog is 31 files and the rest is the compiler's to burn down across upgrades. The extension's webpack wrapper makes the split in ~10 lines:

```ts
// adapted from metamask-extension development/webpack/utils/loaders/reactCompilerLoaderWrapper.ts
// (mobile equivalent: pass a `logger` in babel-plugin-react-compiler options)
logger: {
logEvent(filename, event) {
switch (event.kind) {
case 'CompileSuccess': record(filename, 'compiled'); break;
case 'CompileSkip': record(filename, 'skipped'); break;
case 'CompileError': {
const category = event.detail?.options?.category ?? event.detail?.category;
// 'Todo' = not yet supported by the compiler — no actionable fix on our side
record(filename, category === 'Todo' ? 'unsupported' : 'error');
break;
}
}
},
}
```

The extension exposes this as `yarn webpack --reactCompilerVerbose` (per-file ✅/⏭️/🔍/❌ output + summary stats) and `--reactCompilerDebug={all|critical|none}` (maps to `panicThreshold: '<value>_errors'`). On mobile the same taxonomy is available through the Babel plugin's `logger` option or `eslint-plugin-react-compiler` (the lint rule runs the same analysis the compiler does).

## Coverage accounting

Track four buckets — **compiled / skipped / errors / unsupported** — at file and component granularity, with **worst-status-wins per file** (`error > unsupported > skipped > compiled`): a file with five compiled components and one error is an *error file*, otherwise mixed files inflate the compiled count and the number lies to you.

What the buckets tell you:

- **compiled** — your real optimization coverage. "The compiler is enabled" claims nothing; this number does.
- **errors** — the actionable backlog. Each is a Rules-of-React fix.
- **unsupported** — the compiler's backlog, not yours. Trend it across compiler upgrades.
- **skipped** — intentional exclusions: test/story files, `'use no memo'` directives, and **class components** (never compiled — metamask-mobile#30919 counted 53 at full enablement; migration to function components is the only way to move them into the compiled bucket).

## Staged adoption roadmap

The extension's sequence (epic MetaMask-planning#6549) generalizes to any repo:

1. **Lint clean:** update `eslint-plugin-react-hooks` / `eslint-plugin-react-compiler` to latest; fix violations — these are exactly what the compiler will refuse to compile.
2. **Audit opt-outs:** every `'use no memo'` carries a reason + TODO; the count only goes down. `grep -rn "use no memo" app --include="*.ts*"`.
3. **Ratchet `critical_errors`:** non-prod build passes; fix what surfaces.
4. **Ratchet `all_errors`:** remaining actionable errors fixed; what's left is the `Todo` (unsupported) set, which you wait out.

## Verify

- Per component: `Memo ✨` badge in React DevTools (see [js-profile-react.md](js-profile-react.md)).
- Per repo: the compiled-files count from the logger stats rises (or at least doesn't silently fall) release over release — silent coverage regressions are the failure mode this file exists to catch.
- After a compiler version bump: re-run the verbose build and diff the `unsupported` list — `Todo`s that became `compiled` are free wins; new `error`s are regressions to triage.

## Don't over-correct

- **Never "fix" a `Todo`.** Rewriting working code around an unimplemented compiler feature is churn with no perf evidence; the next compiler release may compile it as-is.
- Don't gate releases on compiler errors (`panicThreshold` stays `'none'` in production builds).
- Don't treat `skipped` as a problem — tests, stories, and deliberate opt-outs belong there. The smell is *unexplained* `'use no memo'` directives, not the bucket itself.
- A component without `Memo ✨` is not automatically a bug to chase — check the buckets first; it may be `unsupported`.

## Related

- [mm-react-compiler.md](mm-react-compiler.md) — enabling the compiler in this repo (Babel config, Metro cache, ESLint healthcheck)
- [js-react-compiler.md](js-react-compiler.md) — how the compiler transforms code; Rules-of-React background
- [mm-selector-cascade.md](mm-selector-cascade.md) — what the compiler **cannot** fix: unstable values crossing file boundaries (selectors, imported hooks)
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ React Compiler auto-memoizes components, callbacks, and computed values at build

On opted-in paths you can gradually drop hand-written `useMemo`/`useCallback`/`React.memo` once the compiler is verified working — but do it deliberately and re-measure. Off opted-in paths, manual memoization still matters.

**Exception — effect dependencies.** Keep any `useMemo`/`useCallback` whose output is used as a `useEffect` dependency, here or in a consumer: the compiler's memoization is not guaranteed to match the manual strategy, and a mismatch causes over/under-firing of effects or infinite loops — a correctness change, not a perf tweak. Official guidance is to leave existing manual memoization in place and only omit it in *new* code ([reactwg/react-compiler#16](https://github.com/reactwg/react-compiler/discussions/16)).

## What breaks compilation (it will skip the component)

- Mutating props or state during render.
Expand All @@ -68,4 +70,5 @@ Fix the ESLint `react-compiler` warnings on a path before/after opting it in.
## Related

- [js-react-compiler.md](js-react-compiler.md) — upstream reference on how the compiler transforms code
- [mm-react-compiler-error-triage.md](mm-react-compiler-error-triage.md) — triaging compiler errors (`Todo`/unsupported vs actionable), `panicThreshold` ratcheting, and measuring real coverage
- [mm-selector-memoization.md](mm-selector-memoization.md) — fix data-layer re-renders the compiler can't
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const browserTabs = useSelector((state: any) => state.browser.tabs); // also dro

**Why it's wrong:** an inline accessor returning an array/object hands a fresh reference to the consumer whenever that slice changes (and defeats reuse/memoization across the app). For derived data it's worse — `useSelector(s => s.items.filter(...))` allocates every render.

**Perf-triage note:** an inline accessor returning a **primitive or stable field** (`s => s.settings.basicFunctionalityEnabled`) is reuse/type debt, not a re-render bug — the new arrow function per render is irrelevant; only the result's identity matters. Flag it for cleanup, not as a perf finding.

**Fix:** create a named selector in `app/selectors/`:
```ts
// selectors/browser.ts
Expand Down Expand Up @@ -86,5 +88,7 @@ grep -rn "dispatch(" app --include="*.ts" --include="*.tsx" | grep -v ".test." \
## Related

- [mm-selector-memoization.md](mm-selector-memoization.md) — the upstream fix for Pattern 1
- [mm-selector-cascade.md](mm-selector-cascade.md) — repairing the whole dependency graph and removing accumulated `isEqual` band-aids after the root fix
- [mm-state-normalization.md](mm-state-normalization.md) — consolidating many `useSelector` calls into one view selector
- [mm-context-performance.md](mm-context-performance.md) — the Context equivalent of over-broad subscriptions
- [js-profile-react.md](js-profile-react.md) — confirm the re-render reduction
Loading