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
149 changes: 149 additions & 0 deletions domains/performance/knowledge/effect-antipatterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
---
name: effect-antipatterns
domain: performance
description: The React `useEffect` patterns that cause unnecessary renders, memory leaks, or race conditions — the canonical, platform-agnostic taxonomy that per-repo effect references instantiate
---

# Effect Anti-Patterns

**This file is the single source for the pattern taxonomy.** Per-repo references — such as
the `mm-hook-dependency-arrays` and `mm-useeffect-antipatterns` references shipped with the
`performance` skill — name these patterns rather than redefining them, and add what only
they can: verified instances with `file:line`, repo-specific lint gaps, and fix recipes.

Two halves, and they fail differently. Patterns 1–2 are about **when an effect re-runs**
(the dependency side). Patterns 3–5 are about **what happens inside and after it** (the
lifecycle side).

## 1. Unstable dependency identity

A dependency array is supposed to be a cheap identity check. Anything that produces a new
value every render defeats it — and usually signals an unstable reference upstream.

```typescript
// ❌ serializes on EVERY render just to build the dep key
useEffect(() => { doSomething(config) }, [JSON.stringify(config)])

// ❌ new object every render → effect runs every render (or loops forever)
useEffect(() => { ... }, [{ id: user.id }])

// ✅ stabilize the reference upstream, then depend on it directly
const stableConfig = useMemo(() => derive(a, b), [a, b])
useEffect(() => { doSomething(stableConfig) }, [stableConfig])

// ✅ or depend on the primitives
useEffect(() => { ... }, [user.id])
```

Stabilizing the source beats hashing it. If you genuinely cannot, a primitive key computed
**once** (`useMemo(() => xs.join(','), [xs])`) still beats a per-render `JSON.stringify`.

Detection: grep for `JSON.stringify` inside a dependency array, and for inline `{`/`[`
literals in the dep position.

## 2. Wrong dependencies

```typescript
// ❌ empty deps but reads state → stale closure, value frozen at first render
const onPress = useCallback(() => doThing(count), [])

// ❌ empty deps and reads nothing → this was never a hook, hoist it out
const config = useMemo(() => ({ a: 1, b: 2 }), [])
```

**Fix:** include what you read; or if there is genuinely nothing to read, move the constant
outside the component. Where `react-hooks/exhaustive-deps` is not enabled, this is not
caught automatically and must be reviewed by hand.

## 3. Derived state via effect + setState

If a value is computable from props/state/store, compute it during render. State plus an
effect is for *synchronizing with something external*, not for derivation.

```typescript
// ❌ two render passes per change: render → effect → setState → render again
const [visible, setVisible] = useState([])
useEffect(() => { setVisible(items.filter((t) => !t.hidden)) }, [items])

// ✅ derive during render — one pass, no state to drift out of sync
const visible = useMemo(() => items.filter((t) => !t.hidden), [items])
```

The React docs call this out directly:
[You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect).

### 3a. Cascading effect chains

The same mistake compounded: effect A sets state, which triggers effect B, which sets
state, which triggers effect C. Each link is a full extra render pass *and* a window where
the UI shows an inconsistent intermediate combination.

**Fix:** collapse the chain into render-time derivation — one `useMemo` per step, or one
for the lot.

## 4. Missing timer cleanup

Every `setInterval` and recurring `setTimeout` started in an effect must be cleared in its
cleanup. Otherwise the timer outlives unmount, fires against dead state, and leaks in
proportion to how often the component mounts.

```typescript
// ❌ BROKEN: timer leaks after unmount
useEffect(() => { setInterval(poll, 1000) }, [])

// ✅ FIXED
useEffect(() => {
const id = setInterval(poll, 1000)
return () => clearInterval(id)
}, [])
```

## 5. Uncancelled async work

Async work started in an effect can resolve *after* unmount — or after the input changed,
letting a stale response overwrite a newer one.

```typescript
// ❌ fetch races unmount; stale data can win
useEffect(() => { fetchMeta(address).then(setMeta) }, [address])

// ✅ cancelled flag — cheapest, works for any promise
useEffect(() => {
let cancelled = false
fetchMeta(address).then((m) => { if (!cancelled) setMeta(m) })
return () => { cancelled = true }
}, [address])

// ✅ AbortController — also cancels the request itself
useEffect(() => {
const ctrl = new AbortController()
fetch(url, { signal: ctrl.signal })
.then((r) => setData(r))
.catch((e) => { if (e.name !== 'AbortError') throw e })
return () => ctrl.abort()
}, [url])
```

Pick one and apply it consistently.

## Why these matter

- **Renders.** Derived-state effects double every render in the affected subtree, and
chains multiply it.
- **Memory.** Uncleared timers and subscriptions leak proportional to mount count.
- **Correctness.** Uncancelled async work produces "state update on an unmounted
component" warnings and, worse, races where an older response overwrites a newer one.

## Don't over-correct

- Don't add `useMemo`/`useCallback` everywhere — only where profiling shows wasted work, or
where a memoized child depends on the reference. Compilers handle many cases on opted-in
paths.
- A `JSON.stringify` on a cold path with a small object is acceptable. Prioritize hot render
paths.

## Related

- `render-cascade` — how effect-driven re-renders propagate through the component graph.
- `selector-antipatterns` — the store-side counterpart; an unstable selector result is a
common source of the unstable dependency in pattern 1.
67 changes: 67 additions & 0 deletions domains/performance/knowledge/metrics-pipeline-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
name: metrics-pipeline-design
domain: performance
description: Four-layer metric pipeline architecture for E2E benchmarks, with domain-specific statistical bounds and split reporting paths.
---

