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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/react-best-practices-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ on:
push:
branches: [main]
paths:
- 'skills/react-best-practices/**'
- 'skills/vercel-react-best-practices/**'
- 'packages/react-best-practices-build/**'
pull_request:
branches: [main]
paths:
- 'skills/react-best-practices/**'
- 'skills/vercel-react-best-practices/**'
- 'packages/react-best-practices-build/**'

jobs:
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Audits a Vercel project for cost, performance, reliability, caching, function us
- Finding caching, ISR, middleware, image, or build-minute issues
- Producing a ranked cost and performance report

### react-best-practices
### vercel-react-best-practices

React and Next.js performance optimization guidelines from Vercel Engineering. Contains 40+ rules across 8 categories, prioritized by impact.

Expand Down Expand Up @@ -115,7 +115,7 @@ React Native best practices optimized for AI agents. Contains 16 rules across 7
- Architecture (Medium) - monorepo structure, imports
- Platform (Medium) - iOS/Android specific patterns

### react-view-transitions
### vercel-react-view-transitions

Implement smooth, native-feeling animations using React's View Transition API. Covers the `<ViewTransition>` component, `addTransitionType`, transition types, and Next.js integration including the `transitionTypes` prop on `next/link`.

Expand All @@ -139,7 +139,7 @@ Implement smooth, native-feeling animations using React's View Transition API. C
- Ready-to-use CSS animation recipes (fade, slide, scale, flip)
- Accessibility (`prefers-reduced-motion`)

### composition-patterns
### vercel-composition-patterns

React composition patterns that scale. Helps avoid boolean prop proliferation through compound components, state lifting, and internal composition.

Expand Down
6 changes: 3 additions & 3 deletions packages/react-best-practices-build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"build": "pnpm build-agents && pnpm extract-tests",
"build-agents": "tsx src/build.ts",
"build-all": "tsx src/build.ts --all",
"build-react": "tsx src/build.ts --skill=react-best-practices",
"build-rn": "tsx src/build.ts --skill=react-native-skills",
"build-composition": "tsx src/build.ts --skill=composition-patterns",
"build-react": "tsx src/build.ts --skill=vercel-react-best-practices",
"build-rn": "tsx src/build.ts --skill=vercel-react-native-skills",
"build-composition": "tsx src/build.ts --skill=vercel-composition-patterns",
"validate": "tsx src/validate.ts",
"extract-tests": "tsx src/extract-tests.ts",
"migrate": "tsx src/migrate.ts",
Expand Down
38 changes: 19 additions & 19 deletions packages/react-best-practices-build/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ export interface SkillConfig {
}

