diff --git a/.claude/commands/add-feature-flag.md b/.claude/commands/add-feature-flag.md new file mode 100644 index 00000000..24d0c3f3 --- /dev/null +++ b/.claude/commands/add-feature-flag.md @@ -0,0 +1,58 @@ +--- +description: Add a feature flag to the correct system (global Remote Config vs per-user) and wire it up +allowed-tools: Bash, Read, Edit, Write, Grep, Glob, Skill +argument-hint: " [and what it gates]" +--- + +Add a feature flag: $ARGUMENTS + +**First load the `mobility-feature-flags` skill** — this repo has two independent flag systems and choosing +wrong is the common failure. + +## 1. Choose the system + +- **Firebase Remote Config** — global toggle/kill switch, flipped for everyone without a deploy, works for + logged-out users. Define in `src/app/interface/RemoteConfig.ts`. +- **Per-user flags** — the backend decides per user (entitlement, staged rollout). If it already appears in + `UserProfile.features[]` from `GET /v1/user`, it's this one. Define in + `src/app/interface/UserFeatureFlags.ts`. + +If the request is ambiguous, ask which one rather than guessing — they have different sources of truth and +different security properties. + +## 2. Define it + +Add the key to the interface **and** to the defaults object in the same file. **Default to `false`** so +missing/unverifiable config fails closed. For per-user flags that single edit is sufficient — the ID type, +the hook, and `toUserFeatureFlags()` all derive from it. + +## 3. Read it + +```tsx +// Server Component — Remote Config +const config = await getUserRemoteConfigValues(); // applies the admin bypass +// Server Component — per-user +const { myNewFlag } = await getServerFlags(); + +// Client — Remote Config +const { config } = useRemoteConfig(); +// Client — per-user +const { myNewFlag } = useUserFeatureFlags(); +``` + +Gate with a ternary, not `&&` (skill rule `rendering-conditional-render`). + +## 4. Check before you finish + +- Does this flag gate **real access** (paywall, admin capability) rather than UI convenience? If per-user: + `POST /api/feature-flags` does not verify its caller, so a client could set its own value. Enforce access + server-side and flag the hardening need to the user. +- Is a same-named flag already in the *other* system? `isNotificationsEnabled` is in both — check for + duplication before adding. +- Don't read flags via `cookies()` inside a `static/` feed-detail route; it breaks static rendering. +- Don't put flags in Redux (`redux-persist` → `localStorage` leaks them across users on shared devices). + +## 5. Verify + +`yarn lint` and `yarn test:ci`. Then tell the user what still needs doing outside the code — creating the +parameter in the Firebase console, or the backend returning the new `features[]` entry. diff --git a/.claude/commands/add-translation.md b/.claude/commands/add-translation.md new file mode 100644 index 00000000..e242228d --- /dev/null +++ b/.claude/commands/add-translation.md @@ -0,0 +1,50 @@ +--- +description: Add or update i18n message keys in both en and fr, keeping the files in parity +allowed-tools: Bash, Read, Edit, Grep, Glob +argument-hint: " [English text]" +--- + +Add or update translation keys: $ARGUMENTS + +Messages live in `messages/en.json` and `messages/fr.json`. Existing namespaces: `common`, +`emailVerification`, `feeds`, `account`, `contactUs`, `gbfs`, `home`, `about`, `footer`. Prefer an existing +namespace over inventing one. + +## Rules + +1. **Always edit both files.** They are currently at full parity — every namespace present in `en.json` + exists in `fr.json`. Keep it that way; a key present in only one locale renders as the raw key. +2. Mirror the same nesting path and key order in both files so diffs stay readable. +3. Provide a real French translation. If you are not confident in the wording for a domain term (transit + jargon like "feed", "dataset", "validation report", "producer"), say so explicitly and mark it for review + rather than shipping a silent guess — check how the same term is already translated elsewhere in + `fr.json` first and stay consistent with it. +4. next-intl uses ICU message syntax. For interpolation use `{count}`, and use proper `plural`/`select` + blocks rather than string concatenation: + ```json + { "feedCount": "{count, plural, =0 {No feeds} one {# feed} other {# feeds}}" } + ``` + French plural rules differ from English — don't copy the English arms verbatim. +5. Do not hardcode user-facing strings in components. Read them via `useTranslations('namespace')` in client + components, `getTranslations()` from `next-intl/server` in server components. + +## Consuming the key + +```tsx +'use client'; +import { useTranslations } from 'next-intl'; +const t = useTranslations('feeds'); +// t('myNewKey') +``` + +## Verify + +- `node -e "require('./messages/en.json'); require('./messages/fr.json')"` — catches JSON syntax errors. +- Confirm parity, e.g.: + ```bash + node -e "const en=require('./messages/en.json'),fr=require('./messages/fr.json');const f=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v!==null?f(v,p+k+'.'):[p+k]);const a=f(en),b=f(fr);console.log('only en:',a.filter(k=>!b.includes(k)));console.log('only fr:',b.filter(k=>!a.includes(k)));" + ``` +- `yarn lint` and `yarn test:ci`. + +**Testing note:** `next-intl` is globally mocked in Jest and `useTranslations()` returns the key itself. So +tests assert on **keys**, not English text — don't update specs to expect your new English string. diff --git a/.claude/commands/e2e.md b/.claude/commands/e2e.md new file mode 100644 index 00000000..6fd942ec --- /dev/null +++ b/.claude/commands/e2e.md @@ -0,0 +1,34 @@ +--- +description: Start the E2E stack (Next + MSW + Firebase emulator) and run Cypress specs +allowed-tools: Bash, Read, Edit, Grep, Glob +argument-hint: "[spec name or path, e.g. signin]" +--- + +Run the Cypress e2e suite. Target: $ARGUMENTS (empty means the whole suite). + +The stack needs two services before Cypress can run — Next on **:3001** with MSW mocking enabled, and the +Firebase auth emulator on **:9099**. + +1. Check whether they're already up (`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001` and + `:9099`). Don't start a second copy if they are. +2. If not, start the stack **in the background**: `yarn e2e:setup:dev` for iteration (uses `next dev`), or + `yarn e2e:setup` to match CI exactly (runs `next build` first — slower, but it's also the only real + typecheck gate). Prefer `:dev` unless the task is about reproducing a CI failure. +3. Wait for both ports to answer before proceeding — poll, don't sleep blindly. +4. Run the specs: + - whole suite: `yarn e2e:run` + - one spec: `CYPRESS_BASE_URL=http://localhost:3001 npx cypress run --spec "cypress/e2e/.cy.ts"` +5. Report actual results. On failure, read the spec and check + `cypress/screenshots/` and `cypress/videos/`. + +Things that will bite you: + +- `cy.createNewUserAndSignIn()` **wipes all Firebase emulator accounts** — specs are not isolated from each + other in that respect. +- The session-renewal block in `cypress/e2e/userFeatureFlags.cy.ts` is `describe.skip` because the renewal + interval doesn't fire under CI's `next start`. Expect it to be skipped; don't un-skip casually. +- Local env comes from `.env.development`. `cypress/e2e/feed-isr-caching.cy.ts` needs `REVALIDATE_SECRET` + to be set there. +- Stop the background stack when you're done. + +For patterns and custom commands, load the `mobility-testing` skill. diff --git a/.claude/commands/regen-api-types.md b/.claude/commands/regen-api-types.md new file mode 100644 index 00000000..f4c7d119 --- /dev/null +++ b/.claude/commands/regen-api-types.md @@ -0,0 +1,32 @@ +--- +description: Regenerate TypeScript types from the OpenAPI specs and fix resulting type errors +allowed-tools: Bash, Read, Edit, Grep, Glob +argument-hint: "[feed | gbfs | user | all]" +--- + +Regenerate generated API types. Target: $ARGUMENTS (default `all`). + +Never hand-edit the generated files and never hand-write API response types — they come from the specs in +`external_types/`. + +| Target | Command | Output | +|---|---|---| +| `feed` | `yarn generate:api-types` | `src/app/services/feeds/types.ts` | +| `gbfs` | `yarn generate:gbfs-validator-types` | `src/app/services/feeds/gbfs-validator-types.ts` | +| `user` | `yarn generate:user-api-types` | `src/app/services/user-service-api-types.ts` | + +Steps: + +1. If a spec in `external_types/` was updated, confirm that with `git diff` first so you know what changed. +2. Run the relevant command(s). The gbfs/user scripts pipe through `eslint --fix` automatically. +3. `git diff --stat` the generated file, then review the **semantic** changes — new/removed/renamed schema + fields, changed optionality, changed enum members. Summarize those for the user; don't just say + "regenerated". +4. Run `npx tsc --noEmit` to find call sites broken by the new types, and fix them. +5. If a schema alias or type guard in `src/app/services/feeds/utils.ts` needs updating to cover a new type, + update it there — that module is the ergonomic layer everything else should consume instead of indexing + raw `paths[...]`. +6. Run `yarn lint` and `yarn test:ci`. + +Report the semantic diff and anything that needs a human decision (e.g. a field that became optional and +now needs a null check with a real fallback, not just `?? ''`). diff --git a/.claude/commands/verify.md b/.claude/commands/verify.md new file mode 100644 index 00000000..be356384 --- /dev/null +++ b/.claude/commands/verify.md @@ -0,0 +1,29 @@ +--- +description: Run the full local CI gate — lint, unit tests, and a typecheck +allowed-tools: Bash(yarn lint), Bash(yarn lint:fix), Bash(yarn test:ci), Bash(npx tsc --noEmit), Read, Edit, Grep, Glob +argument-hint: "[--fix]" +--- + +Run this project's complete pre-PR verification and report results honestly. + +Arguments: $ARGUMENTS (if `--fix` is passed, run `yarn lint:fix` first) + +Run all three in parallel where possible, since they're independent: + +1. `yarn lint` — ESLint over `src`. Remember `prettier/prettier` is an **error** here, so formatting + failures fail CI. +2. `yarn test:ci` — the exact Jest command CI runs. +3. `npx tsc --noEmit` — **not part of CI and there is no `typecheck` script.** CI only catches type errors + indirectly via `next build`, so this is the step most likely to surface something new. Run it. + +Then: + +- Fix what you broke. Do **not** "fix" violations of rules that are deliberately disabled in + `eslint.config.mjs` — notably all `react-hooks/exhaustive-deps` and `rules-of-hooks`, and the + `no-unsafe-*` family. +- If `tsc` reports pre-existing errors unrelated to the current change, say so and list them separately + rather than folding them into your own work. +- Report each command's actual pass/fail state with the relevant output. If something fails and you can't + fix it, say that plainly instead of hedging. + +Note: this does not run Cypress. For e2e use `/e2e`. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..e3bd235e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,50 @@ +{ + "permissions": { + "allow": [ + "Bash(yarn install)", + "Bash(yarn lint)", + "Bash(yarn lint:fix)", + "Bash(yarn test)", + "Bash(yarn test:ci)", + "Bash(yarn test:watch)", + "Bash(yarn build:prod)", + "Bash(yarn build:analyze)", + "Bash(yarn e2e:run)", + "Bash(yarn generate:api-types)", + "Bash(yarn generate:gbfs-validator-types)", + "Bash(yarn generate:user-api-types)", + "Bash(npx tsc --noEmit)", + "Bash(npx jest:*)", + "Bash(npx eslint:*)", + "Bash(npx prettier --check:*)", + "Bash(git status)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git branch:*)", + "Bash(git stash list)", + "Bash(git worktree list)", + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh pr diff:*)", + "Bash(gh run list:*)", + "Bash(gh run view:*)", + "Bash(gh issue view:*)", + "Bash(gh issue list:*)" + ], + "ask": [ + "Bash(git push:*)", + "Bash(gh pr create:*)", + "Bash(gh pr merge:*)", + "Bash(vercel:*)", + "Bash(npx firebase:*)", + "Bash(firebase:*)" + ], + "deny": [ + "Read(./.env)", + "Read(./.env.*)", + "Read(./cypress.env.json)" + ] + } +} diff --git a/.claude/skills/mobility-auth/SKILL.md b/.claude/skills/mobility-auth/SKILL.md new file mode 100644 index 00000000..87e9faf8 --- /dev/null +++ b/.claude/skills/mobility-auth/SKILL.md @@ -0,0 +1,133 @@ +--- +name: mobility-auth +description: How authentication works in this app — server-side GCIP/IAP token minting, the md_session cookie, end-user context propagation, and Firebase Admin setup. Load this when touching anything that calls the Mobility Feed API from the server, mints or verifies tokens, reads md_session, adds a protected route or API route, changes login/logout/session-renewal behavior, or debugs 401/403 responses, "Firebase app already exists", or "Invalid GCIP ID token" errors. +--- + +# Authentication in Mobility Database Web + +Canonical reference: `docs/Authentication.md`. This skill is the operational summary — read the doc for +env-var detail, Vercel deployment, and the full troubleshooting list. + +## The one rule that explains the design + +**The client's Firebase ID token is never forwarded to the server for API calls.** The Mobility Feed API is +behind Google Cloud IAP with Identity Platform (GCIP), and the server mints its own credentials. A client +token reaching a server API call is a bug. + +Two independent tokens are in play: + +| Token | Purpose | Origin | +|---|---|---| +| GCIP ID token | Satisfies IAP — `Authorization: Bearer` | Server-minted for a **service UID**, cached, refreshed ~5 min before expiry | +| User-context JWT | Tells the backend *which end user* — `x-mdb-user-context` | The `md_session` cookie's own JWT, re-used verbatim | + +## Making an authenticated server-side API call + +```tsx +import { getSSRAccessToken, getUserContextJwtFromCookie } from '../../utils/auth-server'; + +const [accessToken, userContextJwt] = await Promise.all([ + getSSRAccessToken(), + getUserContextJwtFromCookie(), +]); +const feed = await getGtfsFeed(feedId, accessToken, userContextJwt); +``` + +Parallelize with `Promise.all` — these are independent. `getSSRAccessToken()` (`src/app/utils/auth-server.ts`) +is the **canonical** token provider; don't call `getGcipIdToken()` directly. The service functions in +`src/app/services/feeds/index.ts` all take `(…, accessToken, userContextJwt?)` and apply +`generateAuthMiddlewareWithToken(accessToken, userContextJwt?)` +(`src/app/services/api-auth-middleware.ts`) via a per-call use/eject pattern — the middleware is +deliberately **not** registered globally, so don't "simplify" it into one. + +## The `md_session` cookie + +Server-signed JWT, httpOnly, `sameSite=lax`, `secure` in production, **1 hour** TTL. Payload: +`uid`, optional `email`, `isGuest`, `iat`, `exp`. Signed with `NEXT_SESSION_JWT_SECRET`. + +Lifecycle: + +1. Client signs in with Firebase Auth (email/password, provider, or anonymous). +2. A Redux saga calls `setUserCookieSession()` (`src/app/services/session-service.ts`), which POSTs the + Firebase ID token to `/api/session`. +3. `src/app/api/session/route.ts` verifies that token with Firebase Admin, derives + `isGuest` from `sign_in_provider === 'anonymous'`, signs the session JWT, sets the cookie. +4. `AuthSessionProvider` (`src/app/components/AuthSessionProvider.tsx`) re-checks every 5 minutes and on + every `onIdTokenChanged`, renewing when stale. On a renewal it also refreshes user feature flags. +5. Logout → `DELETE /api/session` clears both `md_session` and `md_features`. + +Reading it server-side: + +- `getCurrentUserFromCookie()` → decoded `SessionPayload` (who the user is) +- `getUserContextJwtFromCookie()` → the raw verified JWT (to forward to the backend) +- `isMobilityDatabaseAdmin(email)` for admin checks + +Sign/verify helpers live in `src/app/utils/session-jwt.ts`. `auth-server.ts` is `import 'server-only'` — +never import it from a client component. + +## Firebase Admin + +Initialize **only** via `getFirebaseAdminApp()` in `src/lib/firebase-admin.ts`. It reuses an existing app +(avoiding "Firebase app already exists"), prefers inline `GOOGLE_SA_JSON` over `GOOGLE_SA_JSON_PATH`, and +**fails fast rather than falling back to ADC** — that fail-fast is intentional; ADC fallback causes +confusing metadata-server errors locally and in serverless. + +Client-side Firebase is the **compat** SDK (`src/firebase.ts`, `firebase/compat/app` + `firebase/compat/auth`), +though modular `firebase/auth` types are used in places. Under Cypress it points at the auth emulator on +:9099. + +## Client-side auth state + +Two surfaces, and they are not interchangeable: + +- **Redux** `userProfile` (`src/app/store/profile-reducer.ts`) — `status` is a 10-value union. The + load-bearing distinction is `registered` vs `authenticated`, not merely logged-in/out. Selectors in + `profile-selectors.ts`. +- **`useAuthSession()`** (`AuthSessionProvider`) — session/cookie readiness, used by the context providers. + +Auth logic lives in `src/app/store/saga/auth-saga.ts` (login variants, signup, logout, email verification, +password change/reset, token refresh, cookie session, flag application, cross-tab broadcast). +Cross-tab sync goes through `LOGIN_CHANNEL` / `LOGOUT_CHANNEL` in `src/app/services/channel-service.ts`. + +## Protecting things + +- **Pages**: wrap in `components/ProtectedPageWrapper.tsx` (plus `ReduxGateWrapper` where rehydrated state + or `useSearchParams()` is needed). +- **Feed detail**: don't hand-roll — `src/proxy.ts` already routes authed users to `.../authed/` and guests + to `.../static/`. See the `mobility-feed-caching` skill. +- **Server actions and route handlers**: authenticate them like API routes (skill rule + `server-auth-actions`). Verify the caller; don't trust a client-supplied body. + +Known gap: `POST /api/feature-flags` does **not** verify its caller, unlike `/api/session`. Acceptable only +because today's flags are UI-only. Before any flag gates real access, add idToken verification there. + +## Local development without Firebase access + +Mock mode bypasses both the real API and Firebase: + +```bash +npx msw init public/ # one time +NEXT_PUBLIC_API_MOCKING=enabled yarn start:dev:mock +``` + +`NEXT_PUBLIC_API_MOCKING=enabled` is checked in `auth-server.ts` (relaxes server auth), +`remote-config.server.ts` (returns defaults), `src/instrumentation.ts` (starts the MSW node server), and +`providers.tsx` (starts the browser worker). `LOCAL_DEV_NO_ADMIN=1` additionally bypasses Admin init. + +## Env vars + +Server-only: `GOOGLE_SA_JSON` (inline JSON, preferred) or `GOOGLE_SA_JSON_PATH`, `NEXT_SESSION_JWT_SECRET`, +`GCIP_API_KEY`, optional `GCIP_TENANT_ID` / `GCIP_SERVICE_UID` (default `iap-service-caller`). +Shared: `NEXT_PUBLIC_FIREBASE_PROJECT_ID`. Service account JSON must contain `project_id`, `client_email`, +`private_key`. Never expose any of these to client code. + +## Debugging + +| Symptom | Cause | +|---|---| +| "Firebase app already exists" | Admin initialized outside `getFirebaseAdminApp()` | +| "Invalid GCIP ID token" | Sent a Google OIDC token; IAP+Identity Platform requires a **GCIP** token | +| `ENOTFOUND` / metadata errors | No explicit credentials — ADC fallback attempt; set `GOOGLE_SA_JSON` | +| 401/403 from the Feed API | Missing or stale `accessToken`, or `getSSRAccessToken()` bypassed | +| Backend can't identify the user | `userContextJwt` not threaded through to the service call | +| MSW not intercepting | `NEXT_PUBLIC_API_MOCKING` unset, or `public/mockServiceWorker.js` missing | diff --git a/.claude/skills/mobility-feature-flags/SKILL.md b/.claude/skills/mobility-feature-flags/SKILL.md new file mode 100644 index 00000000..86b334bd --- /dev/null +++ b/.claude/skills/mobility-feature-flags/SKILL.md @@ -0,0 +1,140 @@ +--- +name: mobility-feature-flags +description: The two feature-flag systems in this app — global Firebase Remote Config and per-user HMAC-cookie flags — and how to choose, add, read, and test a flag. Load this when adding or removing a feature flag, gating UI or behavior behind a flag, reading flags in a Server Component or client component, touching RemoteConfig.ts / UserFeatureFlags.ts / UserFeatureFlagProvider / RemoteConfigProvider / actions/feature-flags.ts, or debugging a flag that reads stale, false, or flashes on load. +--- + +# Feature flags + +This app has **two independent flag systems**. Picking the wrong one is the most common mistake. + +| | Firebase Remote Config | User feature flags | +|---|---|---| +| Scope | Global — same for everyone | Per-user | +| Source of truth | Firebase Remote Config | `GET /v1/user` → HMAC-signed `md_features` cookie | +| Define in | `src/app/interface/RemoteConfig.ts` | `src/app/interface/UserFeatureFlags.ts` | +| Server read | `getRemoteConfigValues()` / `getUserRemoteConfigValues()` | `getServerFlags()` | +| Client read | `useRemoteConfig()` → `{ config }` | `useUserFeatureFlags()` → flags directly | +| Cache | 5 min dev / 1 hour prod | Cookie, 1 hour TTL | +| Requires login | No | Yes | + +**Choose Remote Config** for a global rollout toggle or kill switch — something you flip for everyone +without a deploy. **Choose user flags** when the backend decides per user (entitlements, per-account +rollout). If the backend already returns it in `UserProfile.features[]`, it's a user flag. + +## Firebase Remote Config + +Definition and defaults: `src/app/interface/RemoteConfig.ts` — add your key to the `RemoteConfigValues` +interface **and** to `defaultRemoteConfigValues`. + +Server (`src/lib/remote-config.server.ts`, `server-only`): + +```tsx +const config = await getUserRemoteConfigValues(); // reads the session cookie itself, applies admin bypass +if (config.enableFeedStatusBadge) { /* … */ } +``` + +- `getRemoteConfigValues()` — plain global values, React `cache()` + `unstable_cache` with tag + `remote-config`. +- `getUserRemoteConfigValues()` — same, but reads the session cookie and applies the admin bypass. Prefer + this in Server Components so admins see gated features; no prop threading needed. +- `refreshRemoteConfig()` — on-demand `revalidateTag('remote-config')`. + +Client: + +```tsx +'use client'; +import { useRemoteConfig } from '../context/RemoteConfigProvider'; +const { config } = useRemoteConfig(); +``` + +The provider hydrates from the server value passed through `[locale]/layout.tsx` → ``, +then re-applies the admin bypass once `isAuthReady`. + +**Admin bypass**: `featureFlagBypass` is a JSON string like `{ "regex": [".+@example.org"] }`. When the +signed-in email matches, `applyAdminBypass()` flips **every boolean flag to true**. Note that +`RemoteConfig.ts` carries a stale `// FEATUTRE BYPASS CURRENTLY DISABLED` comment — the bypass **is** wired +and active. Don't trust that comment. + +**Static-page caveat**: for statically rendered pages the values are baked at build time and persist for the +page's cache lifetime. Flipping a Remote Config value in production generally warrants a redeploy. + +## Per-user flags + +Canonical reference: `docs/user-feature-flags.md` (includes the full architecture diagram and the rationale +for rejecting Redux). Read it before changing the mechanism. + +### Adding one — a single file + +```ts +// src/app/interface/UserFeatureFlags.ts +export interface UserFeatureFlags { + isNotificationsEnabled: boolean; + myNewFlag: boolean; // add here +} + +export const defaultUserFeatureFlags: UserFeatureFlags = { + isNotificationsEnabled: false, + myNewFlag: false, // and here +}; +``` + +`UserFeatureFlagId`, `useUserFeatureFlags()`, and `toUserFeatureFlags()` all pick it up automatically — +`toUserFeatureFlags()` merges unknown keys gracefully and falls back to the default. **Default to `false`** +so a missing or unverifiable cookie fails closed. + +All flags are `boolean` today. `toUserFeatureFlags()` does not check the API's `value_type`; if you add a +non-boolean flag, add a `value_type === 'boolean'` guard before widening the pattern. + +### Reading + +```tsx +// Server Component +import { getServerFlags } from '../actions/feature-flags'; +const { myNewFlag } = await getServerFlags(); + +// Client component +'use client'; +import { useUserFeatureFlags } from '../context/UserFeatureFlagProvider'; +const { myNewFlag } = useUserFeatureFlags(); +``` + +### How it moves (read path ≠ write path) + +- **Write**: login saga (or hourly renewal via `AuthSessionProvider`) gets `UserProfile.features[]` from + `GET /v1/user`, calls `applyUserFeatureFlags()` → `POST /api/feature-flags`, which HMAC-SHA256-signs the + payload into the httpOnly `md_features` cookie (1 h). On success it broadcasts the resolved flags on + `FEATURE_FLAGS_CHANNEL`, which also fires the sending tab's own listener — so **no read-after-write round + trip**, and every open tab updates at once. +- **Read (server)**: `getServerFlags()` (`src/app/actions/feature-flags.ts`) reads the cookie, verifies the + HMAC with `timingSafeEqual` against `NEXT_SESSION_JWT_SECRET`, returns defaults on any failure. +- **Read (client)**: `UserFeatureFlagProvider` holds **ephemeral React state** — not Redux, not persisted. + It's seeded server-side from `[locale]/layout.tsx` → ``, so the first render is + flash-free. It resets to defaults when `isAuthenticated` goes false. + +Failures on the write path are deliberately swallowed — stale flags beat a broken login or session renewal. + +### Security limitation — read before gating anything real + +`POST /api/feature-flags` **does not verify the caller**. It signs whatever array it's handed. That's an +accepted tradeoff only because today's flags are UI conveniences. Before adding a flag that gates real +access (paywall, admin capability), that route needs `/api/session`-style treatment: accept a Firebase ID +token, verify it server-side, and resolve flags from the user service rather than trusting the client. +Enforce actual access server-side regardless. + +## Gotchas + +- `isNotificationsEnabled` exists in **both** systems. The live consumer + (`screens/Feed/components/ClientSubscribeControls.tsx`) reads it from `useRemoteConfig()`. Real + duplication — check which one a given call site means before changing either. +- `useUserFeatureFlags()` currently has no production consumers; the plumbing is newer than its usage. +- Redux was rejected on purpose: `redux-persist` writes to `localStorage`, which leaks one user's flags to + the next on a shared device. Don't move flags into Redux. +- Don't call `cookies()` from a `static/` feed-detail route to read flags — it breaks static rendering. + +## Testing + +- Jest: `next-intl` is globally mocked and returns keys; mock `getServerFlags` or the provider directly. +- Cypress: the provider exposes `window.__featureFlags` under Cypress (mirroring the `window.store` trick), + and specs intercept `POST **/api/feature-flags`. See `cypress/e2e/userFeatureFlags.cy.ts`. +- The session-renewal block in that spec is `describe.skip` — the renewal interval doesn't fire under CI's + `next start`. Don't un-skip it without fixing that. diff --git a/.claude/skills/mobility-feed-caching/SKILL.md b/.claude/skills/mobility-feed-caching/SKILL.md new file mode 100644 index 00000000..e4bd14f1 --- /dev/null +++ b/.claude/skills/mobility-feed-caching/SKILL.md @@ -0,0 +1,100 @@ +--- +name: mobility-feed-caching +description: The feed-detail routing and caching architecture — src/proxy.ts rewriting authed vs static routes, ISR page TTLs, per-user data caches, and cache invalidation via /api/revalidate. Load this when editing anything under src/app/[locale]/feeds/[feedDataType]/[feedId]/, changing src/proxy.ts or proxy-helpers, adding or fetching feed-detail data, setting revalidate/dynamic exports, touching the revalidate endpoint or cache tags, or debugging a stale feed page, an unexpected 404 on a feed route, or x-nextjs-cache MISS/HIT behavior. +--- + +# Feed detail routing & caching + +Canonical references: `docs/feed-detail-caching-flow.md` (sequence diagram) and +`src/app/api/revalidate/README.md` (endpoint contract). + +## The core idea: one URL, two renderings + +Users and crawlers see clean URLs — `/feeds/{type}/{id}` and `/feeds/{type}/{id}/map`. `src/proxy.ts` +(Next 16's renamed `middleware.ts`) rewrites each request to one of two route trees based on the +`md_session` cookie: + +| Visitor | Rewritten to | Header set | Page cache | Data cache | +|---|---|---|---|---| +| Authenticated, not guest | `.../[feedId]/authed/...` | `x-mdb-authed-proxy: 1` | none (private) | per user+feed, 10 min | +| Guest / anonymous / logged out | `.../[feedId]/static/...` | `x-mdb-static-proxy: 1` | ISR, ~14 days at the edge | shared public, ~14 days | + +Why: anonymous traffic is the vast majority and is identical for everyone, so it gets full edge page +caching for fast LCP and good SEO. Authenticated pages show per-user data (subscriptions, admin controls) +so the *page* is never shared — only its API calls are cached, per user, briefly. + +`src/proxy.ts` also injects the default locale when the path has none. + +## Rules for editing these routes + +- **`static/` routes must never call `cookies()` or `headers()`.** That opts the route out of static + rendering and silently destroys the caching strategy. Anything user-specific belongs in `authed/`. +- **`authed/layout.tsx` is a security gate**: `force-dynamic`, and it returns `notFound()` unless + `x-mdb-authed-proxy` is present. A direct hit on `/authed/...` 404s by design. Same for `/static/...` + without its header. If you see an unexpected 404 on a feed route, check the proxy rewrite first. +- **Reuse `src/app/utils/proxy-helpers.ts`** — `isFeedDetailPage`, `isAuthenticatedNotGuest`, + `rewriteFeedRequest`, `hasLocaleInPathname`, `rewriteWithDefaultLocale`, `AUTHED_PROXY_HEADER`, + `STATIC_PROXY_HEADER`. Don't re-parse pathnames by hand; this module has a 295-line spec covering it. +- Keep the two trees in sync. A change to feed-detail rendering usually needs applying to both `authed/` + and `static/` (and their `map/` children). + +## The data layer — reuse, don't re-fetch + +Under `src/app/[locale]/feeds/[feedDataType]/[feedId]/lib/`: + +| Module | Role | +|---|---| +| `feed-data-shared.ts` | The actual fetchers: `fetchFeedByType`, `fetchDatasets`, `fetchRelatedFeeds`, `fetchRoutesData`, `fetchCompleteFeedDataImpl`, type `FeedDataResult` | +| `feed-data.ts` | `fetchCompleteFeedData` — authed path. React `cache()` + `unstable_cache` keyed by **userId + feedId**, `revalidate: 600` (10 min) | +| `guest-feed-data.ts` | `fetchGuestFeedData` — guest path. Shared cache, `revalidate: 1209600` (14 days) | +| `generate-feed-metadata.ts`, `FeedJsonLd.tsx` | SEO metadata and JSON-LD | + +Call the path-appropriate wrapper rather than the raw fetchers, so you inherit the right cache key and TTL. +The authed path is where the `Promise.all([getSSRAccessToken(), getUserContextJwtFromCookie(), getCurrentUserFromCookie()])` +pattern lives — parallelize; these are independent. + +The `static/layout.tsx` sets `revalidate = 1209600`; `authed/layout.tsx` sets `dynamic = 'force-dynamic'`. +If you change a TTL, change it in the layout **and** the matching data wrapper, or the page and its data +will disagree. + +## Invalidation + +Never hand-write `revalidatePath`/`revalidateTag` calls for feeds — use +`src/app/utils/revalidate-feeds.ts`, which owns the tag/path vocabulary (`feed-type-gtfs`, etc.): + +`revalidateSpecificFeeds`, `revalidateAllFeeds`, `revalidateAllGtfsFeeds`, `revalidateAllGtfsRtFeeds`, +`revalidateAllGbfsFeeds`, `revalidateFullSite`. + +External entrypoint `src/app/api/revalidate/route.ts`: + +- `POST` with header `x-revalidate-secret` — used by a GCP workflow when feed data changes. + `type ∈ full | all-feeds | all-gtfs-feeds | all-gtfs-rt-feeds | all-gbfs-feeds | specific-feeds`. +- `GET` with `Authorization: Bearer $CRON_SECRET` — Vercel cron (schedules in `vercel.json`: 4am UTC + Mon–Sat, 7am UTC Sun) revalidates all GBFS feeds. + +Both anonymous edge pages (base + `/map`) and the public data cache must be invalidated together — that's +what these helpers do. Remote Config has its own separate tag (`refreshRemoteConfig()`). + +## The legacy route hack + +`src/app/[locale]/feeds/[feedDataType]/page.tsx` receives a **feedId** in the `feedDataType` slot — old +inbound links. It looks the feed up, then `notFound()` or `redirect('/feeds/{data_type}/{feedId}')`. It's +documented in a docblock at the top of the file. Leave the naming alone; just don't copy the pattern. + +## Debugging + +| Symptom | Where to look | +|---|---| +| Unexpected 404 on a feed route | Proxy rewrite / missing `x-mdb-authed-proxy` or `x-mdb-static-proxy` header | +| Stale feed page for guests | 14-day ISR TTL — needs `/api/revalidate`, not a redeploy wait | +| Authed user sees another user's data | Data cache key missing `userId`; check you used `fetchCompleteFeedData` | +| Guest page rendering dynamically | Something in the `static/` tree calls `cookies()`/`headers()` | +| Cache header assertions | `cypress/e2e/feed-isr-caching.cy.ts` asserts `x-nextjs-cache: MISS/HIT/STALE` and busts cache via `POST /api/revalidate` with `REVALIDATE_SECRET` | + +Note `docs/feed-detail-caching-flow.md` writes the cookie as `session_md`; the code uses **`md_session`**. + +## Related + +- Token minting and the session cookie → `mobility-auth` skill +- Server-side caching and parallel-fetch rules (`server-cache-react`, `server-parallel-fetching`, + `async-parallel`) → `vercel-react-best-practices` skill diff --git a/.claude/skills/mobility-testing/SKILL.md b/.claude/skills/mobility-testing/SKILL.md new file mode 100644 index 00000000..a0e05efe --- /dev/null +++ b/.claude/skills/mobility-testing/SKILL.md @@ -0,0 +1,153 @@ +--- +name: mobility-testing +description: How to write tests that actually pass in this repo — the global Jest mocks (next-intl returns keys, fetch is a bare jest.fn, no MSW in Jest), the pure-function-first idiom, the node environment pragma for server code, transformIgnorePatterns, and Cypress emulator auth patterns. Load this before writing or fixing any Jest spec or Cypress e2e spec, or when a test fails with "Cannot use import statement outside a module", an unexpected translation-key assertion, or a fetch that returns undefined. +--- + +# Testing in Mobility Database Web + +Unit: Jest + React Testing Library, co-located `*.spec.ts(x)` under `src/`. E2E: Cypress in `cypress/e2e/`. +Currently 19 spec files / 195 tests, all green. There are **no `__tests__/` directories** and no +`*.test.tsx` files — follow the co-located `.spec` convention. + +```bash +yarn test # jest +yarn test:watch +yarn test:ci # CI=true jest <- the exact CI command +``` + +Tests must live under `src/` — `testMatch` never picks up anything in `cypress/`. + +## Prefer testing pure functions + +14 of 19 specs test extracted helpers rather than rendering components. When logic is worth testing, extract +it to a named export or a `lib/` module and test that. Reach for `render()` only when the assertion is +genuinely about the DOM. `src/app/screens/Feed/Feed.spec.tsx` even uses `renderToStaticMarkup` for +metadata-generator output instead of RTL. + +## Already mocked globally — do not redo these + +From `src/setupTests.ts`, applied to **every** Jest test: + +1. `@testing-library/jest-dom` matchers. +2. **`next-intl` is mocked: `useTranslations()` returns an identity function.** So `t('myKey')` renders + `"myKey"`. **Assert on translation keys, never English strings.** `useLocale()` returns `'en'`. +3. `next-intl/server`: `getTranslations()` is identity except a tiny hardcoded table (`common.others`, + `common.gtfsSchedule` → `'GTFS schedule'`, `common.gtfsRealtime` → `'GTFS realtime'`, + `feeds.detailPageDescription`). `getLocale()` → `'en'`. +4. **`global.fetch = jest.fn()`** — a bare mock returning `undefined`. Any test hitting fetch must set + `(global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: async () => ({...}) })`. + **MSW is not wired into Jest at all** — don't expect `src/mocks/handlers.ts` to fire. +5. `firebase/auth` provider constructors (`GoogleAuthProvider`, `GithubAuthProvider`, `OAuthProvider`). +6. `next/server` — a hand-rolled `NextResponse` (`next`/`rewrite`/`redirect`/`json` return plain objects + with `{ body, status, json, ok, headers }`), for route-handler and proxy tests. +7. `TextEncoder` polyfill. + +`jest-global-setup.ts` forces `process.env.TZ = 'UTC'`, so date assertions are deterministic. + +## Per-test patterns + +**Mock navigation locally** whenever a component navigates — `next-intl` itself is already handled: + +```tsx +jest.mock('../../../i18n/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + usePathname: () => '/', +})); +``` + +**Wrap MUI components in the real theme.** There is deliberately **no shared `renderWithProviders` helper** — +each spec wraps ad hoc, and you must **not** wrap in the app's `` (it starts MSW and Firebase): + +```tsx +import { ThemeProvider } from '@mui/material/styles'; +import { theme } from '../../Theme'; + +render(); +``` + +**Server code needs the node environment** — route handlers, proxy logic, and `server-only` modules: + +```ts +/** + * @jest-environment node + */ +``` + +See `src/app/api/revalidate/route.spec.ts` and `src/lib/remote-config.server.spec.ts`. Those also show the +env-restoration idiom: `jest.clearAllMocks(); process.env = { ...originalEnv };` in `beforeEach`. + +**Partial-mock a module** to keep the real exports you need: + +```tsx +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); +``` + +**Capturing a Firebase auth callback** (the fullest example is +`src/app/components/AuthSessionProvider.spec.tsx`): mock the app's own `src/firebase` module, capture the +`onIdTokenChanged` callback, then drive auth state by invoking it inside `act()`. Build throwaway stores with +a local `makeStore()` and pass a `wrapper` to `renderHook`. + +Fixtures are large inline typed objects (`const mockFeedsData: AllFeedsType = {…}`), not factories. +Query via `screen` / `within`, selecting by `data-testid`, role, or text. + +## ESM dependencies + +`transformIgnorePatterns` in `jest.config.ts` re-enables transformation for a specific allowlist: +`*.mjs`, `@mui`, `@babel`, `uuid`, `nanoid`, `countries-list`, `@turf`, `openapi-fetch`, `next-intl`. + +If you add an ESM-only dependency imported (even transitively) by tested code and see +**"SyntaxError: Cannot use import statement outside a module"**, add it to that list. + +## Other config facts + +- `moduleNameMapper` maps `@/*` → `src/*` in Jest, **but `tsconfig.json` has no such alias** — so `@/` + imports pass tests and break `next build`. Use relative imports. +- No coverage thresholds; coverage isn't collected unless you pass `--coverage`. CI doesn't enforce it. +- `yarn lint` covers `src` only, so Cypress specs are unlinted — still match the Prettier style + (single quotes incl. JSX, semicolons, 80 cols). + +## Cypress + +```bash +yarn e2e:setup # next build + start :3001 (MSW) + Firebase auth emulator :9099 <- CI uses this +yarn e2e:setup:dev # same but next dev — faster iteration +yarn e2e:run # cypress run against :3001 +yarn e2e:open # interactive +``` + +Env comes from `.env.development` locally, `.env.local` in CI (created by `vercel env pull`); everything in +it is available as `Cypress.env('KEY')`. `baseUrl` is forced to :3001 by the scripts. + +Two auth paths — pick deliberately: + +- **`cy.createNewUserAndSignIn(email, password)`** — the real path against the emulator. It signs out, then + **wipes all emulator accounts**, then creates the user. So specs must not assume other users exist, and + ordering matters. +- **`cy.injectAuthenticatedUser(email)`** — the shortcut: dispatches `userProfile/loginSuccess` straight + into `window.store` (exposed only under Cypress, see `src/app/store/store.ts`). Use when the test is about + something downstream of login, not login itself. + +Other commands: `cy.muiDropdownSelect(elementKey, dataValue)`, `cy.assetMuiError(elementKey)` (sic — it +asserts `Mui-error`). Declared in `cypress/support/index.ts`, implemented in `cypress/support/commands.ts`. + +Data comes from **two layers**: MSW handlers (`src/mocks/handlers.ts`, fixtures imported from +`cypress/fixtures/`) for the shared feed catalog, plus per-test `cy.intercept()` for user/session endpoints +(`GET|PUT **/v1/user`, `POST **/api/feature-flags`, `DELETE **/api/session`, …). To add a mocked catalog +endpoint, append to the handlers array — both the browser worker and the node server pick it up. + +`UserFeatureFlagProvider` also exposes `window.__featureFlags` under Cypress. + +Select by `data-testid` almost exclusively. + +**Currently skipped**: the session-renewal `describe.skip` in `cypress/e2e/userFeatureFlags.cy.ts` — the +renewal interval never fires under CI's `next start`, though it passes locally. Don't un-skip without +fixing the underlying cause. + +## What CI actually gates + +`yarn lint` and `yarn test:ci` for all PRs including forks. Cypress e2e, the Vercel preview, and Lighthouse +run only for same-repo PRs. **Lighthouse has no assertions — it can never block a merge.** No +`tsc --noEmit` step anywhere: type errors surface only via `next build` (which `yarn e2e:setup` runs). diff --git a/.claude/skills/vercel-react-best-practices/AGENTS.md b/.claude/skills/vercel-react-best-practices/AGENTS.md new file mode 100644 index 00000000..e53dde10 --- /dev/null +++ b/.claude/skills/vercel-react-best-practices/AGENTS.md @@ -0,0 +1,2934 @@ +# React Best Practices + +**Version 1.0.0** +Vercel Engineering +January 2026 + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring React and Next.js codebases at Vercel. Humans +> may also find it useful, but guidance here is optimized for automation +> and consistency by AI-assisted workflows. + +--- + +## Abstract + +Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. + +--- + +## Table of Contents + +1. [Eliminating Waterfalls](#1-eliminating-waterfalls) — **CRITICAL** + - 1.1 [Defer Await Until Needed](#11-defer-await-until-needed) + - 1.2 [Dependency-Based Parallelization](#12-dependency-based-parallelization) + - 1.3 [Prevent Waterfall Chains in API Routes](#13-prevent-waterfall-chains-in-api-routes) + - 1.4 [Promise.all() for Independent Operations](#14-promiseall-for-independent-operations) + - 1.5 [Strategic Suspense Boundaries](#15-strategic-suspense-boundaries) +2. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL** + - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports) + - 2.2 [Conditional Module Loading](#22-conditional-module-loading) + - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries) + - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components) + - 2.5 [Preload Based on User Intent](#25-preload-based-on-user-intent) +3. [Server-Side Performance](#3-server-side-performance) — **HIGH** + - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes) + - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props) + - 3.3 [Cross-Request LRU Caching](#33-cross-request-lru-caching) + - 3.4 [Minimize Serialization at RSC Boundaries](#34-minimize-serialization-at-rsc-boundaries) + - 3.5 [Parallel Data Fetching with Component Composition](#35-parallel-data-fetching-with-component-composition) + - 3.6 [Per-Request Deduplication with React.cache()](#36-per-request-deduplication-with-reactcache) + - 3.7 [Use after() for Non-Blocking Operations](#37-use-after-for-non-blocking-operations) +4. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH** + - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners) + - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance) + - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication) + - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data) +5. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM** + - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering) + - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point) + - 5.3 [Do not wrap a simple expression with a primitive result type in useMemo](#53-do-not-wrap-a-simple-expression-with-a-primitive-result-type-in-usememo) + - 5.4 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#54-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant) + - 5.5 [Extract to Memoized Components](#55-extract-to-memoized-components) + - 5.6 [Narrow Effect Dependencies](#56-narrow-effect-dependencies) + - 5.7 [Put Interaction Logic in Event Handlers](#57-put-interaction-logic-in-event-handlers) + - 5.8 [Subscribe to Derived State](#58-subscribe-to-derived-state) + - 5.9 [Use Functional setState Updates](#59-use-functional-setstate-updates) + - 5.10 [Use Lazy State Initialization](#510-use-lazy-state-initialization) + - 5.11 [Use Transitions for Non-Urgent Updates](#511-use-transitions-for-non-urgent-updates) + - 5.12 [Use useRef for Transient Values](#512-use-useref-for-transient-values) +6. [Rendering Performance](#6-rendering-performance) — **MEDIUM** + - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element) + - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists) + - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements) + - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision) + - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering) + - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches) + - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide) + - 6.8 [Use Explicit Conditional Rendering](#68-use-explicit-conditional-rendering) + - 6.9 [Use useTransition Over Manual Loading States](#69-use-usetransition-over-manual-loading-states) +7. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM** + - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing) + - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups) + - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops) + - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls) + - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls) + - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations) + - 7.7 [Early Length Check for Array Comparisons](#77-early-length-check-for-array-comparisons) + - 7.8 [Early Return from Functions](#78-early-return-from-functions) + - 7.9 [Hoist RegExp Creation](#79-hoist-regexp-creation) + - 7.10 [Use Loop for Min/Max Instead of Sort](#710-use-loop-for-minmax-instead-of-sort) + - 7.11 [Use Set/Map for O(1) Lookups](#711-use-setmap-for-o1-lookups) + - 7.12 [Use toSorted() Instead of sort() for Immutability](#712-use-tosorted-instead-of-sort-for-immutability) +8. [Advanced Patterns](#8-advanced-patterns) — **LOW** + - 8.1 [Initialize App Once, Not Per Mount](#81-initialize-app-once-not-per-mount) + - 8.2 [Store Event Handlers in Refs](#82-store-event-handlers-in-refs) + - 8.3 [useEffectEvent for Stable Callback Refs](#83-useeffectevent-for-stable-callback-refs) + +--- + +## 1. Eliminating Waterfalls + +**Impact: CRITICAL** + +Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains. + +### 1.1 Defer Await Until Needed + +**Impact: HIGH (avoids blocking unused code paths)** + +Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them. + +**Incorrect: blocks both branches** + +```typescript +async function handleRequest(userId: string, skipProcessing: boolean) { + const userData = await fetchUserData(userId) + + if (skipProcessing) { + // Returns immediately but still waited for userData + return { skipped: true } + } + + // Only this branch uses userData + return processUserData(userData) +} +``` + +**Correct: only blocks when needed** + +```typescript +async function handleRequest(userId: string, skipProcessing: boolean) { + if (skipProcessing) { + // Returns immediately without waiting + return { skipped: true } + } + + // Fetch only when needed + const userData = await fetchUserData(userId) + return processUserData(userData) +} +``` + +**Another example: early return optimization** + +```typescript +// Incorrect: always fetches permissions +async function updateResource(resourceId: string, userId: string) { + const permissions = await fetchPermissions(userId) + const resource = await getResource(resourceId) + + if (!resource) { + return { error: 'Not found' } + } + + if (!permissions.canEdit) { + return { error: 'Forbidden' } + } + + return await updateResourceData(resource, permissions) +} + +// Correct: fetches only when needed +async function updateResource(resourceId: string, userId: string) { + const resource = await getResource(resourceId) + + if (!resource) { + return { error: 'Not found' } + } + + const permissions = await fetchPermissions(userId) + + if (!permissions.canEdit) { + return { error: 'Forbidden' } + } + + return await updateResourceData(resource, permissions) +} +``` + +This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive. + +### 1.2 Dependency-Based Parallelization + +**Impact: CRITICAL (2-10× improvement)** + +For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment. + +**Incorrect: profile waits for config unnecessarily** + +```typescript +const [user, config] = await Promise.all([ + fetchUser(), + fetchConfig() +]) +const profile = await fetchProfile(user.id) +``` + +**Correct: config and profile run in parallel** + +```typescript +import { all } from 'better-all' + +const { user, config, profile } = await all({ + async user() { return fetchUser() }, + async config() { return fetchConfig() }, + async profile() { + return fetchProfile((await this.$.user).id) + } +}) +``` + +**Alternative without extra dependencies:** + +```typescript +const userPromise = fetchUser() +const profilePromise = userPromise.then(user => fetchProfile(user.id)) + +const [user, config, profile] = await Promise.all([ + userPromise, + fetchConfig(), + profilePromise +]) +``` + +We can also create all the promises first, and do `Promise.all()` at the end. + +Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all) + +### 1.3 Prevent Waterfall Chains in API Routes + +**Impact: CRITICAL (2-10× improvement)** + +In API routes and Server Actions, start independent operations immediately, even if you don't await them yet. + +**Incorrect: config waits for auth, data waits for both** + +```typescript +export async function GET(request: Request) { + const session = await auth() + const config = await fetchConfig() + const data = await fetchData(session.user.id) + return Response.json({ data, config }) +} +``` + +**Correct: auth and config start immediately** + +```typescript +export async function GET(request: Request) { + const sessionPromise = auth() + const configPromise = fetchConfig() + const session = await sessionPromise + const [config, data] = await Promise.all([ + configPromise, + fetchData(session.user.id) + ]) + return Response.json({ data, config }) +} +``` + +For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization). + +### 1.4 Promise.all() for Independent Operations + +**Impact: CRITICAL (2-10× improvement)** + +When async operations have no interdependencies, execute them concurrently using `Promise.all()`. + +**Incorrect: sequential execution, 3 round trips** + +```typescript +const user = await fetchUser() +const posts = await fetchPosts() +const comments = await fetchComments() +``` + +**Correct: parallel execution, 1 round trip** + +```typescript +const [user, posts, comments] = await Promise.all([ + fetchUser(), + fetchPosts(), + fetchComments() +]) +``` + +### 1.5 Strategic Suspense Boundaries + +**Impact: HIGH (faster initial paint)** + +Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads. + +**Incorrect: wrapper blocked by data fetching** + +```tsx +async function Page() { + const data = await fetchData() // Blocks entire page + + return ( +
+
Sidebar
+
Header
+
+ +
+
Footer
+
+ ) +} +``` + +The entire layout waits for data even though only the middle section needs it. + +**Correct: wrapper shows immediately, data streams in** + +```tsx +function Page() { + return ( +
+
Sidebar
+
Header
+
+ }> + + +
+
Footer
+
+ ) +} + +async function DataDisplay() { + const data = await fetchData() // Only blocks this component + return
{data.content}
+} +``` + +Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data. + +**Alternative: share promise across components** + +```tsx +function Page() { + // Start fetch immediately, but don't await + const dataPromise = fetchData() + + return ( +
+
Sidebar
+
Header
+ }> + + + +
Footer
+
+ ) +} + +function DataDisplay({ dataPromise }: { dataPromise: Promise }) { + const data = use(dataPromise) // Unwraps the promise + return
{data.content}
+} + +function DataSummary({ dataPromise }: { dataPromise: Promise }) { + const data = use(dataPromise) // Reuses the same promise + return
{data.summary}
+} +``` + +Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together. + +**When NOT to use this pattern:** + +- Critical data needed for layout decisions (affects positioning) + +- SEO-critical content above the fold + +- Small, fast queries where suspense overhead isn't worth it + +- When you want to avoid layout shift (loading → content jump) + +**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities. + +--- + +## 2. Bundle Size Optimization + +**Impact: CRITICAL** + +Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint. + +### 2.1 Avoid Barrel File Imports + +**Impact: CRITICAL (200-800ms import cost, slow builds)** + +Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`). + +Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts. + +**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph. + +**Incorrect: imports entire library** + +```tsx +import { Check, X, Menu } from 'lucide-react' +// Loads 1,583 modules, takes ~2.8s extra in dev +// Runtime cost: 200-800ms on every cold start + +import { Button, TextField } from '@mui/material' +// Loads 2,225 modules, takes ~4.2s extra in dev +``` + +**Correct: imports only what you need** + +```tsx +import Check from 'lucide-react/dist/esm/icons/check' +import X from 'lucide-react/dist/esm/icons/x' +import Menu from 'lucide-react/dist/esm/icons/menu' +// Loads only 3 modules (~2KB vs ~1MB) + +import Button from '@mui/material/Button' +import TextField from '@mui/material/TextField' +// Loads only what you use +``` + +**Alternative: Next.js 13.5+** + +```js +// next.config.js - use optimizePackageImports +module.exports = { + experimental: { + optimizePackageImports: ['lucide-react', '@mui/material'] + } +} + +// Then you can keep the ergonomic barrel imports: +import { Check, X, Menu } from 'lucide-react' +// Automatically transformed to direct imports at build time +``` + +Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR. + +Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`. + +Reference: [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) + +### 2.2 Conditional Module Loading + +**Impact: HIGH (loads large data only when needed)** + +Load large data or modules only when a feature is activated. + +**Example: lazy-load animation frames** + +```tsx +function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch> }) { + const [frames, setFrames] = useState(null) + + useEffect(() => { + if (enabled && !frames && typeof window !== 'undefined') { + import('./animation-frames.js') + .then(mod => setFrames(mod.frames)) + .catch(() => setEnabled(false)) + } + }, [enabled, frames, setEnabled]) + + if (!frames) return + return +} +``` + +The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed. + +### 2.3 Defer Non-Critical Third-Party Libraries + +**Impact: MEDIUM (loads after hydration)** + +Analytics, logging, and error tracking don't block user interaction. Load them after hydration. + +**Incorrect: blocks initial bundle** + +```tsx +import { Analytics } from '@vercel/analytics/react' + +export default function RootLayout({ children }) { + return ( + + + {children} + + + + ) +} +``` + +**Correct: loads after hydration** + +```tsx +import dynamic from 'next/dynamic' + +const Analytics = dynamic( + () => import('@vercel/analytics/react').then(m => m.Analytics), + { ssr: false } +) + +export default function RootLayout({ children }) { + return ( + + + {children} + + + + ) +} +``` + +### 2.4 Dynamic Imports for Heavy Components + +**Impact: CRITICAL (directly affects TTI and LCP)** + +Use `next/dynamic` to lazy-load large components not needed on initial render. + +**Incorrect: Monaco bundles with main chunk ~300KB** + +```tsx +import { MonacoEditor } from './monaco-editor' + +function CodePanel({ code }: { code: string }) { + return +} +``` + +**Correct: Monaco loads on demand** + +```tsx +import dynamic from 'next/dynamic' + +const MonacoEditor = dynamic( + () => import('./monaco-editor').then(m => m.MonacoEditor), + { ssr: false } +) + +function CodePanel({ code }: { code: string }) { + return +} +``` + +### 2.5 Preload Based on User Intent + +**Impact: MEDIUM (reduces perceived latency)** + +Preload heavy bundles before they're needed to reduce perceived latency. + +**Example: preload on hover/focus** + +```tsx +function EditorButton({ onClick }: { onClick: () => void }) { + const preload = () => { + if (typeof window !== 'undefined') { + void import('./monaco-editor') + } + } + + return ( + + ) +} +``` + +**Example: preload when feature flag is enabled** + +```tsx +function FlagsProvider({ children, flags }: Props) { + useEffect(() => { + if (flags.editorEnabled && typeof window !== 'undefined') { + void import('./monaco-editor').then(mod => mod.init()) + } + }, [flags.editorEnabled]) + + return + {children} + +} +``` + +The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed. + +--- + +## 3. Server-Side Performance + +**Impact: HIGH** + +Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times. + +### 3.1 Authenticate Server Actions Like API Routes + +**Impact: CRITICAL (prevents unauthorized access to server mutations)** + +Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly. + +Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation." + +**Incorrect: no authentication check** + +```typescript +'use server' + +export async function deleteUser(userId: string) { + // Anyone can call this! No auth check + await db.user.delete({ where: { id: userId } }) + return { success: true } +} +``` + +**Correct: authentication inside the action** + +```typescript +'use server' + +import { verifySession } from '@/lib/auth' +import { unauthorized } from '@/lib/errors' + +export async function deleteUser(userId: string) { + // Always check auth inside the action + const session = await verifySession() + + if (!session) { + throw unauthorized('Must be logged in') + } + + // Check authorization too + if (session.user.role !== 'admin' && session.user.id !== userId) { + throw unauthorized('Cannot delete other users') + } + + await db.user.delete({ where: { id: userId } }) + return { success: true } +} +``` + +**With input validation:** + +```typescript +'use server' + +import { verifySession } from '@/lib/auth' +import { z } from 'zod' + +const updateProfileSchema = z.object({ + userId: z.string().uuid(), + name: z.string().min(1).max(100), + email: z.string().email() +}) + +export async function updateProfile(data: unknown) { + // Validate input first + const validated = updateProfileSchema.parse(data) + + // Then authenticate + const session = await verifySession() + if (!session) { + throw new Error('Unauthorized') + } + + // Then authorize + if (session.user.id !== validated.userId) { + throw new Error('Can only update own profile') + } + + // Finally perform the mutation + await db.user.update({ + where: { id: validated.userId }, + data: { + name: validated.name, + email: validated.email + } + }) + + return { success: true } +} +``` + +Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication) + +### 3.2 Avoid Duplicate Serialization in RSC Props + +**Impact: LOW (reduces network payload by avoiding duplicate serialization)** + +RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server. + +**Incorrect: duplicates array** + +```tsx +// RSC: sends 6 strings (2 arrays × 3 items) + +``` + +**Correct: sends 3 strings** + +```tsx +// RSC: send once + + +// Client: transform there +'use client' +const sorted = useMemo(() => [...usernames].sort(), [usernames]) +``` + +**Nested deduplication behavior:** + +```tsx +// string[] - duplicates everything +usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings + +// object[] - duplicates array structure only +users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4) +``` + +Deduplication works recursively. Impact varies by data type: + +- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated + +- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference + +**Operations breaking deduplication: create new references** + +- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]` + +- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())` + +**More examples:** + +```tsx +// ❌ Bad + u.active)} /> + + +// ✅ Good + + +// Do filtering/destructuring in client +``` + +**Exception:** Pass derived data when transformation is expensive or client doesn't need original. + +### 3.3 Cross-Request LRU Caching + +**Impact: HIGH (caches across requests)** + +`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache. + +**Implementation:** + +```typescript +import { LRUCache } from 'lru-cache' + +const cache = new LRUCache({ + max: 1000, + ttl: 5 * 60 * 1000 // 5 minutes +}) + +export async function getUser(id: string) { + const cached = cache.get(id) + if (cached) return cached + + const user = await db.user.findUnique({ where: { id } }) + cache.set(id, user) + return user +} + +// Request 1: DB query, result cached +// Request 2: cache hit, no DB query +``` + +Use when sequential user actions hit multiple endpoints needing the same data within seconds. + +**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis. + +**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching. + +Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache) + +### 3.4 Minimize Serialization at RSC Boundaries + +**Impact: HIGH (reduces data transfer size)** + +The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses. + +**Incorrect: serializes all 50 fields** + +```tsx +async function Page() { + const user = await fetchUser() // 50 fields + return +} + +'use client' +function Profile({ user }: { user: User }) { + return
{user.name}
// uses 1 field +} +``` + +**Correct: serializes only 1 field** + +```tsx +async function Page() { + const user = await fetchUser() + return +} + +'use client' +function Profile({ name }: { name: string }) { + return
{name}
+} +``` + +### 3.5 Parallel Data Fetching with Component Composition + +**Impact: CRITICAL (eliminates server-side waterfalls)** + +React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching. + +**Incorrect: Sidebar waits for Page's fetch to complete** + +```tsx +export default async function Page() { + const header = await fetchHeader() + return ( +
+
{header}
+ +
+ ) +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} +``` + +**Correct: both fetch simultaneously** + +```tsx +async function Header() { + const data = await fetchHeader() + return
{data}
+} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} + +export default function Page() { + return ( +
+
+ +
+ ) +} +``` + +**Alternative with children prop:** + +```tsx +async function Header() { + const data = await fetchHeader() + return
{data}
+} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} + +function Layout({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+ ) +} + +export default function Page() { + return ( + + + + ) +} +``` + +### 3.6 Per-Request Deduplication with React.cache() + +**Impact: MEDIUM (deduplicates within request)** + +Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most. + +**Usage:** + +```typescript +import { cache } from 'react' + +export const getCurrentUser = cache(async () => { + const session = await auth() + if (!session?.user?.id) return null + return await db.user.findUnique({ + where: { id: session.user.id } + }) +}) +``` + +Within a single request, multiple calls to `getCurrentUser()` execute the query only once. + +**Avoid inline objects as arguments:** + +`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits. + +**Incorrect: always cache miss** + +```typescript +const getUser = cache(async (params: { uid: number }) => { + return await db.user.findUnique({ where: { id: params.uid } }) +}) + +// Each call creates new object, never hits cache +getUser({ uid: 1 }) +getUser({ uid: 1 }) // Cache miss, runs query again +``` + +**Correct: cache hit** + +```typescript +const params = { uid: 1 } +getUser(params) // Query runs +getUser(params) // Cache hit (same reference) +``` + +If you must pass objects, pass the same reference: + +**Next.js-Specific Note:** + +In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks: + +- Database queries (Prisma, Drizzle, etc.) + +- Heavy computations + +- Authentication checks + +- File system operations + +- Any non-fetch async work + +Use `React.cache()` to deduplicate these operations across your component tree. + +Reference: [https://react.dev/reference/react/cache](https://react.dev/reference/react/cache) + +### 3.7 Use after() for Non-Blocking Operations + +**Impact: MEDIUM (faster response times)** + +Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response. + +**Incorrect: blocks response** + +```tsx +import { logUserAction } from '@/app/utils' + +export async function POST(request: Request) { + // Perform mutation + await updateDatabase(request) + + // Logging blocks the response + const userAgent = request.headers.get('user-agent') || 'unknown' + await logUserAction({ userAgent }) + + return new Response(JSON.stringify({ status: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} +``` + +**Correct: non-blocking** + +```tsx +import { after } from 'next/server' +import { headers, cookies } from 'next/headers' +import { logUserAction } from '@/app/utils' + +export async function POST(request: Request) { + // Perform mutation + await updateDatabase(request) + + // Log after response is sent + after(async () => { + const userAgent = (await headers()).get('user-agent') || 'unknown' + const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous' + + logUserAction({ sessionCookie, userAgent }) + }) + + return new Response(JSON.stringify({ status: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} +``` + +The response is sent immediately while logging happens in the background. + +**Common use cases:** + +- Analytics tracking + +- Audit logging + +- Sending notifications + +- Cache invalidation + +- Cleanup tasks + +**Important notes:** + +- `after()` runs even if the response fails or redirects + +- Works in Server Actions, Route Handlers, and Server Components + +Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after) + +--- + +## 4. Client-Side Data Fetching + +**Impact: MEDIUM-HIGH** + +Automatic deduplication and efficient data fetching patterns reduce redundant network requests. + +### 4.1 Deduplicate Global Event Listeners + +**Impact: LOW (single listener for N components)** + +Use `useSWRSubscription()` to share global event listeners across component instances. + +**Incorrect: N instances = N listeners** + +```tsx +function useKeyboardShortcut(key: string, callback: () => void) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.key === key) { + callback() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [key, callback]) +} +``` + +When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener. + +**Correct: N instances = 1 listener** + +```tsx +import useSWRSubscription from 'swr/subscription' + +// Module-level Map to track callbacks per key +const keyCallbacks = new Map void>>() + +function useKeyboardShortcut(key: string, callback: () => void) { + // Register this callback in the Map + useEffect(() => { + if (!keyCallbacks.has(key)) { + keyCallbacks.set(key, new Set()) + } + keyCallbacks.get(key)!.add(callback) + + return () => { + const set = keyCallbacks.get(key) + if (set) { + set.delete(callback) + if (set.size === 0) { + keyCallbacks.delete(key) + } + } + } + }, [key, callback]) + + useSWRSubscription('global-keydown', () => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && keyCallbacks.has(e.key)) { + keyCallbacks.get(e.key)!.forEach(cb => cb()) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }) +} + +function Profile() { + // Multiple shortcuts will share the same listener + useKeyboardShortcut('p', () => { /* ... */ }) + useKeyboardShortcut('k', () => { /* ... */ }) + // ... +} +``` + +### 4.2 Use Passive Event Listeners for Scrolling Performance + +**Impact: MEDIUM (eliminates scroll delay caused by event listeners)** + +Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay. + +**Incorrect:** + +```typescript +useEffect(() => { + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) + const handleWheel = (e: WheelEvent) => console.log(e.deltaY) + + document.addEventListener('touchstart', handleTouch) + document.addEventListener('wheel', handleWheel) + + return () => { + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) +``` + +**Correct:** + +```typescript +useEffect(() => { + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) + const handleWheel = (e: WheelEvent) => console.log(e.deltaY) + + document.addEventListener('touchstart', handleTouch, { passive: true }) + document.addEventListener('wheel', handleWheel, { passive: true }) + + return () => { + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) +``` + +**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`. + +**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`. + +### 4.3 Use SWR for Automatic Deduplication + +**Impact: MEDIUM-HIGH (automatic deduplication)** + +SWR enables request deduplication, caching, and revalidation across component instances. + +**Incorrect: no deduplication, each instance fetches** + +```tsx +function UserList() { + const [users, setUsers] = useState([]) + useEffect(() => { + fetch('/api/users') + .then(r => r.json()) + .then(setUsers) + }, []) +} +``` + +**Correct: multiple instances share one request** + +```tsx +import useSWR from 'swr' + +function UserList() { + const { data: users } = useSWR('/api/users', fetcher) +} +``` + +**For immutable data:** + +```tsx +import { useImmutableSWR } from '@/lib/swr' + +function StaticContent() { + const { data } = useImmutableSWR('/api/config', fetcher) +} +``` + +**For mutations:** + +```tsx +import { useSWRMutation } from 'swr/mutation' + +function UpdateButton() { + const { trigger } = useSWRMutation('/api/user', updateUser) + return +} +``` + +Reference: [https://swr.vercel.app](https://swr.vercel.app) + +### 4.4 Version and Minimize localStorage Data + +**Impact: MEDIUM (prevents schema conflicts, reduces storage size)** + +Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data. + +**Incorrect:** + +```typescript +// No version, stores everything, no error handling +localStorage.setItem('userConfig', JSON.stringify(fullUserObject)) +const data = localStorage.getItem('userConfig') +``` + +**Correct:** + +```typescript +const VERSION = 'v2' + +function saveConfig(config: { theme: string; language: string }) { + try { + localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config)) + } catch { + // Throws in incognito/private browsing, quota exceeded, or disabled + } +} + +function loadConfig() { + try { + const data = localStorage.getItem(`userConfig:${VERSION}`) + return data ? JSON.parse(data) : null + } catch { + return null + } +} + +// Migration from v1 to v2 +function migrate() { + try { + const v1 = localStorage.getItem('userConfig:v1') + if (v1) { + const old = JSON.parse(v1) + saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang }) + localStorage.removeItem('userConfig:v1') + } + } catch {} +} +``` + +**Store minimal fields from server responses:** + +```typescript +// User object has 20+ fields, only store what UI needs +function cachePrefs(user: FullUser) { + try { + localStorage.setItem('prefs:v1', JSON.stringify({ + theme: user.preferences.theme, + notifications: user.preferences.notifications + })) + } catch {} +} +``` + +**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled. + +**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags. + +--- + +## 5. Re-render Optimization + +**Impact: MEDIUM** + +Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness. + +### 5.1 Calculate Derived State During Rendering + +**Impact: MEDIUM (avoids redundant renders and state drift)** + +If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead. + +**Incorrect: redundant state and effect** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const [fullName, setFullName] = useState('') + + useEffect(() => { + setFullName(firstName + ' ' + lastName) + }, [firstName, lastName]) + + return

{fullName}

+} +``` + +**Correct: derive during render** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const fullName = firstName + ' ' + lastName + + return

{fullName}

+} +``` + +Reference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect) + +### 5.2 Defer State Reads to Usage Point + +**Impact: MEDIUM (avoids unnecessary subscriptions)** + +Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks. + +**Incorrect: subscribes to all searchParams changes** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const searchParams = useSearchParams() + + const handleShare = () => { + const ref = searchParams.get('ref') + shareChat(chatId, { ref }) + } + + return +} +``` + +**Correct: reads on demand, no subscription** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const handleShare = () => { + const params = new URLSearchParams(window.location.search) + const ref = params.get('ref') + shareChat(chatId, { ref }) + } + + return +} +``` + +### 5.3 Do not wrap a simple expression with a primitive result type in useMemo + +**Impact: LOW-MEDIUM (wasted computation on every render)** + +When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`. + +Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself. + +**Incorrect:** + +```tsx +function Header({ user, notifications }: Props) { + const isLoading = useMemo(() => { + return user.isLoading || notifications.isLoading + }, [user.isLoading, notifications.isLoading]) + + if (isLoading) return + // return some markup +} +``` + +**Correct:** + +```tsx +function Header({ user, notifications }: Props) { + const isLoading = user.isLoading || notifications.isLoading + + if (isLoading) return + // return some markup +} +``` + +### 5.4 Extract Default Non-primitive Parameter Value from Memoized Component to Constant + +**Impact: MEDIUM (restores memoization by using a constant for default value)** + +When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`. + +To address this issue, extract the default value into a constant. + +**Incorrect: `onClick` has different values on every rerender** + +```tsx +const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) { + // ... +}) + +// Used without optional onClick + +``` + +**Correct: stable default value** + +```tsx +const NOOP = () => {}; + +const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) { + // ... +}) + +// Used without optional onClick + +``` + +### 5.5 Extract to Memoized Components + +**Impact: MEDIUM (enables early returns)** + +Extract expensive work into memoized components to enable early returns before computation. + +**Incorrect: computes avatar even when loading** + +```tsx +function Profile({ user, loading }: Props) { + const avatar = useMemo(() => { + const id = computeAvatarId(user) + return + }, [user]) + + if (loading) return + return
{avatar}
+} +``` + +**Correct: skips computation when loading** + +```tsx +const UserAvatar = memo(function UserAvatar({ user }: { user: User }) { + const id = useMemo(() => computeAvatarId(user), [user]) + return +}) + +function Profile({ user, loading }: Props) { + if (loading) return + return ( +
+ +
+ ) +} +``` + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders. + +### 5.6 Narrow Effect Dependencies + +**Impact: LOW (minimizes effect re-runs)** + +Specify primitive dependencies instead of objects to minimize effect re-runs. + +**Incorrect: re-runs on any user field change** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user]) +``` + +**Correct: re-runs only when id changes** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user.id]) +``` + +**For derived state, compute outside effect:** + +```tsx +// Incorrect: runs on width=767, 766, 765... +useEffect(() => { + if (width < 768) { + enableMobileMode() + } +}, [width]) + +// Correct: runs only on boolean transition +const isMobile = width < 768 +useEffect(() => { + if (isMobile) { + enableMobileMode() + } +}, [isMobile]) +``` + +### 5.7 Put Interaction Logic in Event Handlers + +**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)** + +If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action. + +**Incorrect: event modeled as state + effect** + +```tsx +function Form() { + const [submitted, setSubmitted] = useState(false) + const theme = useContext(ThemeContext) + + useEffect(() => { + if (submitted) { + post('/api/register') + showToast('Registered', theme) + } + }, [submitted, theme]) + + return +} +``` + +**Correct: do it in the handler** + +```tsx +function Form() { + const theme = useContext(ThemeContext) + + function handleSubmit() { + post('/api/register') + showToast('Registered', theme) + } + + return +} +``` + +Reference: [https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler) + +### 5.8 Subscribe to Derived State + +**Impact: MEDIUM (reduces re-render frequency)** + +Subscribe to derived boolean state instead of continuous values to reduce re-render frequency. + +**Incorrect: re-renders on every pixel change** + +```tsx +function Sidebar() { + const width = useWindowWidth() // updates continuously + const isMobile = width < 768 + return