# Metrics Pipeline Design

Architecture for adding metric types to an E2E benchmark suite. Separates collection, running, statistics, and reporting into independent layers.

## Architecture

```
Collector → Runner → Statistics → Reporter
```

| Layer | Responsibility |
|-------|----------------|
| **Collector** | Extract raw metric from browser/extension per iteration |
| **Runner** | Per-iteration capture + aggregation orchestration |
| **Statistics** | Domain-specific filtering, outlier detection, percentiles |
| **Reporter** | Per-run spans (for quality gate comparison) + aggregated structured logs (for dashboards) |

Flow files call the collector and return snapshots alongside timers. No flow file does statistics or reporting.

## Adding a New Metric Type

1. **Create collector** — function returning typed snapshot with nullable fields for unobserved metrics
2. **Define types** — per-run snapshot, aggregated (reuse `TimerStatistics` for numeric fields), summary
3. **Add domain-specific bounds** — each numeric field gets `{ min, max, allowZero }`
4. **Wire into runner** — collect alongside timers, call aggregation
5. **Add reporter** — per-run spans with `setMeasurement`, aggregated summary as structured log

## Domain-Specific Statistical Bounds

Generic timer bounds (1ms–120s, zero=invalid) silently discard valid data from other domains.

```typescript
// WRONG: CLS values (0–1) all rejected by min=1ms floor
const result = filterBySanityChecks(clsValues); // → empty array

// RIGHT: per-metric bounds
const BOUNDS = {
inp: { min: 1, max: 30_000, allowZero: false }, // ms
lcp: { min: 1, max: 60_000, allowZero: false }, // ms
cls: { min: 0, max: 10, allowZero: true }, // unitless ratio
};
```

**Rule:** When adding a new metric type, verify whether existing `filterBySanityChecks` assumptions (ms units, zero=invalid) hold. If not, define metric-specific bounds.

`allowZero` is the critical distinction: CLS=0 means perfect stability (valid); timer=0ms means measurement error (invalid).

## Split Reporting Path

| Data | Mechanism | Rationale |
|------|-----------|-----------|
| Aggregated statistics (mean, p75, p95) | Structured log | Low cardinality, dashboard-friendly |
| Per-run snapshots | Sentry spans + `setMeasurement` | Preserves granularity, enables quality gate comparison via Mann-Whitney U |

`tracesSampleRate: 1.0` required in CI so all per-run spans are captured.

## SDK Isolation Pattern

When CI benchmark scripts run in Node but the extension uses a browser SDK (e.g. `@sentry/node` vs `@sentry/browser`): these never share a process. The package manager resolves separate versions per dependency tree. No compatibility issue — they are fully isolated under different lockfile entries.

Risk: a shared module accidentally importing from the wrong SDK at bundle time. Mitigation: keep the CI SDK as a devDependency excluded from extension builds.
68 changes: 68 additions & 0 deletions domains/performance/knowledge/render-cascade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
name: render-cascade
domain: performance
description: React+Redux render cascade failure mode — single state change triggers multiple re-render cycles
---

# Render Cascade

Single state change → broken selector returns new reference → `useSelector` detects "change" → parent re-renders all children → children trigger more selectors → cycle repeats 5+ times before stabilizing.

## Cost Scaling

| Factor | Impact |
|--------|--------|
| Component tree depth | Each level multiplies re-renders |
| User data size | O(n) selectors × n items = O(n²) operations |
| State update frequency | Background polling compounds the problem |

Power users (large datasets, many accounts/tokens/transactions) are disproportionately affected.

## Root Causes

| Cause | Pattern | Fix |
|-------|---------|-----|
| Plain function selector | `export function get...` | Wrap in `createSelector` |
| Identity function selector | Transform in input, identity in result | Move transform to result function |
| Unnecessary deep equality | `createDeepEqualSelector` on stable Immer inputs | Use `createSelector` |
| O(n) lookup | `.find()` in selector | Normalize state to map; use direct access |
| Chained transforms (unmemoized) | Multiple `.map`/`.filter` in plain function | Single `createSelector` with all transforms |
| Context provider instability | `<Context.Provider value={{ a, b }}>` inline | `useMemo` the value |
| Props recreation | `useParams()` passed directly as prop | `useMemo` the props object |

## Selector Creator Decision Tree

```
Is INPUT unstable (not from Immer/Redux)?
├── YES → createDeepEqualSelector
└── NO → Is OUTPUT unstable (new array/object from transform)?
├── YES → createResultEqualSelector (or createShallowResultSelector)
└── NO → createSelector
```

## Fix Order — Root Selectors First

Selectors form a dependency graph. When a root selector returns an unstable reference, the cost cascades:

- recomputations: **O(m)** — all m dependent selectors recompute
- cascade depth: **O(log m)** — propagates through the tree
- re-renders: **O(m × k)** — each selector triggers k subscribers

**Fixing downstream selectors is ineffective until the upstream root is stable** — a fixed `getActiveAccount` still receives a new input every render if `getAccounts` is broken.

```
getAccountsObject (stable)
└─ getAccounts (broken: returns new array)
├─ getActiveAccount ├─ getAccountCount └─ getAccountNames …
```

Triage the dependency graph top-down; fix roots first.

## Why Cascade Breaks All Other Optimizations

| Optimization | Without Cascade Fix | With Cascade Fix |
|---|---|---|
| Virtualization | Parent still re-renders all | Works as intended |
| `React.memo` | Parent defeats it | Works as intended |
| React Compiler | Can't cross file boundaries | Complements selectors |
| `useMemo`/`useCallback` | Recreated on parent render | Stable references |
Loading
Loading