export const SKILLS: Record<string, SkillConfig> = {
'react-best-practices': {
name: 'react-best-practices',
'vercel-react-best-practices': {
name: 'vercel-react-best-practices',
title: 'React Best Practices',
description: 'React and Next.js codebases',
skillDir: join(SKILLS_DIR, 'react-best-practices'),
rulesDir: join(SKILLS_DIR, 'react-best-practices/rules'),
metadataFile: join(SKILLS_DIR, 'react-best-practices/metadata.json'),
outputFile: join(SKILLS_DIR, 'react-best-practices/AGENTS.md'),
skillDir: join(SKILLS_DIR, 'vercel-react-best-practices'),
rulesDir: join(SKILLS_DIR, 'vercel-react-best-practices/rules'),
metadataFile: join(SKILLS_DIR, 'vercel-react-best-practices/metadata.json'),
outputFile: join(SKILLS_DIR, 'vercel-react-best-practices/AGENTS.md'),
sectionMap: {
async: 1,
bundle: 2,
Expand All @@ -43,14 +43,14 @@ export const SKILLS: Record<string, SkillConfig> = {
advanced: 8,
},
},
'react-native-skills': {
name: 'react-native-skills',
'vercel-react-native-skills': {
name: 'vercel-react-native-skills',
title: 'React Native Skills',
description: 'React Native codebases',
skillDir: join(SKILLS_DIR, 'react-native-skills'),
rulesDir: join(SKILLS_DIR, 'react-native-skills/rules'),
metadataFile: join(SKILLS_DIR, 'react-native-skills/metadata.json'),
outputFile: join(SKILLS_DIR, 'react-native-skills/AGENTS.md'),
skillDir: join(SKILLS_DIR, 'vercel-react-native-skills'),
rulesDir: join(SKILLS_DIR, 'vercel-react-native-skills/rules'),
metadataFile: join(SKILLS_DIR, 'vercel-react-native-skills/metadata.json'),
outputFile: join(SKILLS_DIR, 'vercel-react-native-skills/AGENTS.md'),
sectionMap: {
rendering: 1,
'list-performance': 2,
Expand All @@ -68,14 +68,14 @@ export const SKILLS: Record<string, SkillConfig> = {
fonts: 14,
},
},
'composition-patterns': {
name: 'composition-patterns',
'vercel-composition-patterns': {
name: 'vercel-composition-patterns',
title: 'React Composition Patterns',
description: 'React codebases using composition',
skillDir: join(SKILLS_DIR, 'composition-patterns'),
rulesDir: join(SKILLS_DIR, 'composition-patterns/rules'),
metadataFile: join(SKILLS_DIR, 'composition-patterns/metadata.json'),
outputFile: join(SKILLS_DIR, 'composition-patterns/AGENTS.md'),
skillDir: join(SKILLS_DIR, 'vercel-composition-patterns'),
rulesDir: join(SKILLS_DIR, 'vercel-composition-patterns/rules'),
metadataFile: join(SKILLS_DIR, 'vercel-composition-patterns/metadata.json'),
outputFile: join(SKILLS_DIR, 'vercel-composition-patterns/AGENTS.md'),
sectionMap: {
architecture: 1,
state: 2,
Expand All @@ -86,7 +86,7 @@ export const SKILLS: Record<string, SkillConfig> = {
}

// Default skill (for backwards compatibility)
export const DEFAULT_SKILL = 'react-best-practices'
export const DEFAULT_SKILL = 'vercel-react-best-practices'

// Legacy exports for backwards compatibility
export const SKILL_DIR = SKILLS[DEFAULT_SKILL].skillDir
Expand Down
4 changes: 2 additions & 2 deletions packages/react-best-practices-build/test-cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@
"ruleId": "",
"ruleTitle": "Deduplicate Global Event Listeners",
"type": "good",
"code": "import useSWRSubscription from 'swr/subscription'\n\n// Module-level Map to track callbacks per key\nconst keyCallbacks = new Map<string, Set<() => void>>()\n\nfunction useKeyboardShortcut(key: string, callback: () => void) {\n // Register this callback in the Map\n useEffect(() => {\n if (!keyCallbacks.has(key)) {\n keyCallbacks.set(key, new Set())\n }\n keyCallbacks.get(key)!.add(callback)\n\n return () => {\n const set = keyCallbacks.get(key)\n if (set) {\n set.delete(callback)\n if (set.size === 0) {\n keyCallbacks.delete(key)\n }\n }\n }\n }, [key, callback])\n\n useSWRSubscription('global-keydown', () => {\n const handler = (e: KeyboardEvent) => {\n if (e.metaKey && keyCallbacks.has(e.key)) {\n keyCallbacks.get(e.key)!.forEach(cb => cb())\n }\n }\n window.addEventListener('keydown', handler)\n return () => window.removeEventListener('keydown', handler)\n })\n}\n\nfunction Profile() {\n // Multiple shortcuts will share the same listener\n useKeyboardShortcut('p', () => { /* ... */ }) \n useKeyboardShortcut('k', () => { /* ... */ })\n // ...\n}",
"code": "// Module-level Map to track callbacks per key\nconst keyCallbacks = new Map<string, Set<() => void>>()\n\n// Module-level singleton: the single real DOM listener, attached once\nlet globalKeydownHandler: ((e: KeyboardEvent) => void) | null = null\n\nfunction attachGlobalKeydownListener() {\n if (globalKeydownHandler) return\n globalKeydownHandler = (e: KeyboardEvent) => {\n if (e.metaKey && keyCallbacks.has(e.key)) {\n keyCallbacks.get(e.key)!.forEach(cb => cb())\n }\n }\n window.addEventListener('keydown', globalKeydownHandler)\n}\n\nfunction detachGlobalKeydownListenerIfUnused() {\n if (keyCallbacks.size === 0 && globalKeydownHandler) {\n window.removeEventListener('keydown', globalKeydownHandler)\n globalKeydownHandler = null\n }\n}\n\nfunction useKeyboardShortcut(key: string, callback: () => void) {\n // Stabilize the callback so the effect below doesn't need `callback`\n // in its deps -- otherwise an inline arrow (a new identity every\n // render) would tear down and re-attach the singleton listener on\n // every render, the exact churn this pattern exists to avoid.\n const callbackRef = useRef(callback)\n useEffect(() => {\n callbackRef.current = callback\n })\n\n // Register this callback in the Map\n useEffect(() => {\n const stableCallback = () => callbackRef.current()\n\n if (!keyCallbacks.has(key)) {\n keyCallbacks.set(key, new Set())\n }\n keyCallbacks.get(key)!.add(stableCallback)\n attachGlobalKeydownListener()\n\n return () => {\n const set = keyCallbacks.get(key)\n if (set) {\n set.delete(stableCallback)\n if (set.size === 0) {\n keyCallbacks.delete(key)\n }\n }\n detachGlobalKeydownListenerIfUnused()\n }\n }, [key])\n}\n\nfunction Profile() {\n // Multiple shortcuts will share the same listener\n useKeyboardShortcut('p', () => { /* ... */ }) \n useKeyboardShortcut('k', () => { /* ... */ })\n // ...\n}",
"language": "tsx",
"description": "N instances = 1 listener"
},
Expand Down Expand Up @@ -1111,4 +1111,4 @@
"language": "tsx",
"description": "serializes only 1 field"
}
]
]
74 changes: 0 additions & 74 deletions skills/react-best-practices/rules/client-event-listeners.md

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -1373,7 +1373,7 @@ Automatic deduplication and efficient data fetching patterns reduce redundant ne

**Impact: LOW (single listener for N components)**

Use `useSWRSubscription()` to share global event listeners across component instances.
Use a module-level singleton listener to share global event listeners across component instances, instead of each instance attaching its own.

**Incorrect: N instances = N listeners**

Expand All @@ -1396,39 +1396,60 @@ When using the `useKeyboardShortcut` hook multiple times, each instance will reg
**Correct: N instances = 1 listener**

```tsx
import useSWRSubscription from 'swr/subscription'

// Module-level Map to track callbacks per key
const keyCallbacks = new Map<string, Set<() => void>>()

// Module-level singleton: the single real DOM listener, attached once
let globalKeydownHandler: ((e: KeyboardEvent) => void) | null = null

function attachGlobalKeydownListener() {
if (globalKeydownHandler) return
globalKeydownHandler = (e: KeyboardEvent) => {
if (e.metaKey && keyCallbacks.has(e.key)) {
keyCallbacks.get(e.key)!.forEach(cb => cb())
}
}
window.addEventListener('keydown', globalKeydownHandler)
}

function detachGlobalKeydownListenerIfUnused() {
if (keyCallbacks.size === 0 && globalKeydownHandler) {
window.removeEventListener('keydown', globalKeydownHandler)
globalKeydownHandler = null
}
}

function useKeyboardShortcut(key: string, callback: () => void) {
// Stabilize the callback so the effect below doesn't need `callback`
// in its deps -- otherwise an inline arrow (a new identity every
// render) would tear down and re-attach the singleton listener on
// every render, the exact churn this pattern exists to avoid.
const callbackRef = useRef(callback)
useEffect(() => {
callbackRef.current = callback
})

// Register this callback in the Map
useEffect(() => {
const stableCallback = () => callbackRef.current()

if (!keyCallbacks.has(key)) {
keyCallbacks.set(key, new Set())
}
keyCallbacks.get(key)!.add(callback)
keyCallbacks.get(key)!.add(stableCallback)
attachGlobalKeydownListener()

return () => {
const set = keyCallbacks.get(key)
if (set) {
set.delete(callback)
set.delete(stableCallback)
if (set.size === 0) {
keyCallbacks.delete(key)
}
}
detachGlobalKeydownListenerIfUnused()
}
}, [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)
})
}, [key])
}

function Profile() {
Expand Down Expand Up @@ -1685,6 +1706,8 @@ function ShareButton({ chatId }: { chatId: string }) {

**Impact: LOW-MEDIUM (wasted computation on every render)**

**Skip if:** [React Compiler](https://react.dev/learn/react-compiler) is enabled — the compiler automatically optimizes re-renders, so `useMemo` usage generally isn't hand-written or worth auditing.

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.
Expand Down Expand Up @@ -1797,6 +1820,8 @@ function UserProfile({ user, theme }) {

**Impact: MEDIUM (restores memoization by using a constant for default value)**

**Skip if:** [React Compiler](https://react.dev/learn/react-compiler) is enabled — the compiler automatically optimizes re-renders, so manual memoization (and this fix for its broken defaults) is unnecessary.

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.
Expand Down Expand Up @@ -1829,6 +1854,8 @@ const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () =

**Impact: MEDIUM (enables early returns)**

**Skip if:** [React Compiler](https://react.dev/learn/react-compiler) is enabled — the compiler automatically optimizes re-renders, so manual `memo()`/`useMemo()` is unnecessary.

Extract expensive work into memoized components to enable early returns before computation.

**Incorrect: computes avatar even when loading**
Expand Down Expand Up @@ -1863,8 +1890,6 @@ function Profile({ user, loading }: Props) {
}
```

**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.7 Narrow Effect Dependencies

**Impact: LOW (minimizes effect re-runs)**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ Reference these guidelines when:
- Refactoring existing React/Next.js code
- Optimizing bundle size or load times

## Pre-flight Checks

Before recommending manual memoization, detect whether [React Compiler](https://react.dev/learn/react-compiler) is enabled in the target project:

- `next.config.*` sets `reactCompiler: true` (or `experimental.reactCompiler: true` on older Next.js versions), or
- `package.json` lists `babel-plugin-react-compiler` as a dependency

If React Compiler is enabled, skip these three rules — the compiler already handles what they'd otherwise recommend by hand:

- `rerender-memo` — Extract to Memoized Components
- `rerender-memo-with-default-value` — Extract Default Non-primitive Parameter Value from Memoized Component to Constant
- `rerender-simple-expression-in-memo` — Do not wrap a simple expression with a primitive result type in useMemo

All other rules (including the rest of the `rerender-*` category) are unaffected by React Compiler and still apply.

## Rule Categories by Priority

| Priority | Category | Impact | Prefix |
Expand Down
Loading