Skip to content
Merged
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
39 changes: 39 additions & 0 deletions .claude/skills/performance/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
name: performance
description: Performance patterns and anti-patterns for ethereum.org covering TTFB, LCP, INP, CLS, bundle size, build memory, and RSC payload. Use when diagnosing a perf regression or reviewing proposed code changes for perf landmines.
user-invocable: false
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

Noting this does show up when you type the full /performance but anything less and it does not show the auto-complete -- should be find

---

# Performance Knowledge

Knowledge base. No runtime actions; authoring conventions for maintainers below. Read the relevant `references/*.md` files directly via the Read tool.

## Two entry points

- **Diagnosing** an existing regression → `references/diagnose-table.md` (symptom → bucket).
- **Reviewing** a proposed change before merge → `references/review-checklist.md` (diff pattern → rules to load).

Both flows always also load `references/anti-patterns.md`.

## File map

| Topic | File |
| ------------------------------- | -------------------------------- |
| Symptom → bucket routing | `references/diagnose-table.md` |
| Diff pattern → rules routing | `references/review-checklist.md` |
| TTFB, edge caching, CDN | `references/edge-caching.md` |
| Bundle size, code splitting | `references/bundle.md` |
| Build OOM, ENOSPC, file tracing | `references/build.md` |
| RSC payload, HTML size | `references/rsc.md` |
| INP, main-thread work | `references/inp.md` |
| LCP, images | `references/images.md` |
| Cross-cutting anti-patterns | `references/anti-patterns.md` |

For data-fetching patterns (Trigger.dev tasks, Netlify Blobs, `src/lib/data` caching, internal `/api/*` routes, fetcher retries), use the `data-layer` skill instead.

## Conventions for adding rules

- One topic per file; no content duplication across files.
- Cite a PR (`PR #N`) when a rule comes from a specific shipped change. Add a SHA only when no PR exists.
- Forward-looking ("do this, avoid that"). Keep entries prescriptive — no postmortems or incident narratives.
- New review check? Add a row to `review-checklist.md` AND the matching bucket file. Don't restate the rule inline.
14 changes: 14 additions & 0 deletions .claude/skills/performance/references/anti-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Cross-cutting anti-patterns

These look right but aren't. Load alongside the bucket-specific file for any review or diagnose pass.

| Looks like… | Actually… |
|--------------------------------------------------------------|----------------------------------------------------------------------------|
| Adding `CDN-Cache-Control` to fix TTFB | Netlify strips them; CF ignores due to `max-age=0`. |
| Using `unstable_cache` in MDX routes | Triggers ISR → runtime 404s. See `edge-caching.md`. |
| `` await import(`.../${locale}/${ns}.json`) `` | Webpack enumerates all combinations → OOM. Use `fs.readFile`. |
| Adding a `useSession` / `cookies()` in root layout | Every route becomes dynamic. Edge caching dies silently. |
| `priority: true` via `getImageProps()` on manual `<picture>` | `fetchPriority` attribute is not set; add it manually. |
| Bumping `revalidate` to hourly to "fix staleness" | Increases edge miss rate. Keep daily; add client `/api/*` for freshness. |
| Mocking DB/content in perf tests | Perf wins must be measured against real prod build (`pnpm build && start`).|
| Removing `common.json` keys without audit | Unused-lookups throw at runtime. Use `scripts/audit-common-translations.ts`.|
50 changes: 50 additions & 0 deletions .claude/skills/performance/references/build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Build-time: memory, disk, tracing

## `fs.readFile` > `await import()` for bundler-invisible content (PR #17589)

When loading content with a variable path (i18n JSON by locale, markdown by slug), dynamic `import()` forces webpack/Turbopack to enumerate every possible chunk — 3000+ chunks across locales × namespaces.

```ts
// Bad — webpack enumerates all locale×namespace combinations
const messages = await import(`../intl/${locale}/${namespace}.json`)

// Good — invisible to module graph
const messages = JSON.parse(await fs.readFile(
path.join(process.cwd(), "src/intl", locale, `${namespace}.json`),
"utf8"
))
```

Same fix applied to tutorial MD under Turbopack (SHA `30e0ecc6b`, 143k+ file trace).

## Netlify build failures

**OOM during webpack compile** → apply `fs.readFile` pattern above; verify with `NODE_OPTIONS="--max-old-space-size=8192"` locally.

**ENOSPC (disk full)** → `.next/cache` is ~4GB webpack cache that Netlify never reuses. Fix in `netlify.toml`:

```toml
command = "pnpm build && rm -rf .next/cache"
```

(PR #17971, SHA `12c01e79f`). Also check if `.next/server/.segments/` (Next.js 16+) is inflating — it duplicates into `.next/standalone/`.

**Missing files at runtime** → force into serverless bundle via `outputFileTracingIncludes`:

```ts
// next.config.js
outputFileTracingIncludes: {
"/**/*": ["./src/data/**/*"],
}
```

(SHA `f1425bdb4`).

**Large static assets bloating function** → move to `public/` and reference as URL (SHA `11e38234b` — 47MB mp3 reduction). Also exclude via `outputFileTracingExcludes`.

## Turbopack-specific

Both rules apply to Turbopack only — webpack handles these cases correctly.

- `turbopackIgnore` comments **only work on actual `import()` call sites**, not on helper wrappers that call them. Move the magic comment to where the dynamic `import()` actually appears, or remove the wrapper (SHA `3fdc233616`).
- Turbopack file-tracing warnings only resolve **inline string literals** in dynamic import paths. If the path is built from variables (e.g. `${baseDir}/${file}`), Turbopack can't see the target — inline the constant or split the dynamic segment so the literal portion is visible.
27 changes: 27 additions & 0 deletions .claude/skills/performance/references/bundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Bundle size / code splitting

## Lazy-load heavy below-fold deps

Always lazy-load anything above ~20KB gz: Swiper, Prism (`prism-react-renderer`), Solidity highlighting, Radix Dialog bodies, modals, accordion content below the fold, chart libraries.

Consolidate `next/dynamic` imports in `app/[locale]/_components/*LazyImports.tsx` with `ssr: false`:

```tsx
const CodeExamples = dynamic(() => import(".../CodeExamples"), { ssr: false })
const AppsHighlight = dynamic(() => import(".../AppsHighlight"), { ssr: false })
```

Examples shipped:

- PR #17958 — `CodeExamples`, `AppsHighlight`
- PR #17661 — mobile menu content (`React.lazy()` + first-click trigger) — saved ~82KB
- SHA `a1f876f8f` — persona modal
- SHA `355070dc7` — homepage wrapper after upgrade chain

## Asset weight

- **SVG: optimize + provide dark variants** rather than filter hacks (SHA `33132d2f8`).

## Verify

Run `pnpm build` and check the route-level First Load JS deltas. For RSC payload, use Chrome DevTools Network → filter `?_rsc=` or measure raw HTML size with `curl -s URL | wc -c`.
24 changes: 24 additions & 0 deletions .claude/skills/performance/references/diagnose-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Diagnose: symptom → bucket

Pick the matching row. Load the named reference file. Always also load `anti-patterns.md`.

**For LCP, the bucket is layered.** Work `images.md` → `bundle.md` → `rsc.md` in that order — load all three when LCP is the metric, since hero preload, JS budget, and HTML/RSC weight all contribute.

**For TTFB:** this is almost certainly a CF-caching / devops issue, not an app-side fix. Surface that up front before proposing Netlify header changes — see `edge-caching.md`.

| Symptom | Bucket | Reference file |
|-------------------------------------------------|---------------------------------|-----------------------|
| P75 TTFB > 800ms in field data | Edge caching / routing | `edge-caching.md` |
| LCP > 2.5s | Images → bundle → RSC (in order)| `images.md`, `bundle.md`, `rsc.md` |
| INP > 200ms on interaction | Main-thread / analytics / lists | `inp.md` |
| CLS > 0.1 | Image dimensions / late mounts | `images.md` |
| Large HTML payload (>500KB) | RSC / translations | `rsc.md` |
| Large JS bundle on first load | Code splitting / lazy | `bundle.md` |
| Netlify build OOM or ENOSPC | Build-time / webpack tracing | `build.md` |
| Slow page but fast TTFB and small bundle | Data fetching / cache misses | _see `data-layer` skill_ |

## Baseline first

Always collect numbers before coding: PSI field data, Sentry LCP/INP P75, `web-vitals` in dev, `pnpm build` output, `.next/` dir sizes. **No before = no after.**

If the regression only reproduces in `pnpm dev`, rebuild with `pnpm build && pnpm start` before believing it.
19 changes: 19 additions & 0 deletions .claude/skills/performance/references/edge-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Edge caching & TTFB

## Established root cause (memorized)

Netlify Edge uses per-node LRU caching; low-traffic entries get evicted regardless of TTL. Edge misses fall to Durable cache in **us-east-1**, which is a 400–600ms round-trip for global users (top traffic: China, US, India). Cloudflare is in the path but does **not** cache HTML because `max-age=0` is set and `CDN-Cache-Control` headers are stripped by Netlify before reaching CF.

## Patterns that work

1. **Daily revalidate + client-side refresh API for volatile widgets** (PR #17815 — gas table). Keep the full page statically cacheable at `revalidate = 86400`; put volatile data in `/api/*` routes that the client calls on mount. Do not bake hourly data into the SSR'd HTML.

2. **Module-level `Map` caches for hot request-path lookups** (PR #17864 — `getTranslatedLocales()`). Cuts per-build repeated filesystem work and fixes inconsistency across concurrent requests.

3. **Cache translation/metadata registries** instead of re-reading from disk per request. Same pattern as `cachedStaticPages`.

## DON'T

- Do NOT add `unstable_cache` + `revalidate` on MDX/markdown routes with `generateStaticParams`. Triggers ISR and silently 404s on dynamic segments when `src/data` is not in the serverless bundle. Use `cache: "force-cache"` or `React.cache()` instead (SHA `e4d7a342c`, `bd9732ae0`, `e61afdbe2`).
- Do NOT add root-layout hooks that read request data (Sentry `getTraceData`, cookies, headers). One dynamic hook in `app/layout.tsx` makes every page dynamic and destroys edge caching (PR #16718 diagnosis).
- Do NOT propose adding `CDN-Cache-Control` headers as a TTFB fix — Netlify strips them (PR #17838 closed).
32 changes: 32 additions & 0 deletions .claude/skills/performance/references/images.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Images / LCP

1. **Manual `<picture>` + `getImageProps()` drops `fetchPriority`.** Set it manually on the `<img>`:

```tsx
<img {...props} fetchPriority="high" />
```

(PR #17958). `priority: true` on `getImageProps` is not enough.

2. **Remove `priority` from below-fold images** — they compete with the hero for preload budget (SHA `d41f2a9b3` trustlogos).

3. **Re-encode oversized assets** — PNG → JPG, resize to display size (SHA `e2430a276`: 3.3MB → 119KB banner).

4. **Tighten `sizes` to the actual rendered width.** Loose `sizes` (e.g. `100vw` on a contained image, or `1200px` on a 512px column) make the browser pick an oversized source from the `srcSet`. On throttled mobile, those non-LCP images steal bandwidth from the LCP element. Lighthouse "Improve image delivery" almost always points here. SHA `6dc51d32a2` cut ~200 KB / −150 ms LCP by tightening homepage carousel + trust-logo `sizes`.

Match `sizes` to the breakpoints that actually constrain the image. Account for container padding (`calc(100vw - 32px)`), max-width caps, and grid columns. Examples from `SavingsCarousel.tsx` / `TrustLogos.tsx`:

```tsx
// Bad: 100vw lies on desktop where the carousel caps at ~512px
<Image sizes="(max-width: 768px) 100vw, 1200px" ... />

// Good: reflects column width at each breakpoint
<Image sizes="(max-width: 1024px) 384px, 512px" ... />

// Good: account for page padding on mobile
<Image sizes="(max-width: 480px) calc(100vw - 32px), 100vw" ... />
```

Verify in DevTools: inspect the `<img>`, check `currentSrc` — the `&w=` should match the rendered CSS width × DPR, not the largest entry in `srcSet`.

5. **Lower `quality` on non-LCP images** — `quality={65}` is visually indistinguishable from default `75` for photographic content, ~15% smaller. Add new values to the `qualities` allow-list in `next.config.js` (Next.js 15 only honors whitelisted qualities). SHA `6dc51d32a2`.
31 changes: 31 additions & 0 deletions .claude/skills/performance/references/inp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# INP / main-thread work

## Patterns

1. **Virtualize lists above ~30 rows** using `@tanstack/react-virtual` (already a dep). Shipped for:
- `/get-eth/` country picker (240 options): INP 1048ms → 248ms (PR #17912, SHA `db6902280`).
- Find Wallets list (PR #16233).

For `react-select`, virtualize `MenuList`; for tables, wrap rows.

2. **Defer analytics to idle.** `trackCustomEvent` in `requestIdleCallback` (fallback `setTimeout(0)`); cache opt-out flag in memory so per-click does not re-parse `localStorage`. Capture URL **synchronously** before the idle callback (race fix, SHA `3dcaef917`).

3. **Guard Matomo `init()` with `isOptedOut()`** (PR #17922). The tracker script fires a request even without `trackPageView` calls when `init()` runs.

4. **Keep heavy panels mounted** — `<PersistentPanel>` pattern (PR #16694). Drawers/sheets that re-mount filter state every open are a common INP source.

5. **`startTransition` + `useDeferredValue`** for filter updates (PR #16694 ProductTable).

6. **Single-pass `.filter()`** — chained `.filter().filter().filter()` forces N array traversals; combine into one predicate (SHA `298e00f9b`).

7. **Custom `memo` comparator** on filter components that receive heavy props (SHA `16973e2c3`).

8. **Defer below-fold sections with `Suspense` + skeletons** so above-fold paint is not blocked by KPI/chart/simulator hydration (SHA `4d212c956` — homepage KPI/Carousel/Simulator). Pair with `generateMetadata` for the streaming SSR boundary.

## Measure with web-vitals in prod build

```tsx
onINP((m) => console.log(m.value, m.attribution))
```

Compare before/after on the actual slow interaction, not the whole page.
30 changes: 30 additions & 0 deletions .claude/skills/performance/references/review-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Review: diff contains → load this

Mechanical scan of changed code against ethereum.org's perf landmines — patterns that pass normal review but silently regress perf because of decisions in `next.config.js`, our Netlify edge setup, or scars from past incidents. For general Next.js / React hygiene (Server Components first, lazy modals, `next/image`, virtualized lists, `startTransition`), rely on CLAUDE.md and standard tooling.

## How to use

1. List the changed files (`git diff --name-only <base>...HEAD`).
2. Walk the table below, top to bottom. For each row, grep the diff for the pattern.
3. On any hit, load the named reference file and check the change against its rules.
4. Always also load `anti-patterns.md` — it's the cross-cutting list that catches what the table misses.

| Diff contains… | Load |
|----------------------------------------------------------------------|--------------------------------------------------------------------------------|
| Changes under `app/**/layout.tsx` (esp. root layout) | `edge-caching.md` — root-layout dynamic hooks kill edge caching |
| `cookies()` / `headers()` in a server component or route | `edge-caching.md` — opts the route out of static caching |
| `unstable_cache(` on an MDX-rendered route | `anti-patterns.md` — triggers ISR + silent 404s on dynamic segments |
| `import(\`…${` (template-literal dynamic import path) | `build.md` — Webpack/Turbopack enumerates all combos → Netlify OOM |
| `getImageProps(` used inside a manual `<picture>` | `images.md` — `priority: true` does NOT set `fetchPriority`; add it manually |
| New/changed `<Image …sizes=` on a non-LCP image | `images.md` — tighten `sizes` to actual rendered width; loose values over-fetch |
| New file `app/**/page.tsx` | `edge-caching.md`, `rsc.md` — verify `revalidate` strategy + RSC payload |
| New file under `app/api/` | `edge-caching.md` — TTL strategy, internal-only fetch boundary |
| New keys in `src/intl/en/common*.json` | `rsc.md` — server-only vs client (`common.json` vs `common-server.json`) |
| Changes to `next.config.js` / `outputFileTracing*` | `build.md` — function bundle size, included paths |
| New large static asset (mp3, video, large image) | `build.md` — should be in `/public`, excluded from function trace |
| New import of a heavy lib (chart, syntax highlighter, > 20KB gz) | `bundle.md` — verify it's lazy-imported via `LazyImports.tsx` |
| New data fetcher / cron / blob storage | _see `data-layer` skill_ |

If the diff is small or none of the rows apply cleanly, load `anti-patterns.md` alone and scan against it.

For investigating an existing field regression instead of reviewing a diff, use `diagnose-table.md`.
15 changes: 15 additions & 0 deletions .claude/skills/performance/references/rsc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# RSC / translation payload

Targets: HTML < 500KB, RSC push calls < 70 (preferably < 40).

## Patterns

1. **Server Component conversion** (PR #17650 — Footer). If a component has `useTranslation` and one trivial `onClick`, split out the interactive piece (e.g., `GoToTopButton.tsx`) and make the parent a server component using `getTranslations` from `next-intl/server`. ~95% less hydration.

## Measurement

```bash
curl -s -H "RSC: 1" "https://localhost:3000/en/?_rsc=1" | wc -c # raw bytes
```

Before/after examples from closed PR #17633: `/en/` 843 → 537KB, `/en/staking/` 595 → 282KB.
Loading