@baselane/packs 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aliases.js +1 -0
- package/package.json +2 -2
- package/packs/database-review/pack.json +75 -9
- package/packs/database-review/rendered/.claude/agents/database-reviewer.md +75 -2
- package/packs/database-review/rendered/.claude/commands/migration-review.md +6 -0
- package/packs/database-review/rendered/.claude/settings.json +11 -0
- package/packs/database-review/rendered/.claude/skills/database-migrations/SKILL.md +431 -0
- package/packs/database-review/rendered/.claude/skills/mysql-patterns/SKILL.md +414 -0
- package/packs/database-review/rendered/.claude/skills/postgres-patterns/SKILL.md +149 -0
- package/packs/database-review/rendered/.github/copilot-instructions.md +1096 -14
- package/packs/database-review/rendered/AGENTS.md +45 -16
- package/packs/database-review/rendered/CLAUDE.md +4 -0
- package/packs/database-review/rendered/GEMINI.md +987 -0
- package/packs/go-rules/pack.json +65 -9
- package/packs/go-rules/rendered/.claude/agents/go-build-resolver.md +90 -0
- package/packs/go-rules/rendered/.claude/agents/go-reviewer.md +72 -2
- package/packs/go-rules/rendered/.claude/commands/go-build.md +6 -0
- package/packs/go-rules/rendered/.claude/commands/go-review.md +1 -1
- package/packs/go-rules/rendered/.claude/commands/go-test.md +6 -0
- package/packs/go-rules/rendered/.claude/settings.json +9 -0
- package/packs/go-rules/rendered/.claude/skills/golang-patterns/SKILL.md +673 -0
- package/packs/go-rules/rendered/.claude/skills/golang-testing/SKILL.md +719 -0
- package/packs/go-rules/rendered/.github/copilot-instructions.md +1783 -7
- package/packs/go-rules/rendered/AGENTS.md +244 -8
- package/packs/go-rules/rendered/CLAUDE.md +5 -0
- package/packs/go-rules/rendered/GEMINI.md +1394 -0
- package/packs/python-rules/pack.json +87 -10
- package/packs/python-rules/rendered/.claude/agents/django-reviewer.md +159 -0
- package/packs/python-rules/rendered/.claude/agents/fastapi-reviewer.md +70 -0
- package/packs/python-rules/rendered/.claude/agents/python-reviewer.md +92 -2
- package/packs/python-rules/rendered/.claude/commands/django-review.md +6 -0
- package/packs/python-rules/rendered/.claude/commands/fastapi-review.md +6 -0
- package/packs/python-rules/rendered/.claude/commands/python-review.md +1 -1
- package/packs/python-rules/rendered/.claude/settings.json +1 -1
- package/packs/python-rules/rendered/.claude/skills/django-security/SKILL.md +642 -0
- package/packs/python-rules/rendered/.claude/skills/django-tdd/SKILL.md +728 -0
- package/packs/python-rules/rendered/.claude/skills/fastapi-patterns/SKILL.md +512 -0
- package/packs/python-rules/rendered/.claude/skills/python-patterns/SKILL.md +749 -0
- package/packs/python-rules/rendered/.claude/skills/python-testing/SKILL.md +815 -0
- package/packs/python-rules/rendered/.github/copilot-instructions.md +3815 -12
- package/packs/python-rules/rendered/AGENTS.md +73 -13
- package/packs/python-rules/rendered/CLAUDE.md +9 -0
- package/packs/python-rules/rendered/GEMINI.md +3448 -0
- package/packs/security-review/pack.json +51 -7
- package/packs/security-review/rendered/.claude/agents/security-reviewer.md +29 -2
- package/packs/security-review/rendered/.claude/settings.json +11 -0
- package/packs/security-review/rendered/.claude/skills/security-review/SKILL.md +302 -0
- package/packs/security-review/rendered/.claude/skills/security-review/cloud-infrastructure-security.md +171 -0
- package/packs/security-review/rendered/.claude/skills/security-scan-config/SKILL.md +56 -0
- package/packs/security-review/rendered/.github/copilot-instructions.md +658 -15
- package/packs/security-review/rendered/AGENTS.md +283 -17
- package/packs/security-review/rendered/CLAUDE.md +2 -0
- package/packs/security-review/rendered/GEMINI.md +352 -0
- package/packs/software-engineer-harness/pack.json +249 -13
- package/packs/software-engineer-harness/rendered/.claude/agents/code-explorer.md +67 -0
- package/packs/software-engineer-harness/rendered/.claude/agents/performance-optimizer.md +444 -0
- package/packs/software-engineer-harness/rendered/.claude/agents/pr-test-analyzer.md +43 -0
- package/packs/software-engineer-harness/rendered/.claude/commands/feature-dev.md +6 -0
- package/packs/software-engineer-harness/rendered/.claude/settings.json +29 -0
- package/packs/software-engineer-harness/rendered/.claude/skills/api-design/SKILL.md +522 -0
- package/packs/software-engineer-harness/rendered/.claude/skills/architecture-decision-records/SKILL.md +178 -0
- package/packs/software-engineer-harness/rendered/.claude/skills/backend-patterns/SKILL.md +560 -0
- package/packs/software-engineer-harness/rendered/.claude/skills/codebase-onboarding/SKILL.md +232 -0
- package/packs/software-engineer-harness/rendered/.claude/skills/deployment-patterns/SKILL.md +426 -0
- package/packs/software-engineer-harness/rendered/.claude/skills/docker-patterns/SKILL.md +363 -0
- package/packs/software-engineer-harness/rendered/.claude/skills/e2e-testing/SKILL.md +325 -0
- package/packs/software-engineer-harness/rendered/.claude/skills/tdd-workflow/SKILL.md +580 -0
- package/packs/software-engineer-harness/rendered/.github/copilot-instructions.md +3948 -2
- package/packs/software-engineer-harness/rendered/AGENTS.md +194 -4
- package/packs/software-engineer-harness/rendered/CLAUDE.md +20 -0
- package/packs/software-engineer-harness/rendered/GEMINI.md +3208 -0
- package/packs/typescript-rules/pack.json +68 -5
- package/packs/typescript-rules/rendered/.claude/agents/react-reviewer.md +165 -0
- package/packs/typescript-rules/rendered/.claude/agents/type-design-analyzer.md +48 -0
- package/packs/typescript-rules/rendered/.claude/commands/react-review.md +6 -0
- package/packs/typescript-rules/rendered/.claude/settings.json +9 -0
- package/packs/typescript-rules/rendered/.claude/skills/react-patterns/SKILL.md +340 -0
- package/packs/typescript-rules/rendered/.claude/skills/react-performance/SKILL.md +573 -0
- package/packs/typescript-rules/rendered/.claude/skills/react-testing/SKILL.md +422 -0
- package/packs/typescript-rules/rendered/.github/copilot-instructions.md +2459 -53
- package/packs/typescript-rules/rendered/AGENTS.md +893 -17
- package/packs/typescript-rules/rendered/CLAUDE.md +6 -0
- package/packs/typescript-rules/rendered/GEMINI.md +1335 -0
- package/packs/ecc-essentials/pack.json +0 -93
- package/packs/ecc-essentials/rendered/.claude/settings.json +0 -37
- package/packs/ecc-essentials/rendered/.github/copilot-instructions.md +0 -110
- package/packs/ecc-essentials/rendered/AGENTS.md +0 -74
- package/packs/ecc-essentials/rendered/CLAUDE.md +0 -14
- package/packs/ecc-essentials/rendered/GEMINI.md +0 -25
- /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/build-error-resolver.md +0 -0
- /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/refactor-cleaner.md +0 -0
- /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/silent-failure-hunter.md +0 -0
- /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/tdd-guide.md +0 -0
- /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/fix-build.md +0 -0
- /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/hunt-silent-failures.md +0 -0
- /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/tdd.md +0 -0
- /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/skills/tdd-red-green-refactor/SKILL.md +0 -0
|
@@ -49,3 +49,1338 @@ function rename(user, name) {
|
|
|
49
49
|
A locally-scoped object that never escapes the function — a builder/accumulator you allocated and
|
|
50
50
|
return once — is fine to mutate. The rule protects values that are *shared*: arguments, fields on
|
|
51
51
|
long-lived state, anything another holder still references.
|
|
52
|
+
|
|
53
|
+
### react-patterns
|
|
54
|
+
|
|
55
|
+
Use when writing or reviewing React function components, custom hooks, or component trees — covers hooks discipline, state location, Server/Client Component boundaries, Suspense + error boundaries, form actions, data fetching, and accessibility-first composition for React 18/19.
|
|
56
|
+
|
|
57
|
+
# React Patterns
|
|
58
|
+
|
|
59
|
+
Idiomatic React 18/19 patterns for building robust, accessible, performant component trees.
|
|
60
|
+
|
|
61
|
+
## When to Activate
|
|
62
|
+
|
|
63
|
+
- Writing or modifying React function components, custom hooks, or component trees
|
|
64
|
+
- Reviewing JSX/TSX files
|
|
65
|
+
- Designing state shape or component composition
|
|
66
|
+
- Migrating class components or older `forwardRef`/`useEffect`-heavy code
|
|
67
|
+
- Choosing between local state, lifted state, context, and external stores
|
|
68
|
+
- Working with Server Components / Client Components (Next.js App Router, RSC)
|
|
69
|
+
- Implementing forms with React 19 actions or controlled inputs
|
|
70
|
+
- Wiring data fetching with TanStack Query / SWR / RSC
|
|
71
|
+
|
|
72
|
+
## Core Principles
|
|
73
|
+
|
|
74
|
+
### 1. Render is a Pure Function of Props and State
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// Good: derive during render
|
|
78
|
+
function Cart({ items }: { items: CartItem[] }) {
|
|
79
|
+
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
|
|
80
|
+
return <span>{formatMoney(total)}</span>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Bad: derived state stored separately
|
|
84
|
+
function Cart({ items }: { items: CartItem[] }) {
|
|
85
|
+
const [total, setTotal] = useState(0);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
|
|
88
|
+
}, [items]);
|
|
89
|
+
return <span>{formatMoney(total)}</span>;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Derived state in `useEffect` adds a render cycle, can desync, and obscures the data flow.
|
|
94
|
+
|
|
95
|
+
### 2. Side Effects Outside Render
|
|
96
|
+
|
|
97
|
+
Effects, mutations, network calls, and subscriptions live in event handlers or `useEffect` — never in the render body.
|
|
98
|
+
|
|
99
|
+
### 3. Composition Over Inheritance
|
|
100
|
+
|
|
101
|
+
React has no inheritance model for components. Compose with `children`, render props, or component props.
|
|
102
|
+
|
|
103
|
+
## Hooks Discipline
|
|
104
|
+
|
|
105
|
+
See [rules/react/hooks.md](../../rules/react/hooks.md) for the full ruleset. Highlights:
|
|
106
|
+
|
|
107
|
+
- Top-level only, never conditional
|
|
108
|
+
- Cleanup every subscription, interval, listener
|
|
109
|
+
- Functional updater (`setX(prev => prev + 1)`) when new state depends on old
|
|
110
|
+
- Default position: do not memoize — add `useMemo`/`useCallback` only when a profiler or a dependency chain proves it matters
|
|
111
|
+
- Extract a custom hook only when the same hook sequence appears in 2+ components
|
|
112
|
+
|
|
113
|
+
## State Location Decision Tree
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
Used by one component?
|
|
117
|
+
-> useState inside it
|
|
118
|
+
|
|
119
|
+
Used by parent + a few descendants?
|
|
120
|
+
-> lift to nearest common ancestor
|
|
121
|
+
|
|
122
|
+
Used across distant branches AND low-frequency reads (theme, auth, locale)?
|
|
123
|
+
-> React Context
|
|
124
|
+
|
|
125
|
+
High-frequency updates shared across the tree?
|
|
126
|
+
-> external store (Zustand, Jotai, Redux Toolkit)
|
|
127
|
+
|
|
128
|
+
Derived from a server?
|
|
129
|
+
-> server-state library (TanStack Query, SWR, RSC fetch)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Most pages do not need context or a global store. Resist abstraction until duplicated lifting becomes painful.
|
|
133
|
+
|
|
134
|
+
## Server / Client Components (RSC)
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
// Server Component - default, async, never ships JS for itself
|
|
138
|
+
export default async function ProductPage({ params }: { params: { id: string } }) {
|
|
139
|
+
const product = await db.product.findUnique({ where: { id: params.id } });
|
|
140
|
+
if (!product) notFound();
|
|
141
|
+
return <ProductView product={product} />;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Client Component - opt in with "use client"
|
|
145
|
+
"use client";
|
|
146
|
+
export function AddToCartButton({ productId }: { productId: string }) {
|
|
147
|
+
const [pending, startTransition] = useTransition();
|
|
148
|
+
return (
|
|
149
|
+
<button
|
|
150
|
+
disabled={pending}
|
|
151
|
+
onClick={() => startTransition(() => addToCart(productId))}
|
|
152
|
+
>
|
|
153
|
+
{pending ? "Adding..." : "Add to cart"}
|
|
154
|
+
</button>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Boundaries:
|
|
160
|
+
|
|
161
|
+
- Server -> Client: pass serializable props or `children`
|
|
162
|
+
- Client -> Server: invoke Server Actions via `<form action={...}>` or imperatively from event handlers
|
|
163
|
+
- Never `import` a Server Component from a Client Component file — compose them via `children` instead
|
|
164
|
+
|
|
165
|
+
## Suspense + Error Boundaries
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
<ErrorBoundary fallback={<ErrorView />}>
|
|
169
|
+
<Suspense fallback={<UserSkeleton />}>
|
|
170
|
+
<UserDetail id={id} />
|
|
171
|
+
</Suspense>
|
|
172
|
+
</ErrorBoundary>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- Place Suspense boundaries close to the data, not at the route root — progressively reveal content
|
|
176
|
+
- Error Boundary remains a class API; use `react-error-boundary` for a hook-friendly wrapper
|
|
177
|
+
- A boundary catches errors thrown during render, lifecycle, and constructors of its children — NOT in event handlers or async code
|
|
178
|
+
|
|
179
|
+
## Forms
|
|
180
|
+
|
|
181
|
+
### React 19 form actions (preferred for new code)
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
"use client";
|
|
185
|
+
import { useActionState } from "react";
|
|
186
|
+
|
|
187
|
+
const initial = { error: null as string | null };
|
|
188
|
+
|
|
189
|
+
async function updateUserAction(_prev: typeof initial, formData: FormData) {
|
|
190
|
+
"use server";
|
|
191
|
+
const parsed = UserSchema.safeParse(Object.fromEntries(formData));
|
|
192
|
+
if (!parsed.success) return { error: "Invalid input" };
|
|
193
|
+
await db.user.update({ where: { id: parsed.data.id }, data: parsed.data });
|
|
194
|
+
return { error: null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function UserForm() {
|
|
198
|
+
const [state, formAction, pending] = useActionState(updateUserAction, initial);
|
|
199
|
+
return (
|
|
200
|
+
<form action={formAction}>
|
|
201
|
+
<input name="name" required />
|
|
202
|
+
<button type="submit" disabled={pending}>Save</button>
|
|
203
|
+
{state.error && <p role="alert">{state.error}</p>}
|
|
204
|
+
</form>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Controlled inputs
|
|
210
|
+
|
|
211
|
+
Use controlled when the value drives other UI, formats on every keystroke, or implements real-time validation.
|
|
212
|
+
|
|
213
|
+
### Complex forms
|
|
214
|
+
|
|
215
|
+
For multi-step forms, dynamic field arrays, or cross-field validation: use a library (React Hook Form, TanStack Form). Roll-your-own state management for forms past trivial complexity is a maintenance trap.
|
|
216
|
+
|
|
217
|
+
## Data Fetching Decision Matrix
|
|
218
|
+
|
|
219
|
+
| Need | Tool |
|
|
220
|
+
|---|---|
|
|
221
|
+
| Per-request data in Next.js App Router | RSC `await fetch()` |
|
|
222
|
+
| Client-side cache + mutations + invalidation | TanStack Query |
|
|
223
|
+
| Lightweight client cache + revalidation | SWR |
|
|
224
|
+
| Real-time subscriptions | Server-Sent Events, WebSockets, or the lib's subscription API |
|
|
225
|
+
| One-off fire-and-forget | `fetch()` in an event handler |
|
|
226
|
+
|
|
227
|
+
Avoid `useEffect` + `fetch` for application data — race conditions, no cache, no retry, no Suspense integration.
|
|
228
|
+
|
|
229
|
+
## Composition Recipes
|
|
230
|
+
|
|
231
|
+
### Slot via `children`
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
<Layout>
|
|
235
|
+
<Header />
|
|
236
|
+
<Main>{content}</Main>
|
|
237
|
+
</Layout>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Named slots
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
<Page header={<Nav />} sidebar={<Filters />}>
|
|
244
|
+
<Results />
|
|
245
|
+
</Page>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Compound components (shared state via Context)
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
<Tabs defaultValue="profile">
|
|
252
|
+
<Tabs.List>
|
|
253
|
+
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
|
|
254
|
+
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
|
|
255
|
+
</Tabs.List>
|
|
256
|
+
<Tabs.Panel value="profile"><Profile /></Tabs.Panel>
|
|
257
|
+
<Tabs.Panel value="settings"><Settings /></Tabs.Panel>
|
|
258
|
+
</Tabs>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Render prop / function-as-child
|
|
262
|
+
|
|
263
|
+
Useful when the parent needs to pass parameters to the rendered output:
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
<DataLoader id={id}>
|
|
267
|
+
{({ data, isLoading }) => isLoading ? <Spinner /> : <UserCard user={data} />}
|
|
268
|
+
</DataLoader>
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Modern alternative: a hook (`useData(id)`) returning the same shape — usually cleaner.
|
|
272
|
+
|
|
273
|
+
## Performance
|
|
274
|
+
|
|
275
|
+
### When `React.memo` Actually Helps
|
|
276
|
+
|
|
277
|
+
Wrap a component in `React.memo` only when:
|
|
278
|
+
|
|
279
|
+
1. It re-renders frequently
|
|
280
|
+
2. Its props are usually the same between renders
|
|
281
|
+
3. Its render is measurably expensive
|
|
282
|
+
|
|
283
|
+
`React.memo` adds an equality check on every render. If props differ on most renders, the check is pure overhead.
|
|
284
|
+
|
|
285
|
+
### Avoiding Render Cascades
|
|
286
|
+
|
|
287
|
+
- Lift state down rather than up where possible
|
|
288
|
+
- Split context: one context per concern, so a change to `themeContext` does not re-render auth consumers
|
|
289
|
+
- Use `useSyncExternalStore` for external state libraries — required for safe concurrent rendering
|
|
290
|
+
|
|
291
|
+
### Lists
|
|
292
|
+
|
|
293
|
+
- Provide stable `key` props (database id, not array index)
|
|
294
|
+
- Virtualize long lists with `@tanstack/react-virtual` or `react-window` once visible item count exceeds ~50 with non-trivial rows
|
|
295
|
+
|
|
296
|
+
## Accessibility-First Composition
|
|
297
|
+
|
|
298
|
+
- Always render semantic HTML (`<button>`, `<a>`, `<nav>`, `<main>`) before reaching for `role` attributes
|
|
299
|
+
- Every interactive element must be reachable by keyboard
|
|
300
|
+
- Form inputs need labels — `<label htmlFor>` or `aria-label` if visually labeled by an icon
|
|
301
|
+
- Manage focus on route changes and modal open/close
|
|
302
|
+
- Run `axe` in component tests (see [skills/react-testing](../react-testing/SKILL.md))
|
|
303
|
+
- Cross-link: [skills/accessibility/SKILL.md](../accessibility/SKILL.md) covers WCAG criteria and pattern libraries
|
|
304
|
+
|
|
305
|
+
## Routing
|
|
306
|
+
|
|
307
|
+
This skill is router-agnostic. The patterns above work with React Router, TanStack Router, Next.js App Router, Remix Router. Router-specific patterns (loaders, actions, nested layouts) follow the router's documentation — those are framework concerns layered on top of React core.
|
|
308
|
+
|
|
309
|
+
## Out of Scope (Pointer Sections)
|
|
310
|
+
|
|
311
|
+
- **Next.js specifics**: App Router data loading, Route Handlers, Middleware, Parallel Routes — separate concern, use Next.js docs
|
|
312
|
+
- **React Native**: Platform-specific patterns differ enough to warrant a separate `react-native-patterns` skill (not present yet)
|
|
313
|
+
- **Remix**: Loader/action conventions overlap with RSC but follow Remix docs
|
|
314
|
+
|
|
315
|
+
## Related
|
|
316
|
+
|
|
317
|
+
- Rules: [rules/react/](../../rules/react/) — coding-style, hooks, patterns, security, testing
|
|
318
|
+
- Skills: [react-performance](../react-performance/SKILL.md) for the Vercel-derived performance ruleset, [frontend-patterns](../frontend-patterns/SKILL.md) for cross-framework UI concerns, [accessibility](../accessibility/SKILL.md), [angular-developer](../angular-developer/SKILL.md) for framework comparison
|
|
319
|
+
- Agents: `react-reviewer` for code review, `react-build-resolver` for build/bundler errors
|
|
320
|
+
- Commands: `/react-review`, `/react-build`, `/react-test`
|
|
321
|
+
|
|
322
|
+
## Examples
|
|
323
|
+
|
|
324
|
+
### Custom hook for debounced search
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
function useDebounce<T>(value: T, delay = 300): T {
|
|
328
|
+
const [debounced, setDebounced] = useState(value);
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
const id = setTimeout(() => setDebounced(value), delay);
|
|
331
|
+
return () => clearTimeout(id);
|
|
332
|
+
}, [value, delay]);
|
|
333
|
+
return debounced;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function SearchBox() {
|
|
337
|
+
const [query, setQuery] = useState("");
|
|
338
|
+
const debounced = useDebounce(query, 300);
|
|
339
|
+
const { data } = useQuery({
|
|
340
|
+
queryKey: ["search", debounced],
|
|
341
|
+
queryFn: () => searchApi(debounced),
|
|
342
|
+
enabled: debounced.length > 0,
|
|
343
|
+
});
|
|
344
|
+
return (
|
|
345
|
+
<>
|
|
346
|
+
<input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
347
|
+
<Results items={data ?? []} />
|
|
348
|
+
</>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Optimistic UI with React 19 `useOptimistic`
|
|
354
|
+
|
|
355
|
+
```tsx
|
|
356
|
+
"use client";
|
|
357
|
+
import { useOptimistic } from "react";
|
|
358
|
+
|
|
359
|
+
export function MessageList({ messages }: { messages: Message[] }) {
|
|
360
|
+
const [optimistic, addOptimistic] = useOptimistic(
|
|
361
|
+
messages,
|
|
362
|
+
(state, newMessage: Message) => [...state, newMessage],
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
async function send(formData: FormData) {
|
|
366
|
+
const text = String(formData.get("text"));
|
|
367
|
+
addOptimistic({ id: "pending", text, sender: "me" });
|
|
368
|
+
await saveMessage(text);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<>
|
|
373
|
+
<ul>{optimistic.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
|
|
374
|
+
<form action={send}>
|
|
375
|
+
<input name="text" />
|
|
376
|
+
<button type="submit">Send</button>
|
|
377
|
+
</form>
|
|
378
|
+
</>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Splitting context to avoid render cascades
|
|
384
|
+
|
|
385
|
+
```tsx
|
|
386
|
+
// Two contexts: one rarely changes, one frequently
|
|
387
|
+
const ThemeContext = createContext<Theme>("light");
|
|
388
|
+
const NotificationsContext = createContext<Notification[]>([]);
|
|
389
|
+
|
|
390
|
+
// A component that only consumes ThemeContext does NOT re-render when notifications change
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### react-performance
|
|
394
|
+
|
|
395
|
+
Use when writing, reviewing, or refactoring React/Next.js code for performance — 70+ rules across waterfalls, bundle size, server-side performance, client fetching, re-renders, rendering, JS micro-perf, and advanced patterns, adapted from Vercel Engineering's React Best Practices.
|
|
396
|
+
|
|
397
|
+
# React Performance
|
|
398
|
+
|
|
399
|
+
Performance optimization patterns for React 18/19 and Next.js, adapted from [Vercel Labs `react-best-practices`](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices) (MIT, v1.0.0). This skill organizes rules by priority and provides decision-tree guidance for active code review and refactoring.
|
|
400
|
+
|
|
401
|
+
## When to Activate
|
|
402
|
+
|
|
403
|
+
- Writing or reviewing React/Next.js code for performance
|
|
404
|
+
- Diagnosing slow page loads, slow interactions, or high CPU on the client
|
|
405
|
+
- Auditing bundle size or Lighthouse Core Web Vitals regressions
|
|
406
|
+
- Removing waterfalls in Server Components / API routes
|
|
407
|
+
- Reducing client-side re-renders
|
|
408
|
+
- Optimizing long lists, animations, or hydration
|
|
409
|
+
- Auditing optimization choices in PRs touching `app/`, `pages/`, `components/`, or data layers
|
|
410
|
+
|
|
411
|
+
## Priority Index
|
|
412
|
+
|
|
413
|
+
| Priority | Category | Prefix | When it matters |
|
|
414
|
+
|---|---|---|---|
|
|
415
|
+
| 1 — CRITICAL | Eliminating Waterfalls | `async-` | Anytime `await` is followed by independent `await` |
|
|
416
|
+
| 2 — CRITICAL | Bundle Size Optimization | `bundle-` | First-load JS, route-level imports, third-party libs |
|
|
417
|
+
| 3 — HIGH | Server-Side Performance | `server-` | RSC, Server Actions, API routes, SSR |
|
|
418
|
+
| 4 — MEDIUM-HIGH | Client-Side Data Fetching | `client-` | SWR / TanStack Query / raw `fetch` in hooks |
|
|
419
|
+
| 5 — MEDIUM | Re-render Optimization | `rerender-` | High-frequency state updates, parent-child fan-out |
|
|
420
|
+
| 6 — MEDIUM | Rendering Performance | `rendering-` | Long lists, animations, hydration |
|
|
421
|
+
| 7 — LOW-MEDIUM | JavaScript Performance | `js-` | Hot loops, frequent allocations |
|
|
422
|
+
| 8 — LOW | Advanced Patterns | `advanced-` | Effect-event integration, stable refs |
|
|
423
|
+
|
|
424
|
+
## 1. Eliminating Waterfalls (CRITICAL)
|
|
425
|
+
|
|
426
|
+
> "Waterfalls are the #1 performance killer" — every sequential `await` adds full network latency.
|
|
427
|
+
|
|
428
|
+
### Cheap conditions before await
|
|
429
|
+
|
|
430
|
+
Check sync conditions (props, env, hardcoded flags) before awaiting remote data.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
// INCORRECT
|
|
434
|
+
async function Page({ id }: { id: string }) {
|
|
435
|
+
const flag = await getFlag("show-page");
|
|
436
|
+
if (!flag || !id) return null;
|
|
437
|
+
const data = await getData(id);
|
|
438
|
+
// ...
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// CORRECT — short-circuit on cheap sync condition first
|
|
442
|
+
async function Page({ id }: { id: string }) {
|
|
443
|
+
if (!id) return null;
|
|
444
|
+
const flag = await getFlag("show-page");
|
|
445
|
+
if (!flag) return null;
|
|
446
|
+
const data = await getData(id);
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Defer awaits until used
|
|
451
|
+
|
|
452
|
+
Move `await` into the branch that uses it.
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
// INCORRECT — awaits before deciding it needs the data
|
|
456
|
+
const user = await getUser(id);
|
|
457
|
+
if (mode === "guest") return renderGuest();
|
|
458
|
+
return renderUser(user);
|
|
459
|
+
|
|
460
|
+
// CORRECT
|
|
461
|
+
if (mode === "guest") return renderGuest();
|
|
462
|
+
const user = await getUser(id);
|
|
463
|
+
return renderUser(user);
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Promise.all for independent work
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
// INCORRECT — sequential
|
|
470
|
+
const user = await getUser(id);
|
|
471
|
+
const posts = await getPosts(id);
|
|
472
|
+
const followers = await getFollowers(id);
|
|
473
|
+
|
|
474
|
+
// CORRECT — parallel
|
|
475
|
+
const [user, posts, followers] = await Promise.all([
|
|
476
|
+
getUser(id),
|
|
477
|
+
getPosts(id),
|
|
478
|
+
getFollowers(id),
|
|
479
|
+
]);
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Partial dependencies — start early, await late
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
// CORRECT — kick off all promises, await only when each result is needed
|
|
486
|
+
const userP = getUser(id);
|
|
487
|
+
const postsP = getPosts(id);
|
|
488
|
+
const profile = await getProfile(id);
|
|
489
|
+
if (profile.private) return null;
|
|
490
|
+
const [user, posts] = await Promise.all([userP, postsP]);
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
### Suspense for streaming
|
|
494
|
+
|
|
495
|
+
Push `<Suspense>` boundaries close to the data so the page paints what it can while slower sub-trees stream in. The trade-off: layout shift when content arrives — reserve space (skeleton or `min-height`).
|
|
496
|
+
|
|
497
|
+
### Server Components: parallel through composition
|
|
498
|
+
|
|
499
|
+
```tsx
|
|
500
|
+
// INCORRECT — sibling awaits run sequentially inside one component
|
|
501
|
+
export default async function Page() {
|
|
502
|
+
const user = await getUser();
|
|
503
|
+
const cart = await getCart();
|
|
504
|
+
return <View user={user} cart={cart} />;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// CORRECT — split into children, React runs them in parallel
|
|
508
|
+
export default async function Page() {
|
|
509
|
+
return (
|
|
510
|
+
<View>
|
|
511
|
+
<UserSection />
|
|
512
|
+
<CartSection />
|
|
513
|
+
</View>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## 2. Bundle Size Optimization (CRITICAL)
|
|
519
|
+
|
|
520
|
+
### Direct imports, not barrels
|
|
521
|
+
|
|
522
|
+
Barrel `index.ts` files force the bundler to walk the entire module graph even when tree-shaking removes most of it. Direct imports save 200-800ms of first-load JS in many real-world apps.
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
// INCORRECT
|
|
526
|
+
import { Button, Card, Modal } from "@/components";
|
|
527
|
+
|
|
528
|
+
// CORRECT
|
|
529
|
+
import { Button } from "@/components/Button";
|
|
530
|
+
import { Card } from "@/components/Card";
|
|
531
|
+
import { Modal } from "@/components/Modal";
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Next.js 13.5+ has [Optimize Package Imports](https://nextjs.org/docs/app/api-reference/next-config-js/optimizePackageImports) that automates this for listed packages — use it; manual direct imports still required for non-listed libs.
|
|
535
|
+
|
|
536
|
+
### Statically analyzable paths
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
// INCORRECT — defeats bundler/trace analysis
|
|
540
|
+
const mod = await import(`./pages/${name}`);
|
|
541
|
+
|
|
542
|
+
// CORRECT — explicit per branch
|
|
543
|
+
const mod = name === "home" ? await import("./pages/home") : await import("./pages/about");
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### Dynamic imports for heavy components
|
|
547
|
+
|
|
548
|
+
```tsx
|
|
549
|
+
import dynamic from "next/dynamic";
|
|
550
|
+
|
|
551
|
+
const HeavyChart = dynamic(() => import("./HeavyChart"), {
|
|
552
|
+
loading: () => <Skeleton />,
|
|
553
|
+
ssr: false, // when client-only
|
|
554
|
+
});
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### Defer third-party scripts
|
|
558
|
+
|
|
559
|
+
Load analytics, logging, support widgets AFTER hydration. Use `next/script` with `strategy="afterInteractive"` (default) or `"lazyOnload"`.
|
|
560
|
+
|
|
561
|
+
### Conditional module loading
|
|
562
|
+
|
|
563
|
+
```tsx
|
|
564
|
+
if (user.role === "admin") {
|
|
565
|
+
const { AdminPanel } = await import("./admin/AdminPanel");
|
|
566
|
+
// ...
|
|
567
|
+
}
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### Preload on hover/focus
|
|
571
|
+
|
|
572
|
+
Trigger `<link rel="preload">` or `import()` on hover so the bundle is in cache by the time the user clicks.
|
|
573
|
+
|
|
574
|
+
## 3. Server-Side Performance (HIGH)
|
|
575
|
+
|
|
576
|
+
### Authenticate Server Actions like API routes
|
|
577
|
+
|
|
578
|
+
Every `"use server"` function is a public endpoint. Authenticate AND authorize inside the action — never rely on the calling Client Component's gating.
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
"use server";
|
|
582
|
+
export async function deleteUser(formData: FormData) {
|
|
583
|
+
const session = await getSession();
|
|
584
|
+
if (!session?.user) throw new Error("Unauthorized");
|
|
585
|
+
const targetId = String(formData.get("id"));
|
|
586
|
+
if (session.user.role !== "admin" && session.user.id !== targetId) {
|
|
587
|
+
throw new Error("Forbidden");
|
|
588
|
+
}
|
|
589
|
+
await db.user.delete({ where: { id: targetId } });
|
|
590
|
+
}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### `React.cache()` for per-request deduplication
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
import { cache } from "react";
|
|
597
|
+
|
|
598
|
+
export const getUser = cache(async (id: string) => {
|
|
599
|
+
return db.user.findUnique({ where: { id } });
|
|
600
|
+
});
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
`React.cache` dedupes within a single request. Calling `getUser("1")` from three Server Components in the same render = one DB query.
|
|
604
|
+
|
|
605
|
+
### LRU cache for cross-request data
|
|
606
|
+
|
|
607
|
+
For data that does NOT change per request (config, lookup tables), cache outside React with an LRU cache or `unstable_cache`.
|
|
608
|
+
|
|
609
|
+
### Avoid duplicate serialization in RSC props
|
|
610
|
+
|
|
611
|
+
When a Server Component renders the same data into multiple Client Components, the data is serialized once per consumer. Lift the Client Component up and pass children.
|
|
612
|
+
|
|
613
|
+
### Hoist static I/O to module scope
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
// CORRECT — runs once at module load
|
|
617
|
+
const fontData = readFileSync(fontPath);
|
|
618
|
+
|
|
619
|
+
export async function Page() {
|
|
620
|
+
return <Banner font={fontData} />;
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
### No mutable module-level state in RSC/SSR
|
|
625
|
+
|
|
626
|
+
Module state on the server is shared across all requests — a race condition between users. Use request-scoped storage (`headers()`, `cookies()`, async context) instead.
|
|
627
|
+
|
|
628
|
+
### Minimize data passed to Client Components
|
|
629
|
+
|
|
630
|
+
Only serialize what the Client needs. Strip fields, paginate, project columns at the DB layer.
|
|
631
|
+
|
|
632
|
+
### Parallelize nested fetches with Promise.all per item
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
const users = await getUsers();
|
|
636
|
+
const enriched = await Promise.all(
|
|
637
|
+
users.map(async (u) => ({ ...u, posts: await getPostsFor(u.id) })),
|
|
638
|
+
);
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
### Use `after()` for non-blocking work
|
|
642
|
+
|
|
643
|
+
Next.js 15 `after()` runs work after the response is sent — logging, cache warming, analytics.
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
import { after } from "next/server";
|
|
647
|
+
export async function GET() {
|
|
648
|
+
const data = await getData();
|
|
649
|
+
after(() => logAnalytics(data));
|
|
650
|
+
return Response.json(data);
|
|
651
|
+
}
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
## 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
|
655
|
+
|
|
656
|
+
### SWR / TanStack Query for deduplication
|
|
657
|
+
|
|
658
|
+
Multiple components calling `useUser(id)` should share one network request and one cache entry. Use SWR or TanStack Query — never roll your own `useEffect` + `fetch` for shared data.
|
|
659
|
+
|
|
660
|
+
### Deduplicate global event listeners
|
|
661
|
+
|
|
662
|
+
```tsx
|
|
663
|
+
// INCORRECT — every component adds its own
|
|
664
|
+
useEffect(() => {
|
|
665
|
+
window.addEventListener("scroll", handler);
|
|
666
|
+
return () => window.removeEventListener("scroll", handler);
|
|
667
|
+
}, []);
|
|
668
|
+
|
|
669
|
+
// CORRECT — single shared listener via a hook + global subject
|
|
670
|
+
const useScroll = createScrollHook(); // singleton subject under the hood
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Passive listeners for scroll
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
window.addEventListener("scroll", handler, { passive: true });
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
Improves scrolling smoothness; the listener cannot `preventDefault()`.
|
|
680
|
+
|
|
681
|
+
### localStorage: version + minimize
|
|
682
|
+
|
|
683
|
+
- Always store a `version` field; bump on schema change and migrate or discard old data
|
|
684
|
+
- Keep payloads small — `localStorage` is synchronous and blocks main thread
|
|
685
|
+
|
|
686
|
+
## 5. Re-render Optimization (MEDIUM)
|
|
687
|
+
|
|
688
|
+
### Don't subscribe to state used only in callbacks
|
|
689
|
+
|
|
690
|
+
```tsx
|
|
691
|
+
// INCORRECT — re-renders every time count changes
|
|
692
|
+
const count = useStore((s) => s.count);
|
|
693
|
+
const handler = () => doSomething(count);
|
|
694
|
+
|
|
695
|
+
// CORRECT — read once on call
|
|
696
|
+
const handler = () => {
|
|
697
|
+
const count = useStore.getState().count;
|
|
698
|
+
doSomething(count);
|
|
699
|
+
};
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
### Extract expensive work into memoized components
|
|
703
|
+
|
|
704
|
+
```tsx
|
|
705
|
+
// CORRECT — child re-renders only when `items` changes
|
|
706
|
+
const Heavy = memo(function Heavy({ items }: { items: Item[] }) {
|
|
707
|
+
return <Chart data={transform(items)} />;
|
|
708
|
+
});
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
### Hoist default non-primitive props
|
|
712
|
+
|
|
713
|
+
```tsx
|
|
714
|
+
// INCORRECT — new array each render breaks memo
|
|
715
|
+
<List items={items ?? []} />
|
|
716
|
+
|
|
717
|
+
// CORRECT
|
|
718
|
+
const EMPTY: Item[] = [];
|
|
719
|
+
<List items={items ?? EMPTY} />
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
### Primitive dependencies in effects
|
|
723
|
+
|
|
724
|
+
```tsx
|
|
725
|
+
// INCORRECT — new object identity every render
|
|
726
|
+
useEffect(() => {}, [{ id, name }]);
|
|
727
|
+
|
|
728
|
+
// CORRECT — primitives
|
|
729
|
+
useEffect(() => {}, [id, name]);
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Subscribe to derived booleans, not raw values
|
|
733
|
+
|
|
734
|
+
```tsx
|
|
735
|
+
// INCORRECT — re-renders for any cart change
|
|
736
|
+
const cart = useStore((s) => s.cart);
|
|
737
|
+
const hasItems = cart.length > 0;
|
|
738
|
+
|
|
739
|
+
// CORRECT — re-renders only when emptiness flips
|
|
740
|
+
const hasItems = useStore((s) => s.cart.length > 0);
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
### Derive during render, never via `useEffect`
|
|
744
|
+
|
|
745
|
+
```tsx
|
|
746
|
+
// INCORRECT
|
|
747
|
+
const [full, setFull] = useState("");
|
|
748
|
+
useEffect(() => setFull(`${first} ${last}`), [first, last]);
|
|
749
|
+
|
|
750
|
+
// CORRECT
|
|
751
|
+
const full = `${first} ${last}`;
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
### Functional `setState` for stable callbacks
|
|
755
|
+
|
|
756
|
+
```tsx
|
|
757
|
+
// CORRECT
|
|
758
|
+
const increment = useCallback(() => setCount((c) => c + 1), []);
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
### Lazy state initializer for expensive values
|
|
762
|
+
|
|
763
|
+
```tsx
|
|
764
|
+
const [tree] = useState(() => parseTree(largeInput));
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
### Avoid memo for simple primitives
|
|
768
|
+
|
|
769
|
+
`useMemo(() => x + 1, [x])` is overhead. Memo earns its keep on object identity and expensive computation.
|
|
770
|
+
|
|
771
|
+
### Split hooks with independent deps
|
|
772
|
+
|
|
773
|
+
```tsx
|
|
774
|
+
// INCORRECT — both selectors re-run if either source changes
|
|
775
|
+
const { a, b } = useSomething(source1, source2);
|
|
776
|
+
|
|
777
|
+
// CORRECT
|
|
778
|
+
const a = useA(source1);
|
|
779
|
+
const b = useB(source2);
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
### Move interaction logic into event handlers
|
|
783
|
+
|
|
784
|
+
Event handlers run only on the user action — `useEffect` re-runs whenever deps change.
|
|
785
|
+
|
|
786
|
+
### `startTransition` for non-urgent updates
|
|
787
|
+
|
|
788
|
+
```tsx
|
|
789
|
+
const [pending, startTransition] = useTransition();
|
|
790
|
+
startTransition(() => setFilters(newFilters));
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
### `useDeferredValue` for expensive renders
|
|
794
|
+
|
|
795
|
+
```tsx
|
|
796
|
+
const deferredQuery = useDeferredValue(query);
|
|
797
|
+
const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
### `useRef` for transient frequent values
|
|
801
|
+
|
|
802
|
+
For values that change often but should not trigger re-render (timestamps, last-key, accumulators).
|
|
803
|
+
|
|
804
|
+
### Don't define components inside components
|
|
805
|
+
|
|
806
|
+
```tsx
|
|
807
|
+
// INCORRECT — Inner is a new component on every Outer render
|
|
808
|
+
function Outer() {
|
|
809
|
+
const Inner = () => <span />;
|
|
810
|
+
return <Inner />;
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
Each render makes a new `Inner` type, defeating reconciliation and unmounting children.
|
|
815
|
+
|
|
816
|
+
## 6. Rendering Performance (MEDIUM)
|
|
817
|
+
|
|
818
|
+
### Animate the wrapper, not the SVG
|
|
819
|
+
|
|
820
|
+
Transforming a `<div>` wrapper around an SVG is GPU-accelerated; transforming the SVG itself triggers paint.
|
|
821
|
+
|
|
822
|
+
### `content-visibility: auto` for long lists
|
|
823
|
+
|
|
824
|
+
```css
|
|
825
|
+
.row { content-visibility: auto; contain-intrinsic-size: auto 80px; }
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Browser skips offscreen rendering — major win for lists with hundreds of rows.
|
|
829
|
+
|
|
830
|
+
### Hoist static JSX
|
|
831
|
+
|
|
832
|
+
```tsx
|
|
833
|
+
const STATIC_HEADER = <h1>Title</h1>;
|
|
834
|
+
function Page() {
|
|
835
|
+
return <>{STATIC_HEADER}<Body /></>;
|
|
836
|
+
}
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
### SVG: reduce coordinate precision
|
|
840
|
+
|
|
841
|
+
`d="M10.123456,20.654321"` → `d="M10.12,20.65"`. Each digit costs bytes; the visual difference is sub-pixel.
|
|
842
|
+
|
|
843
|
+
### Hydration no-flicker via inline script
|
|
844
|
+
|
|
845
|
+
For values needed before hydration (theme, locale), inline a `<script>` that sets `document.documentElement.dataset.*` before React mounts.
|
|
846
|
+
|
|
847
|
+
### Suppress expected hydration mismatches narrowly
|
|
848
|
+
|
|
849
|
+
```tsx
|
|
850
|
+
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Use ONLY for known-divergent leaf nodes — never on a tree containing other children.
|
|
854
|
+
|
|
855
|
+
### `<Activity>` for show/hide instead of mount/unmount
|
|
856
|
+
|
|
857
|
+
React 19 `<Activity mode="visible|hidden">` keeps tree state and effects mounted but hides — cheaper than unmount/remount for tabs and accordions.
|
|
858
|
+
|
|
859
|
+
### Ternary over `&&` for conditional render
|
|
860
|
+
|
|
861
|
+
```tsx
|
|
862
|
+
// INCORRECT — `0` renders as text node
|
|
863
|
+
{count && <Badge>{count}</Badge>}
|
|
864
|
+
|
|
865
|
+
// CORRECT
|
|
866
|
+
{count > 0 ? <Badge>{count}</Badge> : null}
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
### `useTransition` for loading states
|
|
870
|
+
|
|
871
|
+
Pair `startTransition` with the action; React shows the previous UI as `isPending` while the next state computes.
|
|
872
|
+
|
|
873
|
+
### React DOM resource hints
|
|
874
|
+
|
|
875
|
+
```tsx
|
|
876
|
+
import { preload, preconnect } from "react-dom";
|
|
877
|
+
preload("/api/critical", { as: "fetch" });
|
|
878
|
+
preconnect("https://api.example.com");
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
### `defer` / `async` on `<script>` tags
|
|
882
|
+
|
|
883
|
+
`defer` for ordered execution after DOMContentLoaded; `async` for fire-and-forget.
|
|
884
|
+
|
|
885
|
+
## 7. JavaScript Performance (LOW-MEDIUM)
|
|
886
|
+
|
|
887
|
+
- **Batch DOM/CSS changes** — apply via class swap or `cssText`, not property-by-property
|
|
888
|
+
- **`Map` for repeated lookups** — `O(1)` vs `O(n)` linear scan
|
|
889
|
+
- **Cache property access in loops** — `const len = arr.length`
|
|
890
|
+
- **Memoize pure functions** — module-level `Map<key, result>`
|
|
891
|
+
- **Cache `localStorage` reads** — sync API; one read per render
|
|
892
|
+
- **Combine `filter().map()` into one pass** — `flatMap` or single `for`
|
|
893
|
+
- **Check array length first** before expensive comparisons
|
|
894
|
+
- **Early return** from functions
|
|
895
|
+
- **Hoist RegExp** out of loops — compilation is not free
|
|
896
|
+
- **Loop for min/max** instead of `sort()` — `O(n)` vs `O(n log n)`
|
|
897
|
+
- **`Set`/`Map` for membership** — `O(1)` vs `Array.includes` `O(n)`
|
|
898
|
+
- **`toSorted()` over mutation** when immutability matters
|
|
899
|
+
- **`flatMap` to map and filter in one pass**
|
|
900
|
+
- **`requestIdleCallback`** for non-critical work
|
|
901
|
+
|
|
902
|
+
## 8. Advanced Patterns (LOW)
|
|
903
|
+
|
|
904
|
+
### `useEffectEvent` deps
|
|
905
|
+
|
|
906
|
+
Values from `useEffectEvent` are stable — do NOT add them to effect deps.
|
|
907
|
+
|
|
908
|
+
### Event handler refs
|
|
909
|
+
|
|
910
|
+
For stable callbacks passed to memoized children:
|
|
911
|
+
|
|
912
|
+
```tsx
|
|
913
|
+
const handlerRef = useRef(handler);
|
|
914
|
+
useEffect(() => { handlerRef.current = handler; });
|
|
915
|
+
const stable = useCallback((arg) => handlerRef.current(arg), []);
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
### Init once per app load
|
|
919
|
+
|
|
920
|
+
For module-level singletons (telemetry, logger), guard with a module-scope flag — not `useEffect`.
|
|
921
|
+
|
|
922
|
+
### `useLatest` for stable callback refs
|
|
923
|
+
|
|
924
|
+
```tsx
|
|
925
|
+
function useLatest<T>(value: T) {
|
|
926
|
+
const ref = useRef(value);
|
|
927
|
+
ref.current = value;
|
|
928
|
+
return ref;
|
|
929
|
+
}
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
## Automated Tools
|
|
933
|
+
|
|
934
|
+
Many of these rules are now automated:
|
|
935
|
+
|
|
936
|
+
- **Next.js 13.5+ Optimize Package Imports** — barrel import optimization
|
|
937
|
+
- **React Compiler** (RFC, in canary) — auto-memoization
|
|
938
|
+
- **Turbopack** — faster builds, better tree-shaking
|
|
939
|
+
- **Bundle Analyzer** (`@next/bundle-analyzer`) — visualize first-load JS
|
|
940
|
+
|
|
941
|
+
When the project ships React Compiler, demote `rerender-*` manual memoization rules to "review-only" — the compiler handles them. Manual `useMemo`/`useCallback` becomes unnecessary noise.
|
|
942
|
+
|
|
943
|
+
## Lighthouse / Web Vitals Mapping
|
|
944
|
+
|
|
945
|
+
| Metric | Most relevant categories |
|
|
946
|
+
|---|---|
|
|
947
|
+
| **LCP** (Largest Contentful Paint) | Waterfalls, Bundle Size, Resource Hints |
|
|
948
|
+
| **INP** (Interaction to Next Paint) | Re-render, Rendering, JavaScript |
|
|
949
|
+
| **CLS** (Cumulative Layout Shift) | Rendering (Suspense placement, image dimensions) |
|
|
950
|
+
| **TBT** (Total Blocking Time) | Bundle Size, JavaScript, Defer Third-Party |
|
|
951
|
+
| **FID** (legacy) | Bundle Size, Hydration |
|
|
952
|
+
|
|
953
|
+
## Related
|
|
954
|
+
|
|
955
|
+
- Skills: [react-patterns](../react-patterns/SKILL.md), [react-testing](../react-testing/SKILL.md), [frontend-patterns](../frontend-patterns/SKILL.md), [accessibility](../accessibility/SKILL.md), [nextjs-turbopack](../nextjs-turbopack/SKILL.md)
|
|
956
|
+
- Rules: [rules/react/](../../rules/react/)
|
|
957
|
+
- Agents: `react-reviewer` enforces these rules in code review; `react-build-resolver` handles related build failures
|
|
958
|
+
- Commands: `/react-review`, `/react-build`, `/react-test`
|
|
959
|
+
|
|
960
|
+
## Attribution
|
|
961
|
+
|
|
962
|
+
Adapted from Vercel Labs `react-best-practices` skill (MIT License, copyright Vercel Engineering, v1.0.0 January 2026). Source: [https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices).
|
|
963
|
+
|
|
964
|
+
This skill restructures and adapts the original 70-rule catalog into a single navigable reference. For the full original ruleset with extended examples, see the upstream repository.
|
|
965
|
+
|
|
966
|
+
### react-testing
|
|
967
|
+
|
|
968
|
+
Use when writing or fixing tests for React components, hooks, or pages — React Testing Library query priority, userEvent, async assertions, MSW network mocking, accessibility assertions with axe, and the RTL-vs-Playwright decision boundary.
|
|
969
|
+
|
|
970
|
+
# React Testing
|
|
971
|
+
|
|
972
|
+
Comprehensive React testing patterns for behavior-focused component tests, custom hook tests, accessibility assertions, and network-level mocking.
|
|
973
|
+
|
|
974
|
+
## When to Activate
|
|
975
|
+
|
|
976
|
+
- Writing tests for React components, custom hooks, or pages
|
|
977
|
+
- Adding test coverage to legacy untested components
|
|
978
|
+
- Migrating from Enzyme or class-component-era patterns to React Testing Library
|
|
979
|
+
- Setting up Vitest or Jest for a new React project
|
|
980
|
+
- Mocking HTTP requests in tests
|
|
981
|
+
- Asserting accessibility violations
|
|
982
|
+
- Deciding which tests belong in RTL vs Playwright Component Testing vs full E2E
|
|
983
|
+
|
|
984
|
+
## Core Principle
|
|
985
|
+
|
|
986
|
+
Test what the user sees and does, not implementation details.
|
|
987
|
+
|
|
988
|
+
A test should:
|
|
989
|
+
|
|
990
|
+
- Render the component with the same providers it has in production
|
|
991
|
+
- Interact with it via accessible queries (role, label) and `userEvent`
|
|
992
|
+
- Assert visible output and observable side effects (callback fired, request sent)
|
|
993
|
+
|
|
994
|
+
A test should NOT:
|
|
995
|
+
|
|
996
|
+
- Inspect component state, props passed to children, or which hooks were called
|
|
997
|
+
- Mock React itself or framework hooks
|
|
998
|
+
- Assert on the number of renders or DOM structure beyond what affects users
|
|
999
|
+
|
|
1000
|
+
## Library Choice
|
|
1001
|
+
|
|
1002
|
+
| Runner | When | Note |
|
|
1003
|
+
|---|---|---|
|
|
1004
|
+
| **Vitest** | Vite, Remix, modern setups | Faster, native ESM, Jest-compatible API |
|
|
1005
|
+
| **Jest** | Next.js, CRA, established repos | Default for many React projects |
|
|
1006
|
+
| **Playwright Component Testing** | Real browser engine needed | Use when JSDOM lacks the required feature |
|
|
1007
|
+
| **Cypress Component Testing** | Real browser, Cypress already in use | Alternative to Playwright CT |
|
|
1008
|
+
|
|
1009
|
+
Pick one. Do not run RTL + Vitest AND Playwright CT in the same repo unless you have a clear lane separation.
|
|
1010
|
+
|
|
1011
|
+
## Query Priority
|
|
1012
|
+
|
|
1013
|
+
React Testing Library exposes queries in three tiers — use top-down:
|
|
1014
|
+
|
|
1015
|
+
1. **Accessible to everyone**: `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`, `getByDisplayValue`
|
|
1016
|
+
2. **Semantic**: `getByAltText`, `getByTitle`
|
|
1017
|
+
3. **Test IDs (escape hatch)**: `getByTestId`
|
|
1018
|
+
|
|
1019
|
+
```tsx
|
|
1020
|
+
// Best
|
|
1021
|
+
screen.getByRole("button", { name: /save/i });
|
|
1022
|
+
|
|
1023
|
+
// OK for inputs
|
|
1024
|
+
screen.getByLabelText("Email");
|
|
1025
|
+
|
|
1026
|
+
// Last resort
|
|
1027
|
+
screen.getByTestId("save-btn");
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
Variants:
|
|
1031
|
+
|
|
1032
|
+
- `getBy*` — throws if no match
|
|
1033
|
+
- `queryBy*` — returns `null` (use for "assert absence")
|
|
1034
|
+
- `findBy*` — async, returns a Promise (use for elements that appear after async work)
|
|
1035
|
+
|
|
1036
|
+
## User Interaction with `userEvent`
|
|
1037
|
+
|
|
1038
|
+
```tsx
|
|
1039
|
+
import userEvent from "@testing-library/user-event";
|
|
1040
|
+
|
|
1041
|
+
test("submits the form", async () => {
|
|
1042
|
+
const user = userEvent.setup();
|
|
1043
|
+
const onSubmit = vi.fn();
|
|
1044
|
+
render(<UserForm onSubmit={onSubmit} />);
|
|
1045
|
+
|
|
1046
|
+
await user.type(screen.getByLabelText("Email"), "user@example.com");
|
|
1047
|
+
await user.click(screen.getByRole("button", { name: /save/i }));
|
|
1048
|
+
|
|
1049
|
+
expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com" });
|
|
1050
|
+
});
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
- Always `await` userEvent calls
|
|
1054
|
+
- Call `userEvent.setup()` once per test, reuse the returned `user`
|
|
1055
|
+
- `userEvent` simulates a real browser sequence; `fireEvent` dispatches a single synthetic event — prefer `userEvent`
|
|
1056
|
+
|
|
1057
|
+
## Async Patterns
|
|
1058
|
+
|
|
1059
|
+
```tsx
|
|
1060
|
+
// Element that appears after async work
|
|
1061
|
+
expect(await screen.findByText("Loaded")).toBeInTheDocument();
|
|
1062
|
+
|
|
1063
|
+
// Side effect assertion
|
|
1064
|
+
await waitFor(() => expect(saveSpy).toHaveBeenCalled());
|
|
1065
|
+
|
|
1066
|
+
// Element that should disappear
|
|
1067
|
+
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
Never `setTimeout` + assertion — flaky. Use the matchers above.
|
|
1071
|
+
|
|
1072
|
+
## Network Mocking with MSW
|
|
1073
|
+
|
|
1074
|
+
Mock Service Worker mocks at the network layer. The component, hooks, and fetch library all behave exactly as in production.
|
|
1075
|
+
|
|
1076
|
+
### Setup
|
|
1077
|
+
|
|
1078
|
+
```ts
|
|
1079
|
+
// test/setup.ts
|
|
1080
|
+
import { setupServer } from "msw/node";
|
|
1081
|
+
import { http, HttpResponse } from "msw";
|
|
1082
|
+
|
|
1083
|
+
export const handlers = [
|
|
1084
|
+
http.get("/api/users/:id", ({ params }) =>
|
|
1085
|
+
HttpResponse.json({ id: params.id, name: "Alice" }),
|
|
1086
|
+
),
|
|
1087
|
+
http.post("/api/users", async ({ request }) => {
|
|
1088
|
+
const body = await request.json();
|
|
1089
|
+
return HttpResponse.json({ id: "new-id", ...body }, { status: 201 });
|
|
1090
|
+
}),
|
|
1091
|
+
];
|
|
1092
|
+
|
|
1093
|
+
export const server = setupServer(...handlers);
|
|
1094
|
+
|
|
1095
|
+
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
|
1096
|
+
afterEach(() => server.resetHandlers());
|
|
1097
|
+
afterAll(() => server.close());
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
Configure `onUnhandledRequest: "error"` so any unmocked request fails the test loudly — silent passes are worse than red.
|
|
1101
|
+
|
|
1102
|
+
### Per-test override
|
|
1103
|
+
|
|
1104
|
+
```tsx
|
|
1105
|
+
test("renders error on 500", async () => {
|
|
1106
|
+
server.use(
|
|
1107
|
+
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 })),
|
|
1108
|
+
);
|
|
1109
|
+
render(<UserPage id="1" />);
|
|
1110
|
+
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
|
|
1111
|
+
});
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
## Provider Wrapping
|
|
1115
|
+
|
|
1116
|
+
Wrap providers once in a `test-utils.tsx`:
|
|
1117
|
+
|
|
1118
|
+
```tsx
|
|
1119
|
+
// test-utils.tsx
|
|
1120
|
+
import { render, RenderOptions } from "@testing-library/react";
|
|
1121
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
1122
|
+
|
|
1123
|
+
export function renderWithProviders(
|
|
1124
|
+
ui: React.ReactElement,
|
|
1125
|
+
options?: RenderOptions,
|
|
1126
|
+
) {
|
|
1127
|
+
const queryClient = new QueryClient({
|
|
1128
|
+
defaultOptions: { queries: { retry: false } },
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
return render(
|
|
1132
|
+
<QueryClientProvider client={queryClient}>
|
|
1133
|
+
<ThemeProvider theme={lightTheme}>
|
|
1134
|
+
<MemoryRouter>{ui}</MemoryRouter>
|
|
1135
|
+
</ThemeProvider>
|
|
1136
|
+
</QueryClientProvider>,
|
|
1137
|
+
options,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
export * from "@testing-library/react";
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
Then `import { renderWithProviders, screen } from "test-utils"` in every test file.
|
|
1145
|
+
|
|
1146
|
+
## Custom Hook Testing
|
|
1147
|
+
|
|
1148
|
+
```tsx
|
|
1149
|
+
import { renderHook, act } from "@testing-library/react";
|
|
1150
|
+
|
|
1151
|
+
test("useCounter increments and decrements", () => {
|
|
1152
|
+
const { result } = renderHook(() => useCounter(0));
|
|
1153
|
+
|
|
1154
|
+
expect(result.current.count).toBe(0);
|
|
1155
|
+
|
|
1156
|
+
act(() => result.current.increment());
|
|
1157
|
+
expect(result.current.count).toBe(1);
|
|
1158
|
+
|
|
1159
|
+
act(() => result.current.decrement());
|
|
1160
|
+
expect(result.current.count).toBe(0);
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
test("useCounter accepts initial value", () => {
|
|
1164
|
+
const { result } = renderHook(() => useCounter(10));
|
|
1165
|
+
expect(result.current.count).toBe(10);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
test("useUser fetches user data", async () => {
|
|
1169
|
+
// Instantiate QueryClient ONCE per test outside the wrapper so it survives re-renders.
|
|
1170
|
+
// Creating it inside the wrapper closure resets cache state on every render, producing flaky tests.
|
|
1171
|
+
const queryClient = new QueryClient({
|
|
1172
|
+
defaultOptions: { queries: { retry: false } },
|
|
1173
|
+
});
|
|
1174
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
1175
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
1176
|
+
);
|
|
1177
|
+
|
|
1178
|
+
const { result } = renderHook(() => useUser("1"), { wrapper });
|
|
1179
|
+
|
|
1180
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
1181
|
+
expect(result.current.data).toEqual({ id: "1", name: "Alice" });
|
|
1182
|
+
});
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
- Wrap state-changing calls in `act`
|
|
1186
|
+
- Test through the hook's public API only
|
|
1187
|
+
- For hooks that use context, pass a `wrapper`
|
|
1188
|
+
|
|
1189
|
+
## Accessibility Assertions
|
|
1190
|
+
|
|
1191
|
+
```tsx
|
|
1192
|
+
import { axe, toHaveNoViolations } from "jest-axe"; // or vitest-axe
|
|
1193
|
+
expect.extend(toHaveNoViolations);
|
|
1194
|
+
|
|
1195
|
+
test("UserCard has no a11y violations", async () => {
|
|
1196
|
+
const { container } = render(<UserCard user={mockUser} />);
|
|
1197
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
1198
|
+
});
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
Run axe in component tests for every interactive component. Catches:
|
|
1202
|
+
|
|
1203
|
+
- Missing labels on form inputs
|
|
1204
|
+
- Invalid ARIA usage
|
|
1205
|
+
- Poor color contrast (limited — JSDOM has no real CSS engine, so this works for inline styles only; visual contrast belongs in Playwright)
|
|
1206
|
+
- Missing alt text on images
|
|
1207
|
+
- Heading order violations
|
|
1208
|
+
|
|
1209
|
+
Cross-link: [skills/accessibility/SKILL.md](../accessibility/SKILL.md) for the broader a11y testing playbook.
|
|
1210
|
+
|
|
1211
|
+
## When NOT to Use Snapshot Tests
|
|
1212
|
+
|
|
1213
|
+
Snapshots of rendered output:
|
|
1214
|
+
|
|
1215
|
+
- Break on every styling change
|
|
1216
|
+
- Get rubber-stamped during review
|
|
1217
|
+
- Test implementation detail (DOM structure), not behavior
|
|
1218
|
+
|
|
1219
|
+
Acceptable snapshot uses:
|
|
1220
|
+
|
|
1221
|
+
- Pure data serialization functions (`formatInvoice(invoice)` -> stable string)
|
|
1222
|
+
- Generated config files (e.g., webpack config output)
|
|
1223
|
+
|
|
1224
|
+
For visual regression on components, use Playwright/Cypress screenshots or Percy/Chromatic — actual visual diffs, not DOM strings.
|
|
1225
|
+
|
|
1226
|
+
## When to Reach for Playwright / Cypress
|
|
1227
|
+
|
|
1228
|
+
JSDOM (used by Vitest/Jest) cannot:
|
|
1229
|
+
|
|
1230
|
+
- Render real layout (flexbox, grid, viewport queries)
|
|
1231
|
+
- Run native browser animation, CSS transitions
|
|
1232
|
+
- Test scrolling behavior, drag-and-drop, paste from clipboard
|
|
1233
|
+
- Handle iframes, popups, downloads, cross-origin flows
|
|
1234
|
+
- Run real network in a controlled environment with full DevTools support
|
|
1235
|
+
|
|
1236
|
+
For any of those, use Playwright Component Testing (component test in real browser) or full E2E. See [e2e-testing skill](../e2e-testing/SKILL.md).
|
|
1237
|
+
|
|
1238
|
+
Decision boundary:
|
|
1239
|
+
|
|
1240
|
+
- A hook, a presentational component, a form with logic -> RTL
|
|
1241
|
+
- A component whose layout matters or that uses browser APIs not in JSDOM -> Playwright CT
|
|
1242
|
+
- A full user flow across multiple pages -> Playwright/Cypress E2E
|
|
1243
|
+
|
|
1244
|
+
## Coverage Targets
|
|
1245
|
+
|
|
1246
|
+
| Layer | Target |
|
|
1247
|
+
|---|---|
|
|
1248
|
+
| Pure utilities | >=90% |
|
|
1249
|
+
| Custom hooks | >=85% |
|
|
1250
|
+
| Presentational components | >=80% — behavior, not lines |
|
|
1251
|
+
| Container components | >=70% — golden paths + error states |
|
|
1252
|
+
| Pages | E2E covered separately; smoke test minimum |
|
|
1253
|
+
|
|
1254
|
+
Configure via `vitest.config.ts` / `jest.config.js`:
|
|
1255
|
+
|
|
1256
|
+
```ts
|
|
1257
|
+
// vitest.config.ts
|
|
1258
|
+
test: {
|
|
1259
|
+
coverage: {
|
|
1260
|
+
provider: "v8",
|
|
1261
|
+
reporter: ["text", "html", "lcov"],
|
|
1262
|
+
thresholds: {
|
|
1263
|
+
lines: 80,
|
|
1264
|
+
functions: 80,
|
|
1265
|
+
branches: 70,
|
|
1266
|
+
statements: 80,
|
|
1267
|
+
},
|
|
1268
|
+
},
|
|
1269
|
+
}
|
|
1270
|
+
```
|
|
1271
|
+
|
|
1272
|
+
## Anti-Patterns
|
|
1273
|
+
|
|
1274
|
+
- `container.querySelector("...")` — bypasses accessibility queries, lets tests pass when real users would fail
|
|
1275
|
+
- Asserting on number of renders — implementation detail
|
|
1276
|
+
- `jest.mock("react", ...)` — never mock React. Refactor the component instead
|
|
1277
|
+
- Mocking child components by default — tests the integration, not isolation. Mock only when the child has heavy side effects
|
|
1278
|
+
- Ignoring `act()` warnings — they signal real bugs (state update after unmount, missing async wrapping)
|
|
1279
|
+
- Sharing mutable state across tests — flakes when test order changes
|
|
1280
|
+
- Tests that pass with `it.skip()` removed — your test does not actually assert what you think
|
|
1281
|
+
|
|
1282
|
+
## TDD Workflow
|
|
1283
|
+
|
|
1284
|
+
```
|
|
1285
|
+
RED -> Write failing test for the next requirement
|
|
1286
|
+
GREEN -> Write minimal component code to pass
|
|
1287
|
+
REFACTOR -> Improve the component, tests stay green
|
|
1288
|
+
REPEAT -> Next requirement
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
For new components:
|
|
1292
|
+
|
|
1293
|
+
1. Define the component's prop type and signature
|
|
1294
|
+
2. Write the first test for the simplest case
|
|
1295
|
+
3. Verify it fails for the right reason
|
|
1296
|
+
4. Implement just enough to pass
|
|
1297
|
+
5. Add the next test case
|
|
1298
|
+
6. Refactor when the third similar test reveals a pattern
|
|
1299
|
+
|
|
1300
|
+
## Test Commands
|
|
1301
|
+
|
|
1302
|
+
```bash
|
|
1303
|
+
# Vitest
|
|
1304
|
+
vitest # watch
|
|
1305
|
+
vitest run # one-shot
|
|
1306
|
+
vitest run --coverage # with coverage
|
|
1307
|
+
vitest run path/to/file.test.tsx # single file
|
|
1308
|
+
|
|
1309
|
+
# Jest
|
|
1310
|
+
jest --watch
|
|
1311
|
+
jest --coverage
|
|
1312
|
+
jest path/to/file.test.tsx
|
|
1313
|
+
|
|
1314
|
+
# CI mode
|
|
1315
|
+
CI=true vitest run --coverage
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
## Related
|
|
1319
|
+
|
|
1320
|
+
- Rules: [rules/react/testing.md](../../rules/react/testing.md)
|
|
1321
|
+
- Skills: [react-patterns](../react-patterns/SKILL.md), [accessibility](../accessibility/SKILL.md), [e2e-testing](../e2e-testing/SKILL.md), [tdd-workflow](../tdd-workflow/SKILL.md)
|
|
1322
|
+
- Agents: `react-reviewer` (reviews test quality during code review), `tdd-guide` (enforces TDD process)
|
|
1323
|
+
- Commands: `/react-test`, `/react-review`
|
|
1324
|
+
|
|
1325
|
+
## Examples
|
|
1326
|
+
|
|
1327
|
+
### Form submission with MSW and userEvent
|
|
1328
|
+
|
|
1329
|
+
```tsx
|
|
1330
|
+
test("submits user form and shows success", async () => {
|
|
1331
|
+
server.use(
|
|
1332
|
+
http.post("/api/users", () =>
|
|
1333
|
+
HttpResponse.json({ id: "1", name: "Alice" }, { status: 201 }),
|
|
1334
|
+
),
|
|
1335
|
+
);
|
|
1336
|
+
|
|
1337
|
+
const user = userEvent.setup();
|
|
1338
|
+
renderWithProviders(<UserForm />);
|
|
1339
|
+
|
|
1340
|
+
await user.type(screen.getByLabelText("Name"), "Alice");
|
|
1341
|
+
await user.type(screen.getByLabelText("Email"), "alice@example.com");
|
|
1342
|
+
await user.click(screen.getByRole("button", { name: /save/i }));
|
|
1343
|
+
|
|
1344
|
+
expect(await screen.findByText(/saved successfully/i)).toBeInTheDocument();
|
|
1345
|
+
});
|
|
1346
|
+
```
|
|
1347
|
+
|
|
1348
|
+
### Testing an error boundary
|
|
1349
|
+
|
|
1350
|
+
```tsx
|
|
1351
|
+
function Broken() {
|
|
1352
|
+
throw new Error("boom");
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
test("error boundary renders fallback", () => {
|
|
1356
|
+
// Suppress React's console.error noise for the expected throw, then restore so
|
|
1357
|
+
// the spy does not leak across tests and hide real errors elsewhere.
|
|
1358
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1359
|
+
try {
|
|
1360
|
+
render(
|
|
1361
|
+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|
1362
|
+
<Broken />
|
|
1363
|
+
</ErrorBoundary>,
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
|
1367
|
+
} finally {
|
|
1368
|
+
errorSpy.mockRestore();
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
### Testing a Suspense boundary
|
|
1374
|
+
|
|
1375
|
+
```tsx
|
|
1376
|
+
test("shows loading then content", async () => {
|
|
1377
|
+
renderWithProviders(
|
|
1378
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
1379
|
+
<UserDetail id="1" />
|
|
1380
|
+
</Suspense>,
|
|
1381
|
+
);
|
|
1382
|
+
|
|
1383
|
+
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
|
1384
|
+
expect(await screen.findByText("Alice")).toBeInTheDocument();
|
|
1385
|
+
});
|
|
1386
|
+
```
|