From 6b4953c23d7011a3f5de2b9d6a4cee2e39f3207e Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Mon, 4 May 2026 16:18:04 +0200 Subject: [PATCH 1/2] refactor(skills): split performance into references --- .claude/skills/performance/SKILL.md | 39 +++++++++++++++ .../performance/references/anti-patterns.md | 14 ++++++ .../skills/performance/references/build.md | 50 +++++++++++++++++++ .../skills/performance/references/bundle.md | 27 ++++++++++ .../performance/references/diagnose-table.md | 24 +++++++++ .../performance/references/edge-caching.md | 19 +++++++ .../skills/performance/references/images.md | 13 +++++ .claude/skills/performance/references/inp.md | 31 ++++++++++++ .../references/review-checklist.md | 29 +++++++++++ .claude/skills/performance/references/rsc.md | 15 ++++++ 10 files changed, 261 insertions(+) create mode 100644 .claude/skills/performance/SKILL.md create mode 100644 .claude/skills/performance/references/anti-patterns.md create mode 100644 .claude/skills/performance/references/build.md create mode 100644 .claude/skills/performance/references/bundle.md create mode 100644 .claude/skills/performance/references/diagnose-table.md create mode 100644 .claude/skills/performance/references/edge-caching.md create mode 100644 .claude/skills/performance/references/images.md create mode 100644 .claude/skills/performance/references/inp.md create mode 100644 .claude/skills/performance/references/review-checklist.md create mode 100644 .claude/skills/performance/references/rsc.md diff --git a/.claude/skills/performance/SKILL.md b/.claude/skills/performance/SKILL.md new file mode 100644 index 00000000000..423af0d3f7e --- /dev/null +++ b/.claude/skills/performance/SKILL.md @@ -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 +--- + +# 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. diff --git a/.claude/skills/performance/references/anti-patterns.md b/.claude/skills/performance/references/anti-patterns.md new file mode 100644 index 00000000000..79011f994b2 --- /dev/null +++ b/.claude/skills/performance/references/anti-patterns.md @@ -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 `` | `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`.| diff --git a/.claude/skills/performance/references/build.md b/.claude/skills/performance/references/build.md new file mode 100644 index 00000000000..f090130fea0 --- /dev/null +++ b/.claude/skills/performance/references/build.md @@ -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. diff --git a/.claude/skills/performance/references/bundle.md b/.claude/skills/performance/references/bundle.md new file mode 100644 index 00000000000..3d33ef9157e --- /dev/null +++ b/.claude/skills/performance/references/bundle.md @@ -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`. diff --git a/.claude/skills/performance/references/diagnose-table.md b/.claude/skills/performance/references/diagnose-table.md new file mode 100644 index 00000000000..629c19dd7f3 --- /dev/null +++ b/.claude/skills/performance/references/diagnose-table.md @@ -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. diff --git a/.claude/skills/performance/references/edge-caching.md b/.claude/skills/performance/references/edge-caching.md new file mode 100644 index 00000000000..a903d674c97 --- /dev/null +++ b/.claude/skills/performance/references/edge-caching.md @@ -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). diff --git a/.claude/skills/performance/references/images.md b/.claude/skills/performance/references/images.md new file mode 100644 index 00000000000..b25b61ff37c --- /dev/null +++ b/.claude/skills/performance/references/images.md @@ -0,0 +1,13 @@ +# Images / LCP + +1. **Manual `` + `getImageProps()` drops `fetchPriority`.** Set it manually on the ``: + + ```tsx + + ``` + + (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). diff --git a/.claude/skills/performance/references/inp.md b/.claude/skills/performance/references/inp.md new file mode 100644 index 00000000000..f32cb751757 --- /dev/null +++ b/.claude/skills/performance/references/inp.md @@ -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** — `` 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. diff --git a/.claude/skills/performance/references/review-checklist.md b/.claude/skills/performance/references/review-checklist.md new file mode 100644 index 00000000000..eb14556cc72 --- /dev/null +++ b/.claude/skills/performance/references/review-checklist.md @@ -0,0 +1,29 @@ +# 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 ...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 `` | `images.md` — `priority: true` does NOT set `fetchPriority`; add it manually | +| 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`. diff --git a/.claude/skills/performance/references/rsc.md b/.claude/skills/performance/references/rsc.md new file mode 100644 index 00000000000..76c758381ce --- /dev/null +++ b/.claude/skills/performance/references/rsc.md @@ -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. From e0cc48bbf424e95536786ddd8666e931a98e6f24 Mon Sep 17 00:00:00 2001 From: Pablo Pettinari Date: Tue, 5 May 2026 14:55:06 +0200 Subject: [PATCH 2/2] docs(skills): add sizes prop guidance to performance/images --- .../skills/performance/references/images.md | 19 +++++++++++++++++++ .../references/review-checklist.md | 1 + 2 files changed, 20 insertions(+) diff --git a/.claude/skills/performance/references/images.md b/.claude/skills/performance/references/images.md index b25b61ff37c..3364d9ffe9b 100644 --- a/.claude/skills/performance/references/images.md +++ b/.claude/skills/performance/references/images.md @@ -11,3 +11,22 @@ 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 + + + // Good: reflects column width at each breakpoint + + + // Good: account for page padding on mobile + + ``` + + Verify in DevTools: inspect the ``, 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`. diff --git a/.claude/skills/performance/references/review-checklist.md b/.claude/skills/performance/references/review-checklist.md index eb14556cc72..5d9cb8b413b 100644 --- a/.claude/skills/performance/references/review-checklist.md +++ b/.claude/skills/performance/references/review-checklist.md @@ -16,6 +16,7 @@ Mechanical scan of changed code against ethereum.org's perf landmines — patter | `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 `` | `images.md` — `priority: true` does NOT set `fetchPriority`; add it manually | +| New/changed `